mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 17:08:18 +00:00
Merge pull request #311 from Tria-plc/alpha
Update fare display, notification for guest user and fix seat allocation
This commit is contained in:
@@ -3,15 +3,15 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
// Nationality → home currency mapping
|
// Nationality → home currency mapping (keys are uppercase for case-insensitive lookup)
|
||||||
export const NATIONALITY_CURRENCY_MAP: Record<string, Currency> = {
|
export const NATIONALITY_CURRENCY_MAP: Record<string, Currency> = {
|
||||||
Ethiopian: Currency.ETB,
|
ETHIOPIAN: Currency.ETB,
|
||||||
Djiboutian: Currency.DJF,
|
DJIBOUTIAN: Currency.DJF,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveCurrencyFromNationality(nationality?: string): Currency {
|
export function resolveCurrencyFromNationality(nationality?: string): Currency {
|
||||||
if (!nationality) return Currency.ETB;
|
if (!nationality) return Currency.ETB;
|
||||||
return NATIONALITY_CURRENCY_MAP[nationality] ?? Currency.USD;
|
return NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? Currency.USD;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FareCalculateDto {
|
export class FareCalculateDto {
|
||||||
@@ -29,7 +29,7 @@ export class FareCalculateDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'Ethiopian',
|
example: 'Ethiopian',
|
||||||
description: 'Passenger nationality. Determines the billing currency: Ethiopian → ETB, Djiboutian → DJF, other → USD. Defaults to ETB.',
|
description: 'Passenger nationality. Determines the billing currency: ETHIOPIAN → ETB, DJIBOUTIAN → DJF, other → USD. Case-insensitive. Defaults to ETB.',
|
||||||
})
|
})
|
||||||
@IsOptional() @IsString() nationality?: string;
|
@IsOptional() @IsString() nationality?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -295,20 +295,34 @@ export class NotificationsService {
|
|||||||
{ category: 'PAYMENT', deepLink: `edr://tickets/${ref}` },
|
{ category: 'PAYMENT', deepLink: `edr://tickets/${ref}` },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Resolve SMS phone: prefer the IAM user's stored number, fall back to the phone
|
||||||
|
// the passenger entered on the booking form (contactPhone).
|
||||||
|
const contactPhone: string | null = (booking as any)?.contactPhone ?? (payload.booking as any)?.contactPhone ?? null;
|
||||||
|
const iamPhone = passengerId ? await this.getRecipientAddress(passengerId, 'SMS').catch(() => null) : null;
|
||||||
|
const smsPhone = iamPhone ?? contactPhone;
|
||||||
|
|
||||||
// Ticket not ready (generation failed/raced) — fall back to a payment-only confirmation.
|
// Ticket not ready (generation failed/raced) — fall back to a payment-only confirmation.
|
||||||
if (!ticket || !booking) {
|
if (!ticket || !booking) {
|
||||||
this.logger.warn(`payment.succeeded: ticket not ready for booking ${ref}; sending payment-only confirmation`);
|
this.logger.warn(`payment.succeeded: ticket not ready for booking ${ref}; sending payment-only confirmation`);
|
||||||
const text = `EDR: Payment of ${amount} ${currency} received for booking ${ref}. Your ticket is being prepared.`;
|
const text = `EDR: Payment of ${amount} ${currency} received for booking ${ref}. Your ticket is being prepared.`;
|
||||||
await this.deliverEmail(passengerId, `Payment received — ${ref}`, text);
|
await this.deliverEmail(passengerId, `Payment received — ${ref}`, text);
|
||||||
await this.deliverSms(passengerId, text);
|
if (smsPhone) {
|
||||||
|
await this.smsClient.sendSms({ to: smsPhone, message: text }).catch(() => null);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`No SMS phone for booking ${ref}`);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SMS — short pointer (no HTML/QR over SMS).
|
// SMS — short pointer (no HTML/QR over SMS).
|
||||||
await this.deliverSms(
|
if (smsPhone) {
|
||||||
passengerId,
|
await this.smsClient.sendSms({
|
||||||
`EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`,
|
to: smsPhone,
|
||||||
);
|
message: `EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`,
|
||||||
|
}).catch(() => null);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`No SMS phone for booking ${ref}`);
|
||||||
|
}
|
||||||
|
|
||||||
// EMAIL — rich HTML ticket with plain-text fallback.
|
// EMAIL — rich HTML ticket with plain-text fallback.
|
||||||
await this.deliverEmail(
|
await this.deliverEmail(
|
||||||
|
|||||||
@@ -128,12 +128,16 @@ export class PaymentsController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List payment systems supported by the platform",
|
summary: "List payment systems supported by the platform",
|
||||||
description:
|
description:
|
||||||
"Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger's nationality.",
|
"Returns the global catalog of accepted payment systems. Filter by `currency` (e.g. ETB, DJF, USD) to get methods that settle in that currency, and/or by `region` to match a passenger's nationality. Both filters can be combined.",
|
||||||
})
|
})
|
||||||
|
@ApiQuery({ name: "currency", required: false, example: "DJF", description: "Settlement currency — ETB, DJF, USD, etc." })
|
||||||
@ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false })
|
@ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false })
|
||||||
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
|
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
|
||||||
getMethods(@Query("region") region?: PaymentRegionEnum) {
|
getMethods(
|
||||||
return this.service.getSupportedPaymentMethods(region);
|
@Query("currency") currency?: string,
|
||||||
|
@Query("region") region?: PaymentRegionEnum,
|
||||||
|
) {
|
||||||
|
return this.service.getSupportedPaymentMethods(region, currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("checkout")
|
@Get("checkout")
|
||||||
|
|||||||
@@ -473,7 +473,7 @@ export class PaymentsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
|
getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) {
|
||||||
return this.prisma.paymentMethod.findMany({
|
return this.prisma.paymentMethod.findMany({
|
||||||
where: {
|
where: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -487,6 +487,7 @@ export class PaymentsService {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(currency ? { currency: currency.toUpperCase() } : {}),
|
||||||
},
|
},
|
||||||
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
|
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
|||||||
import { CurrencyService } from '../currency/currency.service';
|
import { CurrencyService } from '../currency/currency.service';
|
||||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||||
import { SegmentsService } from '../segments/segments.service';
|
import { SegmentsService } from '../segments/segments.service';
|
||||||
|
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
const POINTS_TO_MINOR = 10;
|
const POINTS_TO_MINOR = 10;
|
||||||
@@ -307,9 +308,13 @@ export class SearchService {
|
|||||||
(new Date(leg2DepartureAt).getTime() - new Date(leg1ArrivalAt).getTime()) / 60_000,
|
(new Date(leg2DepartureAt).getTime() - new Date(leg1ArrivalAt).getTime()) / 60_000,
|
||||||
);
|
);
|
||||||
|
|
||||||
const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
|
const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
|
||||||
const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
|
const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
|
||||||
const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0);
|
const leg1MinDisplay = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.displayAmountMinor).filter((n: number) => n > 0), Infinity);
|
||||||
|
const leg2MinDisplay = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.displayAmountMinor).filter((n: number) => n > 0), Infinity);
|
||||||
|
const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0);
|
||||||
|
const combinedMinFareDisplay = (isFinite(leg1MinDisplay) ? leg1MinDisplay : 0) + (isFinite(leg2MinDisplay) ? leg2MinDisplay : 0);
|
||||||
|
const displayCurrency = leg1Result.displayCurrency ?? leg2Result.displayCurrency ?? Currency.ETB;
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
type: 'TRANSIT',
|
type: 'TRANSIT',
|
||||||
@@ -318,7 +323,9 @@ export class SearchService {
|
|||||||
connectionMinutes,
|
connectionMinutes,
|
||||||
leg1: leg1Result,
|
leg1: leg1Result,
|
||||||
leg2: leg2Result,
|
leg2: leg2Result,
|
||||||
|
displayCurrency,
|
||||||
combinedMinFareMinor,
|
combinedMinFareMinor,
|
||||||
|
combinedMinFareDisplay,
|
||||||
// Convenience top-level fields so round-trip filter can read them uniformly
|
// Convenience top-level fields so round-trip filter can read them uniformly
|
||||||
departureAt: leg1Result.departureAt,
|
departureAt: leg1Result.departureAt,
|
||||||
arrivalAt: leg2Result.arrivalAt,
|
arrivalAt: leg2Result.arrivalAt,
|
||||||
@@ -379,6 +386,8 @@ export class SearchService {
|
|||||||
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||||
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
||||||
|
|
||||||
|
const displayCurrency = faresByClass[0]?.displayCurrency ?? resolveCurrencyFromNationality(nationality);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'DIRECT',
|
type: 'DIRECT',
|
||||||
scheduleId: schedule.id,
|
scheduleId: schedule.id,
|
||||||
@@ -395,6 +404,7 @@ export class SearchService {
|
|||||||
.map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })),
|
.map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })),
|
||||||
availabilityByClass,
|
availabilityByClass,
|
||||||
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
||||||
|
displayCurrency,
|
||||||
faresByClass,
|
faresByClass,
|
||||||
coachTypes,
|
coachTypes,
|
||||||
};
|
};
|
||||||
@@ -467,7 +477,7 @@ export class SearchService {
|
|||||||
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
|
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
|
||||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
||||||
|
|
||||||
const displayCurrency = dto.displayCurrency ?? Currency.ETB;
|
const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
|
||||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||||
: totalMinor;
|
: totalMinor;
|
||||||
@@ -494,7 +504,9 @@ export class SearchService {
|
|||||||
originStationId: string,
|
originStationId: string,
|
||||||
destinationStationId: string,
|
destinationStationId: string,
|
||||||
nationality?: string,
|
nationality?: string,
|
||||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
|
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
|
||||||
|
const displayCurrency = resolveCurrencyFromNationality(nationality);
|
||||||
|
|
||||||
const seatClassIds: string[] = Array.from(
|
const seatClassIds: string[] = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
schedule.coachAssignments
|
schedule.coachAssignments
|
||||||
@@ -535,8 +547,10 @@ export class SearchService {
|
|||||||
scheduleId: schedule.id,
|
scheduleId: schedule.id,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
seatClassName: fare.seatClassName,
|
seatClassName: fare.seatClassName,
|
||||||
baseFareMinor: fare.baseFarePerPassengerMinor,
|
baseFareMinor: fare.baseFarePerPassengerMinor,
|
||||||
|
displayCurrency: fare.billingCurrency as Currency,
|
||||||
|
displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message);
|
console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message);
|
||||||
@@ -545,7 +559,9 @@ export class SearchService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const validResults = results.filter((r): r is { seatClassName: string; baseFareMinor: number } => r !== null);
|
const validResults = results.filter(
|
||||||
|
(r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null,
|
||||||
|
);
|
||||||
if (validResults.length > 0) {
|
if (validResults.length > 0) {
|
||||||
return validResults;
|
return validResults;
|
||||||
}
|
}
|
||||||
@@ -573,9 +589,12 @@ export class SearchService {
|
|||||||
if (fareRules.length > 0) {
|
if (fareRules.length > 0) {
|
||||||
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
|
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
|
||||||
const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name]));
|
const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name]));
|
||||||
|
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency);
|
||||||
return fareRules.map(rule => ({
|
return fareRules.map(rule => ({
|
||||||
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
|
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
|
||||||
baseFareMinor: rule.baseFareMinor,
|
baseFareMinor: rule.baseFareMinor,
|
||||||
|
displayCurrency,
|
||||||
|
displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -586,13 +605,13 @@ export class SearchService {
|
|||||||
|
|
||||||
private async buildCoachTypeDetails(
|
private async buildCoachTypeDetails(
|
||||||
schedule: any,
|
schedule: any,
|
||||||
faresByClass: Array<{ seatClassName: string; baseFareMinor: number }>,
|
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
|
||||||
): Promise<Array<{
|
): Promise<Array<{
|
||||||
coachTypeId: string;
|
coachTypeId: string;
|
||||||
coachTypeName: string;
|
coachTypeName: string;
|
||||||
coachTypeCode: string;
|
coachTypeCode: string;
|
||||||
coachId: string;
|
coachId: string;
|
||||||
classes: Array<{ name: string; baseFareMinor: number }>;
|
classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>;
|
||||||
}>> {
|
}>> {
|
||||||
const coachTypeMap = new Map<
|
const coachTypeMap = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -621,9 +640,14 @@ export class SearchService {
|
|||||||
.map((className) => {
|
.map((className) => {
|
||||||
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
|
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
|
||||||
if (!fareInfo) return null;
|
if (!fareInfo) return null;
|
||||||
return { name: className, baseFareMinor: fareInfo.baseFareMinor };
|
return {
|
||||||
|
name: className,
|
||||||
|
baseFareMinor: fareInfo.baseFareMinor,
|
||||||
|
displayCurrency: fareInfo.displayCurrency,
|
||||||
|
displayAmountMinor: fareInfo.displayAmountMinor,
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.filter((c): c is { name: string; baseFareMinor: number } => c !== null)
|
.filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => c !== null)
|
||||||
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
|
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
|
|||||||
@@ -307,9 +307,9 @@ export class SeatsService {
|
|||||||
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
|
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocked = seats.filter(s => s.status === 'BLOCKED');
|
const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED' || s.status === 'HELD');
|
||||||
if (blocked.length > 0)
|
if (blocked.length > 0)
|
||||||
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are blocked`);
|
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
|
||||||
|
|
||||||
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
|
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
|
||||||
|
|
||||||
@@ -334,28 +334,34 @@ export class SeatsService {
|
|||||||
select: { seatIds: true, createdBy: true },
|
select: { seatIds: true, createdBy: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = [];
|
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[]; legUnknown: boolean }[] = [];
|
||||||
for (const h of activeHolds) {
|
for (const h of activeHolds) {
|
||||||
|
const rawSeatIds = h.seatIds as string[];
|
||||||
try {
|
try {
|
||||||
if (h.createdBy?.trimStart().startsWith('{')) {
|
if (h.createdBy?.trimStart().startsWith('{')) {
|
||||||
const meta = JSON.parse(h.createdBy);
|
const meta = JSON.parse(h.createdBy);
|
||||||
const holdFrom = seqOf(meta.originStationId);
|
const holdFrom = seqOf(meta.originStationId);
|
||||||
const holdTo = seqOf(meta.destinationStationId);
|
const holdTo = seqOf(meta.destinationStationId);
|
||||||
if (holdFrom !== undefined && holdTo !== undefined) {
|
parsedHolds.push({
|
||||||
parsedHolds.push({
|
seatIds: rawSeatIds,
|
||||||
seatIds: h.seatIds,
|
from: holdFrom ?? 0,
|
||||||
from: holdFrom,
|
to: holdTo ?? Number.MAX_SAFE_INTEGER,
|
||||||
to: holdTo,
|
passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
|
||||||
passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
|
legUnknown: holdFrom === undefined || holdTo === undefined,
|
||||||
});
|
});
|
||||||
}
|
} else {
|
||||||
|
// Legacy plain-string createdBy — can't determine leg; block conservatively.
|
||||||
|
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
// Malformed JSON — block conservatively.
|
||||||
|
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { passengerId, seatId } of dto.passengers) {
|
for (const { passengerId, seatId } of dto.passengers) {
|
||||||
for (const hold of parsedHolds) {
|
for (const hold of parsedHolds) {
|
||||||
const legsOverlap = hold.from < reqTo && reqFrom < hold.to;
|
const legsOverlap = hold.legUnknown || (hold.from < reqTo && reqFrom < hold.to);
|
||||||
if (!legsOverlap) continue;
|
if (!legsOverlap) continue;
|
||||||
|
|
||||||
if (hold.seatIds.includes(seatId)) {
|
if (hold.seatIds.includes(seatId)) {
|
||||||
@@ -364,7 +370,7 @@ export class SeatsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hold.passengerIds.includes(passengerId)) {
|
if (!hold.legUnknown && hold.passengerIds.includes(passengerId)) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Passenger already holds a seat on this journey leg`,
|
`Passenger already holds a seat on this journey leg`,
|
||||||
);
|
);
|
||||||
@@ -386,12 +392,14 @@ export class SeatsService {
|
|||||||
if (!seg.seatId) continue;
|
if (!seg.seatId) continue;
|
||||||
const segFrom = seqOf(seg.departureStationId);
|
const segFrom = seqOf(seg.departureStationId);
|
||||||
const segTo = seqOf(seg.arrivalStationId);
|
const segTo = seqOf(seg.arrivalStationId);
|
||||||
if (segFrom !== undefined && segTo !== undefined) {
|
// If stations can't be resolved, assume overlap (conservative) to prevent double-booking.
|
||||||
if (segFrom < reqTo && reqFrom < segTo) {
|
const overlaps = (segFrom === undefined || segTo === undefined)
|
||||||
throw new ConflictException(
|
? true
|
||||||
`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`,
|
: segFrom < reqTo && reqFrom < segTo;
|
||||||
);
|
if (overlaps) {
|
||||||
}
|
throw new ConflictException(
|
||||||
|
`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,6 +409,13 @@ export class SeatsService {
|
|||||||
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
|
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mark seats as HELD so the status check catches them immediately on any
|
||||||
|
// subsequent hold attempt (avoids relying solely on the SeatHold table scan).
|
||||||
|
await tx.seat.updateMany({
|
||||||
|
where: { id: { in: seatIds } },
|
||||||
|
data: { status: 'HELD' },
|
||||||
|
});
|
||||||
|
|
||||||
return tx.seatHold.create({
|
return tx.seatHold.create({
|
||||||
data: {
|
data: {
|
||||||
scheduleId: dto.scheduleId,
|
scheduleId: dto.scheduleId,
|
||||||
@@ -541,11 +556,16 @@ export class SeatsService {
|
|||||||
async releaseHold(holdId: string) {
|
async releaseHold(holdId: string) {
|
||||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
|
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
|
||||||
if (!hold) throw new NotFoundException('Hold not found');
|
if (!hold) throw new NotFoundException('Hold not found');
|
||||||
await this.prisma.seatHold.delete({ where: { id: holdId } });
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.seat.updateMany({
|
||||||
|
where: { id: { in: hold.seatIds as string[] }, status: 'HELD' },
|
||||||
|
data: { status: 'AVAILABLE' },
|
||||||
|
}),
|
||||||
|
this.prisma.seatHold.delete({ where: { id: holdId } }),
|
||||||
|
]);
|
||||||
return { released: true, holdId };
|
return { released: true, holdId };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Physical seat.status stays AVAILABLE — segment rows are the source of truth for occupancy.
|
|
||||||
async confirmSeats(_seatIds: string[]) {}
|
async confirmSeats(_seatIds: string[]) {}
|
||||||
|
|
||||||
// Delete the Journey (and its JourneySegments) scoped to this booking.
|
// Delete the Journey (and its JourneySegments) scoped to this booking.
|
||||||
@@ -719,7 +739,18 @@ export class SeatsService {
|
|||||||
|
|
||||||
@Cron(CronExpression.EVERY_MINUTE)
|
@Cron(CronExpression.EVERY_MINUTE)
|
||||||
async expireHolds() {
|
async expireHolds() {
|
||||||
// Holds are temporary and don't create Journey rows — just delete expired ones.
|
const expired = await this.prisma.seatHold.findMany({
|
||||||
|
where: { expiresAt: { lt: new Date() } },
|
||||||
|
select: { id: true, seatIds: true },
|
||||||
|
});
|
||||||
|
if (expired.length === 0) return;
|
||||||
|
|
||||||
|
const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]);
|
||||||
|
// Only reset seats that are still HELD — BOOKED seats have been confirmed and must not be touched.
|
||||||
|
await this.prisma.seat.updateMany({
|
||||||
|
where: { id: { in: expiredSeatIds }, status: 'HELD' },
|
||||||
|
data: { status: 'AVAILABLE' },
|
||||||
|
});
|
||||||
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
@@ -15,6 +15,8 @@ interface OfflineValidation {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TicketsService {
|
export class TicketsService {
|
||||||
|
private readonly logger = new Logger(TicketsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
@@ -154,17 +156,29 @@ export class TicketsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Booking not in CONFIRMED state (safety net — should align with SUCCEEDED)
|
// Booking not in CONFIRMED state — could be a webhook delivery failure.
|
||||||
|
// If the intent already SUCCEEDED but the booking is still PENDING_PAYMENT,
|
||||||
|
// self-heal here rather than rejecting a legitimately paid booking.
|
||||||
if (booking.status !== 'CONFIRMED') {
|
if (booking.status !== 'CONFIRMED') {
|
||||||
throw new HttpException(
|
if (booking.status === 'PENDING_PAYMENT') {
|
||||||
{
|
this.logger.warn(
|
||||||
status: 'error',
|
`Booking ${bookingId} is PENDING_PAYMENT but payment intent SUCCEEDED — webhook likely missed. Auto-confirming before ticket generation.`,
|
||||||
message: 'Payment not completed',
|
);
|
||||||
code: 400,
|
await this.prisma.booking.update({
|
||||||
detail: `Booking status: ${booking.status}`,
|
where: { id: bookingId },
|
||||||
},
|
data: { status: 'CONFIRMED' },
|
||||||
HttpStatus.BAD_REQUEST,
|
});
|
||||||
);
|
} else {
|
||||||
|
throw new HttpException(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
message: 'Payment not completed',
|
||||||
|
code: 400,
|
||||||
|
detail: `Booking status: ${booking.status}`,
|
||||||
|
},
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a compact multi-leg payload for the QR so gate scanners see all legs
|
// Build a compact multi-leg payload for the QR so gate scanners see all legs
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import Badge from '@/components/ui/Badge';
|
|||||||
import Pagination from '@/components/ui/Pagination';
|
import Pagination from '@/components/ui/Pagination';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { usePermission } from '@/lib/use-permission';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { bookingsApi, apiClient } from '@/lib/api';
|
import { bookingsApi, apiClient } from '@/lib/api';
|
||||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||||
@@ -26,7 +29,9 @@ const SectionHeader = ({ title }: { title: string }) => (
|
|||||||
</h3>
|
</h3>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function BookingsPage() {
|
function BookingsPageContent() {
|
||||||
|
const canManage = usePermission(PERMS.bookings.manage);
|
||||||
|
const canCancel = usePermission(PERMS.bookings.cancel);
|
||||||
const [filters, setFilters] = useState<BookingFilters>({ page: 1, pageSize: 20, search: '', status: '' });
|
const [filters, setFilters] = useState<BookingFilters>({ page: 1, pageSize: 20, search: '', status: '' });
|
||||||
const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' });
|
const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' });
|
||||||
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
||||||
@@ -481,3 +486,11 @@ export default function BookingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function BookingsPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={PERMS.bookings.view}>
|
||||||
|
<BookingsPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
import { Ticket, Users, DollarSign, Percent } from 'lucide-react';
|
import { Ticket, Users, DollarSign, Percent } from 'lucide-react';
|
||||||
import StatCard from '@/components/dashboard/StatCard';
|
import StatCard from '@/components/dashboard/StatCard';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
@@ -11,7 +13,7 @@ import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, R
|
|||||||
|
|
||||||
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||||
|
|
||||||
export default function DashboardPage() {
|
function DashboardPageContent() {
|
||||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||||
queryKey: ['dashboard-stats'],
|
queryKey: ['dashboard-stats'],
|
||||||
queryFn: dashboardApi.getStats,
|
queryFn: dashboardApi.getStats,
|
||||||
@@ -237,3 +239,11 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={PERMS.dashboard}>
|
||||||
|
<DashboardPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,7 +42,12 @@ export default function LoginPage() {
|
|||||||
await login(email, password);
|
await login(email, password);
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || err.message || 'Invalid credentials. Please try again.');
|
const msg = err.message || err.response?.data?.message || '';
|
||||||
|
if (msg === '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.');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
permission?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a page to enforce auth + optional permission check.
|
||||||
|
* - Not logged in → redirect to /login
|
||||||
|
* - Missing permission → redirect to /dashboard
|
||||||
|
*/
|
||||||
|
export function PermissionGuard({ permission, children }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const hasPermission = useAuthStore((s) => s.hasPermission);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
router.replace('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (permission && !hasPermission(permission)) {
|
||||||
|
router.replace('/dashboard');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, permission, hasPermission, router]);
|
||||||
|
|
||||||
|
if (!isAuthenticated) return null;
|
||||||
|
if (permission && !hasPermission(permission)) return null;
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -39,49 +39,65 @@ import {
|
|||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTheme } from '@/lib/theme-store';
|
import { useTheme } from '@/lib/theme-store';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
const navigationSections = [
|
interface NavItem {
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
permission?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||||
{
|
{
|
||||||
title: 'Overview',
|
title: 'Overview',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, permission: PERMS.dashboard },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Operations',
|
title: 'Operations',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Bookings', href: '/bookings', icon: Ticket },
|
{ name: 'Bookings', href: '/bookings', icon: Ticket, permission: PERMS.bookings.view },
|
||||||
{ name: 'Passengers', href: '/passengers', icon: Users },
|
{ name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view },
|
||||||
{ name: 'Tickets', href: '/tickets', icon: FileText },
|
{ name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
|
||||||
{ name: 'Lugagges', href: '/excess-baggage', icon: Banknote },
|
{ name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Tourism',
|
title: 'Tourism',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Packages', href: '/packages', icon: Package },
|
{ name: 'Packages', href: '/packages', icon: Package, permission: PERMS.admin },
|
||||||
{ name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare },
|
{ name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare, permission: PERMS.admin },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Master Data',
|
title: 'Master Data',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Stations', href: '/stations', icon: MapPin },
|
{ name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.admin },
|
||||||
{ name: 'Trains', href: '/trains', icon: Train },
|
{ name: 'Trains', href: '/trains', icon: Train, permission: PERMS.admin },
|
||||||
{ name: 'Coaches', href: '/coaches', icon: Grid3x3 },
|
{ name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.admin },
|
||||||
{ name: 'Seats', href: '/seats', icon: Armchair },
|
{ name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.admin },
|
||||||
{ name: 'Classes', href: '/classes', icon: Settings },
|
{ name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.admin },
|
||||||
{ name: 'Routes', href: '/routes', icon: Route },
|
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.admin },
|
||||||
{ name: 'Schedules', href: '/schedules', icon: Calendar },
|
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.admin },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Financial',
|
title: 'Financial',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Fares', href: '/pricing', icon: DollarSign },
|
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin },
|
||||||
{ name: 'Currencies', href: '/currencies', icon: Banknote },
|
{ name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage },
|
||||||
{ name: 'Payments', href: '/payments', icon: CreditCard },
|
{ name: 'Payments', href: '/payments', icon: CreditCard, permission: PERMS.payments.view },
|
||||||
{ name: 'Promos', href: '/promos', icon: Gift },
|
{ name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Customer Services',
|
||||||
|
items: [
|
||||||
|
{ name: 'Loyalty Program', href: '/loyalty', icon: Gift, permission: PERMS.passengers.view },
|
||||||
|
{ name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view },
|
||||||
|
{ name: 'Notifications', href: '/notifications', icon: Bell, permission: PERMS.notifications.send },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
@@ -95,32 +111,32 @@ const navigationSections = [
|
|||||||
{
|
{
|
||||||
title: 'Security & Compliance',
|
title: 'Security & Compliance',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Logs', href: '/audit', icon: AlertTriangle },
|
{ name: 'Audit Logs', href: '/audit', icon: AlertTriangle, permission: PERMS.audit.view },
|
||||||
{ name: 'Fraud', href: '/fraud', icon: Shield },
|
{ name: 'Fraud Detection', href: '/fraud', icon: Shield, permission: PERMS.fraud.view },
|
||||||
{ name: 'Verifayda', href: '/verifayda', icon: UserCheck },
|
{ name: 'Verifayda Integration', href: '/verifayda', icon: UserCheck, permission: PERMS.admin },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Analytics & Reports',
|
title: 'Analytics & Reports',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Reports', href: '/reports', icon: BarChart3 },
|
{ name: 'Reports', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
|
||||||
{ name: 'Operational', href: '/operational-reports', icon: FileText },
|
{ name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'System',
|
title: 'System',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Agents', href: '/agents', icon: Briefcase },
|
{ name: 'Agent Operations', href: '/agents', icon: Briefcase, permission: PERMS.agents.view },
|
||||||
{ name: 'Users', href: '/settings/users', icon: Users },
|
{ name: 'User Management', href: '/settings/users', icon: Users, permission: PERMS.admin },
|
||||||
{ name: 'Settings', href: '/settings', icon: Settings },
|
{ name: 'Settings', href: '/settings', icon: Settings, permission: PERMS.admin },
|
||||||
{ name: 'Health', href: '/health', icon: Activity },
|
{ name: 'Health', href: '/health', icon: Activity, permission: PERMS.admin },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout, hasPermission } = useAuthStore();
|
||||||
const { isDark, toggleTheme } = useTheme();
|
const { isDark, toggleTheme } = useTheme();
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
|
||||||
@@ -154,7 +170,12 @@ export default function Sidebar() {
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-6">
|
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-6">
|
||||||
{navigationSections.map((section) => (
|
{navigationSections.map((section) => {
|
||||||
|
const visibleItems = section.items.filter(
|
||||||
|
(item) => !item.permission || hasPermission(item.permission)
|
||||||
|
);
|
||||||
|
if (visibleItems.length === 0) return null;
|
||||||
|
return (
|
||||||
<div key={section.title}>
|
<div key={section.title}>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<h3 className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-white/60 dark:text-slate-400">
|
<h3 className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-white/60 dark:text-slate-400">
|
||||||
@@ -162,7 +183,7 @@ export default function Sidebar() {
|
|||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{section.items.map((item) => {
|
{visibleItems.map((item) => {
|
||||||
// Special handling for Settings to avoid conflict with User Management
|
// Special handling for Settings to avoid conflict with User Management
|
||||||
let isActive;
|
let isActive;
|
||||||
if (item.href === '/settings') {
|
if (item.href === '/settings') {
|
||||||
@@ -194,7 +215,8 @@ export default function Sidebar() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { AdminUser } from '@/types';
|
import { AdminUser } from '@/types';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -20,9 +22,10 @@ interface AuthState {
|
|||||||
logout: () => void;
|
logout: () => void;
|
||||||
setUser: (user: AdminUser, token: string) => void;
|
setUser: (user: AdminUser, token: string) => void;
|
||||||
initialize: () => void;
|
initialize: () => void;
|
||||||
|
hasPermission: (key: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
@@ -34,7 +37,11 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
const userStr = localStorage.getItem('auth_user');
|
const userStr = localStorage.getItem('auth_user');
|
||||||
if (token && userStr) {
|
if (token && userStr) {
|
||||||
try {
|
try {
|
||||||
const user = JSON.parse(userStr);
|
const user = JSON.parse(userStr) as AdminUser;
|
||||||
|
// backfill for sessions stored before permissions were added
|
||||||
|
if (!user.permissions) user.permissions = [];
|
||||||
|
if (user.isSuperAdmin === undefined) user.isSuperAdmin = false;
|
||||||
|
if (user.isOrgAdmin === undefined) user.isOrgAdmin = false;
|
||||||
set({ user, token, isAuthenticated: true });
|
set({ user, token, isAuthenticated: true });
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
@@ -51,23 +58,49 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
const { token, refreshToken } = loginData;
|
const { token, refreshToken } = loginData;
|
||||||
if (!token) throw new Error('No token received from server');
|
if (!token) throw new Error('No token received from server');
|
||||||
|
|
||||||
// Step 2: fetch full user info with the token
|
// Step 2: fetch full user from IAM /v1/auth/me — returns session.userInfo
|
||||||
|
// employee is an array here (unlike /auth/me which transforms it to a single object via parseToken)
|
||||||
const meRes = await axios.get(`${API_URL}/v1/auth/me`, {
|
const meRes = await axios.get(`${API_URL}/v1/auth/me`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
const iamUser = meRes.data?.data ?? meRes.data;
|
const iamUser = meRes.data?.data ?? meRes.data;
|
||||||
|
|
||||||
|
// Role permissions — flat array in data.permissions
|
||||||
|
const rolePerms = (iamUser.permissions ?? []).map((p: any) => String(p.key));
|
||||||
|
// Position permissions — employee[] is an array here; positions[].permissions[] merged by IAM
|
||||||
|
const employeeArr: any[] = Array.isArray(iamUser.employee) ? iamUser.employee : [];
|
||||||
|
const positionPerms = employeeArr.flatMap((emp: any) =>
|
||||||
|
(emp.positions ?? []).flatMap((pos: any) =>
|
||||||
|
(pos.permissions ?? []).map((p: any) => String(p.key))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const permissions = Array.from(new Set([...rolePerms, ...positionPerms]));
|
||||||
|
|
||||||
|
const isSuperAdmin = iamUser.isSuperAdmin ?? false;
|
||||||
|
const isOrgAdmin = iamUser.isOrganizationAdmin ?? false;
|
||||||
|
|
||||||
|
// Block individual (passenger) accounts — backoffice requires at least one of:
|
||||||
|
// super admin, org admin, an employee position, or an explicit permission.
|
||||||
|
if (!isSuperAdmin && !isOrgAdmin && employeeArr.length === 0 && permissions.length === 0) {
|
||||||
|
throw new Error('ACCESS_DENIED');
|
||||||
|
}
|
||||||
|
|
||||||
const user: AdminUser = {
|
const user: AdminUser = {
|
||||||
id: iamUser.id,
|
id: iamUser.id,
|
||||||
email: iamUser.email,
|
email: iamUser.email,
|
||||||
fullName: iamUser.name?.en ?? iamUser.name?.am ?? iamUser.email,
|
fullName: iamUser.name?.en ?? iamUser.name?.am ?? iamUser.email,
|
||||||
role: mapIamRole(iamUser.roles ?? []),
|
role: mapIamRole(iamUser.roles ?? []),
|
||||||
active: true,
|
active: true,
|
||||||
|
permissions,
|
||||||
|
isSuperAdmin,
|
||||||
|
isOrgAdmin,
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('auth_token', token);
|
localStorage.setItem('auth_token', token);
|
||||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||||
if (refreshToken) localStorage.setItem('auth_refresh_token', refreshToken);
|
if (refreshToken) localStorage.setItem('auth_refresh_token', refreshToken);
|
||||||
|
// cookie lets middleware detect auth without reading localStorage
|
||||||
|
document.cookie = `auth_token=${token}; path=/; SameSite=Lax`;
|
||||||
|
|
||||||
set({ user, token, refreshToken: refreshToken ?? null, isAuthenticated: true });
|
set({ user, token, refreshToken: refreshToken ?? null, isAuthenticated: true });
|
||||||
},
|
},
|
||||||
@@ -76,10 +109,18 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
localStorage.removeItem('auth_refresh_token');
|
localStorage.removeItem('auth_refresh_token');
|
||||||
localStorage.removeItem('auth_user');
|
localStorage.removeItem('auth_user');
|
||||||
|
document.cookie = 'auth_token=; path=/; max-age=0';
|
||||||
set({ user: null, token: null, refreshToken: null, isAuthenticated: false });
|
set({ user: null, token: null, refreshToken: null, isAuthenticated: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
setUser: (user: AdminUser, token: string) => {
|
setUser: (user: AdminUser, token: string) => {
|
||||||
set({ user, token, isAuthenticated: true });
|
set({ user, token, isAuthenticated: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
hasPermission: (key: string) => {
|
||||||
|
const { user } = get();
|
||||||
|
if (!user) return false;
|
||||||
|
if (user.isSuperAdmin || user.isOrgAdmin) return true;
|
||||||
|
return user.permissions.includes(key);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
42
apps/edr-passenger-web/backoffice/src/lib/permissions.ts
Normal file
42
apps/edr-passenger-web/backoffice/src/lib/permissions.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
export const PERMS = {
|
||||||
|
dashboard: 'edr_passenger_app:dashboard:view',
|
||||||
|
bookings: {
|
||||||
|
view: 'edr_passenger_app:bookings:view',
|
||||||
|
manage: 'edr_passenger_app:bookings:manage',
|
||||||
|
cancel: 'edr_passenger_app:bookings:cancel',
|
||||||
|
},
|
||||||
|
passengers: {
|
||||||
|
view: 'edr_passenger_app:passengers:view',
|
||||||
|
manage: 'edr_passenger_app:passengers:manage',
|
||||||
|
},
|
||||||
|
tickets: {
|
||||||
|
view: 'edr_passenger_app:tickets:view',
|
||||||
|
manage: 'edr_passenger_app:tickets:manage',
|
||||||
|
},
|
||||||
|
payments: {
|
||||||
|
view: 'edr_passenger_app:payments:view_all',
|
||||||
|
refund: 'edr_passenger_app:payments:refund',
|
||||||
|
manage: 'edr_passenger_app:payments:manage_methods',
|
||||||
|
},
|
||||||
|
reports: {
|
||||||
|
view: 'edr_passenger_app:reports:view',
|
||||||
|
},
|
||||||
|
fraud: {
|
||||||
|
view: 'edr_passenger_app:fraud:view',
|
||||||
|
manage: 'edr_passenger_app:fraud:manage',
|
||||||
|
},
|
||||||
|
audit: {
|
||||||
|
view: 'edr_passenger_app:audit:view',
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
view: 'edr_passenger_app:agents:view',
|
||||||
|
manage: 'edr_passenger_app:agents:manage',
|
||||||
|
},
|
||||||
|
currencies: {
|
||||||
|
manage: 'edr_passenger_app:currencies:manage',
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
send: 'edr_passenger_app:notifications:send',
|
||||||
|
},
|
||||||
|
admin: 'edr_passenger_app:admin',
|
||||||
|
} as const;
|
||||||
15
apps/edr-passenger-web/backoffice/src/lib/use-permission.ts
Normal file
15
apps/edr-passenger-web/backoffice/src/lib/use-permission.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAuthStore } from './auth-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the current user has a given permission key.
|
||||||
|
* Super admins and org admins always return true.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const canCancel = usePermission(PERMS.bookings.cancel);
|
||||||
|
* {canCancel && <button>Cancel Booking</button>}
|
||||||
|
*/
|
||||||
|
export function usePermission(key: string): boolean {
|
||||||
|
return useAuthStore((s) => s.hasPermission(key));
|
||||||
|
}
|
||||||
24
apps/edr-passenger-web/backoffice/src/middleware.ts
Normal file
24
apps/edr-passenger-web/backoffice/src/middleware.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = ['/login'];
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token is stored in localStorage (client-side only), so middleware can't
|
||||||
|
// read it directly. We use a cookie set on login as the server-side signal.
|
||||||
|
const token = request.cookies.get('auth_token')?.value;
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.redirect(new URL('/login', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'],
|
||||||
|
};
|
||||||
@@ -96,6 +96,9 @@ export interface AdminUser {
|
|||||||
fullName: string;
|
fullName: string;
|
||||||
role: 'ADMIN' | 'AGENT' | 'SUPERVISOR';
|
role: 'ADMIN' | 'AGENT' | 'SUPERVISOR';
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
permissions: string[];
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
isOrgAdmin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export EDR types
|
// Re-export EDR types
|
||||||
|
|||||||
@@ -1,9 +1,42 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import { LogIn, UserPlus, Shield, Clock } from 'lucide-react';
|
import { LogIn, UserPlus, ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
onMouseEnter={() => setVisible(true)}
|
||||||
|
onMouseLeave={() => setVisible(false)}
|
||||||
|
onFocus={() => setVisible(true)}
|
||||||
|
onBlur={() => setVisible(false)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<div
|
||||||
|
role="tooltip"
|
||||||
|
className={`absolute bottom-[calc(100%+8px)] left-1/2 -translate-x-1/2 w-52 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg px-3 py-2.5 shadow-xl pointer-events-none transition-all duration-150 z-50 ${
|
||||||
|
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{content.map((item, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-1.5">
|
||||||
|
<span className="text-green-400 flex-shrink-0">✓</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{/* Arrow */}
|
||||||
|
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AuthCheckPage() {
|
export default function AuthCheckPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -19,122 +52,54 @@ export default function AuthCheckPage() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated, router]);
|
}, [isAuthenticated, router]);
|
||||||
|
|
||||||
const handleSignIn = () => {
|
|
||||||
router.push('/login?redirect=/booking/passengers');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGuest = () => {
|
|
||||||
router.push('/booking/passengers');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-primary-50 dark:from-gray-900 dark:to-gray-800 py-12">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex flex-col items-center justify-center px-4">
|
||||||
<div className="container mx-auto px-4">
|
<div className="w-full max-w-sm">
|
||||||
<div className="max-w-5xl mx-auto">
|
<h1 className="text-2xl font-bold text-center text-gray-900 dark:text-gray-100 mb-1">
|
||||||
{/* Header */}
|
Continue your booking
|
||||||
<div className="text-center mb-12 animate-fade-in">
|
</h1>
|
||||||
<h1 className="section-title">Continue your booking</h1>
|
<p className="text-sm text-center text-gray-500 dark:text-gray-400 mb-8">
|
||||||
<p className="section-subtitle mt-2">
|
Choose how you'd like to proceed
|
||||||
Sign in to access saved profiles or continue as a guest
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Options Grid */}
|
<div className="flex flex-col gap-3">
|
||||||
<div className="grid md:grid-cols-2 gap-8 mb-8">
|
<Tooltip content={[
|
||||||
{/* Sign In Option */}
|
'Saved passenger details',
|
||||||
<div
|
'View booking history',
|
||||||
onClick={handleSignIn}
|
'Faster future bookings',
|
||||||
className="card-interactive group"
|
]}>
|
||||||
>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-20 h-20 bg-gradient-to-br from-primary to-primary-700 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg group-hover:shadow-xl transition-shadow">
|
|
||||||
<LogIn className="w-10 h-10 text-white" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">Sign in</h2>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-6 text-balance">
|
|
||||||
Access your saved passenger profiles and booking history for faster checkout
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Benefits */}
|
|
||||||
<div className="space-y-3 mb-6 text-left">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="w-5 h-5 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
||||||
<span className="text-primary dark:text-gray-300 text-xs">✓</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300">Saved passenger details</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="w-5 h-5 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
||||||
<span className="text-primary dark:text-gray-300 text-xs">✓</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300">View booking history</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="w-5 h-5 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
||||||
<span className="text-primary dark:text-gray-300 text-xs">✓</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300">Faster future bookings</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="btn-primary w-full text-center">
|
|
||||||
Sign in to continue
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Guest Option */}
|
|
||||||
<div
|
|
||||||
onClick={handleGuest}
|
|
||||||
className="card-interactive group"
|
|
||||||
>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-20 h-20 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-600 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg group-hover:shadow-xl transition-shadow">
|
|
||||||
<UserPlus className="w-10 h-10 text-gray-700 dark:text-gray-300" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">Continue as guest</h2>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-6 text-balance">
|
|
||||||
Book without an account. You can create one after completing your booking
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Benefits */}
|
|
||||||
<div className="space-y-3 mb-6 text-left">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
||||||
<Clock className="w-3 h-3 text-gray-600 dark:text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300">Quick checkout process</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
||||||
<Shield className="w-3 h-3 text-gray-600 dark:text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300">No account required</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
||||||
<UserPlus className="w-3 h-3 text-gray-600 dark:text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300">Create account later (optional)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="btn-secondary w-full text-center">
|
|
||||||
Continue as guest
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Back Link */}
|
|
||||||
<div className="text-center">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/booking/results')}
|
onClick={() => router.push('/login?redirect=/booking/passengers')}
|
||||||
className="btn-ghost"
|
className="w-full flex items-center justify-center gap-2.5 px-5 py-3.5 bg-primary hover:bg-primary/90 active:scale-[0.98] text-white font-semibold rounded-xl transition-all shadow-md shadow-primary/20"
|
||||||
>
|
>
|
||||||
← Back to Results
|
<LogIn className="w-5 h-5 flex-shrink-0" />
|
||||||
|
Sign in
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content={[
|
||||||
|
'No account required',
|
||||||
|
'Quick checkout',
|
||||||
|
'Create account later (optional)',
|
||||||
|
]}>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/booking/passengers')}
|
||||||
|
className="w-full flex items-center justify-center gap-2.5 px-5 py-3.5 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 active:scale-[0.98] text-gray-800 dark:text-gray-100 font-semibold rounded-xl border border-gray-200 dark:border-gray-700 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-5 h-5 flex-shrink-0" />
|
||||||
|
Continue as guest
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/booking/results')}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
Back to results
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useBookingStore } from '@/lib/booking-store';
|
|||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe } from 'lucide-react';
|
import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe, ChevronLeft } from 'lucide-react';
|
||||||
import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysInEthiopianMonth } from '@/lib/ethiopian-calendar';
|
import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysInEthiopianMonth } from '@/lib/ethiopian-calendar';
|
||||||
|
|
||||||
const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||||
@@ -951,7 +951,8 @@ export default function PassengersPage() {
|
|||||||
...(searchCriteria.promoCode && { promoCode: searchCriteria.promoCode }),
|
...(searchCriteria.promoCode && { promoCode: searchCriteria.promoCode }),
|
||||||
});
|
});
|
||||||
router.push(`/booking/results?${params}`);
|
router.push(`/booking/results?${params}`);
|
||||||
}} className="btn-secondary flex-1" disabled={saving}>
|
}} className="btn-secondary flex-1 flex items-center justify-center gap-2" disabled={saving}>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn-primary flex-1" disabled={saving}>
|
<button type="submit" className="btn-primary flex-1" disabled={saving}>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Wallet,
|
Wallet,
|
||||||
Loader2,
|
Loader2,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
ChevronLeft,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const getIconForMethod = (methodId: string) => {
|
const getIconForMethod = (methodId: string) => {
|
||||||
@@ -22,21 +23,33 @@ const getIconForMethod = (methodId: string) => {
|
|||||||
return Smartphone;
|
return Smartphone;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const NATIONALITY_TO_CURRENCY: Record<string, 'ETB' | 'DJF' | 'USD'> = {
|
||||||
|
ETHIOPIAN: 'ETB',
|
||||||
|
DJIBOUTIAN: 'DJF',
|
||||||
|
};
|
||||||
|
|
||||||
export default function PaymentPage() {
|
export default function PaymentPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore();
|
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore();
|
||||||
const { selectedCurrency, setPaymentIntent, updateStatus } =
|
const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore();
|
||||||
usePaymentStore();
|
|
||||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [paymentError, setPaymentError] = useState<string | null>(null);
|
const [paymentError, setPaymentError] = useState<string | null>(null);
|
||||||
|
|
||||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||||
|
|
||||||
|
const displayCurrency: 'ETB' | 'DJF' | 'USD' =
|
||||||
|
NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ?? 'USD';
|
||||||
|
|
||||||
|
// Keep payment store in sync so the mutation picks up the right currency.
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrency(displayCurrency);
|
||||||
|
}, [displayCurrency, setCurrency]);
|
||||||
|
|
||||||
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery<PaymentMethod[]>({
|
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery<PaymentMethod[]>({
|
||||||
queryKey: ['paymentMethods'],
|
queryKey: ['paymentMethods', displayCurrency],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await apiClient.get<PaymentMethod[]>('/payments/methods');
|
const response = await apiClient.get<PaymentMethod[]>(`/payments/methods?currency=${displayCurrency}`);
|
||||||
return Array.isArray(response) ? response : [];
|
return Array.isArray(response) ? response : [];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -120,7 +133,7 @@ export default function PaymentPage() {
|
|||||||
bookingId,
|
bookingId,
|
||||||
method: selectedMethod,
|
method: selectedMethod,
|
||||||
paymentMethodId: selectedPaymentMethod.id,
|
paymentMethodId: selectedPaymentMethod.id,
|
||||||
currency: selectedCurrency,
|
currency: displayCurrency,
|
||||||
amountMinor: totalAmount,
|
amountMinor: totalAmount,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -152,428 +165,252 @@ export default function PaymentPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reusable journey leg timeline block
|
||||||
|
const JourneyLeg = ({ schedule, color = 'primary', label, fare }: { schedule: any; color?: string; label: string; fare?: number }) => {
|
||||||
|
const dotColor = color === 'blue' ? 'border-blue-500' : 'border-primary';
|
||||||
|
const lineColor = color === 'blue' ? 'from-blue-500' : 'from-primary';
|
||||||
|
const badgeBg = color === 'blue' ? 'bg-blue-500/10 text-blue-600 dark:text-blue-400' : 'bg-primary/10 text-primary';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${color === 'blue' ? 'bg-blue-500' : 'bg-primary'}`} />
|
||||||
|
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">{label}</span>
|
||||||
|
<span className={`ml-auto text-xs px-2 py-0.5 rounded-full font-medium ${badgeBg}`}>
|
||||||
|
{schedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex flex-col items-center w-7 flex-shrink-0">
|
||||||
|
<div className={`w-3 h-3 rounded-full border-4 ${dotColor} bg-white dark:bg-gray-900 z-10`} />
|
||||||
|
<div className={`w-0.5 flex-1 bg-gradient-to-b ${lineColor} via-gray-300 dark:via-gray-700 to-gray-300 my-1.5`} />
|
||||||
|
<div className="w-3 h-3 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col pl-2">
|
||||||
|
<div className="pb-5">
|
||||||
|
<div className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{schedule?.departureTime ? format(new Date(schedule.departureTime), 'HH:mm') : '--:--'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{schedule?.departureTime ? format(new Date(schedule.departureTime), 'EEE, MMM d') : ''}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-white mt-1">{schedule?.origin}</div>
|
||||||
|
</div>
|
||||||
|
<div className="pb-5 flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>{schedule?.duration}</span>
|
||||||
|
<span>Train {schedule?.trainNumber}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{schedule?.arrivalTime ? format(new Date(schedule.arrivalTime), 'HH:mm') : '--:--'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{schedule?.arrivalTime ? format(new Date(schedule.arrivalTime), 'EEE, MMM d') : ''}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-white mt-1">{schedule?.destination}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{fare !== undefined && (
|
||||||
|
<div className="mt-3 pt-2 border-t border-gray-100 dark:border-gray-800 flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">{label} fare</span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-100">{displayCurrency} {(fare / 100).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Order summary card — used in right sticky column (desktop) and inline (mobile)
|
||||||
|
const OrderSummary = () => (
|
||||||
|
<div className="card space-y-4">
|
||||||
|
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
Order summary
|
||||||
|
<span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">Ref: {pnr}</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{isRoundTrip ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<JourneyLeg schedule={outboundSchedule} label="Outbound" fare={outboundBaseFare} />
|
||||||
|
<div className="border-t-2 border-dashed border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<JourneyLeg schedule={inboundSchedule} color="blue" label="Return" fare={inboundBaseFare} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<JourneyLeg schedule={selectedSchedule} label="Your journey" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700 space-y-1.5">
|
||||||
|
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<span>Passengers</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{passengers.length} passenger{passengers.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center pt-1">
|
||||||
|
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
|
||||||
|
<span className="text-xl font-bold text-primary">{displayCurrency} {(totalAmount / 100).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pay + back buttons — desktop sidebar only */}
|
||||||
|
<div className="hidden lg:flex flex-col gap-2 pt-1">
|
||||||
|
{paymentError && (
|
||||||
|
<p className="text-red-600 dark:text-red-400 text-xs">⚠️ {paymentError}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handlePayment}
|
||||||
|
disabled={!selectedMethod || isProcessing}
|
||||||
|
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
`Pay ${displayCurrency} ${(totalAmount / 100).toFixed(2)}`
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => router.back()} disabled={isProcessing} className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 text-center pt-1">
|
||||||
|
🔒 Secure & encrypted payment
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">Complete payment</h1>
|
||||||
Complete payment
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
|
||||||
Booking reference:{" "}
|
|
||||||
<span className="font-bold text-primary">{pnr}</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Payment Processing Overlay */}
|
{/* Payment Processing Overlay */}
|
||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-md text-center">
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 max-w-sm w-full mx-4 text-center shadow-2xl">
|
||||||
{paymentMutation.isSuccess ? (
|
{paymentMutation.isSuccess ? (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
|
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
|
||||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
<h3 className="text-lg font-bold mb-1 text-gray-900 dark:text-gray-100">Payment successful!</h3>
|
||||||
Payment successful!
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Redirecting to confirmation...</p>
|
||||||
</h3>
|
<Loader2 className="w-6 h-6 text-primary animate-spin mx-auto" />
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
||||||
Redirecting to confirmation...
|
|
||||||
</p>
|
|
||||||
<Loader2 className="w-8 h-8 text-primary animate-spin mx-auto" />
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-16 h-16 text-primary animate-spin mx-auto mb-4" />
|
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
|
||||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
|
<h3 className="text-lg font-bold mb-1 text-gray-900 dark:text-gray-100">Processing payment</h3>
|
||||||
Processing payment
|
<p className="text-sm text-gray-500 dark:text-gray-400">Please wait...</p>
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
Please wait while we process your payment...
|
|
||||||
</p>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Order Summary */}
|
{/* Two-column grid */}
|
||||||
<div className="card mb-6">
|
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">
|
||||||
<h2 className="text-xl font-semibold mb-6 text-gray-900 dark:text-gray-100">
|
|
||||||
Order summary
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{isRoundTrip ? (
|
|
||||||
<>
|
|
||||||
{/* Outbound Journey */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
|
||||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Outbound Journey</span>
|
|
||||||
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
|
|
||||||
{outboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Flight-style timeline */}
|
{/* Left column — payment methods (2/3 width) */}
|
||||||
<div className="flex">
|
<div className="lg:col-span-2 space-y-4">
|
||||||
{/* Left column: Timeline with dots and line */}
|
<div className="card">
|
||||||
<div className="flex flex-col items-center w-8 flex-shrink-0">
|
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 mb-4">Select payment method</h2>
|
||||||
{/* Origin dot */}
|
{loadingMethods ? (
|
||||||
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
|
<div className="flex items-center justify-center py-10 gap-2">
|
||||||
{/* Vertical line */}
|
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
||||||
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
|
<span className="text-sm text-gray-500 dark:text-gray-400">Loading payment methods...</span>
|
||||||
{/* Destination dot */}
|
|
||||||
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right column: Content */}
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
{/* Origin */}
|
|
||||||
<div className="pb-8">
|
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
{outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
{outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
|
|
||||||
</div>
|
|
||||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
|
||||||
{outboundSchedule?.origin}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Journey Info */}
|
|
||||||
<div className="pb-8">
|
|
||||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<span className="font-medium">{outboundSchedule?.duration}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
<span className="font-medium">Train {outboundSchedule?.trainNumber}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Destination */}
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
{outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
{outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
|
|
||||||
</div>
|
|
||||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
|
||||||
{outboundSchedule?.destination}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-800">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600 dark:text-gray-400">Outbound fare</span>
|
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">ETB {(outboundBaseFare / 100).toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : error ? (
|
||||||
{/* Return Journey */}
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||||
<div className="pt-4 border-t-2 border-dashed border-gray-200 dark:border-gray-700">
|
<p className="text-red-800 dark:text-red-200 text-sm">Failed to load payment methods. Please refresh.</p>
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<div className="w-2 h-2 bg-blue-500 rounded-full" />
|
|
||||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Return Journey</span>
|
|
||||||
<span className="ml-auto text-xs px-2 py-0.5 bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full font-medium">
|
|
||||||
{inboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Flight-style timeline */}
|
|
||||||
<div className="flex">
|
|
||||||
{/* Left column: Timeline with dots and line */}
|
|
||||||
<div className="flex flex-col items-center w-8 flex-shrink-0">
|
|
||||||
{/* Origin dot */}
|
|
||||||
<div className="w-4 h-4 rounded-full border-4 border-blue-500 bg-white dark:bg-gray-900 z-10" />
|
|
||||||
{/* Vertical line */}
|
|
||||||
<div className="w-0.5 flex-1 bg-gradient-to-b from-blue-500 via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
|
|
||||||
{/* Destination dot */}
|
|
||||||
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right column: Content */}
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
{/* Origin */}
|
|
||||||
<div className="pb-8">
|
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
{inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
{inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
|
|
||||||
</div>
|
|
||||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
|
||||||
{inboundSchedule?.origin}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Journey Info */}
|
|
||||||
<div className="pb-8">
|
|
||||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<span className="font-medium">{inboundSchedule?.duration}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
<span className="font-medium">Train {inboundSchedule?.trainNumber}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Destination */}
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
{inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
{inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
|
|
||||||
</div>
|
|
||||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
|
||||||
{inboundSchedule?.destination}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-800">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600 dark:text-gray-400">Return fare</span>
|
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">ETB {(inboundBaseFare / 100).toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
) : paymentMethods.length === 0 ? (
|
||||||
) : (
|
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||||
<>
|
<p className="text-yellow-800 dark:text-yellow-200 text-sm">No payment methods available at the moment.</p>
|
||||||
{/* One-Way Journey */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
|
||||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Your Journey</span>
|
|
||||||
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
|
|
||||||
{selectedSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Flight-style timeline */}
|
|
||||||
<div className="flex">
|
|
||||||
{/* Left column: Timeline with dots and line */}
|
|
||||||
<div className="flex flex-col items-center w-8 flex-shrink-0">
|
|
||||||
{/* Origin dot */}
|
|
||||||
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
|
|
||||||
{/* Vertical line */}
|
|
||||||
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
|
|
||||||
{/* Destination dot */}
|
|
||||||
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right column: Content */}
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
{/* Origin */}
|
|
||||||
<div className="pb-8">
|
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
{selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
{selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
|
|
||||||
</div>
|
|
||||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
|
||||||
{selectedSchedule?.origin}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Journey Info */}
|
|
||||||
<div className="pb-8">
|
|
||||||
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<span className="font-medium">{selectedSchedule?.duration}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
<span className="font-medium">Train {selectedSchedule?.trainNumber}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Destination */}
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
{selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
{selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
|
|
||||||
</div>
|
|
||||||
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
|
|
||||||
{selectedSchedule?.destination}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
) : (
|
||||||
)}
|
<div className="space-y-3">
|
||||||
|
{paymentMethods.map((method) => {
|
||||||
{/* Passengers and Total */}
|
const Icon = getIconForMethod(method.type);
|
||||||
<div className="pt-4 border-t-2 border-gray-200 dark:border-gray-700">
|
const isSelected = selectedMethod === method.type;
|
||||||
<div className="flex justify-between text-sm mb-3">
|
return (
|
||||||
<span className="text-gray-600 dark:text-gray-400">
|
<button
|
||||||
Passengers
|
key={method.id}
|
||||||
</span>
|
onClick={() => setSelectedMethod(method.type)}
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
disabled={isProcessing || !method.enabled}
|
||||||
{passengers.length} passenger{passengers.length !== 1 ? "s" : ""}
|
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center pt-3 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<span className="text-base font-bold text-gray-900 dark:text-gray-100">
|
|
||||||
Total amount
|
|
||||||
</span>
|
|
||||||
<span className="text-2xl font-bold text-primary dark:text-gray-100">
|
|
||||||
ETB {(totalAmount / 100).toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Payment Methods */}
|
|
||||||
<div className="card mb-6">
|
|
||||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
|
||||||
Select payment method
|
|
||||||
</h2>
|
|
||||||
{loadingMethods ? (
|
|
||||||
<div className="flex justify-center py-8">
|
|
||||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
|
||||||
<p className="ml-2 text-gray-600 dark:text-gray-400">Loading payment methods...</p>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
|
||||||
<p className="text-red-800 dark:text-red-200 text-sm">
|
|
||||||
Failed to load payment methods. Please refresh the page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : paymentMethods.length === 0 ? (
|
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
|
||||||
<p className="text-yellow-800 dark:text-yellow-200 text-sm">
|
|
||||||
No payment methods available at the moment.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{paymentMethods.map((method) => {
|
|
||||||
const Icon = getIconForMethod(method.type);
|
|
||||||
const isSelected = selectedMethod === method.type;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={method.id}
|
|
||||||
onClick={() => setSelectedMethod(method.type)}
|
|
||||||
disabled={isProcessing || !method.enabled}
|
|
||||||
className={`w-full p-4 rounded-lg border-2 transition-all text-left ${
|
|
||||||
isSelected
|
|
||||||
? "border-primary bg-primary/10 dark:bg-primary/20 shadow-md"
|
|
||||||
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary dark:hover:border-primary"
|
|
||||||
} ${isProcessing || !method.enabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className={`w-12 h-12 rounded-lg flex items-center justify-center ${
|
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-primary"
|
? 'border-primary bg-primary/8 dark:bg-primary/15 shadow-md'
|
||||||
: "bg-gray-100 dark:bg-gray-700"
|
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary/50'
|
||||||
}`}
|
} ${isProcessing || !method.enabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
<Icon
|
<div className="flex items-center gap-3">
|
||||||
className={`w-6 h-6 ${isSelected ? "text-white" : "text-primary"}`}
|
<div className={`w-11 h-11 rounded-lg flex items-center justify-center flex-shrink-0 ${isSelected ? 'bg-primary' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
||||||
/>
|
<Icon className={`w-5 h-5 ${isSelected ? 'text-white' : 'text-primary'}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
<p className="font-semibold text-gray-900 dark:text-gray-100">{method.displayName}</p>
|
||||||
{method.displayName}
|
<p className="text-xs text-gray-500 dark:text-gray-400">{method.region} · {method.currency}</p>
|
||||||
</p>
|
</div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
{isSelected && (
|
||||||
{method.region} · {method.currency}
|
<CheckCircle className="w-5 h-5 text-primary flex-shrink-0" />
|
||||||
</p>
|
)}
|
||||||
</div>
|
|
||||||
{isSelected && (
|
|
||||||
<div className="w-6 h-6 bg-primary rounded-full flex items-center justify-center">
|
|
||||||
<CheckCircle className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</button>
|
||||||
</div>
|
);
|
||||||
</button>
|
})}
|
||||||
);
|
</div>
|
||||||
})}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Order summary inline — mobile only */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="lg:hidden">
|
||||||
<button
|
<OrderSummary />
|
||||||
onClick={handlePayment}
|
</div>
|
||||||
disabled={!selectedMethod || isProcessing}
|
|
||||||
className={`btn-primary w-full py-4 text-lg font-semibold ${
|
|
||||||
!selectedMethod || isProcessing
|
|
||||||
? "opacity-50 cursor-not-allowed"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isProcessing ? (
|
|
||||||
<span className="flex items-center justify-center gap-2">
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
|
||||||
Processing...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
`Pay ETB ${(totalAmount / 100).toFixed(2)}`
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => router.back()}
|
|
||||||
disabled={isProcessing}
|
|
||||||
className="btn-secondary w-full py-2"
|
|
||||||
>
|
|
||||||
Back to review
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{paymentError && (
|
|
||||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-4">
|
|
||||||
<p className="text-red-800 dark:text-red-200 text-sm font-medium">
|
|
||||||
⚠️ {paymentError}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Security Notice */}
|
{/* Right column — sticky order summary (desktop only) */}
|
||||||
<div className="mt-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
<div className="hidden lg:block">
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400 text-center">
|
<div className="sticky top-6">
|
||||||
🔒 Your payment is secure and encrypted. We do not store your
|
<OrderSummary />
|
||||||
payment information.
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
</div>{/* end grid */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile sticky bottom bar */}
|
||||||
|
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2.5">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
|
||||||
|
<span className="text-lg font-bold text-primary">{displayCurrency} {(totalAmount / 100).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
{paymentError && (
|
||||||
|
<p className="text-red-600 dark:text-red-400 text-xs mb-2">⚠️ {paymentError}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => router.back()} disabled={isProcessing} className="btn-secondary flex-1 py-2.5 flex items-center justify-center gap-2">
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handlePayment}
|
||||||
|
disabled={!selectedMethod || isProcessing}
|
||||||
|
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<span className="flex items-center justify-center gap-1.5">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
`Pay ${displayCurrency} ${(totalAmount / 100).toFixed(2)}`
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { usePaymentStore } from '@/lib/payment-store';
|
import { usePaymentStore } from '@/lib/payment-store';
|
||||||
import { useEffect, Suspense } from 'react';
|
import { useEffect, Suspense } from 'react';
|
||||||
import { XCircle, Loader2, RefreshCw } from 'lucide-react';
|
import { XCircle, Loader2, RefreshCw, ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
function TelebirrFailureContent() {
|
function TelebirrFailureContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -36,7 +36,8 @@ function TelebirrFailureContent() {
|
|||||||
Try Again
|
Try Again
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => router.push('/booking/review')}
|
<button onClick={() => router.push('/booking/review')}
|
||||||
className="btn-secondary w-full">
|
className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
Back to Review
|
Back to Review
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { usePaymentStore } from '@/lib/payment-store';
|
import { usePaymentStore } from '@/lib/payment-store';
|
||||||
import { useEffect, Suspense } from 'react';
|
import { useEffect, Suspense } from 'react';
|
||||||
import { XCircle, Loader2, RefreshCw } from 'lucide-react';
|
import { XCircle, Loader2, RefreshCw, ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
function WaafiFailureContent() {
|
function WaafiFailureContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -39,7 +39,8 @@ function WaafiFailureContent() {
|
|||||||
Try Again
|
Try Again
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => router.push('/booking/review')}
|
<button onClick={() => router.push('/booking/review')}
|
||||||
className="btn-secondary w-full">
|
className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
Back to Review
|
Back to Review
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -159,7 +159,12 @@ export default function ResultsPage() {
|
|||||||
|
|
||||||
// Find the coach type to get pricing info
|
// Find the coach type to get pricing info
|
||||||
const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code);
|
const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code);
|
||||||
const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0;
|
// Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed.
|
||||||
|
const minFare = coachType?.classes.length
|
||||||
|
? Math.min(...coachType.classes.map(c => c.displayAmountMinor ?? c.baseFareMinor))
|
||||||
|
: 0;
|
||||||
|
const fareCurrency: string =
|
||||||
|
coachType?.classes[0]?.displayCurrency ?? schedule.displayCurrency ?? 'ETB';
|
||||||
|
|
||||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||||
@@ -175,6 +180,7 @@ export default function ResultsPage() {
|
|||||||
duration: durationStr,
|
duration: durationStr,
|
||||||
baseFareAdult: minFare,
|
baseFareAdult: minFare,
|
||||||
baseFareChild: minFare,
|
baseFareChild: minFare,
|
||||||
|
displayCurrency: fareCurrency,
|
||||||
selectedSeatClass: selectedCoachType.name,
|
selectedSeatClass: selectedCoachType.name,
|
||||||
selectedSeatClassName: selectedCoachType.name,
|
selectedSeatClassName: selectedCoachType.name,
|
||||||
selectedCoachTypeId: selectedCoachType.id,
|
selectedCoachTypeId: selectedCoachType.id,
|
||||||
@@ -213,13 +219,22 @@ export default function ResultsPage() {
|
|||||||
const scheduleId = schedule.scheduleId || schedule.id || '';
|
const scheduleId = schedule.scheduleId || schedule.id || '';
|
||||||
const selectedCoachType = selectedCoachTypes[scheduleId];
|
const selectedCoachType = selectedCoachTypes[scheduleId];
|
||||||
|
|
||||||
// Calculate lowest fare from coach types
|
// Calculate lowest fare and display currency from coach types / faresByClass.
|
||||||
|
// Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal).
|
||||||
let lowestFare = null;
|
let lowestFare = null;
|
||||||
|
let displayCurrency = schedule.displayCurrency || 'ETB';
|
||||||
if (schedule.coachTypes?.length) {
|
if (schedule.coachTypes?.length) {
|
||||||
const allFares = schedule.coachTypes.flatMap(ct => ct.classes.map(c => c.baseFareMinor)).filter(f => f > 0);
|
const allClasses = schedule.coachTypes.flatMap(ct => ct.classes);
|
||||||
|
const allFares = allClasses.map(c => c.displayAmountMinor ?? c.baseFareMinor).filter(f => f > 0);
|
||||||
lowestFare = allFares.length ? Math.min(...allFares) : null;
|
lowestFare = allFares.length ? Math.min(...allFares) : null;
|
||||||
|
const firstWithCurrency = allClasses.find(c => c.displayCurrency);
|
||||||
|
if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency;
|
||||||
} else if (schedule.faresByClass?.length) {
|
} else if (schedule.faresByClass?.length) {
|
||||||
lowestFare = Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0));
|
lowestFare = Math.min(...schedule.faresByClass.map(f => f.displayAmountMinor ?? f.baseFareMinor).filter(f => f > 0));
|
||||||
|
const firstWithCurrency = schedule.faresByClass.find(f => f.displayCurrency);
|
||||||
|
if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency;
|
||||||
|
} else if (schedule.combinedMinFareDisplay) {
|
||||||
|
lowestFare = schedule.combinedMinFareDisplay;
|
||||||
}
|
}
|
||||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||||
@@ -287,7 +302,7 @@ export default function ResultsPage() {
|
|||||||
<div className="text-center lg:text-right">
|
<div className="text-center lg:text-right">
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Starting from</div>
|
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Starting from</div>
|
||||||
<div className="text-3xl font-bold text-primary">
|
<div className="text-3xl font-bold text-primary">
|
||||||
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
|
{lowestFare ? `${displayCurrency} ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
|
||||||
{selectedCoachType && (
|
{selectedCoachType && (
|
||||||
@@ -536,7 +551,8 @@ export default function ResultsPage() {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2">
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2">
|
||||||
{coachTypes.map((coachType: any, index: number) => {
|
{coachTypes.map((coachType: any, index: number) => {
|
||||||
const isSelected = selectedCoachType?.id === coachType.coachTypeId;
|
const isSelected = selectedCoachType?.id === coachType.coachTypeId;
|
||||||
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
|
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor)) : 0;
|
||||||
|
const coachCurrency: string = (coachType.classes[0] as any)?.displayCurrency ?? (classModal as any).displayCurrency ?? 'ETB';
|
||||||
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -585,7 +601,7 @@ export default function ResultsPage() {
|
|||||||
}`}>
|
}`}>
|
||||||
{(minPrice / 100).toFixed(2)}
|
{(minPrice / 100).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">ETB</span>
|
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">{coachCurrency}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -615,10 +631,10 @@ export default function ResultsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
|
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
|
||||||
{(cls.baseFareMinor / 100).toFixed(2)}
|
{((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||||
ETB
|
{cls.displayCurrency ?? coachCurrency}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useMutation } from '@tanstack/react-query';
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
// Helper function to decode JWT token and extract passengerId
|
// Helper function to decode JWT token and extract passengerId
|
||||||
function getPassengerIdFromToken(token: string): string | null {
|
function getPassengerIdFromToken(token: string): string | null {
|
||||||
@@ -58,6 +59,14 @@ export default function ReviewPage() {
|
|||||||
|
|
||||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||||
|
|
||||||
|
// Prefer the currency already stored on the selected schedule (set from search results).
|
||||||
|
// Fall back to deriving from nationality so the review page is never left with a stale value.
|
||||||
|
const NATIONALITY_TO_CURRENCY: Record<string, string> = { ETHIOPIAN: 'ETB', DJIBOUTIAN: 'DJF' };
|
||||||
|
const displayCurrency: string =
|
||||||
|
(isRoundTrip ? outboundSchedule?.displayCurrency : selectedSchedule?.displayCurrency) ??
|
||||||
|
NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ??
|
||||||
|
'USD';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!seatHold?.expiresAt) return;
|
if (!seatHold?.expiresAt) return;
|
||||||
|
|
||||||
@@ -79,6 +88,14 @@ export default function ReviewPage() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [seatHold]);
|
}, [seatHold]);
|
||||||
|
|
||||||
|
const buildSeatLabel = (seat: any): string => {
|
||||||
|
const base: string = seat.number || seat.label || seat.seatNumber || '';
|
||||||
|
if (!base) return 'N/A';
|
||||||
|
const posMap: Record<string, string> = { lower: 'L', middle: 'M', upper: 'U' };
|
||||||
|
const suffix = seat.bedPosition ? (posMap[seat.bedPosition] ?? '') : '';
|
||||||
|
return suffix ? `${base}${suffix}` : base;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSeatDetails = async () => {
|
const fetchSeatDetails = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -87,15 +104,12 @@ export default function ReviewPage() {
|
|||||||
// Fetch outbound seat details
|
// Fetch outbound seat details
|
||||||
if (isRoundTrip && outboundSchedule?.id) {
|
if (isRoundTrip && outboundSchedule?.id) {
|
||||||
const outboundSeatMap: any = await apiClient.get(`/seats/seatmap/${outboundSchedule.id}`);
|
const outboundSeatMap: any = await apiClient.get(`/seats/seatmap/${outboundSchedule.id}`);
|
||||||
const outboundCoaches = outboundSeatMap?.coaches || [];
|
const outboundSeats = (outboundSeatMap?.coaches || []).flatMap((coach: any) => coach.seats || []);
|
||||||
const outboundSeats = outboundCoaches.flatMap((coach: any) => coach.seats || []);
|
|
||||||
|
|
||||||
passengers.forEach(p => {
|
passengers.forEach(p => {
|
||||||
if ((p as any).outboundSeatId) {
|
if ((p as any).outboundSeatId) {
|
||||||
const seat = outboundSeats.find((s: any) => s.id === (p as any).outboundSeatId);
|
const seat = outboundSeats.find((s: any) => s.id === (p as any).outboundSeatId);
|
||||||
if (seat) {
|
if (seat) details[`outbound-${(p as any).outboundSeatId}`] = buildSeatLabel(seat);
|
||||||
details[`outbound-${(p as any).outboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -103,15 +117,12 @@ export default function ReviewPage() {
|
|||||||
// Fetch inbound seat details
|
// Fetch inbound seat details
|
||||||
if (isRoundTrip && inboundSchedule?.id) {
|
if (isRoundTrip && inboundSchedule?.id) {
|
||||||
const inboundSeatMap: any = await apiClient.get(`/seats/seatmap/${inboundSchedule.id}`);
|
const inboundSeatMap: any = await apiClient.get(`/seats/seatmap/${inboundSchedule.id}`);
|
||||||
const inboundCoaches = inboundSeatMap?.coaches || [];
|
const inboundSeats = (inboundSeatMap?.coaches || []).flatMap((coach: any) => coach.seats || []);
|
||||||
const inboundSeats = inboundCoaches.flatMap((coach: any) => coach.seats || []);
|
|
||||||
|
|
||||||
passengers.forEach(p => {
|
passengers.forEach(p => {
|
||||||
if ((p as any).inboundSeatId) {
|
if ((p as any).inboundSeatId) {
|
||||||
const seat = inboundSeats.find((s: any) => s.id === (p as any).inboundSeatId);
|
const seat = inboundSeats.find((s: any) => s.id === (p as any).inboundSeatId);
|
||||||
if (seat) {
|
if (seat) details[`inbound-${(p as any).inboundSeatId}`] = buildSeatLabel(seat);
|
||||||
details[`inbound-${(p as any).inboundSeatId}`] = seat.number || seat.label || seat.seatNumber || 'N/A';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -119,15 +130,12 @@ export default function ReviewPage() {
|
|||||||
// Fetch one-way seat details
|
// Fetch one-way seat details
|
||||||
if (!isRoundTrip && selectedSchedule?.id) {
|
if (!isRoundTrip && selectedSchedule?.id) {
|
||||||
const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`);
|
const seatMapData: any = await apiClient.get(`/seats/seatmap/${selectedSchedule.id}`);
|
||||||
const coaches = seatMapData?.coaches || [];
|
const allSeats = (seatMapData?.coaches || []).flatMap((coach: any) => coach.seats || []);
|
||||||
const allSeats = coaches.flatMap((coach: any) => coach.seats || []);
|
|
||||||
|
|
||||||
passengers.forEach(p => {
|
passengers.forEach(p => {
|
||||||
if (p.seatId) {
|
if (p.seatId) {
|
||||||
const seat = allSeats.find((s: any) => s.id === p.seatId);
|
const seat = allSeats.find((s: any) => s.id === p.seatId);
|
||||||
if (seat) {
|
if (seat) details[p.seatId] = buildSeatLabel(seat);
|
||||||
details[p.seatId] = seat.number || seat.label || seat.seatNumber || 'N/A';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -249,7 +257,7 @@ export default function ReviewPage() {
|
|||||||
destinationStationId: searchCriteria.destinationStationId,
|
destinationStationId: searchCriteria.destinationStationId,
|
||||||
seatClassId: seatClassId,
|
seatClassId: seatClassId,
|
||||||
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
|
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
|
||||||
displayCurrency: 'ETB',
|
displayCurrency: displayCurrency,
|
||||||
passengers: passengers.map((p) => {
|
passengers: passengers.map((p) => {
|
||||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||||
return {
|
return {
|
||||||
@@ -288,7 +296,7 @@ export default function ReviewPage() {
|
|||||||
destinationStationId: searchCriteria.destinationStationId,
|
destinationStationId: searchCriteria.destinationStationId,
|
||||||
seatClassId: seatClassId,
|
seatClassId: seatClassId,
|
||||||
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
|
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
|
||||||
displayCurrency: 'ETB',
|
displayCurrency: displayCurrency,
|
||||||
passengers: passengers.map(p => {
|
passengers: passengers.map(p => {
|
||||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||||
return {
|
return {
|
||||||
@@ -373,21 +381,88 @@ export default function ReviewPage() {
|
|||||||
}, 0);
|
}, 0);
|
||||||
const total = baseFare;
|
const total = baseFare;
|
||||||
|
|
||||||
|
// Shared fare sidebar — rendered in right column (desktop) and inline (mobile)
|
||||||
|
const FareSidebar = () => (
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
Fare breakdown
|
||||||
|
</h2>
|
||||||
|
{passengers.map((p, i) => {
|
||||||
|
const outFare = outboundSchedule?.baseFareAdult || 0;
|
||||||
|
const inFare = inboundSchedule?.baseFareAdult || 0;
|
||||||
|
const onewayFare = selectedSchedule?.baseFareAdult || 0;
|
||||||
|
const passengerTotal = isRoundTrip ? outFare + inFare : onewayFare;
|
||||||
|
return (
|
||||||
|
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
|
||||||
|
<div className="flex justify-between mb-0.5">
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[60%]">
|
||||||
|
{p.name || `Passenger ${i + 1}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{displayCurrency} {(passengerTotal / 100).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isRoundTrip && (
|
||||||
|
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Outbound</span>
|
||||||
|
<span>{displayCurrency} {(outFare / 100).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Return</span>
|
||||||
|
<span>{displayCurrency} {(inFare / 100).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="flex justify-between items-center pt-1 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
|
||||||
|
<span className="text-xl font-bold text-primary">{displayCurrency} {(total / 100).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons — visible only in desktop sidebar */}
|
||||||
|
<div className="hidden lg:flex flex-col gap-2 pt-2">
|
||||||
|
{createBookingMutation.isError && (
|
||||||
|
<p className="text-red-600 dark:text-red-400 text-xs">
|
||||||
|
⚠️ {createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred. Please try again.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={createBookingMutation.isPending}
|
||||||
|
className="btn-primary w-full"
|
||||||
|
>
|
||||||
|
{createBookingMutation.isPending ? 'Creating booking...' : `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" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Review your booking</h1>
|
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">Review your booking</h1>
|
||||||
|
|
||||||
{seatHold && (
|
{seatHold && (
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
|
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-4 flex items-center gap-2">
|
||||||
<p className="text-yellow-800 dark:text-yellow-200">
|
<span className="text-yellow-800 dark:text-yellow-200 text-sm">
|
||||||
⏱️ Your seats will be released in: <span className="font-bold">{timeLeft}</span>
|
⏱️ Seats held for: <span className="font-bold">{timeLeft}</span>
|
||||||
</p>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-6">
|
{/* Two-column layout on desktop */}
|
||||||
|
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:items-start">
|
||||||
|
|
||||||
|
{/* Left column — trip details + passengers */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
{/* Outbound Trip Details */}
|
{/* Outbound Trip Details */}
|
||||||
{isRoundTrip && outboundSchedule && (
|
{isRoundTrip && outboundSchedule && (
|
||||||
<div className="card overflow-hidden">
|
<div className="card overflow-hidden">
|
||||||
@@ -646,67 +721,47 @@ export default function ReviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
{/* Fare breakdown — visible only on mobile (desktop shows it in right column) */}
|
||||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Fare breakdown</h2>
|
<div className="lg:hidden mt-4">
|
||||||
<div className="space-y-3">
|
<FareSidebar />
|
||||||
{passengers.map((p, i) => {
|
</div>
|
||||||
const outFare = outboundSchedule?.baseFareAdult || 0;
|
|
||||||
const inFare = inboundSchedule?.baseFareAdult || 0;
|
</div>{/* end left column */}
|
||||||
const onewayFare = selectedSchedule?.baseFareAdult || 0;
|
|
||||||
const passengerTotal = isRoundTrip ? outFare + inFare : onewayFare;
|
{/* Right column — sticky fare card (desktop only) */}
|
||||||
return (
|
<div className="hidden lg:block">
|
||||||
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-3 last:border-0">
|
<div className="sticky top-6">
|
||||||
<div className="flex justify-between mb-1">
|
<FareSidebar />
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{p.name || `Passenger ${i + 1}`}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
ETB {(passengerTotal / 100).toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{isRoundTrip && (
|
|
||||||
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Outbound</span>
|
|
||||||
<span>ETB {(outFare / 100).toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Return</span>
|
|
||||||
<span>ETB {(inFare / 100).toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="flex justify-between text-lg font-bold border-t border-gray-200 dark:border-gray-700 pt-2">
|
|
||||||
<span className="text-gray-900 dark:text-gray-100">Total</span>
|
|
||||||
<span className="text-primary dark:text-gray-100">ETB {(total / 100).toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4">
|
</div>{/* end grid */}
|
||||||
<button onClick={() => router.back()} className="btn-secondary flex-1">
|
</div>
|
||||||
Back
|
</div>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={createBookingMutation.isPending}
|
|
||||||
className="btn-primary flex-1"
|
|
||||||
>
|
|
||||||
{createBookingMutation.isPending ? 'Creating booking...' : `Confirm ${isAuthenticated ? '' : 'and pay'}`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{createBookingMutation.isError && (
|
{/* Mobile sticky bottom bar */}
|
||||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-4">
|
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
|
||||||
<p className="text-red-800 dark:text-red-200 text-sm">
|
<div className="flex items-center justify-between mb-2.5">
|
||||||
⚠️ {createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred while creating your booking. Please try again.'}
|
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
|
||||||
</p>
|
<span className="text-lg font-bold text-primary">{displayCurrency} {(total / 100).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{createBookingMutation.isError && (
|
||||||
</div>
|
<p className="text-red-600 dark:text-red-400 text-xs mb-2">
|
||||||
|
⚠️ {createBookingMutation.error instanceof Error ? createBookingMutation.error.message : 'An error occurred. Please try again.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => router.back()} className="btn-secondary flex-1 py-2.5 flex items-center justify-center gap-2">
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={createBookingMutation.isPending}
|
||||||
|
className="btn-primary flex-1 py-2.5"
|
||||||
|
>
|
||||||
|
{createBookingMutation.isPending ? 'Creating...' : `Confirm ${isAuthenticated ? '' : '& pay'}`}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ import Image from "next/image";
|
|||||||
|
|
||||||
import CustomModal from "@/components/CustomModal";
|
import CustomModal from "@/components/CustomModal";
|
||||||
|
|
||||||
|
const BED_POSITION_SUFFIX: Record<string, string> = { lower: 'L', middle: 'M', upper: 'U' };
|
||||||
|
|
||||||
|
const buildSeatLabel = (seat: any): string => {
|
||||||
|
const base: string = seat.number || seat.label || seat.seatNumber || '';
|
||||||
|
if (!base) return '';
|
||||||
|
const suffix = seat.bedPosition ? (BED_POSITION_SUFFIX[seat.bedPosition] ?? '') : '';
|
||||||
|
return suffix ? `${base}${suffix}` : base;
|
||||||
|
};
|
||||||
|
|
||||||
const BedCard = memo(({ bed, isSelected, onToggle }: any) => {
|
const BedCard = memo(({ bed, isSelected, onToggle }: any) => {
|
||||||
const seatLabel = bed.label || bed.seatNumber || bed.number || "?";
|
const seatLabel = bed.label || bed.seatNumber || bed.number || "?";
|
||||||
const bedPosition = bed.bedPosition || "";
|
const bedPosition = bed.bedPosition || "";
|
||||||
@@ -364,11 +373,7 @@ export default function SeatsPage() {
|
|||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
outboundSeatId: selectedSeats[i],
|
outboundSeatId: selectedSeats[i],
|
||||||
outboundSeatNumber:
|
outboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||||
seatData?.number ||
|
|
||||||
seatData?.label ||
|
|
||||||
seatData?.seatNumber ||
|
|
||||||
"",
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
setPassengers(updatedPassengers);
|
setPassengers(updatedPassengers);
|
||||||
@@ -401,18 +406,13 @@ export default function SeatsPage() {
|
|||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
inboundSeatId: selectedSeats[i],
|
inboundSeatId: selectedSeats[i],
|
||||||
inboundSeatNumber:
|
inboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||||
seatData?.number ||
|
|
||||||
seatData?.label ||
|
|
||||||
seatData?.seatNumber ||
|
|
||||||
"",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
seatId: selectedSeats[i],
|
seatId: selectedSeats[i],
|
||||||
seatNumber:
|
seatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||||
seatData?.number || seatData?.label || seatData?.seatNumber || "",
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
setPassengers(updatedPassengers);
|
setPassengers(updatedPassengers);
|
||||||
@@ -456,8 +456,7 @@ export default function SeatsPage() {
|
|||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
seatId: autoSelectedSeats[i],
|
seatId: autoSelectedSeats[i],
|
||||||
seatNumber:
|
seatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||||
seatData?.number || seatData?.label || seatData?.seatNumber || "",
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
setPassengers(updatedPassengers);
|
setPassengers(updatedPassengers);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface SelectedSchedule {
|
|||||||
duration: string;
|
duration: string;
|
||||||
baseFareAdult: number;
|
baseFareAdult: number;
|
||||||
baseFareChild: number;
|
baseFareChild: number;
|
||||||
|
displayCurrency: string;
|
||||||
selectedSeatClass?: string;
|
selectedSeatClass?: string;
|
||||||
selectedSeatClassName?: string;
|
selectedSeatClassName?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export interface Schedule {
|
|||||||
baseFareChild?: number;
|
baseFareChild?: number;
|
||||||
availableSeats?: number;
|
availableSeats?: number;
|
||||||
availabilityByClass?: Record<string, number>; // API returns this
|
availabilityByClass?: Record<string, number>; // API returns this
|
||||||
faresByClass?: Array<{ seatClassName: string; baseFareMinor: number }>; // API returns this
|
faresByClass?: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency?: string; displayAmountMinor?: number }>; // API returns this
|
||||||
coachTypes?: Array<{
|
coachTypes?: Array<{
|
||||||
coachId: string;
|
coachId: string;
|
||||||
coachTypeName: string;
|
coachTypeName: string;
|
||||||
@@ -46,8 +46,13 @@ export interface Schedule {
|
|||||||
classes: Array<{
|
classes: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
baseFareMinor: number;
|
baseFareMinor: number;
|
||||||
|
displayCurrency?: string;
|
||||||
|
displayAmountMinor?: number;
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
|
displayCurrency?: string;
|
||||||
|
combinedMinFareMinor?: number;
|
||||||
|
combinedMinFareDisplay?: number;
|
||||||
serviceClass?: string;
|
serviceClass?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
hasAvailability?: boolean;
|
hasAvailability?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user