Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-07-05 00:32:03 +03:00
243 changed files with 16021 additions and 4390 deletions

View File

@@ -1,4 +1,4 @@
'use client';
'use client';
import { useSearchParams, useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
@@ -147,10 +147,16 @@ export default function ResultsPage() {
// For one-way, check if outbound has results
// For round-trip, check if BOTH outbound and inbound have results
const hasResults = isRoundTrip
const hasResults = isRoundTrip
? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
: outboundSchedules.length > 0;
// One-way searches that come back with an empty outbound list may still include
// date-shifted alternatives from the API — surface those instead of a dead end.
const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0;
const alternativeOutbound: Schedule[] = isOneWayNoOutbound ? (results.alternativeOutbound || []) : [];
const requestedDate: string = (results && results.requestedDate) || searchData.date;
const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => {
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } }));
};
@@ -219,7 +225,190 @@ export default function ResultsPage() {
router.push('/booking/auth-check');
};
const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false) => {
// Shared "Choose Your Coach" drawer — used by both the normal results view and the
// Alternative Travel Options fallback, so selecting an alternative opens the exact
// same coach-type picker as a normal schedule.
const renderClassModal = () => {
if (!classModal) return null;
const scheduleId = classModal.scheduleId || classModal.id || '';
const selectedCoachType = selectedCoachTypes[scheduleId];
const isOutbound = (classModal as any).isOutbound;
const coachTypes = classModal.coachTypes || [];
const getCoachIcon = (typeName: string) => {
const lower = typeName.toLowerCase();
if (lower.includes('soft') || lower.includes('vip')) return Star;
if (lower.includes('bed')) return Bed;
return Armchair;
};
return (
<>
<div className="fixed inset-0 z-[99] bg-black/50 backdrop-blur-sm" onClick={() => setClassModal(null)} />
<div className="fixed inset-y-0 right-0 z-[100] w-full sm:w-[680px] lg:w-[95vw] xl:w-[90vw] 2xl:w-[85vw] max-w-[1400px] bg-white dark:bg-gray-900 shadow-2xl flex flex-col"
style={{ animation: 'drawer-slide-in 0.3s cubic-bezier(0.22,1,0.36,1)' }}
>
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
<div>
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Choose Your Coach</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-1.5">
<Train className="w-3.5 h-3.5" />
<span className="font-medium">{classModal.trainNumber}</span>
<span className="text-gray-400">·</span>
<span>{classModal.origin?.name} {classModal.destination?.name}</span>
</p>
</div>
<button
type="button"
onClick={() => setClassModal(null)}
className="w-10 h-10 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all"
aria-label="Close"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
{coachTypes.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2">
{coachTypes.map((coachType: any, index: number) => {
const isSelected = selectedCoachType?.id === coachType.coachTypeId;
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
const coachCurrency = 'ETB';
const CoachIcon = getCoachIcon(coachType.coachTypeName);
return (
<button
key={coachType.coachId}
onClick={() => handleSelectCoachType(scheduleId, coachType.coachTypeId, coachType.coachTypeCode, coachType.coachTypeName, coachType.classes?.[0]?.name || coachType.coachTypeName)}
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
isSelected
? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02]'
: 'border-gray-200 dark:border-gray-700 hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50'
}`}
style={{ animation: `fade-in-up 0.3s ease-out ${index * 0.1}s both` }}
>
{isSelected && (
<div className="absolute top-4 right-4 w-7 h-7 bg-primary rounded-full flex items-center justify-center shadow-lg animate-scale-in">
<Check className="w-4 h-4 text-white" strokeWidth={3} />
</div>
)}
<div className="flex flex-col">
<div className="flex items-start gap-4 pr-2">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 transition-all ${
isSelected
? 'bg-primary/15 dark:bg-primary/25 shadow-inner'
: 'bg-gray-100 dark:bg-gray-700 group-hover:bg-primary/10'
}`}>
<CoachIcon className={`w-6 h-6 transition-colors ${
isSelected ? 'text-primary' : 'text-gray-600 dark:text-gray-400 group-hover:text-primary'
}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-2">
<div>
<h3 className="font-bold text-base text-gray-900 dark:text-white leading-tight">
{coachType.coachTypeName}
</h3>
</div>
</div>
<div className="mt-3">
<div className="flex items-baseline gap-1">
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">From</span>
<span className={`text-2xl font-bold tracking-tight ${
isSelected ? 'text-primary' : 'text-gray-900 dark:text-white'
}`}>
{(minPrice / 100).toFixed(2)}
</span>
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">{coachCurrency}</span>
</div>
</div>
</div>
</div>
{coachType.classes.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-200/60 dark:border-gray-700/60">
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-bold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Class Options
</p>
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
{coachType.classes.length} available
</span>
</div>
<div className="space-y-2.5">
{coachType.classes.map((cls: any, idx: number) => (
<div
key={idx}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-50/80 dark:bg-gray-800/40 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 transition-colors"
>
<div className="flex items-center gap-2.5">
<CoachIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{cls.name}
</span>
</div>
<div className="flex items-baseline gap-1">
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
{(cls.baseFareMinor / 100).toFixed(2)}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
{coachCurrency}
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</button>
);
})}
</div>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4">
<Train className="w-8 h-8 text-gray-400" />
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">No coach types available for this journey</p>
</div>
)}
</div>
<div className="px-6 py-5 border-t border-gray-100 dark:border-gray-800 flex-shrink-0 bg-gray-50/50 dark:bg-gray-800/30 flex justify-center">
<div className="w-full max-w-md">
<button
onClick={() => { if (selectedCoachType) { handleSelect(classModal, isOutbound); } }}
disabled={!selectedCoachType}
className="w-full flex items-center justify-center gap-2.5 px-6 py-3.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary/30 disabled:shadow-none hover:shadow-xl hover:scale-[1.02] active:scale-[0.98]"
>
<span>{isRoundTrip && isOutbound ? 'Continue to Return Journey' : 'Continue to Passenger Details'}</span>
<ArrowRight className="w-4 h-4" />
</button>
{!selectedCoachType && (
<p className="text-center text-xs text-gray-500 dark:text-gray-400 mt-3 flex items-center justify-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-pulse" />
Select a coach type to continue
</p>
)}
</div>
</div>
</div>
<style>{`
@keyframes drawer-slide-in{from{transform:translateX(100%)}to{transform:translateX(0)}}
@keyframes fade-in-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
@keyframes scale-in{from{transform:scale(0)}to{transform:scale(1)}}
`}</style>
</>
);
};
const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false, isAlternative: boolean = false) => {
const scheduleId = schedule.scheduleId || schedule.id || '';
const selectedCoachType = selectedCoachTypes[scheduleId];
@@ -244,7 +433,22 @@ export default function ResultsPage() {
const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString();
return (
<div key={scheduleId} className="card">
<div key={scheduleId} className={`card ${isAlternative ? 'border-2 border-amber-200 dark:border-amber-800/60' : ''}`}>
{isAlternative && (
<div className="flex flex-wrap items-center gap-2 mb-4">
<span className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">
<Calendar className="w-3.5 h-3.5" />
Different date {schedule.departureAt ? format(new Date(schedule.departureAt), 'EEEE, MMMM d, yyyy') : 'N/A'}
</span>
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full ${
schedule.hasAvailability
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
}`}>
{schedule.hasAvailability ? 'Seats available' : 'Fully booked'}
</span>
</div>
)}
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
<div className="flex-1">
<div className="flex items-center gap-3 mb-4">
@@ -312,9 +516,10 @@ export default function ResultsPage() {
)}
<button
onClick={() => setClassModal({ ...schedule, isOutbound } as any)}
className="btn-secondary w-full flex items-center justify-center gap-2"
disabled={isAlternative && schedule.hasAvailability === false}
className="btn-secondary w-full flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{selectedCoachType ? 'Change' : 'Select'}
{isAlternative && schedule.hasAvailability === false ? 'Unavailable' : selectedCoachType ? 'Change' : 'Select'}
</button>
</div>
</div>
@@ -480,6 +685,54 @@ export default function ResultsPage() {
}
if (!hasResults) {
// ONE_WAY search with an explicit empty outbound list — surface any date-shifted
// alternatives the API suggests instead of a dead-end "no trains found" screen.
if (isOneWayNoOutbound) {
const requestedDateLabel = requestedDate
? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy')
: 'your selected date';
const hasAlternatives = alternativeOutbound.length > 0;
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
{renderClassModal()}
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="card text-center mb-8 max-w-3xl mx-auto">
<div className="w-20 h-20 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<Calendar className="w-10 h-10 text-amber-500" />
</div>
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">No trains available</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
No trains are available on <span className="font-semibold text-gray-900 dark:text-gray-100">{requestedDateLabel}</span>. This
may be due to no scheduled service or full capacity. {hasAlternatives
? 'Please check the alternative options below or try a different date.'
: 'Please try a different date.'}
</p>
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">
Change travel date
</button>
</div>
{hasAlternatives && (
<div>
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 dark:text-white">Alternative Travel Options</h3>
<p className="text-sm text-amber-600 dark:text-amber-400 mt-1">
These trains run on different dates than requested adjust your travel date to book one of them.
</p>
</div>
<div className="space-y-4">
{alternativeOutbound.map((schedule) => renderScheduleCard(schedule, true, true))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
@@ -507,197 +760,7 @@ export default function ResultsPage() {
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
{classModal && (() => {
const scheduleId = classModal.scheduleId || classModal.id || '';
const selectedCoachType = selectedCoachTypes[scheduleId];
const isOutbound = (classModal as any).isOutbound;
const coachTypes = classModal.coachTypes || [];
const getCoachIcon = (typeName: string) => {
const lower = typeName.toLowerCase();
if (lower.includes('soft') || lower.includes('vip')) return Star;
if (lower.includes('bed')) return Bed;
return Armchair;
};
return (
<>
<div className="fixed inset-0 z-[99] bg-black/50 backdrop-blur-sm" onClick={() => setClassModal(null)} />
<div className="fixed inset-y-0 right-0 z-[100] w-full sm:w-[680px] lg:w-[95vw] xl:w-[90vw] 2xl:w-[85vw] max-w-[1400px] bg-white dark:bg-gray-900 shadow-2xl flex flex-col"
style={{ animation: 'drawer-slide-in 0.3s cubic-bezier(0.22,1,0.36,1)' }}
>
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
<div>
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Choose Your Coach</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-1.5">
<Train className="w-3.5 h-3.5" />
<span className="font-medium">{classModal.trainNumber}</span>
<span className="text-gray-400">·</span>
<span>{classModal.origin?.name} {classModal.destination?.name}</span>
</p>
</div>
<button
type="button"
onClick={() => setClassModal(null)}
className="w-10 h-10 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all"
aria-label="Close"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
{coachTypes.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2">
{coachTypes.map((coachType: any, index: number) => {
const isSelected = selectedCoachType?.id === coachType.coachTypeId;
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
const coachCurrency = 'ETB';
const CoachIcon = getCoachIcon(coachType.coachTypeName);
return (
<button
key={coachType.coachId}
onClick={() => handleSelectCoachType(scheduleId, coachType.coachTypeId, coachType.coachTypeCode, coachType.coachTypeName, coachType.classes?.[0]?.name || coachType.coachTypeName)}
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
isSelected
? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02]'
: 'border-gray-200 dark:border-gray-700 hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50'
}`}
style={{ animation: `fade-in-up 0.3s ease-out ${index * 0.1}s both` }}
>
{isSelected && (
<div className="absolute top-4 right-4 w-7 h-7 bg-primary rounded-full flex items-center justify-center shadow-lg animate-scale-in">
<Check className="w-4 h-4 text-white" strokeWidth={3} />
</div>
)}
<div className="flex flex-col">
<div className="flex items-start gap-4 pr-2">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 transition-all ${
isSelected
? 'bg-primary/15 dark:bg-primary/25 shadow-inner'
: 'bg-gray-100 dark:bg-gray-700 group-hover:bg-primary/10'
}`}>
<CoachIcon className={`w-6 h-6 transition-colors ${
isSelected ? 'text-primary' : 'text-gray-600 dark:text-gray-400 group-hover:text-primary'
}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-2">
<div>
<h3 className="font-bold text-base text-gray-900 dark:text-white leading-tight">
{coachType.coachTypeName}
</h3>
</div>
</div>
<div className="mt-3">
<div className="flex items-baseline gap-1">
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">From</span>
<span className={`text-2xl font-bold tracking-tight ${
isSelected ? 'text-primary' : 'text-gray-900 dark:text-white'
}`}>
{(minPrice / 100).toFixed(2)}
</span>
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">{coachCurrency}</span>
</div>
</div>
</div>
</div>
{coachType.classes.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-200/60 dark:border-gray-700/60">
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-bold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Class Options
</p>
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
{coachType.classes.length} available
</span>
</div>
<div className="space-y-2.5">
{coachType.classes.map((cls: any, idx: number) => {
const seatsAvailable = classModal.availabilityByClass?.[cls.name];
return (
<div
key={idx}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-50/80 dark:bg-gray-800/40 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 transition-colors"
>
<div className="flex items-center gap-2.5">
<CoachIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{cls.name}
</span>
{seatsAvailable !== undefined && (
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded-full ${
seatsAvailable === 0
? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400'
: seatsAvailable <= 5
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400'
: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
}`}>
{seatsAvailable === 0 ? 'Full' : `${seatsAvailable} left`}
</span>
)}
</div>
<div className="flex items-baseline gap-1">
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
{(cls.baseFareMinor / 100).toFixed(2)}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
{coachCurrency}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</button>
);
})}
</div>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4">
<Train className="w-8 h-8 text-gray-400" />
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">No coach types available for this journey</p>
</div>
)}
</div>
<div className="px-6 py-5 border-t border-gray-100 dark:border-gray-800 flex-shrink-0 bg-gray-50/50 dark:bg-gray-800/30 flex justify-center">
<div className="w-full max-w-md">
<button
onClick={() => { if (selectedCoachType) { handleSelect(classModal, isOutbound); } }}
disabled={!selectedCoachType}
className="w-full flex items-center justify-center gap-2.5 px-6 py-3.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary/30 disabled:shadow-none hover:shadow-xl hover:scale-[1.02] active:scale-[0.98]"
>
<span>{isRoundTrip && isOutbound ? 'Continue to Return Journey' : 'Continue to Passenger Details'}</span>
<ArrowRight className="w-4 h-4" />
</button>
{!selectedCoachType && (
<p className="text-center text-xs text-gray-500 dark:text-gray-400 mt-3 flex items-center justify-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-pulse" />
Select a coach type to continue
</p>
)}
</div>
</div>
</div>
<style>{`
@keyframes drawer-slide-in{from{transform:translateX(100%)}to{transform:translateX(0)}}
@keyframes fade-in-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
@keyframes scale-in{from{transform:scale(0)}to{transform:scale(1)}}
`}</style>
</>
);
})()}
{renderClassModal()}
{promoData && (
<div className="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-start gap-3">

View File

@@ -261,11 +261,23 @@ export default function ReviewPage() {
if (localStoragePassengerId) passengerId = localStoragePassengerId;
}
// Fallback 3: Use passengerId from user object
// Fallback 3: Use passengerId from user object. The profile response nests
// it under `passenger.id`, while the login response exposes it top-level as
// `passengerId` — accept either shape so a cached profile user still resolves.
if (!passengerId && user) {
passengerId = (user as any).passengerId;
passengerId = (user as any).passengerId || (user as any).passenger?.id;
}
// Fallback 4: last resort — fetch the passenger profile directly.
if (!passengerId) {
try {
const me: any = await apiClient.get('/passengers/me');
passengerId = me?.id || me?.passengerId || '';
} catch (err) {
console.error('Failed to resolve passengerId from /passengers/me:', err);
}
}
if (!passengerId) {
throw new Error('Passenger ID not found in authentication token. Please log in again.');
}

View File

@@ -9,18 +9,11 @@ import { useAuthStore } from '@/lib/auth-store';
import { useState } from 'react';
import { Train, ShieldCheck } from 'lucide-react';
const registerSchema = z
.object({
fullName: z.string().min(2, 'Full name is required'),
email: z.string().email('Invalid email address'),
phone: z.string().min(9, 'Phone number is required'),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
const registerSchema = z.object({
fullName: z.string().min(2, 'Full name is required'),
email: z.string().email('Invalid email address'),
phone: z.string().min(9, 'Phone number is required'),
});
type RegisterForm = z.infer<typeof registerSchema>;
@@ -38,14 +31,17 @@ export default function RegisterPage() {
setLoading(true);
setError('');
try {
await registerUser({
const result = await registerUser({
fullName: data.fullName,
email: data.email,
phone: data.phone,
password: data.password,
confirmPassword: data.confirmPassword,
});
router.push('/booking/search');
const params = new URLSearchParams({
email: result.email,
userId: result.iamUserId,
phone: result.phoneNumber,
});
router.push(`/verify-account?${params.toString()}`);
} catch (err: any) {
if (err.response?.status === 409) {
setError('An account with this email or phone number already exists.');
@@ -67,7 +63,7 @@ export default function RegisterPage() {
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Create account</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">Book faster and manage your trips</p>
<p className="text-gray-600 dark:text-gray-400 mt-2">We&apos;ll text you a code to verify your phone</p>
</div>
<div className="card">
@@ -120,36 +116,8 @@ export default function RegisterPage() {
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Password</label>
<input
type="password"
{...register('password')}
className="input-field"
placeholder="••••••••"
autoComplete="new-password"
/>
{errors.password && (
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Confirm password</label>
<input
type="password"
{...register('confirmPassword')}
className="input-field"
placeholder="••••••••"
autoComplete="new-password"
/>
{errors.confirmPassword && (
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword.message}</p>
)}
</div>
<button type="submit" className="btn-primary w-full" disabled={loading}>
{loading ? 'Creating account...' : 'Create account'}
{loading ? 'Sending code...' : 'Send verification code'}
</button>
</form>

View File

@@ -0,0 +1,216 @@
'use client';
import { Suspense, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Train, ArrowLeft, ShieldCheck } from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
import { useAuthStore } from '@/lib/auth-store';
// Mirrors the IAM set-password requirement (class-validator @IsStrongPassword defaults):
// min length 8, with lower- and upper-case letters, a number, and a symbol.
function isStrongPassword(pw: string): boolean {
return (
pw.length >= 8 &&
/[a-z]/.test(pw) &&
/[A-Z]/.test(pw) &&
/[0-9]/.test(pw) &&
/[^A-Za-z0-9]/.test(pw)
);
}
function VerifyAccountContent() {
const router = useRouter();
const searchParams = useSearchParams();
const login = useAuthStore((s) => s.login);
const email = searchParams.get('email') || '';
const userId = searchParams.get('userId') || '';
const phone = searchParams.get('phone') || '';
const linkValid = Boolean(email && userId);
const [verificationCode, setVerificationCode] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [resending, setResending] = useState(false);
const [resent, setResent] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!verificationCode.trim()) {
setError('Enter the verification code sent to your phone.');
return;
}
if (!isStrongPassword(newPassword)) {
setError('Password must be at least 8 characters and include upper- and lower-case letters, a number, and a symbol.');
return;
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match.');
return;
}
setLoading(true);
try {
// Completes signup: PATCH /v1/auth/set-password with the SMS code, which activates
// the account and sets the password.
await iamAuthApi.resetPassword({
userId,
email,
verificationCode: verificationCode.trim(),
newPassword,
confirmPassword,
});
// Auto-login with the freshly-set password; login lazy-provisions the passenger record.
await login(email, newPassword);
router.push('/booking/search');
} catch (err: any) {
const msg = err.response?.data?.message || err.message || '';
setError(msg || 'Could not verify your account. Check the code and try again, or resend it.');
setLoading(false);
}
};
const handleResend = async () => {
setError('');
setResent(false);
setResending(true);
try {
await iamAuthApi.resendRegistrationCode({ email, phoneNumber: phone });
setResent(true);
} catch {
setError('Could not resend the code. Please try again in a moment.');
} finally {
setResending(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-[rgb(20_113_76)] from-10% via-transparent to-[rgb(20_113_76)] to-90% dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="w-12 h-12 bg-[rgb(20_113_76)] rounded-lg flex items-center justify-center">
<Train className="w-6 h-6 text-white" />
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Verify your account</h1>
{linkValid && (
<p className="text-gray-600 dark:text-gray-400 mt-2">
Enter the code we sent to your phone and choose a password for{' '}
<span className="font-medium">{email}</span>.
</p>
)}
</div>
<div className="card">
{!linkValid ? (
<div className="space-y-4">
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
This verification link is invalid or incomplete. Please start registration again.
</div>
<Link href="/register" className="btn-primary w-full flex items-center justify-center">
Back to registration
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
{error}
</div>
)}
{resent && !error && (
<div className="flex items-start gap-3 bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300 px-4 py-3 rounded">
<ShieldCheck className="w-5 h-5 flex-shrink-0 mt-0.5" />
<p className="text-sm">A new code has been sent to your phone.</p>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Verification code</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={verificationCode}
onChange={(e) => { setVerificationCode(e.target.value); setError(''); }}
className="input-field tracking-widest"
placeholder="123456"
maxLength={6}
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">New password</label>
<input
type="password"
value={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setError(''); }}
className="input-field"
placeholder="••••••••"
autoComplete="new-password"
minLength={8}
required
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
At least 8 characters with upper &amp; lower case, a number, and a symbol.
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Confirm password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
className="input-field"
placeholder="••••••••"
autoComplete="new-password"
minLength={8}
required
/>
</div>
<button
type="submit"
className="btn-primary w-full"
disabled={loading || !verificationCode || !newPassword || !confirmPassword}
>
{loading ? 'Verifying...' : 'Verify and continue'}
</button>
<button
type="button"
onClick={handleResend}
disabled={resending}
className="w-full text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors disabled:opacity-50"
>
{resending ? 'Resending...' : "Didn't get a code? Resend"}
</button>
<Link
href="/register"
className="flex items-center justify-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to registration
</Link>
</form>
)}
</div>
</div>
</div>
);
}
export default function VerifyAccountPage() {
return (
<Suspense fallback={null}>
<VerifyAccountContent />
</Suspense>
);
}

View File

@@ -11,6 +11,10 @@ export const iamAuthApi = {
forgotPassword: (email: string) =>
axios.post(`${API_URL}/v1/auth/forgot-password`, { email }),
// Re-sends the registration verification code for a still-pending account.
resendRegistrationCode: (data: { email: string; phoneNumber: string }) =>
axios.post(`${API_URL}/auth/register/resend-code`, data),
// Completes the forgot-password flow using the link sent via SMS:
// ${FE_BASE_URL}/reset-password?email=..&userId=..&verificationCode=..
resetPassword: (data: {

View File

@@ -31,7 +31,7 @@ interface AuthState {
isAuthenticated: boolean;
isInitialized: boolean;
login: (email: string, password: string) => Promise<void>;
register: (data: RegisterData) => Promise<void>;
register: (data: RegisterData) => Promise<RegisterResult>;
logout: () => Promise<void>;
setUser: (user: User, token: string) => void;
updateUser: (userData: Partial<User>) => void;
@@ -43,8 +43,12 @@ interface RegisterData {
fullName: string;
email: string;
phone: string;
password: string;
confirmPassword: string;
}
interface RegisterResult {
iamUserId: string;
email: string;
phoneNumber: string;
}
export const useAuthStore = create<AuthState>((set, get) => ({
@@ -118,25 +122,24 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ user, token, isAuthenticated: true });
},
register: async (data: RegisterData) => {
register: async (data: RegisterData): Promise<RegisterResult> => {
// Shape required by the passenger-api RegisterDto; username = email by convention.
// Registration no longer takes a password — the account is created as pending and
// an SMS verification code is sent. The user completes signup on the verify-account
// page (set-password). No token is issued here; the user is NOT logged in yet.
const payload = {
email: data.email,
username: data.email,
phoneNumber: data.phone,
name: { en: data.fullName, am: data.fullName },
password: data.password,
confirmPassword: data.confirmPassword,
};
const response: any = await apiClient.post('/auth/register', payload);
const { token, user } = response.data || response;
if (typeof window !== 'undefined') {
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_user', JSON.stringify(user));
}
set({ user, token, isAuthenticated: true });
const result = response.data || response;
return {
iamUserId: result.iamUserId,
email: result.email,
phoneNumber: result.phoneNumber,
};
},
logout: async () => {