mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #156 from Tria-plc/alpha
Update portal home and fix build error
This commit is contained in:
@@ -1,74 +1,84 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { usePaymentStore } from '@/lib/payment-store';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CreditCard, Smartphone, Wallet, Loader2, CheckCircle } from 'lucide-react';
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useBookingStore } from "@/lib/booking-store";
|
||||
import { usePaymentStore } from "@/lib/payment-store";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
CreditCard,
|
||||
Smartphone,
|
||||
Wallet,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
// Mock payment methods with Ethiopian providers
|
||||
const paymentMethods = [
|
||||
{
|
||||
id: 'TELEBIRR',
|
||||
name: 'Telebirr',
|
||||
icon: Smartphone,
|
||||
description: 'Pay with Telebirr mobile money',
|
||||
color: 'bg-orange-50 border-orange-200 hover:border-orange-400'
|
||||
{
|
||||
id: "TELEBIRR",
|
||||
name: "Telebirr",
|
||||
icon: Smartphone,
|
||||
description: "Pay with Telebirr mobile money",
|
||||
color: "bg-orange-50 border-orange-200 hover:border-orange-400",
|
||||
},
|
||||
{
|
||||
id: 'CBE_BIRR',
|
||||
name: 'CBE Birr',
|
||||
icon: Smartphone,
|
||||
description: 'Pay with CBE Birr',
|
||||
color: 'bg-blue-50 border-blue-200 hover:border-blue-400'
|
||||
{
|
||||
id: "CBE_BIRR",
|
||||
name: "CBE Birr",
|
||||
icon: Smartphone,
|
||||
description: "Pay with CBE Birr",
|
||||
color: "bg-blue-50 border-blue-200 hover:border-blue-400",
|
||||
},
|
||||
{
|
||||
id: 'EBIRR',
|
||||
name: 'eBirr',
|
||||
icon: Smartphone,
|
||||
description: 'Pay with eBirr',
|
||||
color: 'bg-green-50 border-green-200 hover:border-green-400'
|
||||
{
|
||||
id: "EBIRR",
|
||||
name: "eBirr",
|
||||
icon: Smartphone,
|
||||
description: "Pay with eBirr",
|
||||
color: "bg-green-50 border-green-200 hover:border-green-400",
|
||||
},
|
||||
{
|
||||
id: 'CARD',
|
||||
name: 'Card Payment',
|
||||
icon: CreditCard,
|
||||
description: 'Pay with credit/debit card',
|
||||
color: 'bg-purple-50 border-purple-200 hover:border-purple-400'
|
||||
{
|
||||
id: "CARD",
|
||||
name: "Card Payment",
|
||||
icon: CreditCard,
|
||||
description: "Pay with credit/debit card",
|
||||
color: "bg-purple-50 border-purple-200 hover:border-purple-400",
|
||||
},
|
||||
{
|
||||
id: 'WALLET',
|
||||
name: 'Wallet',
|
||||
icon: Wallet,
|
||||
description: 'Pay from your wallet balance',
|
||||
color: 'bg-indigo-50 border-indigo-200 hover:border-indigo-400'
|
||||
{
|
||||
id: "WALLET",
|
||||
name: "Wallet",
|
||||
icon: Wallet,
|
||||
description: "Pay from your wallet balance",
|
||||
color: "bg-indigo-50 border-indigo-200 hover:border-indigo-400",
|
||||
},
|
||||
];
|
||||
|
||||
export default function PaymentPage() {
|
||||
const router = useRouter();
|
||||
const { bookingId, pnr, selectedSchedule, passengers } = useBookingStore();
|
||||
const { selectedCurrency, setPaymentIntent, updateStatus } = usePaymentStore();
|
||||
const { selectedCurrency, setPaymentIntent, updateStatus } =
|
||||
usePaymentStore();
|
||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// Calculate total amount
|
||||
const baseFare = passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0);
|
||||
const baseFare = passengers.reduce(
|
||||
(sum) => sum + (selectedSchedule?.baseFareAdult || 0),
|
||||
0,
|
||||
);
|
||||
const totalAmount = baseFare;
|
||||
|
||||
const paymentMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
// Try to call the real API, fallback to mock if it fails
|
||||
try {
|
||||
return await apiClient.post('/payments/intent', data);
|
||||
return await apiClient.post("/payments/intent", data);
|
||||
} catch (error) {
|
||||
console.log('Payment API not available, using mock payment');
|
||||
console.log("Payment API not available, using mock payment");
|
||||
// Mock payment response
|
||||
return {
|
||||
paymentIntentId: `mock-payment-${Date.now()}`,
|
||||
status: 'PENDING',
|
||||
status: "PENDING",
|
||||
amountMinor: data.amountMinor,
|
||||
currency: data.currency,
|
||||
method: data.method,
|
||||
@@ -77,27 +87,30 @@ export default function PaymentPage() {
|
||||
},
|
||||
onSuccess: async (data: any) => {
|
||||
setPaymentIntent(data.paymentIntentId);
|
||||
updateStatus('PROCESSING');
|
||||
|
||||
updateStatus("PROCESSING");
|
||||
|
||||
// Simulate payment processing
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Generate tickets after successful payment
|
||||
try {
|
||||
await generateTickets();
|
||||
updateStatus('SUCCEEDED');
|
||||
router.push('/booking/confirmation');
|
||||
updateStatus("SUCCEEDED");
|
||||
router.push("/booking/confirmation");
|
||||
} catch (error) {
|
||||
console.error('Ticket generation failed:', error);
|
||||
console.error("Ticket generation failed:", error);
|
||||
// Still proceed to confirmation even if ticket generation fails
|
||||
updateStatus('SUCCEEDED');
|
||||
router.push('/booking/confirmation');
|
||||
updateStatus("SUCCEEDED");
|
||||
router.push("/booking/confirmation");
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Payment failed:', error);
|
||||
updateStatus('FAILED');
|
||||
const errorMessage = error?.response?.data?.message || error?.message || 'Payment failed. Please try again.';
|
||||
console.error("Payment failed:", error);
|
||||
updateStatus("FAILED");
|
||||
const errorMessage =
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
"Payment failed. Please try again.";
|
||||
alert(errorMessage);
|
||||
setIsProcessing(false);
|
||||
},
|
||||
@@ -106,19 +119,21 @@ export default function PaymentPage() {
|
||||
const generateTickets = async () => {
|
||||
// Try to generate tickets via API, fallback to mock
|
||||
try {
|
||||
await apiClient.post('/tickets/generate', {
|
||||
await apiClient.post("/tickets/generate", {
|
||||
bookingId,
|
||||
pnr,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('Ticket API not available, tickets will be generated on confirmation page');
|
||||
console.log(
|
||||
"Ticket API not available, tickets will be generated on confirmation page",
|
||||
);
|
||||
// Mock ticket generation - tickets will be displayed on confirmation page
|
||||
}
|
||||
};
|
||||
|
||||
const handlePayment = async () => {
|
||||
if (!selectedMethod || !bookingId) {
|
||||
alert('Please select a payment method');
|
||||
alert("Please select a payment method");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,12 +152,14 @@ export default function PaymentPage() {
|
||||
// Add a small delay to allow state to be set from previous page
|
||||
const timer = setTimeout(() => {
|
||||
if (!bookingId || !pnr) {
|
||||
console.log('Payment page: Missing booking data, redirecting to search');
|
||||
console.log('bookingId:', bookingId, 'pnr:', pnr);
|
||||
router.push('/booking/search');
|
||||
console.log(
|
||||
"Payment page: Missing booking data, redirecting to search",
|
||||
);
|
||||
console.log("bookingId:", bookingId, "pnr:", pnr);
|
||||
router.push("/booking/search");
|
||||
}
|
||||
}, 500);
|
||||
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [bookingId, pnr, router]);
|
||||
|
||||
@@ -161,9 +178,12 @@ export default function PaymentPage() {
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-100">Complete payment</h1>
|
||||
<h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
||||
Complete payment
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Booking reference: <span className="font-bold text-primary">{pnr}</span>
|
||||
Booking reference:{" "}
|
||||
<span className="font-bold text-primary">{pnr}</span>
|
||||
</p>
|
||||
|
||||
{/* Payment Processing Overlay */}
|
||||
@@ -173,15 +193,23 @@ export default function PaymentPage() {
|
||||
{paymentMutation.isSuccess ? (
|
||||
<>
|
||||
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Payment successful!</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">Generating your tickets...</p>
|
||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
||||
Payment successful!
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Generating your tickets...
|
||||
</p>
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin mx-auto" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="w-16 h-16 text-primary animate-spin mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Processing payment</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">Please wait while we process your payment...</p>
|
||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
||||
Processing payment
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Please wait while we process your payment...
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -190,29 +218,46 @@ export default function PaymentPage() {
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="card mb-6">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Order summary</h2>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||
Order summary
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Route</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule?.origin} → {selectedSchedule?.destination}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedSchedule?.origin} → {selectedSchedule?.destination}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Train</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule?.trainNumber}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedSchedule?.trainNumber}
|
||||
</span>
|
||||
</div>
|
||||
{selectedSchedule?.selectedSeatClassName && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Class</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
Class
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedSchedule.selectedSeatClassName.replace(/_/g, " ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Passengers</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{passengers.length} passenger{passengers.length !== 1 ? 's' : ''}</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
Passengers
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{passengers.length} passenger
|
||||
{passengers.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span className="text-gray-900 dark:text-gray-100">Total amount</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">
|
||||
Total amount
|
||||
</span>
|
||||
<span className="text-primary dark:text-gray-100">
|
||||
ETB {(totalAmount / 100).toFixed(2)}
|
||||
</span>
|
||||
@@ -223,7 +268,9 @@ export default function PaymentPage() {
|
||||
|
||||
{/* Payment Methods */}
|
||||
<div className="card mb-6">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Select payment method</h2>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||
Select payment method
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{paymentMethods.map((method) => {
|
||||
const Icon = method.icon;
|
||||
@@ -235,19 +282,29 @@ export default function PaymentPage() {
|
||||
disabled={isProcessing}
|
||||
className={`w-full p-4 rounded-lg border-2 transition-all text-left ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 dark:bg-primary/20 shadow-md'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary dark:hover:border-primary'
|
||||
} ${isProcessing ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
? "border-primary bg-primary/10 dark:bg-primary/20 shadow-md"
|
||||
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary dark:hover:border-primary"
|
||||
} ${isProcessing ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
|
||||
isSelected ? 'bg-primary' : 'bg-gray-100 dark:bg-gray-700'
|
||||
}`}>
|
||||
<Icon className={`w-6 h-6 ${isSelected ? 'text-white' : 'text-primary'}`} />
|
||||
<div
|
||||
className={`w-12 h-12 rounded-lg flex items-center justify-center ${
|
||||
isSelected
|
||||
? "bg-primary"
|
||||
: "bg-gray-100 dark:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`w-6 h-6 ${isSelected ? "text-white" : "text-primary"}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{method.name}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{method.description}</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{method.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{method.description}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="w-6 h-6 bg-primary rounded-full flex items-center justify-center">
|
||||
@@ -267,7 +324,9 @@ export default function PaymentPage() {
|
||||
onClick={handlePayment}
|
||||
disabled={!selectedMethod || isProcessing}
|
||||
className={`btn-primary w-full py-4 text-lg font-semibold ${
|
||||
!selectedMethod || isProcessing ? 'opacity-50 cursor-not-allowed' : ''
|
||||
!selectedMethod || isProcessing
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{isProcessing ? (
|
||||
@@ -279,7 +338,7 @@ export default function PaymentPage() {
|
||||
`Pay ETB ${(totalAmount / 100).toFixed(2)}`
|
||||
)}
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
disabled={isProcessing}
|
||||
@@ -288,12 +347,13 @@ export default function PaymentPage() {
|
||||
Back to review
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Error Message */}
|
||||
{paymentMutation.isError && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-4">
|
||||
<p className="text-red-800 dark:text-red-200 text-sm font-medium">
|
||||
⚠️ Payment failed. Please try again or contact support if the problem persists.
|
||||
⚠️ Payment failed. Please try again or contact support if the
|
||||
problem persists.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -301,7 +361,8 @@ export default function PaymentPage() {
|
||||
{/* Security Notice */}
|
||||
<div className="mt-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 text-center">
|
||||
🔒 Your payment is secure and encrypted. We do not store your payment information.
|
||||
🔒 Your payment is secure and encrypted. We do not store your
|
||||
payment information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { apiClient } from '@/lib/api-client';
|
||||
import { useBookingStore } from '@/lib/booking-store';
|
||||
import { Station } from '@/types';
|
||||
import {
|
||||
Train, MapPin, ArrowRight, ArrowLeftRight, Plus, Minus, Search,
|
||||
MapPin, ArrowRight, ArrowLeftRight, Plus, Minus, Search,
|
||||
Users, ChevronDown, Gift, Check, X, ChevronLeft, Clock, Zap,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
@@ -169,14 +169,18 @@ function StationModal({
|
||||
function PassengerModal({
|
||||
adultCount,
|
||||
childCount,
|
||||
nationality,
|
||||
onChangeAdult,
|
||||
onChangeChild,
|
||||
onChangeNationality,
|
||||
onClose,
|
||||
}: {
|
||||
adultCount: number;
|
||||
childCount: number;
|
||||
nationality: string;
|
||||
onChangeAdult: (n: number) => void;
|
||||
onChangeChild: (n: number) => void;
|
||||
onChangeNationality: (v: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const rows = [
|
||||
@@ -184,30 +188,31 @@ function PassengerModal({
|
||||
{ label: 'Children', sub: '< 5 years • First child free', val: childCount, min: 0, max: 9, onChange: onChangeChild },
|
||||
];
|
||||
|
||||
const natOptions = [
|
||||
{ value: 'ETHIOPIAN', label: '🇪🇹 Ethiopian' },
|
||||
{ value: 'DJIBOUTIAN', label: '🇩🇯 Djiboutian' },
|
||||
{ value: 'OTHER', label: '🌍 Other' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-[99] bg-black/40" onClick={onClose} />
|
||||
{/* Bottom sheet */}
|
||||
<div
|
||||
className="fixed inset-x-0 bottom-0 z-[100] bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl"
|
||||
className="fixed inset-x-0 bottom-0 sm:inset-auto sm:top-1/2 sm:left-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2 z-[100] bg-white dark:bg-gray-900 rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:w-96"
|
||||
style={{ animation: 'pax-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)' }}
|
||||
>
|
||||
{/* Handle */}
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<div className="w-10 h-1 rounded-full bg-gray-300 dark:bg-gray-600" />
|
||||
</div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">Passengers</h2>
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">Passengers & Nationality</h2>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
|
||||
<button type="button" onClick={onClose} className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Rows */}
|
||||
<div className="px-5 py-4 space-y-5">
|
||||
{rows.map(({ label, sub, val, min, max, onChange }, i) => (
|
||||
<div key={label}>
|
||||
@@ -218,35 +223,38 @@ function PassengerModal({
|
||||
<p className="text-xs text-gray-400 mt-0.5">{sub}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => val > min && onChange(val - 1)}
|
||||
disabled={val <= min}
|
||||
className="w-10 h-10 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center hover:border-primary hover:text-primary transition-colors disabled:opacity-30"
|
||||
>
|
||||
<button type="button" onClick={() => val > min && onChange(val - 1)} disabled={val <= min}
|
||||
className="w-10 h-10 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center hover:border-primary hover:text-primary disabled:opacity-30">
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="w-6 text-center text-lg font-bold text-gray-900 dark:text-white tabular-nums">{val}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => val < max && onChange(val + 1)}
|
||||
disabled={val >= max}
|
||||
className="w-10 h-10 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center hover:border-primary hover:text-primary transition-colors disabled:opacity-30"
|
||||
>
|
||||
<button type="button" onClick={() => val < max && onChange(val + 1)} disabled={val >= max}
|
||||
className="w-10 h-10 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center hover:border-primary hover:text-primary disabled:opacity-30">
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 -mx-5 pt-5 px-5">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Nationality</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{natOptions.map((opt) => (
|
||||
<button key={opt.value} type="button" onClick={() => onChangeNationality(opt.value)}
|
||||
className={`py-2.5 px-2 rounded-xl border-2 text-xs font-semibold transition-all ${
|
||||
nationality === opt.value
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
: 'border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300'
|
||||
}`}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Done */}
|
||||
<div className="px-5 pb-8 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-full py-3.5 bg-[rgb(20,113,76)] text-white font-bold text-sm rounded-xl"
|
||||
>
|
||||
<button type="button" onClick={onClose}
|
||||
className="w-full py-3.5 bg-[rgb(20,113,76)] text-white font-bold text-sm rounded-xl">
|
||||
Done — {adultCount + childCount} Passenger{adultCount + childCount !== 1 ? 's' : ''}
|
||||
</button>
|
||||
</div>
|
||||
@@ -265,6 +273,7 @@ function StationDropdown({
|
||||
onSelect,
|
||||
error,
|
||||
recentIds,
|
||||
onOpen,
|
||||
}: {
|
||||
stations: Station[];
|
||||
value: string;
|
||||
@@ -273,6 +282,7 @@ function StationDropdown({
|
||||
onSelect: (s: Station) => void;
|
||||
error?: string;
|
||||
recentIds: string[];
|
||||
onOpen?: () => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -315,7 +325,7 @@ function StationDropdown({
|
||||
ref={inputRef}
|
||||
value={displayValue}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
||||
onFocus={() => { setQuery(''); setOpen(true); }}
|
||||
onFocus={() => { setQuery(''); setOpen(true); onOpen?.(); }}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-10 pr-8 py-3.5 bg-transparent rounded-xl focus:outline-none text-sm text-gray-900 dark:text-white placeholder-gray-400"
|
||||
/>
|
||||
@@ -331,7 +341,7 @@ function StationDropdown({
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-50 max-h-56 overflow-y-auto overflow-x-hidden scrollbar-hide">
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-[200] max-h-56 overflow-y-auto overflow-x-hidden scrollbar-hide">
|
||||
{!query && recentIds.length > 0 && (
|
||||
<div className="px-3 pt-2 pb-1">
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide mb-1">Recent</p>
|
||||
@@ -383,7 +393,6 @@ export default function SearchPage() {
|
||||
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
||||
const { user, isAuthenticated } = useAuthStore();
|
||||
|
||||
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
|
||||
const [passengerModalOpen, setPassengerModalOpen] = useState(false);
|
||||
const [promoVisible, setPromoVisible] = useState(false);
|
||||
const [promoCode, setPromoCode] = useState('');
|
||||
@@ -395,13 +404,23 @@ export default function SearchPage() {
|
||||
try { return JSON.parse(localStorage.getItem('edr_recent_stations') || '[]'); } catch { return []; }
|
||||
});
|
||||
const passengerRef = useRef<HTMLDivElement>(null);
|
||||
const widgetRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollWidgetIntoView = () => {
|
||||
const el = widgetRef.current;
|
||||
if (!el) return;
|
||||
const headerHeight = 64;
|
||||
const marginTop = 24;
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - headerHeight - marginTop;
|
||||
window.scrollTo({ top, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const { data: stations = [], isLoading, error } = useQuery<Station[]>({
|
||||
queryKey: ['stations'],
|
||||
queryFn: async () => await apiClient.get('/stations') as Station[],
|
||||
});
|
||||
|
||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
|
||||
const { handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
|
||||
resolver: zodResolver(searchSchema as any),
|
||||
defaultValues: {
|
||||
adultCount: 1,
|
||||
@@ -437,7 +456,7 @@ export default function SearchPage() {
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (passengerRef.current && !passengerRef.current.contains(e.target as Node)) {
|
||||
setIsPassengerOpen(false);
|
||||
setPassengerModalOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
@@ -517,14 +536,16 @@ export default function SearchPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="bg-gray-50 dark:bg-gray-950">
|
||||
{/* Passenger modal (mobile) */}
|
||||
{passengerModalOpen && (
|
||||
<PassengerModal
|
||||
adultCount={adultCount || 1}
|
||||
childCount={childCount || 0}
|
||||
nationality={watch('nationality')}
|
||||
onChangeAdult={(n) => setValue('adultCount', n)}
|
||||
onChangeChild={(n) => setValue('childCount', n)}
|
||||
onChangeNationality={(v) => setValue('nationality', v as any)}
|
||||
onClose={() => setPassengerModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -557,299 +578,219 @@ export default function SearchPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hero Banner */}
|
||||
<div className="relative bg-gradient-to-br from-[rgb(14,80,54)] via-[rgb(20,113,76)] to-[rgb(16,140,90)] overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-4 right-8 w-32 h-32 border-2 border-white rounded-full" />
|
||||
<div className="absolute top-12 right-20 w-20 h-20 border border-white rounded-full" />
|
||||
<div className="absolute -bottom-6 left-10 w-40 h-40 border border-white rounded-full" />
|
||||
{/* ── 90vh hero with banner image ── */}
|
||||
<section
|
||||
className="relative"
|
||||
style={{ height: '90vh', minHeight: '560px' }}
|
||||
>
|
||||
{/* Background image */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: 'url(/banner.jpg)' }}
|
||||
/>
|
||||
{/* Gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-black/70 via-black/40 to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
|
||||
|
||||
{/* Hero headline — top area */}
|
||||
<div className="relative z-10 pt-16 md:pt-20 px-6 md:px-12 max-w-6xl mx-auto">
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white leading-tight drop-shadow-lg max-w-2xl">
|
||||
Where are you<br className="hidden sm:block" /> headed today?
|
||||
</h1>
|
||||
<p className="text-white/60 text-sm md:text-base mt-3">Book your train journey across East Africa</p>
|
||||
</div>
|
||||
<div className="container mx-auto px-4 pt-8 pb-20 md:pt-10 md:pb-24 relative z-10">
|
||||
|
||||
{/* ── Widget — absolutely positioned at bottom with margin ── */}
|
||||
<div className="absolute bottom-8 left-0 right-0 z-[50] px-4 md:px-6" ref={widgetRef}>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white leading-tight">
|
||||
Where are you headed?
|
||||
</h1>
|
||||
<p className="text-white/70 text-sm md:text-base mt-2">Search and book train tickets fast & easy</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-white/20 overflow-visible">
|
||||
|
||||
{/* Search Card — pulled up over the hero */}
|
||||
<div className="container mx-auto px-4 -mt-14 relative z-20 pb-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-gray-100 dark:border-gray-800 overflow-visible">
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-5 py-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm border-b border-red-100 dark:border-red-900/30 rounded-t-2xl">
|
||||
<span>⚠️</span>
|
||||
<span>Unable to load stations. Please check your connection.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-5 md:p-6 space-y-3">
|
||||
|
||||
{/* ── Row 1 (mobile stacked): Stations + Date ── */}
|
||||
|
||||
{/* Mobile station fields */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">From</label>
|
||||
<button type="button" onClick={() => setStationModal('origin')} className="w-full">
|
||||
<div className={`flex items-center gap-2.5 px-3.5 py-3.5 border-2 rounded-xl transition-all ${
|
||||
errors.originStationId ? 'border-red-400' : originId ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-700'
|
||||
}`}>
|
||||
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
<span className={`text-sm ${originStation ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-400'}`}>
|
||||
{originStation?.name ?? 'Select departure'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{errors.originStationId && <p className="text-xs text-red-500">{errors.originStationId.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">To</label>
|
||||
<button type="button" onClick={handleSwap} disabled={!originId || !destId} className="flex items-center gap-1 text-xs text-primary font-medium disabled:opacity-30">
|
||||
<ArrowLeftRight className={`w-3.5 h-3.5 transition-transform duration-300 ${swapping ? 'rotate-180' : ''}`} />
|
||||
Swap
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={() => setStationModal('destination')} className="w-full">
|
||||
<div className={`flex items-center gap-2.5 px-3.5 py-3.5 border-2 rounded-xl transition-all ${
|
||||
errors.destinationStationId ? 'border-red-400' : destId ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-700'
|
||||
}`}>
|
||||
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
<span className={`text-sm ${destStation ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-400'}`}>
|
||||
{destStation?.name ?? 'Select destination'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{errors.destinationStationId && <p className="text-xs text-red-500">{errors.destinationStationId.message}</p>}
|
||||
</div>
|
||||
{/* Date (mobile) */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Departure Date</label>
|
||||
<div className="relative z-30">
|
||||
<ModernDatePicker
|
||||
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
|
||||
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
|
||||
minDate={new Date()}
|
||||
placeholder="Select date"
|
||||
/>
|
||||
</div>
|
||||
{errors.departureDate && <p className="text-xs text-red-500">{errors.departureDate.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Row 1: From [swap] To + Date (3 equal cols) */}
|
||||
<div className="hidden md:grid md:grid-cols-3 gap-3 items-end">
|
||||
{/* From + To with swap */}
|
||||
<div className="col-span-2 flex items-end gap-2">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">From</label>
|
||||
<StationDropdown stations={stations} value={originId} excludeId={destId} placeholder="Select departure" recentIds={recentStationIds} onSelect={(s) => { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} />
|
||||
{errors.originStationId && <p className="text-xs text-red-500">{errors.originStationId.message}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSwap}
|
||||
disabled={!originId || !destId}
|
||||
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all duration-200 disabled:opacity-30 ${swapping ? 'rotate-180' : ''}`}
|
||||
title="Swap stations"
|
||||
>
|
||||
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">To</label>
|
||||
<StationDropdown stations={stations} value={destId} excludeId={originId} placeholder="Select destination" recentIds={recentStationIds} onSelect={(s) => { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} />
|
||||
{errors.destinationStationId && <p className="text-xs text-red-500">{errors.destinationStationId.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Date */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Departure Date</label>
|
||||
<div className="relative z-30">
|
||||
<ModernDatePicker
|
||||
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
|
||||
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
|
||||
minDate={new Date()}
|
||||
placeholder="Select date"
|
||||
/>
|
||||
</div>
|
||||
{errors.departureDate && <p className="text-xs text-red-500">{errors.departureDate.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route preview pill */}
|
||||
{originStation && destStation && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-primary/5 dark:bg-primary/10 rounded-lg text-xs text-primary font-medium animate-bounce-in">
|
||||
<Train className="w-3.5 h-3.5" />
|
||||
<span>{originStation.name}</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
<span>{destStation.name}</span>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-5 py-3 bg-red-50 text-red-600 text-sm border-b border-red-100 rounded-t-2xl">
|
||||
<span>⚠️</span>
|
||||
<span>Unable to load stations. Please check your connection.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Row 2: Passengers | Nationality | Search Button ── */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 items-end">
|
||||
<div className="p-4 md:p-5">
|
||||
|
||||
{/* Passengers */}
|
||||
<div className="space-y-1.5" ref={passengerRef}>
|
||||
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Passengers</label>
|
||||
|
||||
{/* Mobile: opens bottom-sheet modal */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPassengerModalOpen(true)}
|
||||
className="sm:hidden w-full flex items-center justify-between px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{/* Mobile: stacked */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
|
||||
<button type="button" onClick={() => { window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior }); setStationModal('origin'); }} className="w-full">
|
||||
<div className={`flex items-center gap-2.5 px-3.5 py-3 border-2 rounded-xl transition-all ${
|
||||
errors.originStationId ? 'border-red-400' : originId ? 'border-primary bg-primary/5' : 'border-gray-200'
|
||||
}`}>
|
||||
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
<span className={`text-sm ${originStation ? 'font-semibold text-gray-900' : 'text-gray-400'}`}>
|
||||
{originStation?.name ?? 'Select departure'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
|
||||
<button type="button" onClick={handleSwap} disabled={!originId || !destId}
|
||||
className="flex items-center gap-1 text-xs text-primary font-medium disabled:opacity-30">
|
||||
<ArrowLeftRight className={`w-3.5 h-3.5 transition-transform duration-300 ${swapping ? 'rotate-180' : ''}`} />
|
||||
Swap
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={() => { window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior }); setStationModal('destination'); }} className="w-full">
|
||||
<div className={`flex items-center gap-2.5 px-3.5 py-3 border-2 rounded-xl transition-all ${
|
||||
errors.destinationStationId ? 'border-red-400' : destId ? 'border-primary bg-primary/5' : 'border-gray-200'
|
||||
}`}>
|
||||
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
<span className={`text-sm ${destStation ? 'font-semibold text-gray-900' : 'text-gray-400'}`}>
|
||||
{destStation?.name ?? 'Select destination'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Date</label>
|
||||
<div className="relative z-30">
|
||||
<ModernDatePicker
|
||||
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
|
||||
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
|
||||
minDate={new Date()} placeholder="Select date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Pax + Nationality combined trigger */}
|
||||
<button type="button" onClick={() => setPassengerModalOpen(true)}
|
||||
className="w-full flex items-center justify-between px-3.5 py-3 border-2 border-gray-200 rounded-xl bg-white">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
{totalPassengers} Passenger{totalPassengers !== 1 ? 's' : ''}
|
||||
{childCount > 0 && <span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded-md">{childCount} child</span>}
|
||||
{totalPassengers} Pax · {watch('nationality') === 'ETHIOPIAN' ? '🇪🇹' : watch('nationality') === 'DJIBOUTIAN' ? '🇩🇯' : '🌍'}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-primary" />
|
||||
</button>
|
||||
|
||||
{/* sm+: inline dropdown */}
|
||||
<div className="hidden sm:block relative z-20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPassengerOpen(!isPassengerOpen)}
|
||||
className={`w-full flex items-center justify-between px-3.5 py-3.5 border-2 rounded-xl transition-all duration-200 bg-white dark:bg-gray-800 ${
|
||||
isPassengerOpen ? 'border-primary ring-2 ring-primary/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
{totalPassengers} Pax
|
||||
{childCount > 0 && <span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded-md">{childCount} child</span>}
|
||||
</span>
|
||||
<ChevronDown className={`w-4 h-4 text-primary transition-transform duration-200 ${isPassengerOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{isPassengerOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-50 p-4 space-y-4">
|
||||
{[
|
||||
{ label: 'Adults', sub: '≥ 5 years', key: 'adultCount' as const, val: adultCount || 1, min: 1, max: 9 },
|
||||
{ label: 'Children', sub: '< 5 years • First free', key: 'childCount' as const, val: childCount || 0, min: 0, max: 9 },
|
||||
].map(({ label, sub, key, val, min, max }, i) => (
|
||||
<div key={key}>
|
||||
{i > 0 && <div className="border-t border-gray-100 dark:border-gray-700 -mx-4 mb-4" />}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{sub}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={() => val > min && setValue(key, val - 1)} disabled={val <= min} className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-600 flex items-center justify-center hover:border-primary hover:text-primary transition-colors disabled:opacity-30"><Minus className="w-3.5 h-3.5" /></button>
|
||||
<span className="w-6 text-center font-bold text-gray-900 dark:text-white tabular-nums">{val}</span>
|
||||
<button type="button" onClick={() => val < max && setValue(key, val + 1)} disabled={val >= max} className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-600 flex items-center justify-center hover:border-primary hover:text-primary transition-colors disabled:opacity-30"><Plus className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setIsPassengerOpen(false)} className="w-full py-2.5 bg-primary text-white text-sm font-semibold rounded-lg">Done</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nationality */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Nationality</label>
|
||||
<select
|
||||
{...register('nationality')}
|
||||
className="w-full px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white transition-all"
|
||||
>
|
||||
<option value="ETHIOPIAN">🇪🇹 Ethiopian</option>
|
||||
<option value="DJIBOUTIAN">🇩🇯 Djiboutian</option>
|
||||
<option value="OTHER">🌍 Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Search Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center justify-center gap-2.5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Promo Code (collapsed by default) ── */}
|
||||
<div>
|
||||
{!promoVisible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPromoVisible(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-primary font-medium hover:underline transition-colors"
|
||||
>
|
||||
<Gift className="w-3.5 h-3.5" />
|
||||
Apply Promo Code
|
||||
<button type="submit" disabled={isLoading}
|
||||
className="w-full flex items-center justify-center gap-2 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg disabled:opacity-50">
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5 animate-bounce-in">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
|
||||
<input
|
||||
type="text"
|
||||
value={promoCode}
|
||||
onChange={(e) => { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }}
|
||||
placeholder="Enter promo code"
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())}
|
||||
className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 transition-all"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleValidatePromo}
|
||||
disabled={!promoCode || promoLoading}
|
||||
className="px-4 py-2.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-40 text-sm font-semibold"
|
||||
>
|
||||
{promoLoading ? '...' : 'Apply'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setPromoVisible(false); setPromoCode(''); setPromoValidation(null); }}
|
||||
className="p-2.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{promoValidation && (
|
||||
<div className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{promoValidation.valid && <Check className="w-3.5 h-3.5" />}
|
||||
{promoValidation.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: single row — From [swap] To | Date | Pax+Nat | Search */}
|
||||
<div className="hidden md:flex items-end gap-2">
|
||||
{/* From */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
|
||||
<StationDropdown stations={stations} value={originId} excludeId={destId} placeholder="Departure station"
|
||||
recentIds={recentStationIds} onSelect={(s) => { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} />
|
||||
{errors.originStationId && <p className="text-xs text-red-500">{errors.originStationId.message}</p>}
|
||||
</div>
|
||||
)}
|
||||
{/* Swap */}
|
||||
<button type="button" onClick={handleSwap} disabled={!originId || !destId}
|
||||
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? 'rotate-180' : ''}`}>
|
||||
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
{/* To */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
|
||||
<StationDropdown stations={stations} value={destId} excludeId={originId} placeholder="Destination station"
|
||||
recentIds={recentStationIds} onSelect={(s) => { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} />
|
||||
{errors.destinationStationId && <p className="text-xs text-red-500">{errors.destinationStationId.message}</p>}
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
|
||||
{/* Date */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Date</label>
|
||||
<div className="relative z-30">
|
||||
<ModernDatePicker
|
||||
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
|
||||
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
|
||||
minDate={new Date()} placeholder="Select date"
|
||||
/>
|
||||
</div>
|
||||
{errors.departureDate && <p className="text-xs text-red-500">{errors.departureDate.message}</p>}
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
|
||||
{/* Pax + Nationality combined — opens shared modal */}
|
||||
<div className="w-44 flex-shrink-0 space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Passengers</label>
|
||||
<button type="button" onClick={() => setPassengerModalOpen(true)}
|
||||
className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 rounded-xl bg-white hover:border-gray-300 transition-all">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 truncate">
|
||||
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
{totalPassengers} Pax · {watch('nationality') === 'ETHIOPIAN' ? '🇪🇹' : watch('nationality') === 'DJIBOUTIAN' ? '🇩🇯' : '🌍'}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Search */}
|
||||
<button type="submit" disabled={isLoading}
|
||||
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50">
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Promo */}
|
||||
<div className="mt-3">
|
||||
{!promoVisible ? (
|
||||
<button type="button" onClick={() => setPromoVisible(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-primary font-medium hover:underline">
|
||||
<Gift className="w-3.5 h-3.5" />
|
||||
Apply Promo Code
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
|
||||
<input type="text" value={promoCode}
|
||||
onChange={(e) => { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }}
|
||||
placeholder="Enter promo code"
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())}
|
||||
className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white placeholder-gray-400"
|
||||
autoFocus />
|
||||
</div>
|
||||
<button type="button" onClick={handleValidatePromo} disabled={!promoCode || promoLoading}
|
||||
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 disabled:opacity-40 text-sm font-semibold">
|
||||
{promoLoading ? '...' : 'Apply'}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setPromoVisible(false); setPromoCode(''); setPromoValidation(null); }}
|
||||
className="p-2.5 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{promoValidation && (
|
||||
<div className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{promoValidation.valid && <Check className="w-3.5 h-3.5" />}
|
||||
{promoValidation.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Popular Routes ── */}
|
||||
<div className="mt-8">
|
||||
{/* Popular Routes — below hero */}
|
||||
<div className="bg-gray-50 dark:bg-gray-950 py-10">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Zap className="w-4 h-4 text-primary" />
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">Popular Routes</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{POPULAR_ROUTES.map((route, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => handlePopularRoute(route.from, route.to)}
|
||||
className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4 hover:border-primary hover:shadow-md transition-all text-left group active:scale-95"
|
||||
>
|
||||
<button key={idx} type="button" onClick={() => handlePopularRoute(route.from, route.to)}
|
||||
className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4 hover:border-primary hover:shadow-md transition-all text-left active:scale-95">
|
||||
<div className="text-xl mb-2">{route.icon}</div>
|
||||
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<span>{route.from}</span>
|
||||
@@ -867,14 +808,92 @@ export default function SearchPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Promotions Section */}
|
||||
<div className="bg-white dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<span className="text-lg">🎁</span>
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">Offers & Promotions</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
|
||||
{/* Wide promo card */}
|
||||
<div className="md:col-span-2 relative rounded-2xl overflow-hidden min-h-[220px] group cursor-pointer">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)]" />
|
||||
<div className="absolute inset-0 opacity-10" style={{
|
||||
backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 20px, rgba(255,255,255,0.3) 20px, rgba(255,255,255,0.3) 21px)'
|
||||
}} />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/30 to-transparent" />
|
||||
<div className="relative z-10 p-7 flex flex-col justify-between h-full min-h-[220px]">
|
||||
<div>
|
||||
<span className="inline-block px-3 py-1 bg-white/20 text-white text-xs font-semibold rounded-full mb-3 backdrop-blur-sm">
|
||||
Limited Time
|
||||
</span>
|
||||
<h3 className="text-2xl font-extrabold text-white leading-tight mb-2">
|
||||
20% Off Weekend<br />Travel
|
||||
</h3>
|
||||
<p className="text-white/70 text-sm max-w-xs">
|
||||
Book any weekend journey and save 20%. Valid for all seat classes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<span className="text-white/60 text-xs">Valid until 31 Dec 2024</span>
|
||||
<span className="flex items-center gap-1.5 text-white text-sm font-semibold group-hover:gap-3 transition-all">
|
||||
Book now <ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Narrow promo cards */}
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="relative rounded-2xl overflow-hidden min-h-[100px] group cursor-pointer">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-amber-500 to-orange-600" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/20 to-transparent" />
|
||||
<div className="relative z-10 p-5 flex flex-col justify-between h-full min-h-[100px]">
|
||||
<div>
|
||||
<span className="inline-block px-2.5 py-0.5 bg-white/25 text-white text-xs font-semibold rounded-full mb-2 backdrop-blur-sm">New</span>
|
||||
<h3 className="text-lg font-bold text-white leading-tight">Family Package</h3>
|
||||
<p className="text-white/75 text-xs mt-1">4 tickets for the price of 3</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end mt-3">
|
||||
<span className="flex items-center gap-1 text-white text-xs font-semibold group-hover:gap-2 transition-all">
|
||||
Learn more <ArrowRight className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative rounded-2xl overflow-hidden min-h-[100px] group cursor-pointer">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-600 to-indigo-700" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/20 to-transparent" />
|
||||
<div className="relative z-10 p-5 flex flex-col justify-between h-full min-h-[100px]">
|
||||
<div>
|
||||
<span className="inline-block px-2.5 py-0.5 bg-white/25 text-white text-xs font-semibold rounded-full mb-2 backdrop-blur-sm">Student</span>
|
||||
<h3 className="text-lg font-bold text-white leading-tight">Student Discount</h3>
|
||||
<p className="text-white/75 text-xs mt-1">15% off with valid student ID</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end mt-3">
|
||||
<span className="flex items-center gap-1 text-white text-xs font-semibold group-hover:gap-2 transition-all">
|
||||
Learn more <ArrowRight className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
.animate-slide-up { animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1); }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,438 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getTranslation, Language, useLanguage } from '@/lib/i18n';
|
||||
import Link from 'next/link';
|
||||
import { SearchWidget } from '@/components/SearchWidget';
|
||||
import { Zap, Heart, Shield, Clock, ArrowRight, Train, MapPin, Calendar } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { Station } from '@/types';
|
||||
|
||||
const styles = `
|
||||
.hero-section {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent);
|
||||
padding: 80px 20px;
|
||||
text-align: center;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.dark .hero-section {
|
||||
background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.hero-heading {
|
||||
font-size: clamp(1rem, 3vw, 2.75rem);
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
color: #ffffff;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dark .hero-heading {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hero-subheading {
|
||||
font-size: clamp(1rem, 3vw, 1.5rem);
|
||||
color: #4b5563;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.dark .hero-subheading {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
padding: 32px 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
.dark .stats-grid {
|
||||
border-top-color: #374151;
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: rgb(20, 113, 76);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.dark .stat-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.features-section {
|
||||
padding: 80px 20px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.dark .features-section {
|
||||
background-color: #111827;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 40px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.dark .section-title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background-color: white;
|
||||
border: 2px solid #f3f4f6;
|
||||
border-radius: 18px;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dark .feature-card {
|
||||
background-color: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: rgb(20, 113, 76);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.feature-icon-bg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-color: rgb(20, 113, 76);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.dark .feature-title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.dark .feature-desc {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.cta-section {
|
||||
padding: 80px 20px;
|
||||
background: #f3f4f6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dark .cta-section {
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.cta-content {
|
||||
max-width: 42rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.dark .cta-title {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cta-text {
|
||||
font-size: 1.125rem;
|
||||
color: #4b5563;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.dark .cta-text {
|
||||
color: #e0e7ff;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 32px;
|
||||
background-color: white;
|
||||
color: rgb(20 113 76 / var(--tw-bg-opacity, 1));
|
||||
font-weight: 700;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background-color: #f0f9ff;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.bounce {
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
.bounce:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.bounce:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.search-widget-transparent {
|
||||
background-color: rgba(255, 255, 255, 0.95) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
border-color: rgba(255, 255, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
.dark .search-widget-transparent {
|
||||
background-color: rgba(31, 41, 55, 0.95) !important;
|
||||
border-color: rgba(55, 65, 81, 0.2) !important;
|
||||
}
|
||||
`;
|
||||
import { Suspense } from 'react';
|
||||
import SearchPage from '@/app/booking/search/page';
|
||||
|
||||
export default function Home() {
|
||||
const [lang, setLang] = useState<Language>('en');
|
||||
const { getLang } = useLanguage();
|
||||
const t = (key: string) => getTranslation(lang, key);
|
||||
|
||||
useEffect(() => {
|
||||
setLang(getLang());
|
||||
const handleLanguageChange = (e: any) => setLang(e.detail);
|
||||
window.addEventListener('languageChange', handleLanguageChange);
|
||||
return () => window.removeEventListener('languageChange', handleLanguageChange);
|
||||
}, [getLang]);
|
||||
|
||||
const { data: stations } = useQuery<Station[]>({
|
||||
queryKey: ['stations'],
|
||||
queryFn: async () => await apiClient.get('/stations') as Station[],
|
||||
});
|
||||
|
||||
const getStationByName = (name: string) => {
|
||||
if (!stations) return null;
|
||||
const exactMatch = stations.find(s => s.name.toLowerCase() === name.toLowerCase());
|
||||
if (exactMatch) return exactMatch;
|
||||
return stations.find(s => s.name.toLowerCase().includes(name.toLowerCase()));
|
||||
};
|
||||
|
||||
const handlePopularRoute = (fromName: string, toName: string) => {
|
||||
const origin = getStationByName(fromName);
|
||||
const destination = getStationByName(toName);
|
||||
|
||||
if (origin && destination) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const popularRoutes = [
|
||||
{ from: 'Sebeta', to: 'Nagad', duration: '12h' },
|
||||
{ from: 'Sebeta', to: 'Diredawa', duration: '8h' },
|
||||
{ from: 'Diredawa', to: 'Nagad', duration: '4h' },
|
||||
];
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Heart,
|
||||
title: t('home.comfortable'),
|
||||
desc: t('home.comfortDesc'),
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: t('home.affordable'),
|
||||
desc: t('home.affordableDesc'),
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: t('home.safe'),
|
||||
desc: t('home.safeDesc'),
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: t('home.fast'),
|
||||
desc: t('home.fastDesc'),
|
||||
},
|
||||
];
|
||||
|
||||
const highlights = [
|
||||
{
|
||||
icon: Train,
|
||||
title: 'Modern fleet',
|
||||
desc: 'Comfortable trains with modern amenities',
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
title: '21 stations',
|
||||
desc: 'Connecting Ethiopia and Djibouti',
|
||||
},
|
||||
{
|
||||
icon: Calendar,
|
||||
title: 'Easy booking',
|
||||
desc: 'Book tickets in just a few clicks',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{styles}</style>
|
||||
<main>
|
||||
{/* Hero Section */}
|
||||
<section className="hero-section">
|
||||
<div className="hero-content">
|
||||
<h1 className="hero-heading">{t('home.hero')}</h1>
|
||||
<p className="hero-subheading">{t('home.heroSub')}</p>
|
||||
|
||||
<SearchWidget />
|
||||
|
||||
{/* Highlights */}
|
||||
<div className="grid md:grid-cols-3 gap-6 mt-12 max-w-6xl mx-auto">
|
||||
{highlights.map((highlight, idx) => {
|
||||
const Icon = highlight.icon;
|
||||
return (
|
||||
<div key={idx} className="feature-card">
|
||||
<div className="feature-icon-bg">
|
||||
<Icon size={24} color="white" />
|
||||
</div>
|
||||
<h3 className="feature-title">{highlight.title}</h3>
|
||||
<p className="feature-desc">{highlight.desc}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Popular Routes Section */}
|
||||
<section className="py-16 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<h2 className="section-title">Popular Routes</h2>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
{popularRoutes.map((route, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => handlePopularRoute(route.from, route.to)}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-5 hover:border-primary hover:shadow-md transition-all text-left group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-gray-900 dark:text-gray-100 mb-1">{route.from}</div>
|
||||
<ArrowRight className="w-4 h-4 text-primary my-2" />
|
||||
<div className="font-semibold text-gray-900 dark:text-gray-100">{route.to}</div>
|
||||
</div>
|
||||
<Train className="w-5 h-5 text-primary opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{route.duration} journey</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="features-section">
|
||||
<h2 className="section-title">{t('home.features')}</h2>
|
||||
<div className="features-grid">
|
||||
{features.map((feature, idx) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<div key={idx} className="feature-card">
|
||||
<div className="feature-icon-bg">
|
||||
<Icon size={24} color="white" />
|
||||
</div>
|
||||
<h3 className="feature-title">{feature.title}</h3>
|
||||
<p className="feature-desc">{feature.desc}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="cta-section">
|
||||
<div className="cta-content">
|
||||
<h2 className="cta-title">Ready to start your journey?</h2>
|
||||
<p className="cta-text">Book your train tickets in just a few minutes and enjoy a comfortable ride.</p>
|
||||
<Link href="/booking/search" className="cta-button">
|
||||
{t('home.cta')}
|
||||
<ArrowRight size={20} />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
<Suspense>
|
||||
<SearchPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user