Merge pull request #609 from Tria-plc/alpha

Alpha
This commit is contained in:
Stephanos A.
2026-07-11 00:19:01 +03:00
committed by GitHub
15 changed files with 313 additions and 223 deletions

View File

@@ -31,7 +31,7 @@ export class CurrenciesController {
}
@Delete(':id')
@PassengerAdmin()
@PassengerStaff(PASSENGER_PERMS.currencies.manage)
@ApiBearerAuth('IAM-auth')
deleteCurrency(@Param('id') id: string) {
return this.currenciesService.deleteCurrency(id);

View File

@@ -13,7 +13,7 @@ export class CurrenciesService {
async getAllCurrencies() {
const rates = await this.prisma.currencyExchangeRate.findMany({
distinct: ['toCurrency'],
orderBy: { toCurrency: 'asc' },
orderBy: { createdAt: 'desc' },
});
const base = {
@@ -83,12 +83,14 @@ export class CurrenciesService {
throw new BadRequestException('Exchange rate must be positive');
}
const updated = await this.prisma.currencyExchangeRate.update({
where: { id },
data: {
rate: dto.exchangeRate,
},
});
// Upsert today's record so getRateOrThrow (orderBy effectiveDate desc) picks it up
const updated = await this.currencyService.upsertRate(
existing.fromCurrency,
existing.toCurrency,
dto.exchangeRate ?? Number(existing.rate),
undefined,
'MANUAL',
);
return {
id: updated.id,
@@ -112,8 +114,9 @@ export class CurrenciesService {
throw new NotFoundException('Currency not found');
}
await this.prisma.currencyExchangeRate.delete({
where: { id },
// Delete all records for this currency pair so no stale rates remain
await this.prisma.currencyExchangeRate.deleteMany({
where: { fromCurrency: existing.fromCurrency, toCurrency: existing.toCurrency },
});
return { message: 'Currency deleted successfully' };

View File

@@ -99,6 +99,8 @@ export class PaymentsService {
priceTierId: true,
adultCount: true,
childCount: true,
totalMinor: true,
currency: true,
priceTier: { select: { priceMinor: true } },
},
},
@@ -129,7 +131,7 @@ export class PaymentsService {
id: item.id,
reference: item.id.substring(0, 8),
bookingId: item.bookingId,
booking: { bookingRef: b?.bookingRef },
booking: { bookingRef: b?.bookingRef, totalMinor: b?.totalMinor, currency: b?.currency },
amountMinor,
currency: item.currency,
method: item.method,

View File

@@ -837,13 +837,16 @@ export class SeatsService {
async removeSeat(seatId: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
if (!seat.seatNumber) throw new BadRequestException('Seat already removed');
if (!seat.seatNumber || seat.seatNumber.startsWith('-')) throw new BadRequestException('Seat already removed');
// Mark as removed, then renumber all active seats in the coach
await this.prisma.seat.update({
where: { id: seatId },
data: { seatNumber: `-${seat.seatNumber}` },
});
await this.renumberCoachSeats(seat.coachId);
return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
}
@@ -854,10 +857,38 @@ export class SeatsService {
throw new BadRequestException('Seat is not removed');
}
const originalNumber = seat.seatNumber.slice(1);
await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: originalNumber } });
// Restore with a temporary placeholder number, then renumber
await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: `__restore__${seatId}` } });
await this.renumberCoachSeats(seat.coachId);
return { restored: true, seatId, seatNumber: originalNumber };
const restored = await this.prisma.seat.findUnique({ where: { id: seatId } });
return { restored: true, seatId, seatNumber: restored?.seatNumber };
}
/**
* Renumbers all active (non-removed) seats in a coach sequentially starting from 1,
* ordered by row then col. Removed seats (prefixed with "-") keep their slot but
* are excluded from the numbering sequence so numbers remain continuous.
*/
private async renumberCoachSeats(coachId: string): Promise<void> {
const allSeats = await this.prisma.seat.findMany({
where: { coachId },
orderBy: [{ row: 'asc' }, { col: 'asc' }],
select: { id: true, seatNumber: true },
});
const activeSeats = allSeats.filter(
(s) => s.seatNumber && !s.seatNumber.startsWith('-') && !s.seatNumber.startsWith('__restore__'),
);
await Promise.all(
activeSeats.map((s, idx) =>
this.prisma.seat.update({
where: { id: s.id },
data: { seatNumber: String(idx + 1) },
}),
),
);
}
@Cron(CronExpression.EVERY_MINUTE)

View File

@@ -4,6 +4,7 @@ import { PrismaService } from '../../common/prisma.service';
export const CONFIG_KEYS = {
SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes',
HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure',
BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE: 'boarding_window_hours_before_departure',
THROTTLE_AUTH_LIMIT: 'throttle_auth_limit',
THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms',
THROTTLE_STRICT_LIMIT: 'throttle_strict_limit',
@@ -15,6 +16,7 @@ export const CONFIG_KEYS = {
const DEFAULTS: Record<string, string> = {
[CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5',
[CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2',
[CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE]: '4',
[CONFIG_KEYS.THROTTLE_AUTH_LIMIT]: '5',
[CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000',
[CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20',

View File

@@ -3,9 +3,10 @@ import { TicketsController } from './tickets.controller';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
import { NotificationsModule } from '../notifications/notifications.module';
import { SystemConfigModule } from '../system-config/system-config.module';
@Module({
imports: [NotificationsModule],
imports: [NotificationsModule, SystemConfigModule],
controllers: [TicketsController],
providers: [TicketsService, JwtGuard],
exports: [TicketsService, JwtGuard],

View File

@@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
import * as QRCode from 'qrcode';
interface OfflineValidation {
@@ -20,6 +21,7 @@ export class TicketsService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly systemConfig: SystemConfigService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
@@ -423,26 +425,20 @@ export class TicketsService {
// Check if ticket date matches today
const today = new Date();
const todayDateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD format
if ((booking as any).schedule?.departureAt) {
const departureDate = new Date((booking as any).schedule.departureAt);
const departureDateStr = departureDate.toISOString().split('T')[0];
// Check if ticket is for today
if (departureDateStr !== todayDateStr) {
if (departureDateStr < todayDateStr) {
throw new BadRequestException('Ticket has expired - departure date has passed');
} else {
throw new BadRequestException('Ticket is for a future date - cannot board early');
}
}
// Additional check: ticket expires 4 hours after departure time
const departureTime = new Date((booking as any).schedule.departureAt);
const expiryTime = new Date(departureTime.getTime() + 4 * 60 * 60 * 1000); // 4 hours after departure
if (today > expiryTime) {
throw new BadRequestException('Ticket has expired - boarding window closed');
const boardingWindowHours = await this.systemConfig.getNumber(CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE);
const boardingOpenTime = new Date(departureTime.getTime() - boardingWindowHours * 60 * 60 * 1000);
if (today < boardingOpenTime) {
throw new BadRequestException(
`Boarding opens ${boardingWindowHours} hour(s) before departure at ${boardingOpenTime.toISOString()}`,
);
}
if (today >= departureTime) {
throw new BadRequestException('Boarding is closed — departure time has passed');
}
}

View File

@@ -254,12 +254,16 @@ function BookingsPageContent() {
},
{
key: 'contact', label: 'Primary contact',
render: (booking: any) => (
<div>
<div className="font-medium">{booking.contactPhone || booking.passenger?.phone}</div>
<div className="text-sm text-muted-foreground">{booking.contactEmail || booking.passenger?.email}</div>
</div>
),
render: (booking: any) => {
const phone = booking.contactPhone || booking.passenger?.phone || '—';
const email = booking.contactEmail || booking.passenger?.email || '—';
return (
<div>
<div className="font-medium">{phone}</div>
<div className="text-sm text-muted-foreground truncate" title={email}>{email}</div>
</div>
);
},
},
{
key: 'paymentStatus', label: 'Payment',

View File

@@ -146,7 +146,13 @@ export default function CurrenciesPage() {
const actions = [
{ label: 'Edit', onClick: handleEdit, variant: 'secondary' as const, icon: Edit },
{ label: 'Delete', onClick: (c: CurrencyRate) => setDeleteConfirm(c), variant: 'danger' as const, icon: Trash2 },
{
label: 'Delete',
onClick: (c: CurrencyRate) => setDeleteConfirm(c),
variant: 'danger' as const,
icon: Trash2,
show: (c: CurrencyRate) => c.id !== 'etb-base',
},
];
return (

View File

@@ -93,7 +93,7 @@ export default function PaymentsPage() {
switch (key) {
case 'reference': return payment.reference || payment.id?.substring(0, 8) || '';
case 'booking': return payment.booking?.bookingRef || 'N/A';
case 'amount': return formatCurrency(payment.amountMinor, payment.currency);
case 'amount': return formatCurrency(payment.booking?.totalMinor ?? payment.amountMinor, 'ETB');
case 'method': return payment.method || '';
case 'status': return payment.status || '';
case 'createdAt': return payment.createdAt ? new Date(payment.createdAt).toLocaleString() : '';
@@ -116,7 +116,7 @@ export default function PaymentsPage() {
const columns = [
{ key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> },
{ key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' },
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) },
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.booking?.totalMinor ?? payment.amountMinor, 'ETB') },
{ key: 'method', label: 'Method', render: (payment: any) => <Badge>{payment.method}</Badge> },
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
@@ -204,7 +204,7 @@ export default function PaymentsPage() {
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Amount', value: formatCurrency(p.amountMinor, p.currency) },
{ label: 'Amount', value: formatCurrency(p.booking?.totalMinor ?? p.amountMinor, 'ETB') },
{ label: 'Method', value: p.method || '—' },
{ label: 'Booking', value: p.booking?.bookingRef || '—' },
].map(({ label, value }) => (
@@ -222,7 +222,7 @@ export default function PaymentsPage() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-muted/40 rounded-lg p-3 col-span-2">
<p className="text-xs text-muted-foreground mb-1">Amount</p>
<p className="text-xl font-bold">{formatCurrency(p.amountMinor, p.currency || 'ETB')}</p>
<p className="text-xl font-bold">{formatCurrency(p.booking?.totalMinor ?? p.amountMinor, 'ETB')}</p>
</div>
<Field label="Method" value={p.method} />
<Field label="Status" value={p.status} />

View File

@@ -10,6 +10,7 @@ export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<Tab>('general');
const [seatHoldMinutes, setSeatHoldMinutes] = useState('5');
const [holdCutoffHours, setHoldCutoffHours] = useState('2');
const [boardingWindowHours, setBoardingWindowHours] = useState('4');
const [throttleAuthLimit, setThrottleAuthLimit] = useState('5');
const [throttleStrictLimit, setThrottleStrictLimit] = useState('20');
const [throttleDefaultLimit, setThrottleDefaultLimit] = useState('100');
@@ -24,6 +25,7 @@ export default function SettingsPage() {
.then((data) => {
if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes);
if (data?.hold_cutoff_hours_before_departure) setHoldCutoffHours(data.hold_cutoff_hours_before_departure);
if (data?.boarding_window_hours_before_departure) setBoardingWindowHours(data.boarding_window_hours_before_departure);
if (data?.throttle_auth_limit) setThrottleAuthLimit(data.throttle_auth_limit);
if (data?.throttle_strict_limit) setThrottleStrictLimit(data.throttle_strict_limit);
if (data?.throttle_default_limit) setThrottleDefaultLimit(data.throttle_default_limit);
@@ -39,6 +41,7 @@ export default function SettingsPage() {
await systemConfigApi.update({
seat_hold_duration_minutes: seatHoldMinutes,
hold_cutoff_hours_before_departure: holdCutoffHours,
boarding_window_hours_before_departure: boardingWindowHours,
throttle_auth_limit: throttleAuthLimit,
throttle_strict_limit: throttleStrictLimit,
throttle_default_limit: throttleDefaultLimit,
@@ -184,6 +187,23 @@ export default function SettingsPage() {
Seat holds are rejected when this many hours or fewer remain before departure. Default: 2 hours.
</p>
</div>
<div className="max-w-sm space-y-2">
<label className="label" htmlFor="boarding-window">
Boarding Window Before Departure (hours)
</label>
<input
id="boarding-window"
type="number"
min="1"
max="24"
className="input"
value={boardingWindowHours}
onChange={(e) => setBoardingWindowHours(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Boarding opens this many hours before departure and closes exactly at departure time. Default: 4 hours.
</p>
</div>
</>
)}
<div className="flex items-center gap-3">

View File

@@ -72,6 +72,36 @@ function clearPendingFaydaIndex() {
window.sessionStorage.removeItem(FAYDA_PENDING_INDEX_KEY);
}
// Verification is a full-page redirect out to Fayda and back (same flow on desktop and mobile —
// no popup). The in-progress form only lives in React memory, which the reload wipes, so we
// snapshot it to sessionStorage before leaving and restore it (in the form's defaultValues) on
// return. sessionStorage survives a same-tab navigation, including the cross-origin round trip.
const FAYDA_FORM_SNAPSHOT_KEY = 'edr_fayda_form_snapshot';
function saveFaydaFormSnapshot(snapshot: unknown) {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(FAYDA_FORM_SNAPSHOT_KEY, JSON.stringify(snapshot));
} catch {
// sessionStorage full/unavailable — verification still works, only unsaved fields are lost.
}
}
function getFaydaFormSnapshot(): { passengers?: any[]; createAccount?: boolean } | null {
if (typeof window === 'undefined') return null;
try {
const raw = window.sessionStorage.getItem(FAYDA_FORM_SNAPSHOT_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function clearFaydaFormSnapshot() {
if (typeof window === 'undefined') return;
window.sessionStorage.removeItem(FAYDA_FORM_SNAPSHOT_KEY);
}
// Fayda may return gender as "MALE"/"M" etc — normalize to the form's expected values
function normalizeFaydaGender(raw: unknown): 'Male' | 'Female' | '' {
const g = String(raw || '').trim().toUpperCase();
@@ -85,11 +115,13 @@ function DobPickerModal({
onChange,
error,
passengerType = 'ADULT',
disabled = false,
}: {
value: string;
onChange: (iso: string) => void;
error?: string;
passengerType?: 'ADULT' | 'CHILD';
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [manualMode, setManualMode] = useState(false);
@@ -286,9 +318,10 @@ function DobPickerModal({
<button
type="button"
onClick={() => setOpen(true)}
disabled={disabled}
className={`input-field w-full text-left flex items-center justify-between ${
error ? 'border-red-500' : ''
}`}
} ${disabled ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''}`}
>
<span className={displayValue ? 'text-gray-900 dark:text-white text-sm' : 'text-gray-400 text-sm'}>
{displayValue || 'Select date of birth'}
@@ -470,12 +503,14 @@ function PhoneInput({
onInterimChange,
onNormalized,
error,
disabled = false,
}: {
nationality: string;
storedValue: string;
onInterimChange: (full: string) => void;
onNormalized: (full: string) => void;
error?: string;
disabled?: boolean;
}) {
const nat = getPhoneNat(nationality);
const preset = PHONE_PRESETS[nat];
@@ -519,7 +554,10 @@ function PhoneInput({
onBlur={handleBlur}
placeholder={preset.example}
autoComplete="tel"
className="flex-1 px-3 py-2.5 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-white outline-none min-w-0"
readOnly={disabled}
className={`flex-1 px-3 py-2.5 text-sm text-gray-900 dark:text-white outline-none min-w-0 ${
disabled ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : 'bg-white dark:bg-gray-900'
}`}
/>
</div>
{error ? (
@@ -565,6 +603,10 @@ const passengerSchema = z.object({
passportIssuingAuthority: z.string().optional(),
faydaVerified: z.boolean().optional(),
faydaSub: z.string().optional(),
// Set when the corresponding contact value was supplied by Fayda (vs typed by the user) —
// a Fayda-supplied phone/email is locked; a field Fayda left blank stays editable.
faydaEmailLocked: z.boolean().optional(),
faydaPhoneLocked: z.boolean().optional(),
formExpanded: z.boolean().optional(),
}).superRefine((data, ctx) => {
if (data.gender !== 'Male' && data.gender !== 'Female') {
@@ -635,7 +677,7 @@ function createFormSchema(adultCount: number) {
type FormData = z.infer<ReturnType<typeof createFormSchema>>;
export default function PassengersPage() {
function PassengersForm() {
const router = useRouter();
const { searchCriteria, passengers: storedPassengers, setPassengers, setCreateAccount } = useBookingStore();
const { user, isAuthenticated, updateUser } = useAuthStore();
@@ -662,6 +704,11 @@ export default function PassengersPage() {
mode: 'onChange',
defaultValues: {
passengers: Array.from({ length: totalPassengers }, (_, i) => {
// Returning from a Fayda redirect: restore the exact in-progress form we snapshotted
// before leaving, so no passenger's typed data is lost. The completion effect then
// applies the verified attributes on top for the passenger who initiated it.
const snap = getFaydaFormSnapshot()?.passengers?.[i];
if (snap) return snap;
const stored = storedPassengers[i];
if (stored?.name) {
return {
@@ -700,7 +747,7 @@ export default function PassengersPage() {
formExpanded: i >= adultCount,
};
}),
createAccount: false,
createAccount: getFaydaFormSnapshot()?.createAccount ?? false,
},
});
@@ -748,6 +795,26 @@ export default function PassengersPage() {
}
}, []);
// The redirect snapshot is consumed once, during the form's defaultValues at mount. Clear it
// afterwards so a later visit to this page doesn't restore stale data.
useEffect(() => {
clearFaydaFormSnapshot();
}, []);
// Show the green "verified" banner for any passenger restored from a snapshot as already
// Fayda-verified (verificationStatus is React state and doesn't survive the redirect).
useEffect(() => {
if (!formInitialized) return;
setVerificationStatus((prev) => {
const next = { ...prev };
passengers.forEach((p, i) => {
if ((p as any)?.faydaVerified && !next[i]) next[i] = 'success';
});
return next;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formInitialized]);
// Complete Fayda verification once the form is ready and callback params are present.
// This effect runs whenever this route reloads with ?code&state — which happens either
// inside the verification popup, or, if the browser refused to open a popup, as a full
@@ -764,8 +831,10 @@ export default function PassengersPage() {
`/fayda/verification/complete?code=${encodeURIComponent(faydaParams.code)}&state=${encodeURIComponent(faydaParams.state)}`
);
if (response?.success && response?.data?.verified) {
const d = response.data;
// apiClient already unwraps the { success, data } envelope, so `response` is the
// verification result itself.
const d = response;
if (d?.verified) {
const faydaSub: string | undefined = d.sub || d.faydaSub || d.fin;
// A single Fayda identity can't be reused across two different passengers.
@@ -786,12 +855,22 @@ export default function PassengersPage() {
if (normalizedGender) setValue(`passengers.${targetIndex}.gender`, normalizedGender, { shouldValidate: true });
if (faydaSub) setValue(`passengers.${targetIndex}.faydaSub`, faydaSub);
// Only fill in this passenger's own contact fields if they haven't entered them yet.
if (d.email && !watch(`passengers.${targetIndex}.email`)) setValue(`passengers.${targetIndex}.email`, d.email, { shouldValidate: true });
if (d.phoneNumber && !watch(`passengers.${targetIndex}.phone`)) setValue(`passengers.${targetIndex}.phone`, d.phoneNumber, { shouldValidate: true });
if (d.email && !watch(`passengers.${targetIndex}.email`)) {
setValue(`passengers.${targetIndex}.email`, d.email, { shouldValidate: true });
setValue(`passengers.${targetIndex}.faydaEmailLocked`, true);
}
if (d.phoneNumber && !watch(`passengers.${targetIndex}.phone`)) {
setValue(`passengers.${targetIndex}.phone`, d.phoneNumber, { shouldValidate: true });
setValue(`passengers.${targetIndex}.faydaPhoneLocked`, true);
}
setValue(`passengers.${targetIndex}.faydaVerified`, true);
setValue(`passengers.${targetIndex}.formExpanded`, true);
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'success' }));
setFaydaErrors((prev) => { const next = { ...prev }; delete next[targetIndex]; return next; });
if (targetIndex === 0 && isAuthenticated) {
updateUser({ fullName: d.fullName, faydaVerified: true });
}
}
} else {
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' }));
@@ -824,6 +903,15 @@ export default function PassengersPage() {
useEffect(() => {
const populateForm = async () => {
if (!isInitialized) return;
// Returning from a Fayda redirect (?code&state): the snapshot restore + completion effect
// own the form here — don't overwrite passenger 0 with the profile fetch.
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
if (params.get('code') && params.get('state')) {
setFormInitialized(true);
return;
}
}
if (!isAuthenticated || !user?.id || !searchCriteria) {
setFormInitialized(true);
return;
@@ -864,16 +952,19 @@ export default function PassengersPage() {
const openFaydaVerification = async (index: number) => {
if (typeof window === 'undefined') return;
// Only one passenger can verify at a time this keeps the status poll below
// (which has no passenger identifier of its own) unambiguous about who it belongs to.
// Only one passenger can verify at a time so the returning ?code&state is unambiguously
// applied to the passenger who started it.
if (verifyingIndex !== null) return;
setVerifyingIndex(index);
setVerificationStatus((prev) => ({ ...prev, [index]: 'pending' }));
setFaydaErrors((prev) => { const next = { ...prev }; delete next[index]; return next; });
// Persist which passenger this is for so it survives a full-page redirect/reload
// if the browser can't open a popup (e.g. some mobile browsers).
// Stash the verifying passenger index and a full snapshot of the in-progress form. Both live
// in sessionStorage, which survives the same-tab round trip out to Fayda and back — so no
// typed data is lost and the callback is applied to the right passenger. Identical flow on
// desktop and mobile: a full-page redirect, no popup and no window.opener dependency.
setPendingFaydaIndex(index);
saveFaydaFormSnapshot({ passengers: watch('passengers'), createAccount: watch('createAccount') });
try {
const response: any = await apiClient.post('/fayda/verification/start', {
@@ -881,78 +972,16 @@ export default function PassengersPage() {
platform: 'WEB',
saveToAccount: index === 0 && isAuthenticated,
});
const authorizationUrl = response.authorizationUrl;
const width = 600;
const height = 700;
const left = (window.screen.width - width) / 2;
const top = (window.screen.height - height) / 2;
const popup = window.open(
authorizationUrl,
'FaydaVerification',
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`
);
if (!popup) {
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
setFaydaErrors((prev) => ({ ...prev, [index]: 'Unable to open the Fayda verification window. Please allow pop-ups and try again.' }));
setVerifyingIndex(null);
clearPendingFaydaIndex();
return;
}
const checkPopup = setInterval(async () => {
if (popup.closed) {
clearInterval(checkPopup);
try {
const statusResponse: any = await apiClient.get('/fayda/verification/status');
if (statusResponse.verified) {
const faydaSub: string | undefined = statusResponse.sub || statusResponse.faydaSub || statusResponse.fin;
const usedByOther = faydaSub && passengers.some(
(p, i) => i !== index && (p as any).faydaSub === faydaSub,
);
if (usedByOther) {
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
setFaydaErrors((prev) => ({ ...prev, [index]: 'This Fayda identity is already linked to another passenger on this booking.' }));
} else {
setValue(`passengers.${index}.name`, statusResponse.fullName || '', { shouldValidate: true });
if (statusResponse.dateOfBirth) setValue(`passengers.${index}.dateOfBirth`, statusResponse.dateOfBirth, { shouldValidate: true });
const normalizedGender = normalizeFaydaGender(statusResponse.gender);
if (normalizedGender) setValue(`passengers.${index}.gender`, normalizedGender, { shouldValidate: true });
if (faydaSub) setValue(`passengers.${index}.faydaSub`, faydaSub);
setValue(`passengers.${index}.faydaVerified`, true);
setValue(`passengers.${index}.formExpanded`, true);
setVerificationStatus((prev) => ({ ...prev, [index]: 'success' }));
setFaydaErrors((prev) => { const next = { ...prev }; delete next[index]; return next; });
if (index === 0 && isAuthenticated) {
updateUser({
fullName: statusResponse.fullName,
faydaVerified: true,
faydaVerifiedAt: statusResponse.verifiedAt,
});
}
}
} else {
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
setFaydaErrors((prev) => ({ ...prev, [index]: 'Fayda verification was not completed. Please try again or enter details manually.' }));
}
} catch (error) {
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to confirm verification status. Please try again.' }));
} finally {
setVerifyingIndex(null);
clearPendingFaydaIndex();
}
}
}, 1000);
// Redirect the whole tab to eSignet. Fayda returns the browser to this same route
// (FAYDA_WEB_REDIRECT_URI = <portal>/booking/passengers) with ?code&state, which the
// completion effect above picks up on mount.
window.location.href = response.authorizationUrl;
} catch (error) {
setVerificationStatus((prev) => ({ ...prev, [index]: 'error' }));
setFaydaErrors((prev) => ({ ...prev, [index]: 'Failed to start verification. Please try again.' }));
setVerifyingIndex(null);
clearPendingFaydaIndex();
clearFaydaFormSnapshot();
}
};
@@ -1025,12 +1054,8 @@ export default function PassengersPage() {
}
};
useEffect(() => {
if (!searchCriteria) {
window.location.href = '/';
}
}, [searchCriteria, router]);
// searchCriteria is guaranteed present here — the PassengersPage gate below only mounts this
// component after the persisted booking store has rehydrated and confirmed a booking exists.
if (!searchCriteria) return null;
if (!formInitialized || faydaCompleting) {
@@ -1070,6 +1095,13 @@ export default function PassengersPage() {
const isVerifyingThis = verifyingIndex === index;
const isVerifyingOther = verifyingIndex !== null && verifyingIndex !== index;
const faydaError = faydaErrors[index];
// Identity fields sourced from a completed Fayda verification are locked — the
// passenger can't edit the verified name / date of birth / gender.
const isFaydaLocked = !!passengers[index]?.faydaVerified;
// Contact fields lock only when Fayda actually supplied them; a value Fayda left
// blank stays editable so the passenger can add their own phone/email.
const isPhoneLocked = !!passengers[index]?.faydaPhoneLocked;
const isEmailLocked = !!passengers[index]?.faydaEmailLocked;
return (
<div key={field.id} className="card">
@@ -1148,7 +1180,7 @@ export default function PassengersPage() {
{status === 'success' && (
<div className="p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg mb-4">
<p className="text-green-700 dark:text-green-300 text-sm flex items-center gap-2">
<CheckCircle className="w-4 h-4" /> Verified with Fayda details auto-filled below
<CheckCircle className="w-4 h-4" /> Verified with Fayda verified details are locked below
</p>
</div>
)}
@@ -1165,7 +1197,8 @@ export default function PassengersPage() {
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
<input
{...register(`passengers.${index}.name`)}
className={`input-field ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
readOnly={isFaydaLocked}
className={`input-field ${isFaydaLocked ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''} ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
placeholder="Full name as per ID"
/>
{errors.passengers?.[index]?.name && (
@@ -1181,20 +1214,29 @@ export default function PassengersPage() {
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
error={errors.passengers?.[index]?.dateOfBirth?.message}
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
disabled={isFaydaLocked}
/>
</div>
{/* Gender */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
<select
{...register(`passengers.${index}.gender`)}
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
>
<option value="">Select gender</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
{isFaydaLocked ? (
<input
value={passengers[index]?.gender || ''}
readOnly
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
/>
) : (
<select
{...register(`passengers.${index}.gender`)}
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
>
<option value="">Select gender</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
)}
{errors.passengers?.[index]?.gender && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
)}
@@ -1226,6 +1268,7 @@ export default function PassengersPage() {
onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
error={errors.passengers?.[index]?.phone?.message}
disabled={isPhoneLocked}
/>
</div>
@@ -1235,7 +1278,8 @@ export default function PassengersPage() {
<input
type="email"
{...register(`passengers.${index}.email`)}
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
readOnly={isEmailLocked}
className={`input-field ${isEmailLocked ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''} ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
placeholder="email@example.com"
/>
{errors.passengers?.[index]?.email && (
@@ -1270,20 +1314,29 @@ export default function PassengersPage() {
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
error={errors.passengers?.[index]?.dateOfBirth?.message}
passengerType={isChildPassenger ? 'CHILD' : 'ADULT'}
disabled={isFaydaLocked}
/>
</div>
{/* Gender */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
<select
{...register(`passengers.${index}.gender`)}
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
>
<option value="">Select gender</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
{isFaydaLocked ? (
<input
value={passengers[index]?.gender || ''}
readOnly
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
/>
) : (
<select
{...register(`passengers.${index}.gender`)}
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
>
<option value="">Select gender</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
)}
{errors.passengers?.[index]?.gender && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
)}
@@ -1324,7 +1377,8 @@ export default function PassengersPage() {
<input
type="email"
{...register(`passengers.${index}.email`)}
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
readOnly={isEmailLocked}
className={`input-field ${isEmailLocked ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''} ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
placeholder="email@example.com"
/>
{errors.passengers?.[index]?.email && (
@@ -1446,3 +1500,40 @@ export default function PassengersPage() {
</div>
);
}
/**
* Gate that waits for the persisted booking store to finish rehydrating from localStorage before
* mounting the form. This matters on a full page load — notably returning from the Fayda redirect
* (`/booking/passengers?code&state`) — where reading searchCriteria too early would (a) bounce to
* home and (b) initialise react-hook-form with the wrong passenger count. Once hydrated: no
* booking → redirect home; booking present → render the form with correct defaults.
*/
export default function PassengersPage() {
const searchCriteria = useBookingStore((s) => s.searchCriteria);
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
if (useBookingStore.persist.hasHydrated()) {
setHydrated(true);
return;
}
const unsub = useBookingStore.persist.onFinishHydration(() => setHydrated(true));
return unsub;
}, []);
useEffect(() => {
if (hydrated && !searchCriteria) {
window.location.href = '/';
}
}, [hydrated, searchCriteria]);
if (!hydrated || !searchCriteria) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
return <PassengersForm />;
}

View File

@@ -831,7 +831,7 @@ export default function SearchPage() {
etc.) are portaled to <body> — see ModernDatePicker — so they aren't
capped by this wrapper's own stacking context. ── */}
<div
className="relative pt-4 pb-4 md:pt-0 md:pb-0 md:absolute md:bottom-8 md:left-0 md:right-0 z-[35] px-4 md:px-6"
className="relative pt-4 pb-4 md:pt-0 md:pb-0 md:absolute md:bottom-8 md:left-0 md:right-0 z-[30] px-4 md:px-6"
ref={widgetRef}
>
<div className="max-w-6xl mx-auto">

View File

@@ -60,16 +60,6 @@ const FAQ_CATEGORIES: FAQCategory[] = [
answer:
'Yes. When booking as a logged-in user you can save passenger profiles. On subsequent bookings you can select a saved passenger instead of re-entering their details.',
},
{
question: 'How do I modify my booking?',
answer:
'Log in and go to your profile, find the booking, and select Modify. Changes are allowed up to 24 hours before departure. Fare differences may apply.',
},
{
question: 'What is the cancellation policy?',
answer:
'Cancellations made at least 48 hours before departure receive a full refund. Cancellations within 48 hours may be subject to a fee. Refunds are returned to your original payment method or wallet.',
},
],
},
{
@@ -84,28 +74,13 @@ const FAQ_CATEGORIES: FAQCategory[] = [
{
question: 'What are the passenger age categories?',
answer:
'Adults are passengers aged 5 years and above and pay 100% of the fare. Children are passengers under 5 years old — the first child in a booking travels free, and any additional children pay the full fare.',
'Adults are passengers aged 5 years and above and pay 100% of the fare. Children are passengers under 5 years old — the first child per adult travels free, and any additional children pay the full fare.',
},
{
question: 'How is a child\'s age determined?',
answer:
'Age is calculated automatically from the date of birth you enter for each passenger. Make sure to enter the correct date of birth so the right fare is applied.',
},
{
question: 'Example: how much does a family of 2 adults + 3 children pay?',
answer:
'The first child is free, so you pay for 2 adults + 2 children = 4× the base fare for that seat class and distance.',
},
{
question: 'What seat classes are available?',
answer:
'Three classes are available: Economy Regular (standard seating), Economy Bed (sleeping berth in economy), and VIP Bed (premium sleeping berth). Each has its own base fare.',
},
{
question: 'What is the nationality field for?',
answer:
'Nationality determines which ID verification path applies. Ethiopian nationals are verified via the Verifayda national ID system. Djiboutian and other international passengers use their passport instead.',
},
],
},
{
@@ -153,11 +128,6 @@ const FAQ_CATEGORIES: FAQCategory[] = [
answer:
'On the search page, tap the From or To field and browse or search the full list of stations. Each station shows its code and country.',
},
{
question: 'Are prices shown in my local currency?',
answer:
'All transactions are processed in Ethiopian Birr (ETB). You can view prices in ETB, Djiboutian Franc (DJF), or US Dollar (USD) by selecting your preferred display currency on the fare or booking screen.',
},
],
},
{
@@ -167,22 +137,12 @@ const FAQ_CATEGORIES: FAQCategory[] = [
{
question: 'What payment methods are accepted?',
answer:
'We accept Telebirr, CBE Birr, eBirr, credit/debit cards, and EDR Wallet balance. You can choose your preferred method at checkout.',
},
{
question: 'What is the EDR Wallet?',
answer:
'The EDR Wallet is a stored-value account linked to your profile. You can top it up and use it to pay for tickets instantly. Your wallet balance and transaction history are available in your profile.',
},
{
question: 'When will I receive my refund?',
answer:
'Refunds are processed within 57 business days to your original payment method. If you paid via EDR Wallet, the refund is credited to your wallet immediately.',
'We accept Telebirr, Waafi, D-Money, CBE Birr, and more. You can choose your preferred method at checkout.',
},
{
question: 'Is my payment information secure?',
answer:
'Yes. We do not store card details. All payments are processed through certified payment providers. Transactions are encrypted end-to-end.',
'Yes. All payments are processed through certified payment providers. Transactions are encrypted end-to-end.',
},
],
},
@@ -226,16 +186,6 @@ const FAQ_CATEGORIES: FAQCategory[] = [
answer:
'Tap "Forgot password" on the login page, enter your registered email, and follow the reset link sent to your inbox.',
},
{
question: 'How do I set up Verifayda on my account?',
answer:
'Go to your profile and find the Fayda Setup section. Enter your national ID to link your verified identity to your account. This enables faster booking as your details are pre-filled.',
},
{
question: 'Can I use the app in multiple languages?',
answer:
'Yes. The app supports English, Amharic (አማርኛ), Afaan Oromoo, and French. Change your language from the navigation bar.',
},
],
},
];

View File

@@ -1,6 +1,6 @@
'use client';
import { Moon, Sun, Monitor } from 'lucide-react';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from './ThemeProvider';
import { useEffect, useState } from 'react';
@@ -12,27 +12,11 @@ export default function ThemeToggle() {
setMounted(true);
}, []);
const cycleTheme = () => {
if (theme === 'light') {
setTheme('dark');
} else if (theme === 'dark') {
setTheme('system');
} else {
setTheme('light');
}
};
const cycleTheme = () => setTheme(theme === 'light' ? 'dark' : 'light');
const getIcon = () => {
if (theme === 'light') return <Sun className="w-5 h-5" />;
if (theme === 'dark') return <Moon className="w-5 h-5" />;
return <Monitor className="w-5 h-5" />;
};
const getIcon = () => theme === 'dark' ? <Moon className="w-5 h-5" /> : <Sun className="w-5 h-5" />;
const getLabel = () => {
if (theme === 'light') return 'Light';
if (theme === 'dark') return 'Dark';
return 'System';
};
const getLabel = () => theme === 'dark' ? 'Dark' : 'Light';
// Prevent hydration mismatch by not rendering until mounted
if (!mounted) {