Merge branch 'dev' into reschedule

This commit is contained in:
Abubeker Yasin
2026-08-28 16:36:51 +03:00
637 changed files with 53614 additions and 8442 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair, Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
@@ -13,7 +13,7 @@ import { usePagination } from '@/lib/use-pagination';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
type Tab = 'types' | 'coaches' | 'utilization';
type Tab = 'types' | 'coaches';
const getBedLabel = (bedPosition: string | null): string => {
if (bedPosition === 'upper') return 'U';
@@ -152,8 +152,6 @@ function CoachesPageContent() {
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, item: null });
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
const [isBedCoach, setIsBedCoach] = useState(false);
const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false);
const [exportUtilFormat, setExportUtilFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
const queryClient = useQueryClient();
@@ -169,12 +167,6 @@ function CoachesPageContent() {
queryFn: () => fleetApi.getCoaches({}),
});
const { data: utilizationData, isLoading: utilizationLoading } = useQuery({
queryKey: ['coach-utilization'],
queryFn: () => apiClient.get<any[]>('/fleet/coaches/utilization'),
enabled: activeTab === 'utilization',
});
// Coach Type Mutations
const createCoachTypeMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data),
@@ -531,16 +523,6 @@ function CoachesPageContent() {
>
Coaches
</button>
<button
onClick={() => { setActiveTab('utilization'); setSearch(''); }}
className={`px-4 py-3 font-medium transition-colors ${
activeTab === 'utilization'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Utilization Report
</button>
</div>
{/* Coach Types Tab */}
@@ -591,105 +573,6 @@ function CoachesPageContent() {
</div>
)}
{/* Utilization Tab */}
{activeTab === 'utilization' && (() => {
const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || [];
const UTIL_COLS = [
{ key: 'number', label: 'Coach' },
{ key: 'coachType', label: 'Type' },
{ key: 'totalSeats', label: 'Total Seats' },
{ key: 'availableSeats', label: 'Available' },
{ key: 'bookedSeats', label: 'Booked' },
{ key: 'blockedSeats', label: 'Blocked' },
{ key: 'maintenanceSeats', label: 'Maintenance' },
{ key: 'utilizationRate', label: 'Utilization %' },
{ key: 'totalAssignments', label: 'Assignments' },
{ key: 'totalBookings', label: 'Total Bookings' },
];
const doExport = () => {
if (!rows.length) { alert('No data to export'); return; }
const headers = UTIL_COLS.map(c => c.label);
const exportRows = rows.map((r: any) => UTIL_COLS.map(({ key }) => String(r[key] ?? '')));
const dateStr = new Date().toISOString().split('T')[0];
if (exportUtilFormat === 'pdf') {
const w = window.open('', '_blank')!;
w.document.write(`<!DOCTYPE html><html><head><title>Coach Utilization Report</title><style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>`);
w.document.write(`<h2>Coach Utilization Report — ${dateStr}</h2><table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
exportRows.forEach((r: string[]) => { w.document.write(`<tr>${r.map((v: string) => `<td>${v}</td>`).join('')}</tr>`); });
w.document.write('</tbody></table></body></html>');
w.document.close(); w.print();
} else if (exportUtilFormat === 'excel') {
const tsv = [headers.join('\t'), ...exportRows.map((r: string[]) => r.join('\t'))].join('\n');
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `coach-utilization-${dateStr}.xls`; a.click(); URL.revokeObjectURL(url);
} else {
const csv = [headers.map(h => `"${h}"`).join(','), ...exportRows.map((r: string[]) => r.map((v: string) => `"${v.replace(/"/g, '""')}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `coach-utilization-${dateStr}.csv`; a.click(); URL.revokeObjectURL(url);
}
setExportUtilModalOpen(false);
};
return (
<div className="pt-6 space-y-4">
<div className="flex justify-end">
<ActionButton icon={Download} variant="secondary" onClick={() => setExportUtilModalOpen(true)}>Export</ActionButton>
</div>
<DataTable
columns={[
{ key: 'number', label: 'Coach', render: (r: any) => <span className="font-medium">{r.number}</span> },
{ key: 'coachType', label: 'Type', render: (r: any) => <span className="text-sm">{r.coachType || 'N/A'}</span> },
{ key: 'totalSeats', label: 'Total Seats', render: (r: any) => <span className="font-mono">{r.totalSeats}</span> },
{ key: 'availableSeats', label: 'Available', render: (r: any) => <span className="font-mono text-green-600">{r.availableSeats}</span> },
{ key: 'bookedSeats', label: 'Booked', render: (r: any) => <span className="font-mono text-red-600">{r.bookedSeats}</span> },
{ key: 'blockedSeats', label: 'Blocked', render: (r: any) => <span className="font-mono text-gray-500">{r.blockedSeats}</span> },
{ key: 'maintenanceSeats', label: 'Maintenance', render: (r: any) => <span className="font-mono text-orange-500">{r.maintenanceSeats}</span> },
{
key: 'utilizationRate', label: 'Utilization',
render: (r: any) => (
<div className="flex items-center gap-2">
<div className="w-20 h-2 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${r.utilizationRate}%` }} />
</div>
<span className="font-mono text-sm">{r.utilizationRate}%</span>
</div>
),
},
{ key: 'totalAssignments', label: 'Assignments', render: (r: any) => <span className="font-mono">{r.totalAssignments}</span> },
{ key: 'totalBookings', label: 'Total Bookings', render: (r: any) => <span className="font-mono font-semibold">{r.totalBookings}</span> },
]}
data={rows}
actions={[]}
loading={utilizationLoading}
emptyMessage="No coach utilization data available"
/>
<Modal isOpen={exportUtilModalOpen} onClose={() => setExportUtilModalOpen(false)} title="Export Utilization Report" size="sm">
<div className="space-y-4">
<div>
<p className="text-sm font-medium mb-2">Export Format</p>
<div className="flex gap-3">
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="utilExportFormat" value={fmt} checked={exportUtilFormat === fmt} onChange={() => setExportUtilFormat(fmt)} className="w-4 h-4" />
<span className="text-sm font-medium capitalize">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportUtilModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={doExport}>Export</ActionButton>
</div>
</div>
</Modal>
</div>
);
})()}
</div>
{/* Delete Confirmation */}

View File

@@ -1,13 +1,13 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, RefreshCw, Send, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { excessBaggageApi, apiClient } from '@/lib/api';
import { excessBaggageApi, apiClient, bookingsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
@@ -28,7 +28,7 @@ export default function ExcessBaggagePage() {
const [waiveReason, setWaiveReason] = useState('');
const [waiveError, setWaiveError] = useState<string | null>(null);
const [logModal, setLogModal] = useState(false);
const [logForm, setLogForm] = useState({ bookingReference: '', excessWeightKg: '', collectCash: false });
const [logForm, setLogForm] = useState({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' });
const [logError, setLogError] = useState<string | null>(null);
const [resendModal, setResendModal] = useState<any>(null);
const [resendSuccess, setResendSuccess] = useState(false);
@@ -54,12 +54,36 @@ export default function ExcessBaggagePage() {
}),
});
useEffect(() => {
if (!logModal) return;
const bookingRef = logForm.bookingReference.trim();
if (!bookingRef) {
setLogForm((prev) => ({ ...prev, paymentPhone: '' }));
return;
}
const timeout = setTimeout(async () => {
try {
const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 });
const items = response?.items ?? [];
const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0];
if (!match) return;
const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? '';
setLogForm((prev) => ({ ...prev, paymentPhone: prev.paymentPhone || nextPhone }));
} catch {
// Ignore lookup failures: the agent can still override the number manually.
}
}, 250);
return () => clearTimeout(timeout);
}, [logForm.bookingReference, logModal]);
const logMutation = useMutation({
mutationFn: (data: any) => excessBaggageApi.logCharge(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['excess-baggage'] });
setLogModal(false);
setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false });
setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' });
setLogError(null);
},
onError: (e: any) => setLogError(e?.response?.data?.message || e?.message || 'Failed to log charge'),
@@ -178,7 +202,7 @@ export default function ExcessBaggagePage() {
<h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1>
<p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p>
</div>
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false }); }}>
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}>
Log Excess Luggage
</ActionButton>
</div>
@@ -256,6 +280,15 @@ export default function ExcessBaggagePage() {
onChange={(e) => setLogForm({ ...logForm, bookingReference: e.target.value })}
/>
</div>
<div>
<label className="label">Payment SMS Phone</label>
<input
className="input"
placeholder="e.g. +251911223344"
value={logForm.paymentPhone}
onChange={(e) => setLogForm({ ...logForm, paymentPhone: e.target.value })}
/>
</div>
<div>
<label className="label">Excess Weight (kg)</label>
<input
@@ -282,7 +315,7 @@ export default function ExcessBaggagePage() {
</label>
{!logForm.collectCash && (
<p className="text-xs text-muted-foreground">
A payment link will be sent to the passenger's email and phone on file.
The payment link will be sent to the phone above and the booking's saved email when present.
</p>
)}
</>
@@ -302,6 +335,7 @@ export default function ExcessBaggagePage() {
bookingReference: logForm.bookingReference.trim(),
excessWeightKg: parseInt(logForm.excessWeightKg),
collectCash: logForm.collectCash,
contactPhone: logForm.paymentPhone.trim() || undefined,
});
}}
>

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function GroupBookingLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import {
TicketCheck, Users, TrendingUp, ShieldCheck, MailCheck,
} from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
import { getErrorMessage } from '@/lib/api-client';
const EDR_GREEN = 'rgb(20, 113, 76)';
@@ -52,11 +53,11 @@ export default function LoginPage() {
await login(identifier.trim(), password);
router.push('/dashboard');
} catch (err: any) {
const msg = err.message || err.response?.data?.message || '';
if (msg === 'ACCESS_DENIED') {
const rawMessage = err.response?.data?.message;
if (rawMessage === 'ACCESS_DENIED') {
setError('This account does not have back-office access. Contact your administrator.');
} else {
setError(err.response?.data?.message || msg || 'Invalid credentials. Please try again.');
setError(getErrorMessage(err, 'Invalid credentials. Please try again.'));
}
} finally {
setLoading(false);
@@ -71,11 +72,11 @@ export default function LoginPage() {
await iamAuthApi.forgotPassword(forgotIdentifier.trim());
setForgotSent(true);
} catch (err: any) {
const msg = err.response?.data?.message || err.message || '';
const rawMessage = err.response?.data?.message;
setForgotError(
msg === 'user_not_found'
rawMessage === 'user_not_found'
? 'No account found with that email or phone number.'
: msg || 'Failed to send the reset link. Please try again.'
: getErrorMessage(err, 'Failed to send the reset link. Please try again.')
);
} finally {
setForgotLoading(false);

View File

@@ -10,6 +10,7 @@ import Modal from '@/components/ui/Modal';
import Pagination from '@/components/ui/Pagination';
import { packagesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { getErrorMessage } from '@/lib/api-client';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
@@ -116,7 +117,7 @@ export default function PackageBookingsPage() {
<div className="card">
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
Error: {(error as any)?.response?.data?.message || (error as any)?.message || String(error)}
Error: {getErrorMessage(error)}
</div>
)}
<div className="flex flex-wrap gap-3 mb-4">

View File

@@ -2,15 +2,21 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, CheckCircle, Eye, Layers, Trash2 } from 'lucide-react';
import { Plus, Edit, CheckCircle, Eye, Layers, Trash2, ImagePlus, ImageOff, X } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal';
import { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api';
import { getErrorMessage } from '@/lib/api-client';
import { formatDateTime, formatCurrency } from '@/lib/utils';
// Mirrors the backend's own limits (packages/package-image-upload.options.ts) so a bad file is
// rejected instantly client-side instead of round-tripping to the server first.
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const toLocal = (iso?: string) => {
if (!iso) return '';
const d = new Date(iso);
@@ -48,6 +54,15 @@ export default function PackagesPage() {
const [deletePackageConfirm, setDeletePackageConfirm] = useState<any>(null);
const [deletePackageError, setDeletePackageError] = useState<string | null>(null);
const [deletePackageCascade, setDeletePackageCascade] = useState(false);
// Image upload: `imageFile`/`imagePreviewUrl` track a newly-selected-but-not-yet-uploaded file
// (local object URL preview); `existingImageUrl` is the package's current server-side image
// when editing, shown until/unless the admin picks a replacement.
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
const [existingImageUrl, setExistingImageUrl] = useState<string | null>(null);
const [imageError, setImageError] = useState<string | null>(null);
const [imageUploadError, setImageUploadError] = useState<string | null>(null);
const [removeImageConfirm, setRemoveImageConfirm] = useState<any>(null);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
@@ -141,6 +156,23 @@ export default function PackagesPage() {
onError: (e: any) => setTierError(e?.response?.data?.message || e?.message || 'Failed to delete tier'),
});
const uploadImageMutation = useMutation({
mutationFn: ({ id, file }: { id: string; file: File }) => packagesApi.uploadImage(id, file),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setImageUploadError(null); },
onError: (e: any) => setImageUploadError(getErrorMessage(e, 'Failed to upload package image')),
});
const removeImageMutation = useMutation({
mutationFn: (id: string) => packagesApi.removeImage(id),
onSuccess: (updated: any) => {
queryClient.invalidateQueries({ queryKey: ['packages'] });
setRemoveImageConfirm(null);
setExistingImageUrl(updated?.imageUrl ?? null);
setViewPackage((prev: any) => (prev && prev.id === updated?.id ? { ...prev, imageUrl: null } : prev));
},
onError: (e: any) => setImageUploadError(getErrorMessage(e, 'Failed to remove package image')),
});
const openEditTier = (tier: any) => {
setEditingTier(tier);
setTierForm({ seatClassId: tier.seatClassId ?? '', seatType: tier.seatType, label: tier.label, priceMinor: String(tier.priceMinor), availableSeats: String(tier.availableSeats) });
@@ -163,13 +195,29 @@ export default function PackagesPage() {
}
};
// Deliberately does not touch imageUploadError — that's shown in a page-level banner (outside
// this modal) precisely because it can still be set after the modal has already auto-closed
// (see handleSubmit), and clearing it here would wipe it out before the user ever sees it.
const resetImageSelection = () => {
setImageFile(null);
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
setImagePreviewUrl(null);
setImageError(null);
};
const openCreate = () => {
setForm(emptyForm);
setEditingId(null);
resetImageSelection();
setImageUploadError(null);
setExistingImageUrl(null);
setModalMode('create');
};
const openEdit = (pkg: any) => {
resetImageSelection();
setImageUploadError(null);
setExistingImageUrl(pkg.imageUrl ?? null);
setForm({
code: pkg.code ?? '',
name: pkg.name ?? '',
@@ -216,11 +264,23 @@ export default function PackagesPage() {
validUntil: form.validUntil,
priceTiers: [],
};
// The image is uploaded as a separate follow-up call (the DTO here carries no image field —
// see packages.service.ts's uploadImage) so it must run after the package itself exists.
let targetId = editingId;
if (modalMode === 'edit' && editingId) {
await updateMutation.mutateAsync({ id: editingId, data: payload });
} else {
await createMutation.mutateAsync(payload);
const created = await createMutation.mutateAsync(payload);
targetId = created?.id ?? null;
}
if (imageFile && targetId) {
try {
await uploadImageMutation.mutateAsync({ id: targetId, file: imageFile });
} catch {
// surfaced via imageUploadError banner — the package itself was already saved successfully
}
}
resetImageSelection();
};
const field = (key: keyof typeof form) => ({
@@ -229,6 +289,24 @@ export default function PackagesPage() {
setForm((f) => ({ ...f, [key]: e.target.value })),
});
const handleImageFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = ''; // allow re-selecting the same file after a validation error
if (!file) return;
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
setImageError('Image must be JPEG, PNG, WEBP, or GIF.');
return;
}
if (file.size > MAX_IMAGE_BYTES) {
setImageError(`Image must be ${Math.round(MAX_IMAGE_BYTES / (1024 * 1024))}MB or smaller.`);
return;
}
setImageError(null);
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
setImageFile(file);
setImagePreviewUrl(URL.createObjectURL(file));
};
const scheduleLabel = (s: any) => {
const from = s.originStation?.name ?? s.originStationId ?? '?';
const to = s.destinationStation?.name ?? s.destinationStationId ?? '?';
@@ -237,7 +315,19 @@ export default function PackagesPage() {
};
const columns = [
{ key: 'code', label: 'Package',
{
key: 'image', label: '',
render: (p: any) => (
p.imageUrl ? (
<img src={p.imageUrl} alt="" className="h-10 w-10 rounded object-cover border border-border" />
) : (
<div className="h-10 w-10 rounded border border-border bg-muted flex items-center justify-center">
<ImageOff className="h-4 w-4 text-muted-foreground" />
</div>
)
),
},
{ key: 'code', label: 'Package',
render: (pkg: any) => (
<div className="text-sm">
<div>{pkg.code}</div>
@@ -299,7 +389,7 @@ export default function PackagesPage() {
},
];
const isPending = createMutation.isPending || updateMutation.isPending;
const isPending = createMutation.isPending || updateMutation.isPending || uploadImageMutation.isPending;
const allItems: any[] = data?.items || [];
const filteredItems = allItems.filter((p) => {
@@ -320,6 +410,18 @@ export default function PackagesPage() {
<ActionButton icon={Plus} onClick={openCreate}>New Package</ActionButton>
</div>
{/* The package itself may already be saved and this modal closed by the time an image
upload/removal fails (see handleSubmit) — surfaced here rather than inside the modal
so it's never silently lost. */}
{imageUploadError && (
<div className="flex items-center justify-between rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-300">
<span>{imageUploadError}</span>
<button type="button" className="ml-3 shrink-0" onClick={() => setImageUploadError(null)}>
<X className="h-4 w-4" />
</button>
</div>
)}
<div className="card">
<div className="mb-4 space-y-3">
<div className="flex flex-wrap gap-3">
@@ -366,6 +468,14 @@ export default function PackagesPage() {
<Modal isOpen={!!viewPackage} onClose={() => setViewPackage(null)} title="Package Details" size="lg">
{viewPackage && (
<div className="space-y-4 text-sm">
{viewPackage.imageUrl ? (
<img src={viewPackage.imageUrl} alt={viewPackage.name} className="w-full max-h-56 rounded-lg object-cover border border-border" />
) : (
<div className="w-full h-32 rounded-lg border border-dashed border-border bg-muted flex items-center justify-center gap-2 text-muted-foreground">
<ImageOff className="h-5 w-5" />
<span>No image</span>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div><span className="label">Code</span><p className="font-mono font-semibold">{viewPackage.code}</p></div>
<div><span className="label">Status</span><p>{viewPackage.status}</p></div>
@@ -549,10 +659,23 @@ export default function PackagesPage() {
error={tierError ?? undefined}
/>
{/* Remove Image Confirmation */}
<ConfirmDialog
isOpen={!!removeImageConfirm}
onClose={() => setRemoveImageConfirm(null)}
onConfirm={() => removeImageMutation.mutate(removeImageConfirm.id)}
title="Remove Package Image"
message={`Remove the image for "${removeImageConfirm?.name}"? The package itself will not be deleted.`}
confirmText="Remove Image"
isDanger
isLoading={removeImageMutation.isPending}
error={imageUploadError ?? undefined}
/>
{/* Create / Edit Modal */}
<Modal
isOpen={modalMode !== null}
onClose={() => setModalMode(null)}
onClose={() => { setModalMode(null); resetImageSelection(); }}
title={modalMode === 'edit' ? 'Edit Package' : 'New Package'}
size="lg"
>
@@ -571,6 +694,40 @@ export default function PackagesPage() {
<textarea className="input" rows={2} placeholder="Optional description" {...field('description')} />
</div>
<div className="col-span-2">
<label className="label">Package Image</label>
<div className="flex items-start gap-4">
{imagePreviewUrl ? (
<img src={imagePreviewUrl} alt="Preview" className="h-24 w-24 rounded-lg object-cover border border-border" />
) : existingImageUrl ? (
<img src={existingImageUrl} alt="Current" className="h-24 w-24 rounded-lg object-cover border border-border" />
) : (
<div className="h-24 w-24 rounded-lg border border-dashed border-border bg-muted flex items-center justify-center">
<ImagePlus className="h-6 w-6 text-muted-foreground" />
</div>
)}
<div className="flex-1 space-y-2">
<input type="file" accept="image/jpeg,image/png,image/webp,image/gif" className="input" onChange={handleImageFileChange} />
<p className="text-xs text-muted-foreground">JPEG, PNG, WEBP, or GIF. Max 5MB.</p>
{imageError && <p className="text-xs text-red-600 dark:text-red-400">{imageError}</p>}
{imageFile && (
<button type="button" className="text-xs text-primary underline" onClick={resetImageSelection}>
<X className="h-3 w-3 inline -mt-0.5 mr-0.5" />Clear selected file
</button>
)}
{!imageFile && modalMode === 'edit' && existingImageUrl && (
<button
type="button"
className="text-xs text-red-600 dark:text-red-400 underline block"
onClick={() => { setImageUploadError(null); setRemoveImageConfirm({ id: editingId, name: form.name }); }}
>
Remove current image
</button>
)}
</div>
</div>
</div>
<div>
<label className="label">Origin Station *</label>
<select className="input" required {...field('originStationId')}>
@@ -660,7 +817,7 @@ export default function PackagesPage() {
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton type="button" variant="secondary" onClick={() => setModalMode(null)}>Cancel</ActionButton>
<ActionButton type="button" variant="secondary" onClick={() => { setModalMode(null); resetImageSelection(); }}>Cancel</ActionButton>
<ActionButton type="submit" loading={isPending}>
{modalMode === 'edit' ? 'Update Package' : 'Create Package'}
</ActionButton>

View File

@@ -1,9 +1,10 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { PlusCircle } from 'lucide-react';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
import { bookingsApi } from '@/lib/api';
import { useCreateSupplementaryCharge } from './useSupplementaryCharges';
const REASONS = ['UNDERPAYMENT', 'FARE_CORRECTION', 'CURRENCY_ADJUSTMENT', 'OTHER'];
@@ -14,13 +15,37 @@ interface Props {
}
export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' });
const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '', paymentPhone: '' });
const [formError, setFormError] = useState<string | null>(null);
const [createSuccess, setCreateSuccess] = useState<string | null>(null);
useEffect(() => {
if (!isOpen) return;
const bookingRef = form.bookingRef.trim();
if (!bookingRef) {
setForm((prev) => ({ ...prev, paymentPhone: '' }));
return;
}
const timeout = setTimeout(async () => {
try {
const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 });
const items = response?.items ?? [];
const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0];
if (!match) return;
const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? '';
setForm((prev) => ({ ...prev, paymentPhone: prev.paymentPhone || nextPhone }));
} catch {
// Ignore lookup failures here; the staff member can still type a phone override manually.
}
}, 300);
return () => clearTimeout(timeout);
}, [form.bookingRef, isOpen]);
const createMutation = useCreateSupplementaryCharge(() => {
setCreateSuccess('Charge created and payment link sent.');
setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' });
setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '', paymentPhone: '' });
setFormError(null);
setTimeout(() => { setCreateSuccess(null); onClose(); }, 2000);
});
@@ -31,7 +56,13 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
if (!form.bookingRef.trim()) return setFormError('Booking reference is required');
if (!form.amountEtb || isNaN(amountMinor) || amountMinor <= 0) return setFormError('Enter a valid amount');
try {
await createMutation.mutateAsync({ bookingRef: form.bookingRef.trim(), amountMinor, reason: form.reason, notes: form.notes || undefined });
await createMutation.mutateAsync({
bookingRef: form.bookingRef.trim(),
amountMinor,
reason: form.reason,
notes: form.notes || undefined,
contactPhone: form.paymentPhone.trim() || undefined,
});
} catch (e: any) {
setFormError(e?.response?.data?.message ?? e?.message ?? 'Failed to create charge');
}
@@ -52,6 +83,10 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
<label className="label">Booking Reference <span className="text-red-500">*</span></label>
<input className="input" placeholder="e.g. EDR-20240001" value={form.bookingRef} onChange={(e) => setForm({ ...form, bookingRef: e.target.value })} />
</div>
<div>
<label className="label">Payment SMS Phone</label>
<input className="input" placeholder="e.g. +251911223344" value={form.paymentPhone} onChange={(e) => setForm({ ...form, paymentPhone: e.target.value })} />
</div>
<div>
<label className="label">Amount Owed (ETB) <span className="text-red-500">*</span></label>
<input className="input" type="number" min="0.01" step="0.01" placeholder="e.g. 50.00" value={form.amountEtb} onChange={(e) => setForm({ ...form, amountEtb: e.target.value })} />
@@ -69,7 +104,7 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
</div>
<p className="text-xs text-muted-foreground">
A payment link will be sent to the passenger's registered phone/email. The link expires in 72 hours.
The payment link sends to the phone above, falling back to the booking's saved contact details if left empty. The link expires in 72 hours.
</p>
<div className="flex justify-end gap-2 pt-2 border-t border-muted">

View File

@@ -13,8 +13,14 @@ export function useSupplementaryCharges(filters: { bookingRef?: string; status?:
export function useCreateSupplementaryCharge(onSuccess: () => void) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) =>
paymentsApi.supplementary.create(data),
mutationFn: (data: {
bookingRef: string;
amountMinor: number;
reason: string;
notes?: string;
contactPhone?: string;
contactEmail?: string;
}) => paymentsApi.supplementary.create(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['supplementary-charges'] });
onSuccess();

View File

@@ -0,0 +1,362 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Activity, BarChart3, Download, Search } from "lucide-react";
import { apiClient } from "@/lib/api-client";
import ActionButton from "@/components/ui/ActionButton";
import Pagination from "@/components/ui/Pagination";
import { usePagination } from "@/lib/use-pagination";
interface ScheduleOption {
id: string;
label: string;
}
interface CoachUtilizationRow {
id: string;
number: string;
coachType: string | null;
status: string | null;
totalSeats: number;
availableSeats: number;
bookedSeats: number;
blockedSeats: number;
maintenanceSeats: number;
utilizationRate: number;
totalAssignments: number;
totalBookings: number;
}
export default function CoachUtilizationReportPage() {
const [scheduleId, setScheduleId] = useState("");
const [search, setSearch] = useState("");
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
queryKey: ["report-schedules-all"],
queryFn: () => apiClient.get("/reports/schedules?all=true"),
});
const { data, isLoading, isError } = useQuery<CoachUtilizationRow[]>({
queryKey: ["coach-utilization-report", scheduleId],
queryFn: () => apiClient.get(`/fleet/coaches/utilization?scheduleId=${scheduleId}`),
enabled: !!scheduleId,
});
const schedules = schedulesRaw ?? [];
const rows = data ?? [];
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return rows;
return rows.filter((row) => {
const coachType = row.coachType ?? "";
const status = row.status ?? "";
return (
row.number.toLowerCase().includes(q) ||
coachType.toLowerCase().includes(q) ||
status.toLowerCase().includes(q)
);
});
}, [rows, search]);
const { paged, page, totalPages, setPage, reset } = usePagination(filteredRows, 25);
const summary = useMemo(() => {
if (!rows.length) return null;
const totals = rows.reduce(
(acc, row) => {
acc.totalSeats += row.totalSeats;
acc.availableSeats += row.availableSeats;
acc.bookedSeats += row.bookedSeats;
acc.blockedSeats += row.blockedSeats;
acc.maintenanceSeats += row.maintenanceSeats;
acc.totalBookings += row.totalBookings;
acc.totalAssignments += row.totalAssignments;
return acc;
},
{
totalSeats: 0,
availableSeats: 0,
bookedSeats: 0,
blockedSeats: 0,
maintenanceSeats: 0,
totalBookings: 0,
totalAssignments: 0,
},
);
const avgUtilization = rows.length
? rows.reduce((sum, row) => sum + row.utilizationRate, 0) / rows.length
: 0;
return {
totalCoaches: rows.length,
totalSeats: totals.totalSeats,
availableSeats: totals.availableSeats,
bookedSeats: totals.bookedSeats,
blockedSeats: totals.blockedSeats,
maintenanceSeats: totals.maintenanceSeats,
avgUtilization,
totalAssignments: totals.totalAssignments,
totalBookings: totals.totalBookings,
};
}, [rows]);
const doExport = () => {
if (!filteredRows.length) return;
const headers = [
"Coach",
"Type",
"Status",
"Total Seats",
"Available",
"Booked",
"Blocked",
"Maintenance",
"Utilization %",
"Assignments",
"Total Bookings",
];
const rowsCsv = filteredRows.map((row) => [
row.number,
row.coachType ?? "—",
row.status ?? "—",
String(row.totalSeats),
String(row.availableSeats),
String(row.bookedSeats),
String(row.blockedSeats),
String(row.maintenanceSeats),
`${row.utilizationRate}%`,
String(row.totalAssignments),
String(row.totalBookings),
]);
const csv = [
headers.map((header) => `"${header}"`).join(","),
...rowsCsv.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",")),
].join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `coach-utilization-${scheduleId || "fleet"}-${new Date().toISOString().split("T")[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Coach Utilization Report</h1>
<p className="text-muted-foreground mt-1">
Occupancy, availability, and booking load by coach for a selected schedule.
</p>
</div>
<div className="card">
<div className="flex items-end gap-4 flex-wrap">
<div className="flex-1 min-w-72">
<label className="label">Schedule</label>
<select
className="input"
value={scheduleId}
onChange={(e) => {
setScheduleId(e.target.value);
setSearch("");
reset();
}}
disabled={loadingSchedules}
>
<option value="">
{loadingSchedules ? "Loading schedules…" : "Select a schedule…"}
</option>
{schedules.map((schedule) => (
<option key={schedule.id} value={schedule.id}>
{schedule.label}
</option>
))}
</select>
</div>
</div>
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading</p>}
{isError && <p className="text-xs text-red-500 mt-2">Failed to load coach utilization.</p>}
</div>
{!scheduleId && (
<div className="card py-16 text-center text-muted-foreground">
<Activity className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-base font-medium text-foreground">Choose a schedule to review coach occupancy</p>
<p className="text-xs mt-1">The report drills into availability, bookings, and maintenance status for each assigned coach.</p>
</div>
)}
{data && summary && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4">
<div className="card">
<p className="text-muted-foreground text-sm font-medium">Coaches</p>
<p className="text-2xl font-bold mt-2 text-foreground">{summary.totalCoaches}</p>
<p className="text-xs text-muted-foreground mt-1">Assigned coaches</p>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Total Seats</p>
<p className="text-2xl font-bold mt-2 text-foreground">{summary.totalSeats}</p>
<p className="text-xs text-muted-foreground mt-1">Across all coaches</p>
</div>
<BarChart3 className="h-8 w-8 text-emerald-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Booked</p>
<p className="text-2xl font-bold mt-2 text-rose-600 dark:text-rose-400">{summary.bookedSeats}</p>
<p className="text-xs text-muted-foreground mt-1">Occupied seats</p>
</div>
<Activity className="h-8 w-8 text-rose-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Available</p>
<p className="text-2xl font-bold mt-2 text-emerald-600 dark:text-emerald-400">{summary.availableSeats}</p>
<p className="text-xs text-muted-foreground mt-1">Open seats</p>
</div>
<Activity className="h-8 w-8 text-emerald-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Blocked</p>
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">{summary.blockedSeats}</p>
<p className="text-xs text-muted-foreground mt-1">Unavailable seats</p>
</div>
<Activity className="h-8 w-8 text-slate-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Avg Utilization</p>
<p className="text-2xl font-bold mt-2 text-amber-600 dark:text-amber-400">
{summary.avgUtilization.toFixed(1)}%
</p>
<p className="text-xs text-muted-foreground mt-1">Across coach set</p>
</div>
<BarChart3 className="h-8 w-8 text-amber-500 opacity-30" />
</div>
</div>
</div>
<div className="card p-0">
<div className="flex items-center justify-between px-4 pt-4 pb-3 gap-4 flex-wrap">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Coach Details
</h3>
<div className="flex items-center gap-3 flex-wrap">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
className="input max-w-xs pl-10"
placeholder="Coach, type, or status…"
value={search}
onChange={(event) => {
setSearch(event.target.value);
reset();
}}
/>
</div>
<ActionButton icon={Download} variant="secondary" onClick={doExport} disabled={!filteredRows.length}>
Export CSV
</ActionButton>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{[
"Coach",
"Type",
"Status",
"Total Seats",
"Available",
"Booked",
"Blocked",
"Maintenance",
"Utilization",
"Assignments",
"Bookings",
].map((header) => (
<th
key={header}
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap"
>
{header}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{paged.map((row) => (
<tr key={row.id} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
<td className="px-4 py-3 font-medium whitespace-nowrap">{row.number}</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">{row.coachType ?? "—"}</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">{row.status ?? "—"}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums">{row.totalSeats}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-emerald-600 dark:text-emerald-400">{row.availableSeats}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-rose-600 dark:text-rose-400">{row.bookedSeats}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-600 dark:text-slate-400">{row.blockedSeats}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-amber-600 dark:text-amber-400">{row.maintenanceSeats}</td>
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-2">
<div className="w-20 h-2 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${Math.min(row.utilizationRate, 100)}%` }} />
</div>
<span className="font-mono text-sm">{row.utilizationRate.toFixed(1)}%</span>
</div>
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums">{row.totalAssignments}</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums">{row.totalBookings}</td>
</tr>
))}
{paged.length === 0 && (
<tr>
<td colSpan={11} className="py-8 text-center text-sm text-muted-foreground">
No coach utilization rows found
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
</div>
</>
)}
{!data && !isLoading && scheduleId && (
<div className="card py-12 text-center text-muted-foreground">
No utilization data found for this schedule.
</div>
)}
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Eye, EyeOff, ArrowRight, ArrowLeft, Loader2, CheckCircle2 } from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
import { getErrorMessage } from '@/lib/api-client';
function ResetPasswordForm() {
const router = useRouter();
@@ -41,8 +42,7 @@ function ResetPasswordForm() {
setSuccess(true);
setTimeout(() => router.push('/login'), 2000);
} catch (err: any) {
const msg = err.response?.data?.message || err.message || '';
setError(msg || 'Failed to reset password. The link may have expired — request a new one from the sign-in page.');
setError(getErrorMessage(err, 'Failed to reset password. The link may have expired — request a new one from the sign-in page.'));
} finally {
setLoading(false);
}

View File

@@ -30,9 +30,22 @@ interface Schedule {
destinationStation?: { id: string; name: string };
coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>;
isPackageOnly?: boolean;
isGroupBookingOnly?: boolean;
liveStatus?: { delayMinutes: number } | null;
}
/** Mutually-exclusive UI view over the two independent isPackageOnly/isGroupBookingOnly flags
* the API actually stores — same pair, just presented as one choice instead of two checkboxes. */
type ScheduleVisibility = 'NORMAL' | 'PACKAGE_ONLY' | 'GROUP_ONLY';
function visibilityOf(isPackageOnly?: boolean, isGroupBookingOnly?: boolean): ScheduleVisibility {
if (isGroupBookingOnly) return 'GROUP_ONLY';
if (isPackageOnly) return 'PACKAGE_ONLY';
return 'NORMAL';
}
function visibilityFlags(v: ScheduleVisibility): { isPackageOnly: boolean; isGroupBookingOnly: boolean } {
return { isPackageOnly: v === 'PACKAGE_ONLY', isGroupBookingOnly: v === 'GROUP_ONLY' };
}
interface Train {
id: string;
name: string;
@@ -84,7 +97,7 @@ function SchedulesPageContent() {
const [bulkCoachRows, setBulkCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false });
const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({
@@ -122,6 +135,7 @@ function SchedulesPageContent() {
status: 'SCHEDULED',
coachIds: [] as string[],
isPackageOnly: false,
isGroupBookingOnly: false,
});
const [filters, setFilters] = useState({
@@ -188,7 +202,7 @@ function SchedulesPageContent() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['schedules'] });
setShowAddModal(false);
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false });
setAddCoachRows([]);
setError(null);
},
@@ -288,6 +302,8 @@ function SchedulesPageContent() {
routeId: addForm.routeId,
departureAt: dep.toISOString(),
arrivalAt: arr.toISOString(),
isPackageOnly: addForm.isPackageOnly,
isGroupBookingOnly: addForm.isGroupBookingOnly,
...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }),
});
};
@@ -312,6 +328,7 @@ function SchedulesPageContent() {
arrivalAt: eatLocalToISO(editForm.arrivalAt),
status: editForm.status,
isPackageOnly: editForm.isPackageOnly,
isGroupBookingOnly: editForm.isGroupBookingOnly,
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
coachId,
positionNumber: idx + 1,
@@ -356,6 +373,7 @@ function SchedulesPageContent() {
status: schedule.status,
coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [],
isPackageOnly: schedule.isPackageOnly ?? false,
isGroupBookingOnly: schedule.isGroupBookingOnly ?? false,
});
setError(null);
setShowEditModal(true);
@@ -494,6 +512,9 @@ function SchedulesPageContent() {
{schedule.isPackageOnly && (
<span className="edr-badge edr-badge-warning">PKG</span>
)}
{schedule.isGroupBookingOnly && (
<span className="edr-badge edr-badge-warning">GROUP</span>
)}
</div>
),
},
@@ -778,7 +799,7 @@ function SchedulesPageContent() {
<Modal
isOpen={showAddModal}
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false }); setAddCoachRows([]); setError(null); }}
title="Add Schedule"
size="xl"
>
@@ -817,6 +838,37 @@ function SchedulesPageContent() {
/>
</div>
<div className="border-t pt-4">
<label className="label mb-2">Visibility</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{([
{ value: 'NORMAL', title: 'Normal', desc: 'Bookable through the passenger portal like any other schedule' },
{ value: 'PACKAGE_ONLY', title: 'Package Only', desc: 'Hide from public search — reserved for package bookings' },
{ value: 'GROUP_ONLY', title: 'Group Booking Only', desc: 'Hide from public search — reserved for staff group bookings' },
] as const).map((opt) => {
const selected = visibilityOf(addForm.isPackageOnly, addForm.isGroupBookingOnly) === opt.value;
return (
<label
key={opt.value}
className={`flex items-start gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${selected ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}`}
>
<input
type="radio"
name="add-visibility"
className="w-4 h-4 mt-0.5"
checked={selected}
onChange={() => setAddForm({ ...addForm, ...visibilityFlags(opt.value) })}
/>
<span className="text-sm">
<span className="font-medium block">{opt.title}</span>
<span className="block text-xs text-muted-foreground">{opt.desc}</span>
</span>
</label>
);
})}
</div>
</div>
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-2">
<label className="label mb-0">Coaches</label>
@@ -875,7 +927,7 @@ function SchedulesPageContent() {
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton type="button" variant="secondary" onClick={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}>Cancel</ActionButton>
<ActionButton type="button" variant="secondary" onClick={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false }); setAddCoachRows([]); setError(null); }}>Cancel</ActionButton>
<ActionButton type="submit" loading={createScheduleMutation.isPending}>Create Schedule</ActionButton>
</div>
</form>
@@ -1155,18 +1207,35 @@ function SchedulesPageContent() {
</select>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg border border-border">
<input
type="checkbox"
id="isPackageOnly"
checked={editForm.isPackageOnly}
onChange={(e) => setEditForm({ ...editForm, isPackageOnly: e.target.checked })}
className="w-4 h-4 rounded"
/>
<label htmlFor="isPackageOnly" className="text-sm cursor-pointer">
<span className="font-medium">Package Only</span>
<span className="block text-xs text-muted-foreground">Hide from public search reserved for package bookings</span>
</label>
<div>
<label className="label mb-2">Visibility</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{([
{ value: 'NORMAL', title: 'Normal', desc: 'Bookable through the passenger portal like any other schedule' },
{ value: 'PACKAGE_ONLY', title: 'Package Only', desc: 'Hide from public search — reserved for package bookings' },
{ value: 'GROUP_ONLY', title: 'Group Booking Only', desc: 'Hide from public search — reserved for staff group bookings' },
] as const).map((opt) => {
const selected = visibilityOf(editForm.isPackageOnly, editForm.isGroupBookingOnly) === opt.value;
return (
<label
key={opt.value}
className={`flex items-start gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${selected ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}`}
>
<input
type="radio"
name="edit-visibility"
className="w-4 h-4 mt-0.5"
checked={selected}
onChange={() => setEditForm({ ...editForm, ...visibilityFlags(opt.value) })}
/>
<span className="text-sm">
<span className="font-medium block">{opt.title}</span>
<span className="block text-xs text-muted-foreground">{opt.desc}</span>
</span>
</label>
);
})}
</div>
</div>
<div>

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { LogIn, ListCollapse, Trash2, Printer, Package } from 'lucide-react';
import { Download } from 'lucide-react';
@@ -39,6 +39,7 @@ export default function TicketsPage() {
const [excessTicket, setExcessTicket] = useState<any>(null);
const [excessKg, setExcessKg] = useState('');
const [excessCollectCash, setExcessCollectCash] = useState(false);
const [excessPaymentPhone, setExcessPaymentPhone] = useState('');
const [excessError, setExcessError] = useState<string | null>(null);
const [excessResult, setExcessResult] = useState<any>(null);
@@ -163,10 +164,35 @@ export default function TicketsPage() {
onError: (e: any) => setExcessError(e?.response?.data?.message || e?.message || 'Failed to log charge'),
});
useEffect(() => {
if (!excessModalOpen || !excessTicket) return;
const bookingRef = excessTicket?.booking?.bookingRef ?? '';
if (!bookingRef) {
setExcessPaymentPhone('');
return;
}
const timeout = setTimeout(async () => {
try {
const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 });
const items = response?.items ?? [];
const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0];
if (!match) return;
const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? '';
setExcessPaymentPhone((prev) => prev || nextPhone);
} catch {
// Ignore lookup failures here; the staff member can still type a phone override manually.
}
}, 250);
return () => clearTimeout(timeout);
}, [excessModalOpen, excessTicket]);
const openExcessModal = (ticket: any) => {
setExcessTicket(ticket);
setExcessKg('');
setExcessCollectCash(false);
setExcessPaymentPhone('');
setExcessError(null);
setExcessResult(null);
setExcessModalOpen(true);
@@ -179,6 +205,7 @@ export default function TicketsPage() {
bookingId: excessTicket.booking?.id ?? excessTicket.bookingId,
excessWeightKg: parseInt(excessKg),
collectCash: excessCollectCash,
contactPhone: excessPaymentPhone.trim() || undefined,
});
};
@@ -979,6 +1006,15 @@ export default function TicketsPage() {
required
/>
</div>
<div>
<label className="label">Payment SMS Phone</label>
<input
className="input"
placeholder="e.g. +251911223344"
value={excessPaymentPhone}
onChange={(e) => setExcessPaymentPhone(e.target.value)}
/>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"

View File

@@ -40,6 +40,7 @@ import {
Activity,
Smartphone,
Layers,
UsersRound,
} from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils';
@@ -64,6 +65,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
title: 'Operations',
items: [
{ name: 'Bookings', href: '/bookings', icon: Ticket, permission: PERMS.bookings.view },
{ name: 'Group Booking', href: '/group-booking', icon: UsersRound, permission: PERMS.bookings.manage },
{ name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view },
{ name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
{ name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.manage },
@@ -126,6 +128,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
items: [
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: PERMS.reports.view },
{ name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: PERMS.reports.view },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view },
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },

View File

@@ -2,6 +2,30 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.';
const NETWORK_ERROR_MESSAGE = 'Could not reach the server. Please check your connection and try again.';
/**
* Extracts a user-facing message from a failed request. Prefers a real backend-provided message
* (joining a NestJS validation array into one line); otherwise falls back to a friendly generic
* message — never raw client/network text like "Request failed with status code 500" or
* "Network Error", which is what axios puts in `error.message` when there's nothing better.
* Every page in this app should use this (or rely on the response interceptor below, which
* normalizes the same error in place) instead of reading `err.message` directly.
*/
export function getErrorMessage(error: unknown, fallback: string = GENERIC_ERROR_MESSAGE): string {
const err = error as any;
const raw = err?.response?.data?.message;
if (Array.isArray(raw) && raw.length > 0) {
const joined = raw.filter((m: unknown) => typeof m === 'string' && m.trim()).join('; ');
if (joined) return joined;
} else if (typeof raw === 'string' && raw.trim()) {
return raw;
}
if (err?.isAxiosError && !err.response) return NETWORK_ERROR_MESSAGE;
return fallback;
}
class ApiClient {
private client: AxiosInstance;
@@ -30,6 +54,20 @@ class ApiClient {
window.location.href = '/login';
}
}
// Normalize in place so every existing `err?.response?.data?.message || err?.message ||
// '<fallback>'` call site across the app picks up a friendly message automatically,
// instead of raw axios/network text or an unjoined NestJS validation array.
try {
const friendly = getErrorMessage(error);
if (error.response?.data && typeof error.response.data === 'object') {
error.response.data.message = friendly;
}
error.message = friendly;
} catch {
// Best-effort — never let normalization itself break the original rejection.
}
return Promise.reject(error);
},
);

View File

@@ -0,0 +1,258 @@
import { apiClient } from '@/lib/api-client';
// ── Search (POST /search) ──────────────────────────────────────────────────
export interface SearchTripsRequest {
originStationId: string;
destinationStationId: string;
date: string;
adultCount: number;
childCount?: number;
journeyType: 'ONE_WAY' | 'ROUND_TRIP';
/** Required when journeyType is ROUND_TRIP. */
returnDate?: string;
/** Drives which fare tier (Local vs International) gets quoted — see fareTier in the page component. */
nationality?: string;
/** Always 'GROUP_BOOKING' for this app — search returns ONLY schedules marked
* isGroupBookingOnly (an exclusive partition, not additive): normal passenger-facing
* schedules never show up here, and group-only schedules never show up in the portal. */
channel?: 'PORTAL' | 'GROUP_BOOKING';
}
export interface ScheduleClassOption {
name: string;
baseFareMinor: number;
displayCurrency: string;
displayAmountMinor: number;
available: number;
}
export interface ScheduleCoachType {
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
coachId: string;
classes: ScheduleClassOption[];
}
export interface ScheduleResult {
type: 'DIRECT';
scheduleId: string;
trainNumber: string;
trainName: string;
origin: { id: string; code: string; name: string; city: string; sequence: number };
destination: { id: string; code: string; name: string; city: string; sequence: number };
departureAt: string;
arrivalAt: string;
durationMinutes: number;
status: string;
hasAvailability: boolean;
displayCurrency: string;
coachTypes: ScheduleCoachType[];
}
export type SearchEmptyReasonCode =
| 'NO_ROUTE'
| 'NO_SCHEDULE_ON_DATE'
| 'CANCELLED'
| 'PACKAGE_ONLY'
| 'GROUP_BOOKING_ONLY'
| 'CHECKIN_CLOSED'
| 'FULLY_BOOKED';
/** Structured, not a string — always render via a code→message lookup, never directly. */
export interface SearchEmptyReason {
code: SearchEmptyReasonCode;
originStationName: string;
destinationStationName: string;
}
export interface SearchTripsResponse {
journeyType: string;
outbound: ScheduleResult[];
requestedDate: string;
outboundReason?: SearchEmptyReason;
/** Nearby schedules for the same station pair on a different date, offered when `outbound` is empty. */
alternativeOutbound?: ScheduleResult[];
/** Return-leg schedules — present when the request's journeyType was ROUND_TRIP. */
inbound?: ScheduleResult[];
requestedReturnDate?: string;
inboundReason?: SearchEmptyReason;
alternativeInbound?: ScheduleResult[];
}
// ── Seat classes (GET /seat-classes) ───────────────────────────────────────
export interface SeatClassOption {
id: string;
name: string;
}
// ── Auto-assign + hold (POST /seats/auto-assign-hold) ─────────────────────
export interface AutoAssignHoldRequest {
scheduleId: string;
originStationId: string;
destinationStationId: string;
seatClassName: string;
adultCount: number;
childCount?: number;
/** Round-trip leg tag — omit for a one-way booking. */
journeyDirection?: 'OUTBOUND' | 'RETURN';
}
export interface HeldPassengerSeat {
passengerId: string;
seat: {
id: string;
label?: string;
seatNumber?: string;
coach?: string;
row?: number;
col?: string;
};
}
export interface AutoAssignHoldResponse {
holdId: string;
expiresAt: string;
ttlSeconds: number;
schedule: { id: string; trainNumber: string; trainName: string; departureAt: string; arrivalAt: string } | null;
passengers: HeldPassengerSeat[];
}
// ── Group booking creation (POST /bookings/group) ──────────────────────────
export interface GroupBookingPassengerInput {
/** Omit for a free child (ONE_WAY only) — matches guest-booking.dto.ts's own optional seatId. */
seatId?: string;
passengerName: string;
dateOfBirth: string;
idDocumentType: 'NATIONAL_ID' | 'PASSPORT' | 'DRIVING_LICENSE' | 'OTHER';
idDocumentNumber?: string;
passportNumber?: string;
passportCountry?: string;
nationality?: string;
phone?: string;
email?: string;
/** Return-leg seat ID — required when the booking is ROUND_TRIP. */
returnSeatId?: string;
}
export interface CreateGroupBookingRequest {
scheduleId: string;
holdId: string;
originStationId: string;
destinationStationId: string;
seatClassId: string;
bookingType: 'ONE_WAY' | 'ROUND_TRIP';
passengers: GroupBookingPassengerInput[];
/** ROUND_TRIP only. */
returnScheduleId?: string;
returnHoldId?: string;
returnOriginStationId?: string;
returnDestinationStationId?: string;
/** Falls back to seatClassId on the backend if omitted. */
returnSeatClassId?: string;
}
export interface GroupBookingSeat {
seatId: string;
passengerName: string;
passengerCategory: 'ADULT' | 'CHILD';
/** 1 = outbound leg, 2 = return leg. Absent on a plain ONE_WAY booking. */
leg?: number;
seat: { seatNumber: string; bedPosition?: string | null; coach: { number: string } };
}
export interface CreateGroupBookingResponse {
id: string;
bookingRef: string;
status: string;
totalMinor: number;
currency: string;
adultCount: number;
childCount: number;
seats: GroupBookingSeat[];
schedule: {
departureAt: string;
arrivalAt: string;
train: { number: string; name: string };
originStation: { name: string };
destinationStation: { name: string };
};
}
// ── Payment (GET /payments/methods, POST /payments/initiate) ───────────────
export type PaymentMethodType =
| 'TELEBIRR' | 'CBE_BIRR' | 'EBIRR' | 'WAAFI' | 'DMONEY' | 'CAC_BANK' | 'CARD' | 'WALLET' | 'CBE_BILL';
export interface SupportedPaymentMethod {
id: string;
type: PaymentMethodType;
displayName: string;
region: string;
currency: string;
enabled: boolean;
}
export interface InitiatePaymentRequest {
bookingId: string;
method: PaymentMethodType;
paymentMethodId?: string;
platform?: 'web' | 'mobile' | 'inapp';
}
export interface PaymentClientAction {
type: 'REDIRECT' | 'LAUNCH_APP' | 'INVOKE_BRIDGE' | 'COLLECT_OTP' | 'AWAIT_PUSH' | 'SHOW_BILL_REFERENCE';
url?: string;
/** Set when type=SHOW_BILL_REFERENCE (CBE bill payment) — the number the payer enters at any CBE channel. */
billReference?: string;
instructions?: string;
expiresAt?: string;
message?: string;
payerAccountMasked?: string;
}
export interface InitiatePaymentResponse {
intentId: string;
status: string;
clientAction?: PaymentClientAction;
merchantOrderId?: string;
failureCode?: string;
failureMessage?: string;
sessionExpiresAt?: string;
paymentDeadline?: string;
}
export const groupBookingApi = {
searchTrips: (dto: SearchTripsRequest) =>
apiClient.post<SearchTripsResponse>('/search', dto),
getSeatClasses: () => apiClient.get<SeatClassOption[]>('/seat-classes'),
autoAssignHold: (dto: AutoAssignHoldRequest) =>
apiClient.post<AutoAssignHoldResponse>('/seats/auto-assign-hold', dto),
/** Best-effort early release — e.g. freeing an outbound hold when the return leg's auto-assign fails. */
releaseHold: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
createGroupBooking: (dto: CreateGroupBookingRequest) =>
apiClient.post<CreateGroupBookingResponse>('/bookings/group', dto),
// Guards against a non-array response the same way dashboardApi.getPaymentMethods /
// paymentsApi.getMethods already do elsewhere in this app — never lets a bad/unexpected
// response shape reach a caller expecting a plain array.
getPaymentMethods: async (): Promise<SupportedPaymentMethod[]> => {
try {
const response = await apiClient.get<SupportedPaymentMethod[]>('/payments/methods');
return Array.isArray(response) ? response : [];
} catch {
return [];
}
},
initiatePayment: (dto: InitiatePaymentRequest) =>
apiClient.post<InitiatePaymentResponse>('/payments/initiate', dto),
};

View File

@@ -200,8 +200,14 @@ export const paymentsApi = {
updateMethod: (id: string, data: any) => apiClient.patch(`/payments/methods/${id}`, data),
deleteMethod: (id: string) => apiClient.delete(`/payments/methods/${id}`),
supplementary: {
create: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) =>
apiClient.post<any>('/payments/supplementary', data),
create: (data: {
bookingRef: string;
amountMinor: number;
reason: string;
notes?: string;
contactPhone?: string;
contactEmail?: string;
}) => apiClient.post<any>('/payments/supplementary', data),
getAll: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([, v]) => v !== '' && v !== undefined && v !== null)
@@ -469,6 +475,15 @@ export const packagesApi = {
addTier: (packageId: string, data: any) => apiClient.post<any>(`/packages/${packageId}/tiers`, data),
updateTier: (tierId: string, data: any) => apiClient.patch<any>(`/packages/tiers/${tierId}`, data),
deleteTier: (tierId: string) => apiClient.delete(`/packages/tiers/${tierId}`),
// `apiClient` pins Content-Type: application/json on every request — clearing it (rather than
// setting multipart/form-data by hand, which omits the boundary) is what lets the browser
// generate a proper boundary of its own. Same pattern as features/support/supportApi.ts.
uploadImage: (id: string, file: File) => {
const form = new FormData();
form.append('image', file);
return apiClient.post<any>(`/packages/${id}/image`, form, { headers: { 'Content-Type': undefined } });
},
removeImage: (id: string) => apiClient.delete<any>(`/packages/${id}/image`),
};
// Package Inquiries API

View File

@@ -0,0 +1,149 @@
import ExcelJS from 'exceljs';
// Brand palette — matches finance-workbook.ts / ActionButton's primary variant, kept as a
// small local copy rather than a shared import since these are two unrelated export domains.
const BRAND = 'FF14714C';
const BRAND_TINT = 'FFEAF5EF';
const INK = 'FF1F2937';
const MUTED = 'FF6B7280';
const BORDER = 'FFE2E5E1';
const WHITE = 'FFFFFFFF';
const THIN_BORDER: Partial<ExcelJS.Borders> = {
top: { style: 'thin', color: { argb: BORDER } },
left: { style: 'thin', color: { argb: BORDER } },
bottom: { style: 'thin', color: { argb: BORDER } },
right: { style: 'thin', color: { argb: BORDER } },
};
/** Column order is the contract — passenger-excel.ts reads by this same header order. */
export const PASSENGER_TEMPLATE_COLUMNS = [
'Full Name',
'Date of Birth (YYYY-MM-DD)',
'Passenger Type',
'ID Document Type',
'ID Document Number',
'Passport Number',
'Passport Country',
'Nationality',
'Phone',
'Email',
] as const;
const REQUIRED_ROW = 200;
export interface PassengerTemplateInput {
trainNumber: string;
origin: string;
destination: string;
travelDate: string;
seatClassName: string;
adultCount: number;
childCount: number;
}
export async function buildPassengerTemplate(input: PassengerTemplateInput): Promise<Blob> {
const wb = new ExcelJS.Workbook();
wb.creator = 'EDR Passenger Backoffice';
wb.created = new Date();
const ws = wb.addWorksheet('Passengers', { views: [{ state: 'frozen', ySplit: 5 }] });
ws.columns = PASSENGER_TEMPLATE_COLUMNS.map((h) => ({ width: h.length < 14 ? 18 : h.length + 4 }));
// ── Title + trip context banner ──────────────────────────────────────────
ws.mergeCells(1, 1, 1, PASSENGER_TEMPLATE_COLUMNS.length);
const title = ws.getCell(1, 1);
title.value = 'EDR Group Booking — Passenger Template';
title.font = { bold: true, size: 16, color: { argb: WHITE } };
title.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } };
title.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
ws.getRow(1).height = 30;
for (let c = 1; c <= PASSENGER_TEMPLATE_COLUMNS.length; c++) ws.getCell(1, c).fill = title.fill;
ws.mergeCells(2, 1, 2, PASSENGER_TEMPLATE_COLUMNS.length);
const subtitle = ws.getCell(2, 1);
subtitle.value = `Train ${input.trainNumber} · ${input.origin}${input.destination} · ${input.travelDate} · ${input.seatClassName}`;
subtitle.font = { size: 11, color: { argb: INK } };
subtitle.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
ws.getRow(2).height = 20;
ws.mergeCells(3, 1, 3, PASSENGER_TEMPLATE_COLUMNS.length);
const requirement = ws.getCell(3, 1);
const total = input.adultCount + input.childCount;
requirement.value = `Fill in exactly ${total} passenger row${total === 1 ? '' : 's'} below — ${input.adultCount} Adult${input.adultCount === 1 ? '' : 's'} + ${input.childCount} Child${input.childCount === 1 ? '' : 'ren'}. One row per passenger, in any order.`;
requirement.font = { italic: true, size: 10, color: { argb: MUTED } };
requirement.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
ws.getRow(3).height = 18;
ws.mergeCells(4, 1, 4, PASSENGER_TEMPLATE_COLUMNS.length);
const instructions = ws.getCell(4, 1);
instructions.value =
'Columns marked * are required. Date of Birth must be YYYY-MM-DD and not in the future — it determines Adult/Child pricing (under 5 = Child). ' +
'ID Document Type must be one of: NATIONAL_ID, PASSPORT, DRIVING_LICENSE, OTHER. Do not rename or reorder columns.';
instructions.font = { size: 9, color: { argb: MUTED } };
instructions.alignment = { vertical: 'middle', horizontal: 'left', indent: 1, wrapText: true };
ws.getRow(4).height = 28;
// ── Header row ────────────────────────────────────────────────────────────
const headerRow = ws.getRow(5);
const requiredCols = new Set([0, 1, 2, 3]); // Full Name, DOB, Passenger Type, ID Document Type
PASSENGER_TEMPLATE_COLUMNS.forEach((h, i) => {
const cell = headerRow.getCell(i + 1);
cell.value = requiredCols.has(i) ? `${h} *` : h;
cell.font = { bold: true, color: { argb: WHITE }, size: 11 };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } };
cell.alignment = { vertical: 'middle', horizontal: 'left', wrapText: true };
cell.border = THIN_BORDER;
});
headerRow.height = 30;
// ── One filled example row so the format is obvious at a glance ──────────
const example = ws.getRow(6);
const exampleValues = [
'Abebe Kebede',
'1990-05-15',
'Adult',
'NATIONAL_ID',
'ET123456789',
'',
'',
'Ethiopian',
'+251911234567',
'abebe@example.com',
];
exampleValues.forEach((v, i) => {
const cell = example.getCell(i + 1);
cell.value = v;
cell.font = { italic: true, color: { argb: MUTED } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND_TINT } };
cell.border = THIN_BORDER;
});
// ── Blank rows with borders + dropdown validation for Passenger Type / ID Document Type ──
// exceljs's types only expose per-cell `cell.dataValidation`, not a worksheet-level range API.
for (let r = 7; r <= REQUIRED_ROW; r++) {
const row = ws.getRow(r);
for (let c = 1; c <= PASSENGER_TEMPLATE_COLUMNS.length; c++) {
row.getCell(c).border = THIN_BORDER;
}
row.getCell(3).dataValidation = {
type: 'list',
allowBlank: true,
formulae: ['"Adult,Child"'],
showErrorMessage: true,
errorTitle: 'Invalid Passenger Type',
error: 'Choose Adult or Child.',
};
row.getCell(4).dataValidation = {
type: 'list',
allowBlank: true,
formulae: ['"NATIONAL_ID,PASSPORT,DRIVING_LICENSE,OTHER"'],
showErrorMessage: true,
errorTitle: 'Invalid ID Document Type',
error: 'Choose NATIONAL_ID, PASSPORT, DRIVING_LICENSE, or OTHER.',
};
}
const buffer = await wb.xlsx.writeBuffer();
return new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}

View File

@@ -0,0 +1,258 @@
import ExcelJS from 'exceljs';
const VALID_ID_TYPES = ['NATIONAL_ID', 'PASSPORT', 'DRIVING_LICENSE', 'OTHER'];
/** Exact mirror of guest-booking.service.ts's calculateAge — calendar-based, not a 365.25-day
* approximation, so this file's CHILD/ADULT determination never disagrees with the backend's. */
function calculateAge(dateOfBirth: Date): number {
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
const monthDiff = today.getMonth() - dateOfBirth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dateOfBirth.getDate())) age--;
return age;
}
/**
* Mirrors guest-booking.service.ts's per-passenger nationality inference exactly (NATIONAL_ID
* always forces 'Ethiopian'; PASSPORT falls back to Djiboutian/Other by passport country), so a
* row that will actually be priced at a different fare tier than the one quoted at search time
* is caught here instead of silently mispricing the group later.
*/
function inferredFareTier(docType: string, nationality: string, passportCountry: string): 'LOCAL' | 'INTERNATIONAL' {
const natUpper = nationality.trim().toUpperCase();
const isEthiopian = natUpper === 'ETHIOPIAN' || docType === 'NATIONAL_ID';
let resolved = nationality;
if (isEthiopian && docType === 'NATIONAL_ID') resolved = 'Ethiopian';
else if (!isEthiopian && docType === 'PASSPORT') resolved = nationality || (passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
else if (isEthiopian && docType === 'PASSPORT') resolved = 'Ethiopian';
const resolvedUpper = resolved.trim().toUpperCase();
return resolvedUpper === 'ETHIOPIAN' || resolvedUpper === 'DJIBOUTIAN' ? 'LOCAL' : 'INTERNATIONAL';
}
export interface ParsedPassengerRow {
/** 1-based row number in the sheet, for error messages ("row 8"). */
rowNumber: number;
fullName: string;
dateOfBirth: string; // normalized YYYY-MM-DD, empty if invalid/missing
passengerType: 'Adult' | 'Child' | '';
/** Backend-authoritative category from DOB alone (age < 5), regardless of the Type column. */
isChildByAge: boolean;
idDocumentType: string;
idDocumentNumber: string;
passportNumber: string;
passportCountry: string;
nationality: string;
phone: string;
email: string;
errors: string[];
warnings: string[];
}
export interface ParsePassengerExcelResult {
rows: ParsedPassengerRow[];
/** Structural problems (wrong file, missing columns) — nothing in `rows` can be trusted if this is non-empty. */
fileErrors: string[];
}
function cellText(row: ExcelJS.Row, colIndex: number): string {
if (colIndex < 1) return '';
const v = row.getCell(colIndex).value;
if (v === null || v === undefined) return '';
if (v instanceof Date) return v.toISOString().split('T')[0];
if (typeof v === 'object') {
const anyV = v as any;
if (typeof anyV.text === 'string') return anyV.text.trim();
if (anyV.result !== undefined) return String(anyV.result).trim();
if (anyV.richText) return anyV.richText.map((t: any) => t.text).join('').trim();
}
return String(v).trim();
}
/** Strips a trailing " *" (required-column marker) so header matching survives the template's own formatting. */
function normalizeHeader(h: string): string {
return h.replace(/\s*\*\s*$/, '').trim();
}
export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' | 'INTERNATIONAL'): Promise<ParsePassengerExcelResult> {
const buffer = await file.arrayBuffer();
const wb = new ExcelJS.Workbook();
try {
await wb.xlsx.load(buffer);
} catch {
return {
rows: [],
fileErrors: ['Could not read this file. Make sure it is a valid .xlsx or .xls file exported from the downloaded template.'],
};
}
const ws = wb.worksheets[0];
if (!ws) return { rows: [], fileErrors: ['The workbook has no sheets.'] };
// Locate the header row by scanning the first several rows for one starting with "Full Name" —
// the template puts it at row 5 (after the title/instruction banners), but scanning is more
// forgiving of an edited file than hardcoding a row number.
let headerRowIndex = -1;
let headers: string[] = [];
for (let r = 1; r <= 10; r++) {
const row = ws.getRow(r);
const values: string[] = [];
for (let c = 1; c <= 12; c++) values.push(normalizeHeader(cellText(row, c)));
if (values.some((v) => v.toLowerCase().startsWith('full name'))) {
headerRowIndex = r;
headers = values;
break;
}
}
if (headerRowIndex === -1) {
return {
rows: [],
fileErrors: ['Could not find the expected header row (starting with "Full Name"). Please use the downloaded template without changing its structure.'],
};
}
const colFor = (label: string) => headers.findIndex((h) => h.toLowerCase().startsWith(label.toLowerCase())) + 1;
const idx = {
fullName: colFor('Full Name'),
dob: colFor('Date of Birth'),
type: colFor('Passenger Type'),
docType: colFor('ID Document Type'),
docNumber: colFor('ID Document Number'),
passportNumber: colFor('Passport Number'),
passportCountry: colFor('Passport Country'),
nationality: colFor('Nationality'),
phone: colFor('Phone'),
email: colFor('Email'),
};
if (idx.fullName < 1 || idx.dob < 1 || idx.type < 1 || idx.docType < 1) {
return {
rows: [],
fileErrors: ['One or more required columns (Full Name, Date of Birth, Passenger Type, ID Document Type) are missing. Please use the downloaded template.'],
};
}
const rows: ParsedPassengerRow[] = [];
const lastRow = ws.actualRowCount || ws.rowCount;
for (let r = headerRowIndex + 1; r <= lastRow; r++) {
const row = ws.getRow(r);
const fullName = cellText(row, idx.fullName);
const dobRaw = cellText(row, idx.dob);
const typeRaw = cellText(row, idx.type);
const docTypeRaw = cellText(row, idx.docType).toUpperCase();
const docNumber = cellText(row, idx.docNumber);
const passportNumber = cellText(row, idx.passportNumber);
const passportCountry = cellText(row, idx.passportCountry);
const nationality = cellText(row, idx.nationality);
const phone = cellText(row, idx.phone);
const email = cellText(row, idx.email);
// Skip fully blank trailing rows (the template pre-formats borders down to row 200).
if (![fullName, dobRaw, typeRaw, docTypeRaw, docNumber, passportNumber, nationality, phone, email].some((v) => v)) {
continue;
}
const errors: string[] = [];
const warnings: string[] = [];
if (!fullName) errors.push('Full Name is required');
let dateOfBirth = '';
let isChildByAge = false;
if (!dobRaw) {
errors.push('Date of Birth is required');
} else {
const parsed = new Date(dobRaw);
if (isNaN(parsed.getTime())) {
errors.push(`Date of Birth "${dobRaw}" is not a valid date (use YYYY-MM-DD)`);
} else if (parsed.getTime() > Date.now()) {
errors.push('Date of Birth cannot be in the future');
} else {
dateOfBirth = parsed.toISOString().split('T')[0];
isChildByAge = calculateAge(parsed) < 5;
}
}
let passengerType: 'Adult' | 'Child' | '' = '';
const normalizedType = typeRaw.trim().toLowerCase();
if (normalizedType === 'adult') passengerType = 'Adult';
else if (normalizedType === 'child') passengerType = 'Child';
else errors.push(`Passenger Type "${typeRaw}" must be "Adult" or "Child"`);
// The backend computes ADULT/CHILD from date of birth alone (under 5 = Child), regardless
// of this column — flag a mismatch so the uploader notices before it surprises them later.
if (passengerType && dateOfBirth) {
const impliedType = isChildByAge ? 'Child' : 'Adult';
if (impliedType !== passengerType) {
warnings.push(`Date of Birth implies ${impliedType}, but Passenger Type is set to ${passengerType} — seats/fare are priced by age, not this column`);
}
}
if (!docTypeRaw) {
errors.push('ID Document Type is required');
} else if (!VALID_ID_TYPES.includes(docTypeRaw)) {
errors.push(`ID Document Type "${docTypeRaw}" must be one of NATIONAL_ID, PASSPORT, DRIVING_LICENSE, OTHER`);
}
if (docTypeRaw === 'PASSPORT' && !passportNumber) {
warnings.push('Passport Number is empty for a PASSPORT document type');
}
// The whole group is priced at one uniform fare tier (the one quoted at search time) — a
// row whose document type/nationality would actually resolve to the other tier will be
// priced wrong (over- or under-charged) with no per-passenger fare split to fix it.
if (quotedFareTier && VALID_ID_TYPES.includes(docTypeRaw)) {
const rowTier = inferredFareTier(docTypeRaw, nationality, passportCountry);
if (rowTier !== quotedFareTier) {
warnings.push(
`This passenger's documents imply ${rowTier === 'LOCAL' ? 'Local (Ethiopian/Djiboutian)' : 'International'} pricing, but the group was quoted at ${quotedFareTier === 'LOCAL' ? 'Local' : 'International'} rates — this passenger's actual fare will differ from the group rate`,
);
}
}
rows.push({
rowNumber: r,
fullName,
dateOfBirth,
passengerType,
isChildByAge,
idDocumentType: docTypeRaw,
idDocumentNumber: docNumber,
passportNumber,
passportCountry,
nationality,
phone,
email,
errors,
warnings,
});
}
if (rows.length === 0) {
return { rows: [], fileErrors: ['No passenger rows found below the header. Fill in at least one row and try again.'] };
}
return { rows, fileErrors: [] };
}
export function countByType(rows: ParsedPassengerRow[]): { adults: number; children: number } {
return {
adults: rows.filter((r) => !r.isChildByAge).length,
children: rows.filter((r) => r.isChildByAge).length,
};
}
/**
* Mirrors the passenger portal's isFirstChild rule exactly (fare-utils.ts): the first
* `adultCount` children in passenger order travel free with no assigned seat; any child
* beyond that gets a real seat and pays the child fare. Returns one boolean per row, true
* where that row is a free, unseated child.
*/
export function resolveFreeChildIndexes(rows: ParsedPassengerRow[], adultCount: number): boolean[] {
let childrenSeen = 0;
return rows.map((row) => {
if (!row.isChildByAge) return false;
const free = childrenSeen < adultCount;
childrenSeen++;
return free;
});
}

View File

@@ -1,3 +1,20 @@
// Package images are served by edr-passenger-api's own origin (public/uploads/packages via
// app.useStaticAssets — see apps/edr-passenger-api/src/main.ts), a different origin than this
// app, so next/image needs it explicitly whitelisted or it 400s every package image at runtime.
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
let apiImagePattern;
try {
const apiUrl = new URL(API_URL);
apiImagePattern = {
protocol: apiUrl.protocol.replace(':', ''),
hostname: apiUrl.hostname,
port: apiUrl.port || '',
pathname: '/uploads/**',
};
} catch {
apiImagePattern = { protocol: 'http', hostname: 'localhost', port: '4000', pathname: '/uploads/**' };
}
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
@@ -5,6 +22,7 @@ const nextConfig = {
transpilePackages: ['@edr/types', '@edr/ui-common'],
images: {
unoptimized: false,
remotePatterns: [apiImagePattern],
},
};

View File

@@ -659,8 +659,13 @@ export default function ConfirmationPage() {
{(() => {
// The server-confirmed settled amount is authoritative — prefer it over
// any client-side session state, which can go stale (e.g. after a refresh).
// Despite its name, PaymentIntent.amountMinor holds MAJOR units — it is the
// charge amount produced by currencyService.*ToChargeMajor (see initiate() in
// payments.service.ts; reports.service.ts multiplies it by 100 to get real
// minor units). Do NOT divide by 100 here — the voucher does the same via
// fareIsMajorUnits.
if (_booking?.payment?.amountMinor != null) {
return `${_booking.payment.currency || 'ETB'} ${(_booking.payment.amountMinor / 100).toFixed(2)}`;
return `${_booking.payment.currency || 'ETB'} ${_booking.payment.amountMinor.toFixed(2)}`;
}
if (reviewedTotalMinor != null)
return `${paidCurrency || 'ETB'} ${(reviewedTotalMinor / 100).toFixed(2)}`;

View File

@@ -20,7 +20,7 @@ const COUNTRIES = [
'Bolivia','Bosnia and Herzegovina','Botswana','Brazil','Brunei','Bulgaria','Burkina Faso','Burundi','Cabo Verde','Cambodia',
'Cameroon','Canada','Central African Republic','Chad','Chile','China','Colombia','Comoros','Congo','Costa Rica',
'Croatia','Cuba','Cyprus','Czech Republic','Denmark','Dominica','Dominican Republic','Ecuador','Egypt',
'El Salvador','Equatorial Guinea','Eritrea','Estonia','Eswatini','Fiji','Finland','France','Gabon',
'El Salvador','Equatorial Guinea','Eritrea','Estonia','Eswatini','Ethiopia','Fiji','Finland','France','Gabon',
'Gambia','Georgia','Germany','Ghana','Greece','Grenada','Guatemala','Guinea','Guinea-Bissau','Guyana',
'Haiti','Honduras','Hungary','Iceland','India','Indonesia','Iran','Iraq','Ireland','Israel',
'Italy','Jamaica','Japan','Jordan','Kazakhstan','Kenya','Kiribati','Kuwait','Kyrgyzstan','Laos',

View File

@@ -93,6 +93,12 @@ function emptyReasonCopy(
message: `The only train between ${originStationName} and ${destinationStationName} on ${date} is bookable as part of a travel package, not as a standalone ticket. Please choose another date below.`,
showAlternatives: true,
};
case Passenger.SearchEmptyReasonCode.GroupBookingOnly:
return {
title: "Reserved for a group booking",
message: `The only train between ${originStationName} and ${destinationStationName} on ${date} is reserved for a group booking, not individual tickets. Please choose another date below.`,
showAlternatives: true,
};
case Passenger.SearchEmptyReasonCode.NoScheduleOnDate:
default:
return {

View File

@@ -8,7 +8,7 @@ import { apiClient } from '@/lib/api-client';
import { format } from 'date-fns';
import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format';
import { useState, useEffect, useRef } from 'react';
import { ChevronLeft } from 'lucide-react';
import { ChevronLeft, Loader2 } from 'lucide-react';
import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
// Helper function to decode JWT token and extract passengerId
@@ -54,6 +54,17 @@ export default function ReviewPage() {
const [fareBreakdown, setFareBreakdown] = useState<any>(null);
const [returnFareBreakdown, setReturnFareBreakdown] = useState<any>(null);
const [computedTotal, setComputedTotal] = useState<number>(0);
// createBookingMutation.isPending only covers the mutation's own network call, but
// handleConfirm does real async work (seat-class lookup, passengerId resolution) before ever
// calling it — during that window the button showed no loading state and stayed clickable,
// letting a double-click race through and create two bookings for the same seat hold. This
// covers the whole handleConfirm run, not just the mutation's slice of it.
const [isSubmitting, setIsSubmitting] = useState(false);
// setIsSubmitting alone isn't enough to stop a fast double-click: React state updates aren't
// synchronous, so a second click event dispatched before the first setIsSubmitting(true) has
// actually re-rendered the button as disabled would still slip through. A ref updates
// immediately, closing that gap regardless of render timing.
const isSubmittingRef = useRef(false);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
@@ -228,6 +239,9 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
});
const handleConfirm = async () => {
if (isSubmittingRef.current || createBookingMutation.isPending) return;
isSubmittingRef.current = true;
setIsSubmitting(true);
try {
const { searchCriteria } = useBookingStore.getState();
@@ -287,6 +301,8 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
if (!seatClassId) {
alert('Unable to determine seat class. Please go back and re-select your seats.');
isSubmittingRef.current = false;
setIsSubmitting(false);
return;
}
@@ -489,8 +505,16 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
bookingData.reviewedTotalMinor = computedTotal;
await createBookingMutation.mutateAsync(bookingData);
// Deliberately not resetting isSubmitting here: every path that reaches this point is
// about to navigate away (router.push, either here or inside onSuccess's setTimeout above).
// Clearing it now would flip the button back to its idle label for the gap between the
// booking actually being created and the navigation landing — exactly the "did it get
// stuck?" flash this state exists to prevent. It only needs resetting on a genuine failure,
// handled in the catch block below, so the user can retry.
} catch (error) {
alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.');
isSubmittingRef.current = false;
setIsSubmitting(false);
}
};
@@ -691,10 +715,16 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
)}
<button
onClick={handleConfirm}
disabled={createBookingMutation.isPending}
className="btn-primary w-full"
disabled={isSubmitting || createBookingMutation.isPending}
className="btn-primary w-full disabled:opacity-50 disabled:cursor-not-allowed"
>
{createBookingMutation.isPending ? 'Creating booking...' : `Confirm ${isAuthenticated ? '' : 'and pay'}`}
{isSubmitting || createBookingMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Creating booking...
</span>
) : (
`Confirm ${isAuthenticated ? '' : 'and pay'}`
)}
</button>
<button onClick={() => router.back()} className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
@@ -1032,10 +1062,16 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
</button>
<button
onClick={handleConfirm}
disabled={createBookingMutation.isPending}
className="btn-primary flex-1 py-2.5"
disabled={isSubmitting || createBookingMutation.isPending}
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
{createBookingMutation.isPending ? 'Creating...' : `Confirm ${isAuthenticated ? '' : '& pay'}`}
{isSubmitting || createBookingMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Creating...
</span>
) : (
`Confirm ${isAuthenticated ? '' : '& pay'}`
)}
</button>
</div>
</div>

View File

@@ -1,50 +1,295 @@
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { useState, Suspense } from 'react';
import { iamAuthApi } from '@/lib/api/auth';
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
import { useState, useRef, Suspense } from 'react';
import Link from 'next/link';
import { Train, ShieldCheck, Eye, EyeOff } from 'lucide-react';
import { Train, Eye, EyeOff, Pencil } from 'lucide-react';
const loginSchema = z.object({
// Accepts either an email or a phone number. Passengers who registered without an
// email sign in with their phone number, which is sent in the same `email` field —
// the IAM matches on either identifier.
email: z.string().min(1, 'Phone or email is required'),
password: z.string().min(6, 'Password must be at least 6 characters'),
});
/**
* Staged sign-in.
*
* The passenger gives one identifier — phone or email — and the server decides which of three
* things happens next. Previously this page asked for identifier *and* password up front and
* offered three competing links underneath ("Create account", "Already verified with Fayda?",
* "Forgot password?"), which made the user guess something only the server knows: whether their
* number has an account, and whether that account has a password yet. Guessing wrong dead-ended.
*
* Now exactly one branch is ever on screen.
*/
type Step = 'identifier' | 'password' | 'setup' | 'signup';
type LoginForm = z.infer<typeof loginSchema>;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Loose enough to accept every shape a passenger might type (`+2519…`, `2519…`, `09…`) and the
* occasional foreign number, strict enough that free text never reaches the signup branch — an
* identifier that is neither an email nor a number would otherwise be stored as a phone the SMS
* code can never reach. Mirrors the 7-digit floor in the API's `normalizePhoneVariants`.
*/
const looksLikePhone = (v: string) => v.replace(/[^\d]/g, '').length >= 7;
function LoginContent() {
const router = useRouter();
const searchParams = useSearchParams();
const login = useAuthStore((s) => s.login);
const registerUser = useAuthStore((s) => s.register);
const setUser = useAuthStore((s) => s.setUser);
const [step, setStep] = useState<Step>('identifier');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
resolver: zodResolver(loginSchema as any),
});
// Step 1
const [identifier, setIdentifier] = useState('');
const identifierRef = useRef<HTMLInputElement>(null);
const onSubmit = async (data: LoginForm) => {
// Step 2 — sign in
const [password, setPassword] = useState('');
// Step 3 — set a password (existing account with none, or a fresh signup)
const [maskedPhone, setMaskedPhone] = useState('');
const [setupMethod, setSetupMethod] = useState<'fayda' | 'pending' | 'new'>('pending');
const [otp, setOtp] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [resendNote, setResendNote] = useState('');
// Step 4 — signup
const [fullName, setFullName] = useState('');
const [secondaryContact, setSecondaryContact] = useState('');
const identifierIsEmail = EMAIL_RE.test(identifier.trim());
const finish = () => {
const redirect = searchParams.get('redirect') || '/booking/search';
router.push(redirect);
};
const goBackToIdentifier = () => {
setStep('identifier');
setError('');
setPassword('');
setOtp('');
setNewPassword('');
setConfirmPassword('');
setResendNote('');
// Keep what they typed — they are usually fixing a typo, not starting over — but select
// it, so typing replaces the value instead of appending to it. Without this, clicking
// into a controlled input that still holds the old identifier silently concatenates.
setTimeout(() => identifierRef.current?.select(), 0);
};
const apiMessage = (err: any, fallback: string) =>
err?.response?.data?.message || fallback;
// --- Step 1: who are you? ---------------------------------------------------
const submitIdentifier = async (e: React.FormEvent) => {
e.preventDefault();
const value = identifier.trim();
if (!value) {
setError('Enter your phone number or email');
return;
}
if (!EMAIL_RE.test(value) && !looksLikePhone(value)) {
setError('Enter a valid phone number or email address');
return;
}
setLoading(true);
setError('');
try {
await login(data.email, data.password);
const redirect = searchParams.get('redirect') || '/booking/search';
router.push(redirect);
const res = await iamAuthApi.lookupIdentifier(identifier.trim());
const result = res.data.data;
if (result.status === 'PASSWORD') {
setStep('password');
return;
}
if (result.status === 'NEEDS_PASSWORD_SETUP') {
setMaskedPhone(result.maskedPhone || '');
setSetupMethod(result.method || 'pending');
// Fire the code now so the next screen is already actionable. It resolves even for
// an unknown identifier, so a failure here is a transport problem, not a verdict.
await iamAuthApi.requestPasswordSetup(identifier.trim());
setStep('setup');
return;
}
setStep('signup');
} catch (err: any) {
setError(err.response?.data?.message || 'Login failed. Please check your credentials.');
setError(apiMessage(err, 'Something went wrong. Please try again.'));
} finally {
setLoading(false);
}
};
// --- Step 2: existing account, has a password -------------------------------
const submitPassword = async (e: React.FormEvent) => {
e.preventDefault();
if (!password) {
setError('Enter your password');
return;
}
setLoading(true);
setError('');
try {
await login(identifier.trim(), password);
finish();
} catch (err: any) {
setError(apiMessage(err, 'Incorrect password. Please try again.'));
} finally {
setLoading(false);
}
};
// --- Step 3: set a password with the SMS code -------------------------------
const submitSetup = async (e: React.FormEvent) => {
e.preventDefault();
if (!otp.trim()) {
setError('Enter the code we sent you');
return;
}
if (!isStrongPassword(newPassword)) {
setError(PASSWORD_RULE);
return;
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
return;
}
setLoading(true);
setError('');
try {
const res = await iamAuthApi.completePasswordSetup({
identifier: identifier.trim(),
otp: otp.trim(),
newPassword,
confirmPassword,
});
const { token, user } = res.data.data;
// The response carries a real session, so the user lands signed in instead of being
// sent back to the form. `setUser` is the same action `login()` persists through.
setUser(user as any, token);
finish();
} catch (err: any) {
setError(apiMessage(err, 'That code is not valid. Please try again.'));
} finally {
setLoading(false);
}
};
const resend = async () => {
setLoading(true);
setError('');
setResendNote('');
try {
await iamAuthApi.requestPasswordSetup(identifier.trim());
setResendNote('We sent a new code.');
} catch (err: any) {
setError(apiMessage(err, 'Could not send a new code. Please try again.'));
} finally {
setLoading(false);
}
};
// --- Step 4: no account yet --------------------------------------------------
const submitSignup = async (e: React.FormEvent) => {
e.preventDefault();
const name = fullName.trim();
const other = secondaryContact.trim();
if (name.length < 2) {
setError('Enter your full name');
return;
}
// A phone is always required — the verification code is sent by SMS and there is no
// email channel for it. An email is optional.
if (identifierIsEmail) {
if (!other) {
setError('Enter your phone number');
return;
}
if (!looksLikePhone(other)) {
setError('Enter a valid phone number — your verification code is sent by SMS');
return;
}
} else if (other && !EMAIL_RE.test(other)) {
// Only validate the shape when they actually typed something.
setError('Enter a valid email address');
return;
}
const phone = identifierIsEmail ? other : identifier.trim();
// The IAM requires a non-empty account identifier in its `email` field but never checks
// that it is email-shaped, so a passenger with no email address signs up under their phone
// number — the same fallback `/register` uses. Both then match on either identifier.
const email = identifierIsEmail ? identifier.trim() : other || phone;
setLoading(true);
setError('');
try {
await registerUser({ fullName: name, email, phone });
setMaskedPhone(phone);
setSetupMethod('new');
setStep('setup');
} catch (err: any) {
if (err?.response?.status === 409) {
setError('An account with this email or phone number already exists. Go back and sign in.');
} else {
setError(apiMessage(err, 'Could not create your account. Please try again.'));
}
} finally {
setLoading(false);
}
};
/**
* The identifier, shown on every step after the first, with one way back to change it.
*
* It is a real `autocomplete="username"` input rather than a `<span>`, and it is rendered
* *inside* each form. That is what makes password managers behave: a password field sitting
* alone in a form gives Chrome nothing to match a saved credential against, so it fills
* whichever password it holds for the origin — a password belonging to some other account.
* Pairing it with the username lets the manager fill the right credential, or none at all.
*/
const identifierChip = (
<div className="flex items-center justify-between gap-3 mb-4 px-3 py-2 rounded bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<input
type="text"
value={identifier}
readOnly
tabIndex={-1}
autoComplete="username"
aria-label="Signing in as"
onFocus={(e) => e.currentTarget.blur()}
className="flex-1 min-w-0 truncate bg-transparent border-0 p-0 text-sm text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-0 cursor-default"
/>
<button
type="button"
onClick={goBackToIdentifier}
className="flex items-center gap-1 text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline shrink-0"
>
<Pencil className="w-3.5 h-3.5" />
Change
</button>
</div>
);
const heading = {
identifier: { title: 'Sign in', subtitle: 'Enter your phone number or email to continue' },
password: { title: 'Welcome back', subtitle: 'Enter your password to sign in' },
setup: { title: 'Set your password', subtitle: 'Enter the code we sent, then choose a password' },
signup: { title: 'Create your account', subtitle: 'We just need a couple of details' },
}[step];
const setupBlurb =
setupMethod === 'fayda'
? 'Your Fayda-verified account does not have a password yet.'
: setupMethod === 'new'
? 'Your account is almost ready.'
: 'You started signing up but never chose a password.';
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">
@@ -54,86 +299,227 @@ function LoginContent() {
<Train className="w-6 h-6 text-white" />
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Sign in</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">Welcome back</p>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{heading.title}</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">{heading.subtitle}</p>
</div>
<div className="card">
<form onSubmit={handleSubmit(onSubmit)} 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>
)}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone or email</label>
<input
type="text"
{...register('email')}
className="input-field"
placeholder="+251912345678 or your@email.com"
autoComplete="username"
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
)}
{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 mb-4">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Password</label>
<div className="relative">
{step === 'identifier' && (
<form onSubmit={submitIdentifier} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
Phone number or email
</label>
<input
ref={identifierRef}
type="text"
value={identifier}
onChange={(e) => { setIdentifier(e.target.value); setError(''); }}
className="input-field"
placeholder="+251912345678 or your@email.com"
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
autoFocus
/>
</div>
<button type="submit" className="btn-primary w-full" disabled={loading}>
{loading ? 'Checking...' : 'Continue'}
</button>
</form>
)}
{step === 'password' && (
<form onSubmit={submitPassword} className="space-y-4">
{identifierChip}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
Password
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => { setPassword(e.target.value); setError(''); }}
className="input-field pr-10"
placeholder="••••••••"
autoComplete="current-password"
autoFocus
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
aria-label={showPassword ? 'Hide password' : 'Show password'}
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
<div className="flex justify-end mt-1">
<Link
href="/forgot-password"
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline"
>
Forgot password?
</Link>
</div>
</div>
<button type="submit" className="btn-primary w-full" disabled={loading}>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
)}
{step === 'setup' && (
<form onSubmit={submitSetup} className="space-y-4">
{identifierChip}
<p className="text-sm text-gray-600 dark:text-gray-400">
{setupBlurb}{' '}
{maskedPhone
? <>We sent a code to <span className="font-medium">{maskedPhone}</span>.</>
: 'We sent a code to your registered phone.'}
</p>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
Verification code
</label>
<input
type="text"
value={otp}
onChange={(e) => { setOtp(e.target.value); setError(''); }}
className="input-field tracking-widest"
placeholder="A1b2C3"
// The IAM issues codes with generateRandomString(6): letters and digits,
// and case-sensitive — so no numeric keypad and no autocapitalise.
inputMode="text"
autoComplete="one-time-code"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
maxLength={6}
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
New password
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setError(''); }}
className="input-field pr-10"
placeholder="••••••••"
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
aria-label={showPassword ? 'Hide password' : 'Show password'}
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{PASSWORD_RULE}</p>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
Confirm password
</label>
<input
type={showPassword ? 'text' : 'password'}
{...register('password')}
className="input-field pr-10"
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
className="input-field"
placeholder="••••••••"
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
aria-label={showPassword ? 'Hide password' : 'Show password'}
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{errors.password && (
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
)}
<div className="flex justify-end mt-1">
<Link
href="/forgot-password"
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline"
>
Forgot password?
</Link>
<button type="submit" className="btn-primary w-full" disabled={loading}>
{loading ? 'Setting password...' : 'Set password and sign in'}
</button>
<div className="text-center">
{resendNote ? (
<span className="text-sm text-gray-600 dark:text-gray-400">{resendNote}</span>
) : (
<button
type="button"
onClick={resend}
disabled={loading}
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline disabled:opacity-50"
>
Didn&apos;t get a code? Send it again
</button>
)}
</div>
</div>
</form>
)}
<button type="submit" className="btn-primary w-full" disabled={loading}>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
{step === 'signup' && (
<form onSubmit={submitSignup} className="space-y-4">
{identifierChip}
<p className="text-sm text-gray-600 dark:text-gray-400">
We couldn&apos;t find an account for that {identifierIsEmail ? 'email' : 'number'}, so
let&apos;s create one.
</p>
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
<div className="text-center">
<span className="text-sm text-gray-600 dark:text-gray-400">Don&apos;t have an account? </span>
<Link href="/register" className="text-sm font-medium text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline">
Create account
</Link>
</div>
<Link
href="/fayda-setup"
className="flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors"
>
<ShieldCheck className="w-4 h-4" />
Already verified with Fayda? Set up your password
</Link>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
Full name
</label>
<input
type="text"
value={fullName}
onChange={(e) => { setFullName(e.target.value); setError(''); }}
className="input-field"
placeholder="e.g. Abebe Kebede"
autoComplete="name"
autoFocus
/>
</div>
<div className="mt-4 text-center">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
{identifierIsEmail ? 'Phone number' : 'Email address (optional)'}
</label>
<input
type={identifierIsEmail ? 'tel' : 'email'}
value={secondaryContact}
onChange={(e) => { setSecondaryContact(e.target.value); setError(''); }}
className="input-field"
placeholder={identifierIsEmail ? '+251912345678' : 'your@email.com'}
autoComplete={identifierIsEmail ? 'tel' : 'email'}
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{identifierIsEmail
? "We'll text your verification code to this number."
: "For receipts and booking confirmations. Your verification code is sent by SMS either way."}
</p>
</div>
<button type="submit" className="btn-primary w-full" disabled={loading}>
{loading ? 'Creating account...' : 'Create account'}
</button>
</form>
)}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 text-center">
<button
onClick={() => router.push('/booking/search')}
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-400"

View File

@@ -83,6 +83,7 @@ interface PackageDetail {
code: string;
name: string;
description: string | null;
imageUrl?: string | null;
status: string;
boardingTime: string;
departureTime: string;
@@ -1042,7 +1043,7 @@ export default function PackageDetailPage() {
{/* Hero */}
<div className="relative h-56 md:h-80 bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)] overflow-hidden">
<Image
src="/packages/package.jpeg"
src={pkg.imageUrl || "/packages/package.jpeg"}
alt={pkg.name}
fill
className="object-cover"

View File

@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Train, CheckCircle, ArrowLeft, ArrowRight } from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
function ResetPasswordContent() {
const router = useRouter();
@@ -24,8 +25,8 @@ function ResetPasswordContent() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (newPassword.length < 6) {
setError('Password must be at least 6 characters.');
if (!isStrongPassword(newPassword)) {
setError(PASSWORD_RULE);
return;
}
if (newPassword !== confirmPassword) {

View File

@@ -6,18 +6,8 @@ import Link from 'next/link';
import { Train, ArrowLeft, ShieldCheck } from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
import { useAuthStore } from '@/lib/auth-store';
import { isStrongPassword } from '@/lib/password';
// 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 searchParams = useSearchParams();

View File

@@ -192,18 +192,12 @@ export default function AppSidebar() {
)}
</div>
) : (
<div className="flex items-center gap-2 px-1 pt-1">
<div className="px-1 pt-1">
<Link
href="/login"
className="flex-1 text-center px-3 py-2 text-sm font-medium text-gray-100 hover:bg-white/10 rounded-lg transition-colors"
className="block text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
>
Sign in
</Link>
<Link
href="/register"
className="flex-1 text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
>
Register
Sign in or register
</Link>
</div>
)}

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { createPortal } from 'react-dom';
import { X, CheckCircle } from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
interface ChangePasswordModalProps {
isOpen: boolean;
@@ -32,8 +33,8 @@ export default function ChangePasswordModal({ isOpen, onClose }: ChangePasswordM
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (newPassword.length < 6) {
setError('New password must be at least 6 characters.');
if (!isStrongPassword(newPassword)) {
setError(PASSWORD_RULE);
return;
}
if (newPassword !== confirmPassword) {

View File

@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Train, ShieldCheck, CheckCircle, Info, ArrowLeft, ArrowRight } from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
interface FaydaSetupWizardProps {
// Prefilled OTP when landing from the SMS link (/set-password?verificationCode=...)
@@ -41,8 +42,8 @@ export default function FaydaSetupWizard({ initialOtp }: FaydaSetupWizardProps)
const handleSetPassword = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (newPassword.length < 6) {
setError('Password must be at least 6 characters.');
if (!isStrongPassword(newPassword)) {
setError(PASSWORD_RULE);
return;
}
if (newPassword !== confirmPassword) {

View File

@@ -42,6 +42,7 @@ interface HolidayPackage {
code: string;
name: string;
description?: string | null;
imageUrl?: string | null;
status: string;
departureTime: string;
validFrom: string;
@@ -165,7 +166,7 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
{/* ── Left: Image ── */}
<div className="relative md:w-[46%] h-64 md:h-auto flex-shrink-0 overflow-hidden">
<Image
src="/packages/package.jpeg"
src={pkg.imageUrl || "/packages/package.jpeg"}
alt={pkg.name}
fill
className="object-cover group-hover:scale-105 transition-transform duration-700 ease-out"
@@ -330,11 +331,21 @@ function PackageCard({ pkg }: { pkg: HolidayPackage }) {
return (
<Link href={`/packages/${pkg.id}`} className="group block h-full">
<div className="bg-white dark:bg-gray-900 rounded-2xl overflow-hidden border border-gray-200 dark:border-gray-800 hover:border-primary/60 hover:shadow-xl transition-all duration-300 flex flex-col h-full">
{/* Image / Gradient */}
{/* Image / Gradient fallback */}
<div className="relative h-44 overflow-hidden bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)] flex-shrink-0">
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-6xl opacity-20">🌍</span>
</div>
{pkg.imageUrl ? (
<Image
src={pkg.imageUrl}
alt={pkg.name}
fill
className="object-cover group-hover:scale-105 transition-transform duration-700 ease-out"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-6xl opacity-20">🌍</span>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
<div className="absolute top-3 left-3 flex items-center gap-2">

View File

@@ -36,6 +36,42 @@ export const iamAuthApi = {
headers: { Authorization: `Bearer ${localStorage.getItem('auth_token')}` },
}),
// --- Staged sign-in (/login) -------------------------------------------------
// Step 1: hand the server one field and let it say which branch follows. `identifier`
// is a phone number or an email; the server works out which.
lookupIdentifier: (identifier: string) =>
axios.post<{
success: boolean;
data: {
status: 'PASSWORD' | 'NEEDS_PASSWORD_SETUP' | 'NOT_FOUND';
method?: 'fayda' | 'pending';
maskedPhone?: string;
};
}>(`${API_URL}/auth/identifier/lookup`, { identifier }),
// Step 2a: SMS the code for an account that exists but has no password yet.
// Always resolves — the server reports { sent: true } even for an unknown identifier.
requestPasswordSetup: (identifier: string) =>
axios.post(`${API_URL}/auth/password-setup/request`, { identifier }),
// Step 2b: redeem the code and set the password. Unlike the older Fayda dance this
// returns a usable session directly, so the user lands signed in rather than back on
// the login form. Same response shape as POST /auth/login.
completePasswordSetup: (data: {
identifier: string;
otp: string;
newPassword: string;
confirmPassword: string;
}) =>
axios.post<{
success: boolean;
data: {
token: string;
refreshToken: string;
user: { id: string; iamUserId: string; email: string | null; passengerId: string };
};
}>(`${API_URL}/auth/password-setup/complete`, data),
faydaRequestPasswordSetup: (phoneNumber: string) =>
axios.post(`${API_URL}/auth/fayda/request-password-setup`, { phoneNumber }),

View File

@@ -113,13 +113,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
login: async (email: string, password: string) => {
const response: any = await apiClient.post('/auth/login', { email, password });
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 });
// `setUser` is the one place a session is persisted. The staged sign-in's
// password-setup branch establishes a session without going through /auth/login,
// so it calls the same action rather than duplicating the storage writes.
get().setUser(user, token);
},
register: async (data: RegisterData): Promise<RegisterResult> => {

View File

@@ -0,0 +1,21 @@
/**
* The one password rule the portal enforces.
*
* It mirrors class-validator's `@IsStrongPassword` defaults, which is what the IAM applies on
* `PATCH /v1/auth/set-password` and what `POST /auth/password-setup/complete` applies on the
* passenger API. Screens that used a looser check (`length < 6`) accepted passwords the server
* then rejected with an opaque 400, so every screen shares this instead.
*/
export 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)
);
}
/** The rule stated for humans. Shown as helper text and reused as the validation message. */
export const PASSWORD_RULE =
'Password must be at least 8 characters and include an upper-case letter, a lower-case letter, a number and a symbol.';