mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Add nationality selection on package
This commit is contained in:
@@ -95,7 +95,7 @@ interface PackageDetail {
|
|||||||
busTransferRoute: string | null;
|
busTransferRoute: string | null;
|
||||||
validFrom: string;
|
validFrom: string;
|
||||||
validUntil: string;
|
validUntil: string;
|
||||||
journeyType: 'ONE_WAY' | 'ROUND_TRIP';
|
journeyType: "ONE_WAY" | "ROUND_TRIP";
|
||||||
priceTiers: PriceTier[];
|
priceTiers: PriceTier[];
|
||||||
outboundSchedule: Schedule;
|
outboundSchedule: Schedule;
|
||||||
returnSchedule: Schedule | null;
|
returnSchedule: Schedule | null;
|
||||||
@@ -109,17 +109,28 @@ interface PackageDetail {
|
|||||||
function fmt(iso: string, opts?: Intl.DateTimeFormatOptions): string {
|
function fmt(iso: string, opts?: Intl.DateTimeFormatOptions): string {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleDateString("en-US", {
|
return new Date(iso).toLocaleDateString("en-US", {
|
||||||
weekday: "short", month: "short", day: "numeric",
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
...opts,
|
...opts,
|
||||||
timeZone: "Africa/Addis_Ababa",
|
timeZone: "Africa/Addis_Ababa",
|
||||||
});
|
});
|
||||||
} catch { return iso; }
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtTime(iso: string): string {
|
function fmtTime(iso: string): string {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true, timeZone: "Africa/Addis_Ababa" });
|
return new Date(iso).toLocaleTimeString("en-US", {
|
||||||
} catch { return iso; }
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
timeZone: "Africa/Addis_Ababa",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function durationLabel(minutes: number): string {
|
function durationLabel(minutes: number): string {
|
||||||
@@ -138,7 +149,13 @@ function formatPrice(minor: number, currency: string): string {
|
|||||||
|
|
||||||
// ─── Journey Card ─────────────────────────────────────────────────────────────
|
// ─── Journey Card ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function JourneyCard({ schedule, label }: { schedule: Schedule; label: string }) {
|
function JourneyCard({
|
||||||
|
schedule,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
schedule: Schedule;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
const dep = new Date(schedule.departureAt);
|
const dep = new Date(schedule.departureAt);
|
||||||
const arr = new Date(schedule.arrivalAt);
|
const arr = new Date(schedule.arrivalAt);
|
||||||
const isSameDay = dep.toDateString() === arr.toDateString();
|
const isSameDay = dep.toDateString() === arr.toDateString();
|
||||||
@@ -147,8 +164,12 @@ function JourneyCard({ schedule, label }: { schedule: Schedule; label: string })
|
|||||||
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-100 dark:border-gray-800 overflow-hidden">
|
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-100 dark:border-gray-800 overflow-hidden">
|
||||||
<div className="flex items-center gap-2 px-5 py-3 bg-gray-50 dark:bg-gray-800/60 border-b border-gray-100 dark:border-gray-800">
|
<div className="flex items-center gap-2 px-5 py-3 bg-gray-50 dark:bg-gray-800/60 border-b border-gray-100 dark:border-gray-800">
|
||||||
<Train className="w-4 h-4 text-primary" />
|
<Train className="w-4 h-4 text-primary" />
|
||||||
<span className="text-sm font-bold text-gray-800 dark:text-white">{label}</span>
|
<span className="text-sm font-bold text-gray-800 dark:text-white">
|
||||||
<span className="ml-auto text-xs text-gray-400">{schedule.train.number}</span>
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-xs text-gray-400">
|
||||||
|
{schedule.train.number}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
@@ -156,11 +177,15 @@ function JourneyCard({ schedule, label }: { schedule: Schedule; label: string })
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Origin */}
|
{/* Origin */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">From</p>
|
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">
|
||||||
|
From
|
||||||
|
</p>
|
||||||
<p className="text-xl font-extrabold text-gray-900 dark:text-white truncate">
|
<p className="text-xl font-extrabold text-gray-900 dark:text-white truncate">
|
||||||
{schedule.originStation.code}
|
{schedule.originStation.code}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 truncate">{schedule.originStation.name.trim()}</p>
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
{schedule.originStation.name.trim()}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center: duration + arrow */}
|
{/* Center: duration + arrow */}
|
||||||
@@ -172,31 +197,53 @@ function JourneyCard({ schedule, label }: { schedule: Schedule; label: string })
|
|||||||
<div className="h-px w-full bg-gray-200 dark:bg-gray-700" />
|
<div className="h-px w-full bg-gray-200 dark:bg-gray-700" />
|
||||||
<ArrowRight className="w-3 h-3 text-primary absolute -right-1" />
|
<ArrowRight className="w-3 h-3 text-primary absolute -right-1" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-gray-400">{schedule.stopsCount} stops</span>
|
<span className="text-[10px] text-gray-400">
|
||||||
|
{schedule.stopsCount} stops
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Destination */}
|
{/* Destination */}
|
||||||
<div className="flex-1 min-w-0 text-right">
|
<div className="flex-1 min-w-0 text-right">
|
||||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">To</p>
|
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">
|
||||||
|
To
|
||||||
|
</p>
|
||||||
<p className="text-xl font-extrabold text-gray-900 dark:text-white truncate">
|
<p className="text-xl font-extrabold text-gray-900 dark:text-white truncate">
|
||||||
{schedule.destinationStation.code}
|
{schedule.destinationStation.code}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 truncate">{schedule.destinationStation.name.trim()}</p>
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
{schedule.destinationStation.name.trim()}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Times */}
|
{/* Times */}
|
||||||
<div className="flex items-end justify-between mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
|
<div className="flex items-end justify-between mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-bold text-primary">{fmtTime(schedule.departureAt)}</p>
|
<p className="text-lg font-bold text-primary">
|
||||||
<p className="text-xs text-gray-400">{fmt(schedule.departureAt, { weekday: "short", month: "short", day: "numeric" })}</p>
|
{fmtTime(schedule.departureAt)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{fmt(schedule.departureAt, {
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-lg font-bold text-primary">
|
<p className="text-lg font-bold text-primary">
|
||||||
{fmtTime(schedule.arrivalAt)}
|
{fmtTime(schedule.arrivalAt)}
|
||||||
{!isSameDay && <sup className="text-[10px] text-orange-400 ml-0.5">+1</sup>}
|
{!isSameDay && (
|
||||||
|
<sup className="text-[10px] text-orange-400 ml-0.5">+1</sup>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{fmt(schedule.arrivalAt, {
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-400">{fmt(schedule.arrivalAt, { weekday: "short", month: "short", day: "numeric" })}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,7 +262,16 @@ function groupTiersByCoachType(tiers: PriceTier[]): Array<{
|
|||||||
minPrice: number;
|
minPrice: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
}> {
|
}> {
|
||||||
const map = new Map<string, { coachTypeId: string; coachTypeName: string; coachTypeCode: string; coachTypeType: string; tiers: PriceTier[] }>();
|
const map = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
coachTypeId: string;
|
||||||
|
coachTypeName: string;
|
||||||
|
coachTypeCode: string;
|
||||||
|
coachTypeType: string;
|
||||||
|
tiers: PriceTier[];
|
||||||
|
}
|
||||||
|
>();
|
||||||
for (const tier of tiers) {
|
for (const tier of tiers) {
|
||||||
const ct = tier.seatClass?.coachType;
|
const ct = tier.seatClass?.coachType;
|
||||||
const key = ct?.id ?? `__ungrouped__${tier.seatType}`;
|
const key = ct?.id ?? `__ungrouped__${tier.seatType}`;
|
||||||
@@ -223,8 +279,8 @@ function groupTiersByCoachType(tiers: PriceTier[]): Array<{
|
|||||||
map.set(key, {
|
map.set(key, {
|
||||||
coachTypeId: ct?.id ?? key,
|
coachTypeId: ct?.id ?? key,
|
||||||
coachTypeName: ct?.name ?? tier.label ?? tier.seatType,
|
coachTypeName: ct?.name ?? tier.label ?? tier.seatType,
|
||||||
coachTypeCode: ct?.code ?? '',
|
coachTypeCode: ct?.code ?? "",
|
||||||
coachTypeType: ct?.type ?? 'passenger',
|
coachTypeType: ct?.type ?? "passenger",
|
||||||
tiers: [],
|
tiers: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -233,20 +289,20 @@ function groupTiersByCoachType(tiers: PriceTier[]): Array<{
|
|||||||
return Array.from(map.values()).map((g) => ({
|
return Array.from(map.values()).map((g) => ({
|
||||||
...g,
|
...g,
|
||||||
minPrice: Math.min(...g.tiers.map((t) => t.priceMinor)),
|
minPrice: Math.min(...g.tiers.map((t) => t.priceMinor)),
|
||||||
currency: g.tiers[0]?.currency ?? 'ETB',
|
currency: g.tiers[0]?.currency ?? "ETB",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCoachIcon(coachTypeType: string) {
|
function getCoachIcon(coachTypeType: string) {
|
||||||
const lower = coachTypeType.toLowerCase();
|
const lower = coachTypeType.toLowerCase();
|
||||||
if (lower.includes('sleeper')) return Star;
|
if (lower.includes("sleeper")) return Star;
|
||||||
if (lower.includes('bed') || lower.includes('sleep')) return Bed;
|
if (lower.includes("bed") || lower.includes("sleep")) return Bed;
|
||||||
return Armchair;
|
return Armchair;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capitalise first letter of each word, replace underscores with spaces
|
// Capitalise first letter of each word, replace underscores with spaces
|
||||||
function formatCoachTypeLabel(type: string): string {
|
function formatCoachTypeLabel(type: string): string {
|
||||||
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
return type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
function PriceTiersPanel({
|
function PriceTiersPanel({
|
||||||
@@ -265,14 +321,18 @@ function PriceTiersPanel({
|
|||||||
if (!tiers?.length) {
|
if (!tiers?.length) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800">
|
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800">
|
||||||
<p className="text-sm text-gray-400 text-center py-4">No price tiers available</p>
|
<p className="text-sm text-gray-400 text-center py-4">
|
||||||
|
No price tiers available
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800">
|
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800">
|
||||||
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-4">Choose Coach Type</h2>
|
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Choose Coach Type
|
||||||
|
</h2>
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
{groups.map((group, index) => {
|
{groups.map((group, index) => {
|
||||||
const CoachIcon = getCoachIcon(group.coachTypeType);
|
const CoachIcon = getCoachIcon(group.coachTypeType);
|
||||||
@@ -283,37 +343,58 @@ function PriceTiersPanel({
|
|||||||
key={group.coachTypeId}
|
key={group.coachTypeId}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={allSoldOut ? -1 : 0}
|
tabIndex={allSoldOut ? -1 : 0}
|
||||||
onClick={() => !allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)}
|
onClick={() =>
|
||||||
|
!allSoldOut &&
|
||||||
|
setSelectedId(isSelected ? null : group.coachTypeId)
|
||||||
|
}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (!allSoldOut && (e.key === 'Enter' || e.key === ' ')) {
|
if (!allSoldOut && (e.key === "Enter" || e.key === " ")) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedId(isSelected ? null : group.coachTypeId);
|
setSelectedId(isSelected ? null : group.coachTypeId);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`group relative w-full p-2 rounded-2xl border-2 text-left transition-all duration-200 ${allSoldOut
|
className={`group relative w-full p-2 rounded-2xl border-2 text-left transition-all duration-200 ${
|
||||||
? 'border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed'
|
allSoldOut
|
||||||
|
? "border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed"
|
||||||
: isSelected
|
: isSelected
|
||||||
? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02] cursor-pointer'
|
? "border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02] cursor-pointer"
|
||||||
: 'border-gray-200 dark:border-gray-700 shadow-sm hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50 cursor-pointer'
|
: "border-gray-200 dark:border-gray-700 shadow-sm hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50 cursor-pointer"
|
||||||
}`}
|
}`}
|
||||||
style={{ animation: `fade-in-up 0.3s ease-out ${index * 0.08}s both` }}
|
style={{
|
||||||
|
animation: `fade-in-up 0.3s ease-out ${index * 0.08}s both`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Radio indicator */}
|
{/* Radio indicator */}
|
||||||
{!allSoldOut && (
|
{!allSoldOut && (
|
||||||
<span className={`absolute top-4 right-4 w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-all ${isSelected ? 'border-primary' : 'border-gray-300 dark:border-gray-600 group-hover:border-primary/50'
|
<span
|
||||||
}`}>
|
className={`absolute top-4 right-4 w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-all ${
|
||||||
{isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}
|
isSelected
|
||||||
|
? "border-primary"
|
||||||
|
: "border-gray-300 dark:border-gray-600 group-hover:border-primary/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-start gap-2 pr-2">
|
<div className="flex items-start gap-2 pr-2">
|
||||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all ${isSelected
|
<div
|
||||||
? 'bg-primary/15 dark:bg-primary/25 shadow-inner'
|
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all ${
|
||||||
: 'bg-gray-100 dark:bg-gray-700 group-hover:bg-primary/10'
|
isSelected
|
||||||
}`}>
|
? "bg-primary/15 dark:bg-primary/25 shadow-inner"
|
||||||
<CoachIcon className={`w-4 h-4 transition-colors ${isSelected ? 'text-primary' : 'text-gray-600 dark:text-gray-400 group-hover:text-primary'
|
: "bg-gray-100 dark:bg-gray-700 group-hover:bg-primary/10"
|
||||||
}`} />
|
}`}
|
||||||
|
>
|
||||||
|
<CoachIcon
|
||||||
|
className={`w-4 h-4 transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "text-primary"
|
||||||
|
: "text-gray-600 dark:text-gray-400 group-hover:text-primary"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -321,15 +402,26 @@ function PriceTiersPanel({
|
|||||||
{formatCoachTypeLabel(group.coachTypeType)}
|
{formatCoachTypeLabel(group.coachTypeType)}
|
||||||
</p>
|
</p>
|
||||||
{allSoldOut && (
|
{allSoldOut && (
|
||||||
<span className="text-xs font-bold text-red-500 mt-0.5 block">Sold out</span>
|
<span className="text-xs font-bold text-red-500 mt-0.5 block">
|
||||||
|
Sold out
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="mt-1 flex items-baseline gap-1">
|
<div className="mt-1 flex items-baseline gap-1">
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">From</span>
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||||
<span className={`text-sm font-bold tracking-tight ${isSelected ? 'text-primary' : 'text-gray-900 dark:text-white'
|
From
|
||||||
}`}>
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-bold tracking-tight ${
|
||||||
|
isSelected
|
||||||
|
? "text-primary"
|
||||||
|
: "text-gray-900 dark:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{((group.minPrice * priceMultiplier) / 100).toFixed(2)}
|
{((group.minPrice * priceMultiplier) / 100).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">{group.currency}</span>
|
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">
|
||||||
|
{group.currency}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,17 +435,26 @@ function PriceTiersPanel({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tier.id}
|
key={tier.id}
|
||||||
className={`flex flex-col py-1 px-1 rounded-lg bg-gray-50/80 dark:bg-gray-800/40 ${soldOut ? 'opacity-50' : ''}`}
|
className={`flex flex-col py-1 px-1 rounded-lg bg-gray-50/80 dark:bg-gray-800/40 ${soldOut ? "opacity-50" : ""}`}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{tier.seatType.trim()}</span>
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{tier.seatType.trim()}
|
||||||
|
</span>
|
||||||
{soldOut ? (
|
{soldOut ? (
|
||||||
<span className="text-xs font-bold text-red-500 mt-0.5">Sold out</span>
|
<span className="text-xs font-bold text-red-500 mt-0.5">
|
||||||
|
Sold out
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-baseline gap-1 mt-0.5">
|
<div className="flex items-baseline gap-1 mt-0.5">
|
||||||
<span className="text-sm font-bold tabular-nums text-primary">
|
<span className="text-sm font-bold tabular-nums text-primary">
|
||||||
{((tier.priceMinor * priceMultiplier) / 100).toFixed(2)}
|
{(
|
||||||
|
(tier.priceMinor * priceMultiplier) /
|
||||||
|
100
|
||||||
|
).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||||
|
{tier.currency}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">{tier.currency}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -372,7 +473,10 @@ function PriceTiersPanel({
|
|||||||
{isSelected && !allSoldOut && (
|
{isSelected && !allSoldOut && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); onBookNow(group.coachTypeId); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onBookNow(group.coachTypeId);
|
||||||
|
}}
|
||||||
className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all shadow-md shadow-primary/30 hover:shadow-lg active:scale-[0.98]"
|
className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all shadow-md shadow-primary/30 hover:shadow-lg active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
Book Now <ArrowRight className="w-4 h-4" />
|
Book Now <ArrowRight className="w-4 h-4" />
|
||||||
@@ -406,7 +510,13 @@ function PassengerCountModal({
|
|||||||
}: {
|
}: {
|
||||||
tier: PriceTier;
|
tier: PriceTier;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => void;
|
onConfirm: (
|
||||||
|
adultCount: number,
|
||||||
|
childCount: number,
|
||||||
|
departureStationId: string,
|
||||||
|
departureStationName: string,
|
||||||
|
nationality: "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER",
|
||||||
|
) => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
priceMultiplier: number;
|
priceMultiplier: number;
|
||||||
@@ -414,51 +524,116 @@ function PassengerCountModal({
|
|||||||
}) {
|
}) {
|
||||||
const [adultCount, setAdultCount] = useState(1);
|
const [adultCount, setAdultCount] = useState(1);
|
||||||
const [childCount, setChildCount] = useState(0);
|
const [childCount, setChildCount] = useState(0);
|
||||||
const [departureStationId, setDepartureStationId] = useState('');
|
const [departureStationId, setDepartureStationId] = useState("");
|
||||||
const [showStationError, setShowStationError] = useState(false);
|
const [showStationError, setShowStationError] = useState(false);
|
||||||
|
// Only used to decide Fayda verification show/hide on /booking/passengers
|
||||||
|
// (via each passenger's real nationality, same logic the normal flow already
|
||||||
|
// uses) — package pricing/currency is fixed per tier and is never affected by
|
||||||
|
// this. No default: nationality is a deliberate choice, same as the normal
|
||||||
|
// flow's search-step picker.
|
||||||
|
const [nationality, setNationality] = useState<"" | "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER">("");
|
||||||
|
const [showNationalityError, setShowNationalityError] = useState(false);
|
||||||
|
const natOptions: Array<{ value: "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER"; label: string }> = [
|
||||||
|
{ value: "ETHIOPIAN", label: "🇪🇹 Ethiopian" },
|
||||||
|
{ value: "DJIBOUTIAN", label: "🇩🇯 Djiboutian" },
|
||||||
|
{ value: "OTHER", label: "🌍 Other" },
|
||||||
|
];
|
||||||
const remaining = tier.availableSeats - tier.bookedSeats;
|
const remaining = tier.availableSeats - tier.bookedSeats;
|
||||||
const freeChildren = Math.min(childCount, adultCount);
|
const freeChildren = Math.min(childCount, adultCount);
|
||||||
const paidChildren = Math.max(0, childCount - adultCount);
|
const paidChildren = Math.max(0, childCount - adultCount);
|
||||||
const totalMinor = (adultCount * tier.priceMinor + paidChildren * tier.priceMinor) * priceMultiplier;
|
const totalMinor =
|
||||||
|
(adultCount * tier.priceMinor + paidChildren * tier.priceMinor) *
|
||||||
|
priceMultiplier;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-[90] bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
<div
|
||||||
|
className="fixed inset-0 z-[90] bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4">
|
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4">
|
||||||
<div className="w-full sm:max-w-sm bg-white dark:bg-gray-900 rounded-t-3xl sm:rounded-2xl shadow-2xl overflow-hidden">
|
<div className="w-full sm:max-w-sm bg-white dark:bg-gray-900 rounded-t-3xl sm:rounded-2xl shadow-2xl overflow-hidden">
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">Number of passengers & boarding station selection</h2>
|
<h2 className="text-base font-bold text-gray-900 dark:text-white">
|
||||||
<button type="button" onClick={onClose} className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800">
|
Number of passengers & boarding station selection
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
<X className="w-4 h-4 text-gray-500" />
|
<X className="w-4 h-4 text-gray-500" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10">
|
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10">
|
||||||
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Coach type</p>
|
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">
|
||||||
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.seatClass?.coachType?.name ?? tier.label.trim()}</p>
|
Coach type
|
||||||
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)</p>
|
</p>
|
||||||
|
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">
|
||||||
|
{tier.seatClass?.coachType?.name ?? tier.label.trim()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
{remaining} seats remaining · from{" "}
|
||||||
|
{formatPrice(tier.priceMinor * priceMultiplier, tier.currency)}{" "}
|
||||||
|
per adult · 1st child per adult free (no seat)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-5 space-y-4">
|
<div className="px-6 py-5 space-y-4">
|
||||||
{[
|
{[
|
||||||
{ label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: (v: number) => { setAdultCount(v); const newMax = Math.min(v * PKG_CHILDREN_PER_ADULT, v + Math.max(0, remaining - v)); setChildCount(c => Math.min(c, newMax)); } },
|
{
|
||||||
{ label: "Children", sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 1st per adult FREE (no seat)`, value: childCount, min: 0, max: Math.min(adultCount * PKG_CHILDREN_PER_ADULT, adultCount + Math.max(0, remaining - adultCount)), set: setChildCount },
|
label: "Adults",
|
||||||
|
sub: `Age 5+ · max ${PKG_MAX_ADULTS}`,
|
||||||
|
value: adultCount,
|
||||||
|
min: 1,
|
||||||
|
max: Math.min(PKG_MAX_ADULTS, remaining),
|
||||||
|
set: (v: number) => {
|
||||||
|
setAdultCount(v);
|
||||||
|
const newMax = Math.min(
|
||||||
|
v * PKG_CHILDREN_PER_ADULT,
|
||||||
|
v + Math.max(0, remaining - v),
|
||||||
|
);
|
||||||
|
setChildCount((c) => Math.min(c, newMax));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Children",
|
||||||
|
sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 1st per adult FREE (no seat)`,
|
||||||
|
value: childCount,
|
||||||
|
min: 0,
|
||||||
|
max: Math.min(
|
||||||
|
adultCount * PKG_CHILDREN_PER_ADULT,
|
||||||
|
adultCount + Math.max(0, remaining - adultCount),
|
||||||
|
),
|
||||||
|
set: setChildCount,
|
||||||
|
},
|
||||||
].map(({ label, sub, value, min, max, set }) => (
|
].map(({ label, sub, value, min, max, set }) => (
|
||||||
<div key={label} className="flex items-center justify-between">
|
<div key={label} className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-gray-400">{sub}</p>
|
<p className="text-xs text-gray-400">{sub}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button type="button" onClick={() => set(Math.max(min, value - 1))}
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => set(Math.max(min, value - 1))}
|
||||||
disabled={value <= min}
|
disabled={value <= min}
|
||||||
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
|
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
−
|
−
|
||||||
</button>
|
</button>
|
||||||
<span className="w-6 text-center text-base font-bold text-gray-900 dark:text-white">{value}</span>
|
<span className="w-6 text-center text-base font-bold text-gray-900 dark:text-white">
|
||||||
<button type="button" onClick={() => set(value + 1)}
|
{value}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => set(value + 1)}
|
||||||
disabled={value >= max}
|
disabled={value >= max}
|
||||||
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
|
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -467,22 +642,64 @@ function PassengerCountModal({
|
|||||||
|
|
||||||
{freeChildren > 0 && (
|
{freeChildren > 0 && (
|
||||||
<div className="flex items-center justify-between text-xs text-green-600 dark:text-green-400">
|
<div className="flex items-center justify-between text-xs text-green-600 dark:text-green-400">
|
||||||
<span>{freeChildren} child{freeChildren > 1 ? 'ren' : ''} travel free (no seat)</span>
|
<span>
|
||||||
|
{freeChildren} child{freeChildren > 1 ? "ren" : ""} travel
|
||||||
|
free (no seat)
|
||||||
|
</span>
|
||||||
<span>ETB 0.00</span>
|
<span>ETB 0.00</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
|
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
|
||||||
<span className="text-sm text-gray-500">Total{priceMultiplier === 2 ? ' (round-trip) from:' : ''}</span>
|
<span className="text-sm text-gray-500">
|
||||||
<span className="text-base font-extrabold text-primary">{formatPrice(totalMinor, tier.currency)}</span>
|
Total{priceMultiplier === 2 ? " (round-trip) from:" : ""}
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-extrabold text-primary">
|
||||||
|
{formatPrice(totalMinor, tier.currency)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
|
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
|
||||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
<p className="text-xs text-red-600 dark:text-red-400">{error}</p>
|
<p className="text-xs text-red-600 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Nationality */}
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-800 pt-3">
|
||||||
|
<p className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
||||||
|
Nationality
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{natOptions.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setNationality(opt.value);
|
||||||
|
setShowNationalityError(false);
|
||||||
|
}}
|
||||||
|
className={`py-2.5 px-2 rounded-xl border-2 text-xs font-semibold transition-all ${
|
||||||
|
nationality === opt.value
|
||||||
|
? "border-primary bg-primary/5 text-primary"
|
||||||
|
: showNationalityError
|
||||||
|
? "border-red-300 dark:border-red-800 text-gray-600 dark:text-gray-400 hover:border-gray-300"
|
||||||
|
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{showNationalityError && !nationality && (
|
||||||
|
<p className="text-xs text-red-500 mt-1.5">
|
||||||
|
Please select a nationality to continue.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Departure Station */}
|
{/* Departure Station */}
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center gap-1.5 text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5 border-t pt-3">
|
<label className="flex items-center gap-1.5 text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5 border-t pt-3">
|
||||||
@@ -490,30 +707,69 @@ function PassengerCountModal({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={departureStationId}
|
value={departureStationId}
|
||||||
onChange={(e) => { setDepartureStationId(e.target.value); setShowStationError(false); }}
|
onChange={(e) => {
|
||||||
className={`w-full rounded-xl border bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/40 ${showStationError && !departureStationId ? 'border-red-400 dark:border-red-500' : 'border-gray-200 dark:border-gray-700'
|
setDepartureStationId(e.target.value);
|
||||||
}`}
|
setShowStationError(false);
|
||||||
|
}}
|
||||||
|
className={`w-full rounded-xl border bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/40 ${
|
||||||
|
showStationError && !departureStationId
|
||||||
|
? "border-red-400 dark:border-red-500"
|
||||||
|
: "border-gray-200 dark:border-gray-700"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<option value="">Select your boarding station</option>
|
<option value="">Select your boarding station</option>
|
||||||
{stations.map((s) => (
|
{stations.map((s) => (
|
||||||
<option key={s.id} value={s.id}>{s.name.trim()} ({s.code})</option>
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name.trim()} ({s.code})
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-[10px] text-gray-400 mt-1">For informational purposes — pricing remains fixed regardless of boarding point.</p>
|
<p className="text-[10px] text-gray-400 mt-1">
|
||||||
|
For informational purposes — pricing remains fixed regardless of
|
||||||
|
boarding point.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showStationError && !departureStationId && (
|
{showStationError && !departureStationId && (
|
||||||
<p className="text-xs text-red-500 -mt-2">Please select your boarding station to continue.</p>
|
<p className="text-xs text-red-500 -mt-2">
|
||||||
|
Please select your boarding station to continue.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button type="button" onClick={() => {
|
<button
|
||||||
if (!departureStationId) { setShowStationError(true); return; }
|
type="button"
|
||||||
const station = stations.find(s => s.id === departureStationId);
|
onClick={() => {
|
||||||
onConfirm(adultCount, childCount, departureStationId, station?.name ?? '');
|
if (!nationality) {
|
||||||
}}
|
setShowNationalityError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!departureStationId) {
|
||||||
|
setShowStationError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const station = stations.find(
|
||||||
|
(s) => s.id === departureStationId,
|
||||||
|
);
|
||||||
|
onConfirm(
|
||||||
|
adultCount,
|
||||||
|
childCount,
|
||||||
|
departureStationId,
|
||||||
|
station?.name ?? "",
|
||||||
|
nationality,
|
||||||
|
);
|
||||||
|
}}
|
||||||
disabled={loading || adultCount + childCount < 1}
|
disabled={loading || adultCount + childCount < 1}
|
||||||
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-60 text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2">
|
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-60 text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2"
|
||||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> Loading...</> : <>Continue <ArrowRight className="w-4 h-4" /></>}
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Continue <ArrowRight className="w-4 h-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -528,14 +784,30 @@ export default function PackageDetailPage() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const id = params?.id as string;
|
const id = params?.id as string;
|
||||||
const { clearBooking, setSearchCriteria, setSelectedSchedule, setOutboundSchedule, setInboundSchedule, setPassengers, setPackageContext } = useBookingStore();
|
const {
|
||||||
|
clearBooking,
|
||||||
|
setSearchCriteria,
|
||||||
|
setSelectedSchedule,
|
||||||
|
setOutboundSchedule,
|
||||||
|
setInboundSchedule,
|
||||||
|
setPassengers,
|
||||||
|
setPackageContext,
|
||||||
|
} = useBookingStore();
|
||||||
|
|
||||||
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string | null>(null);
|
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [passengerModalOpen, setPassengerModalOpen] = useState(false);
|
const [passengerModalOpen, setPassengerModalOpen] = useState(false);
|
||||||
const [bookingContextLoading, setBookingContextLoading] = useState(false);
|
const [bookingContextLoading, setBookingContextLoading] = useState(false);
|
||||||
const [bookingContextError, setBookingContextError] = useState<string | null>(null);
|
const [bookingContextError, setBookingContextError] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const { data: pkg, isLoading, isError } = useQuery<PackageDetail>({
|
const {
|
||||||
|
data: pkg,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery<PackageDetail>({
|
||||||
queryKey: ["package", id],
|
queryKey: ["package", id],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(await apiClient.get<PackageDetail>(`/packages/${id}`)) as PackageDetail,
|
(await apiClient.get<PackageDetail>(`/packages/${id}`)) as PackageDetail,
|
||||||
@@ -549,15 +821,26 @@ export default function PackageDetailPage() {
|
|||||||
|
|
||||||
// For the passenger modal, use the cheapest available tier in the selected coach type group
|
// For the passenger modal, use the cheapest available tier in the selected coach type group
|
||||||
const groups = pkg ? groupTiersByCoachType(pkg.priceTiers) : [];
|
const groups = pkg ? groupTiersByCoachType(pkg.priceTiers) : [];
|
||||||
const selectedGroup = groups.find((g) => g.coachTypeId === selectedCoachTypeId);
|
const selectedGroup = groups.find(
|
||||||
|
(g) => g.coachTypeId === selectedCoachTypeId,
|
||||||
|
);
|
||||||
// Representative tier for the modal: cheapest available in the selected group
|
// Representative tier for the modal: cheapest available in the selected group
|
||||||
const representativeTier = selectedGroup?.tiers
|
const representativeTier =
|
||||||
.filter((t) => t.availableSeats > 0)
|
selectedGroup?.tiers
|
||||||
.sort((a, b) => a.priceMinor - b.priceMinor)[0] ?? selectedGroup?.tiers[0] ?? null;
|
.filter((t) => t.availableSeats > 0)
|
||||||
|
.sort((a, b) => a.priceMinor - b.priceMinor)[0] ??
|
||||||
|
selectedGroup?.tiers[0] ??
|
||||||
|
null;
|
||||||
|
|
||||||
const isRoundTripPkg = pkg?.journeyType === 'ROUND_TRIP';
|
const isRoundTripPkg = pkg?.journeyType === "ROUND_TRIP";
|
||||||
|
|
||||||
const handleBookNow = async (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => {
|
const handleBookNow = async (
|
||||||
|
adultCount: number,
|
||||||
|
childCount: number,
|
||||||
|
departureStationId: string,
|
||||||
|
departureStationName: string,
|
||||||
|
nationality: "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER",
|
||||||
|
) => {
|
||||||
if (!representativeTier || !pkg) return;
|
if (!representativeTier || !pkg) return;
|
||||||
setBookingContextLoading(true);
|
setBookingContextLoading(true);
|
||||||
setBookingContextError(null);
|
setBookingContextError(null);
|
||||||
@@ -577,10 +860,13 @@ export default function PackageDetailPage() {
|
|||||||
origin: s.originStation?.name ?? "",
|
origin: s.originStation?.name ?? "",
|
||||||
destination: s.destinationStation?.name ?? "",
|
destination: s.destinationStation?.name ?? "",
|
||||||
originStationId: s.originStationId ?? s.originStation?.id ?? "",
|
originStationId: s.originStationId ?? s.originStation?.id ?? "",
|
||||||
destinationStationId: s.destinationStationId ?? s.destinationStation?.id ?? "",
|
destinationStationId:
|
||||||
|
s.destinationStationId ?? s.destinationStation?.id ?? "",
|
||||||
departureTime: s.departureAt,
|
departureTime: s.departureAt,
|
||||||
arrivalTime: s.arrivalAt,
|
arrivalTime: s.arrivalAt,
|
||||||
duration: s.durationMinutes ? `${Math.floor(s.durationMinutes / 60)}h ${s.durationMinutes % 60}m` : "",
|
duration: s.durationMinutes
|
||||||
|
? `${Math.floor(s.durationMinutes / 60)}h ${s.durationMinutes % 60}m`
|
||||||
|
: "",
|
||||||
baseFareAdult: Math.round(ctx.totalMinor / passengerCount),
|
baseFareAdult: Math.round(ctx.totalMinor / passengerCount),
|
||||||
baseFareChild: 0,
|
baseFareChild: 0,
|
||||||
displayCurrency: representativeTier.currency,
|
displayCurrency: representativeTier.currency,
|
||||||
@@ -590,19 +876,27 @@ export default function PackageDetailPage() {
|
|||||||
selectedCoachTypeId: ctx.coachTypeId ?? "",
|
selectedCoachTypeId: ctx.coachTypeId ?? "",
|
||||||
selectedCoachTypeCode: ctx.coachTypeCode ?? "",
|
selectedCoachTypeCode: ctx.coachTypeCode ?? "",
|
||||||
selectedCoachTypeName: ctx.coachTypeName ?? "",
|
selectedCoachTypeName: ctx.coachTypeName ?? "",
|
||||||
coachTypes: Array.isArray(ctx.coachTypes) ? ctx.coachTypes : groups.map((g) => ({
|
coachTypes: Array.isArray(ctx.coachTypes)
|
||||||
coachId: g.coachTypeId,
|
? ctx.coachTypes
|
||||||
coachTypeId: g.coachTypeId,
|
: groups.map((g) => ({
|
||||||
coachTypeName: g.coachTypeName,
|
coachId: g.coachTypeId,
|
||||||
coachTypeCode: g.coachTypeCode,
|
coachTypeId: g.coachTypeId,
|
||||||
classes: g.tiers.map((t) => ({ name: t.label, baseFareMinor: t.priceMinor })),
|
coachTypeName: g.coachTypeName,
|
||||||
})),
|
coachTypeCode: g.coachTypeCode,
|
||||||
|
classes: g.tiers.map((t) => ({
|
||||||
|
name: t.label,
|
||||||
|
baseFareMinor: t.priceMinor,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
// booking-context returns flat originStationId/destinationStationId, not nested objects.
|
// booking-context returns flat originStationId/destinationStationId, not nested objects.
|
||||||
// Use the user's selected departure station as the true origin for both the
|
// Use the user's selected departure station as the true origin for both the
|
||||||
// search criteria and the schedule objects so hold/booking APIs get the right segment.
|
// search criteria and the schedule objects so hold/booking APIs get the right segment.
|
||||||
const outboundDestId = ctx.outboundSchedule.destinationStationId ?? ctx.outboundSchedule.destinationStation?.id ?? "";
|
const outboundDestId =
|
||||||
|
ctx.outboundSchedule.destinationStationId ??
|
||||||
|
ctx.outboundSchedule.destinationStation?.id ??
|
||||||
|
"";
|
||||||
|
|
||||||
const outboundSched = {
|
const outboundSched = {
|
||||||
...toSchedule(ctx.outboundSchedule),
|
...toSchedule(ctx.outboundSchedule),
|
||||||
@@ -618,7 +912,16 @@ export default function PackageDetailPage() {
|
|||||||
tripType: isRoundTrip ? "ROUND_TRIP" : "ONE_WAY",
|
tripType: isRoundTrip ? "ROUND_TRIP" : "ONE_WAY",
|
||||||
adultCount,
|
adultCount,
|
||||||
childCount,
|
childCount,
|
||||||
nationality: "ETHIOPIAN",
|
// Must be the real picked value, not hardcoded — /booking/passengers' form
|
||||||
|
// only reads a passenger's own stored nationality when resuming a session
|
||||||
|
// with a name already filled in; for a brand-new booking (every package
|
||||||
|
// passenger starts with an empty name) it falls back to searchCriteria's
|
||||||
|
// nationality only, so a hardcoded value here would silently override
|
||||||
|
// whatever was actually picked and always show the Fayda gate. Safe for
|
||||||
|
// amount/currency: package schedules always carry their own fixed
|
||||||
|
// displayCurrency (set below in toSchedule()), which takes priority over
|
||||||
|
// any nationality-based fallback, so this has no effect on price/currency.
|
||||||
|
nationality,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isRoundTrip) {
|
if (isRoundTrip) {
|
||||||
@@ -643,8 +946,16 @@ export default function PackageDetailPage() {
|
|||||||
name: "",
|
name: "",
|
||||||
// Free children travel without a seat — give them a synthetic DOB that
|
// Free children travel without a seat — give them a synthetic DOB that
|
||||||
// makes isChild() return true so seat eligibility logic excludes them.
|
// makes isChild() return true so seat eligibility logic excludes them.
|
||||||
dateOfBirth: isFreeChild ? new Date(Date.now() - 2 * 365.25 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) : "",
|
dateOfBirth: isFreeChild
|
||||||
nationality: "ETHIOPIAN",
|
? new Date(Date.now() - 2 * 365.25 * 24 * 60 * 60 * 1000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10)
|
||||||
|
: "",
|
||||||
|
// Drives Fayda verification show/hide on /booking/passengers (existing
|
||||||
|
// logic there) — searchCriteria.nationality above is deliberately left
|
||||||
|
// as "ETHIOPIAN" since package pricing/currency is fixed per tier and
|
||||||
|
// must not change based on this.
|
||||||
|
nationality,
|
||||||
isPrimaryPassenger: i === 0,
|
isPrimaryPassenger: i === 0,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -653,12 +964,20 @@ export default function PackageDetailPage() {
|
|||||||
// Store per-adult tier price (×1 leg); review page applies round-trip multiplier and child pricing
|
// Store per-adult tier price (×1 leg); review page applies round-trip multiplier and child pricing
|
||||||
// Store the group's cheapest tier price as a placeholder — the actual berth fare
|
// Store the group's cheapest tier price as a placeholder — the actual berth fare
|
||||||
// (Upper/Middle/Lower) will be resolved and saved when the user picks a seat.
|
// (Upper/Middle/Lower) will be resolved and saved when the user picks a seat.
|
||||||
setPackageContext(id, representativeTier.id, representativeTier.priceMinor, pkg.name, departureStationId, departureStationName);
|
setPackageContext(
|
||||||
|
id,
|
||||||
|
representativeTier.id,
|
||||||
|
representativeTier.priceMinor,
|
||||||
|
pkg.name,
|
||||||
|
departureStationId,
|
||||||
|
departureStationName,
|
||||||
|
);
|
||||||
|
|
||||||
router.push("/booking/passengers");
|
router.push("/booking/passengers");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setBookingContextError(
|
setBookingContextError(
|
||||||
err?.response?.data?.message ?? "Failed to load booking context. Please try again.",
|
err?.response?.data?.message ??
|
||||||
|
"Failed to load booking context. Please try again.",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setBookingContextLoading(false);
|
setBookingContextLoading(false);
|
||||||
@@ -681,7 +1000,9 @@ export default function PackageDetailPage() {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<AlertCircle className="w-8 h-8 text-red-400 mx-auto mb-3" />
|
<AlertCircle className="w-8 h-8 text-red-400 mx-auto mb-3" />
|
||||||
<p className="text-sm text-gray-500 mb-4">Unable to load package details.</p>
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Unable to load package details.
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="text-sm text-primary font-medium flex items-center gap-1 mx-auto hover:underline"
|
className="text-sm text-primary font-medium flex items-center gap-1 mx-auto hover:underline"
|
||||||
@@ -701,7 +1022,10 @@ export default function PackageDetailPage() {
|
|||||||
{passengerModalOpen && representativeTier && (
|
{passengerModalOpen && representativeTier && (
|
||||||
<PassengerCountModal
|
<PassengerCountModal
|
||||||
tier={representativeTier}
|
tier={representativeTier}
|
||||||
onClose={() => { setPassengerModalOpen(false); setBookingContextError(null); }}
|
onClose={() => {
|
||||||
|
setPassengerModalOpen(false);
|
||||||
|
setBookingContextError(null);
|
||||||
|
}}
|
||||||
onConfirm={handleBookNow}
|
onConfirm={handleBookNow}
|
||||||
loading={bookingContextLoading}
|
loading={bookingContextLoading}
|
||||||
error={bookingContextError}
|
error={bookingContextError}
|
||||||
@@ -751,7 +1075,9 @@ export default function PackageDetailPage() {
|
|||||||
<ArrowRight className="w-3.5 h-3.5" />
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
{destination.name.trim()}
|
{destination.name.trim()}
|
||||||
{pkg.returnSchedule && (
|
{pkg.returnSchedule && (
|
||||||
<span className="text-white/50 text-xs ml-1">· Round Trip</span>
|
<span className="text-white/50 text-xs ml-1">
|
||||||
|
· Round Trip
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -763,7 +1089,6 @@ export default function PackageDetailPage() {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* ── Main content ── */}
|
{/* ── Main content ── */}
|
||||||
<div className="lg:col-span-2 space-y-5">
|
<div className="lg:col-span-2 space-y-5">
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{pkg.description && (
|
{pkg.description && (
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 border border-gray-100 dark:border-gray-800">
|
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 border border-gray-100 dark:border-gray-800">
|
||||||
@@ -778,12 +1103,18 @@ export default function PackageDetailPage() {
|
|||||||
|
|
||||||
{/* Outbound journey */}
|
{/* Outbound journey */}
|
||||||
{pkg.outboundSchedule && (
|
{pkg.outboundSchedule && (
|
||||||
<JourneyCard schedule={pkg.outboundSchedule} label="Outbound Journey" />
|
<JourneyCard
|
||||||
|
schedule={pkg.outboundSchedule}
|
||||||
|
label="Outbound Journey"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Return journey */}
|
{/* Return journey */}
|
||||||
{pkg.returnSchedule && (
|
{pkg.returnSchedule && (
|
||||||
<JourneyCard schedule={pkg.returnSchedule} label="Return Journey" />
|
<JourneyCard
|
||||||
|
schedule={pkg.returnSchedule}
|
||||||
|
label="Return Journey"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bus transfer */}
|
{/* Bus transfer */}
|
||||||
@@ -814,23 +1145,42 @@ export default function PackageDetailPage() {
|
|||||||
Travel Information
|
Travel Information
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<InfoRow icon={<Clock className="w-4 h-4 text-primary" />} label="Boarding Time">
|
<InfoRow
|
||||||
|
icon={<Clock className="w-4 h-4 text-primary" />}
|
||||||
|
label="Boarding Time"
|
||||||
|
>
|
||||||
{fmtTime(pkg.boardingTime)} · {fmt(pkg.boardingTime)}
|
{fmtTime(pkg.boardingTime)} · {fmt(pkg.boardingTime)}
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
<InfoRow icon={<Calendar className="w-4 h-4 text-primary" />} label="Departure Time">
|
<InfoRow
|
||||||
|
icon={<Calendar className="w-4 h-4 text-primary" />}
|
||||||
|
label="Departure Time"
|
||||||
|
>
|
||||||
{fmtTime(pkg.departureTime)} · {fmt(pkg.departureTime)}
|
{fmtTime(pkg.departureTime)} · {fmt(pkg.departureTime)}
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
<InfoRow icon={<MapPin className="w-4 h-4 text-primary" />} label="Arrival">
|
<InfoRow
|
||||||
|
icon={<MapPin className="w-4 h-4 text-primary" />}
|
||||||
|
label="Arrival"
|
||||||
|
>
|
||||||
{fmtTime(pkg.arrivalTime)} · {fmt(pkg.arrivalTime)}
|
{fmtTime(pkg.arrivalTime)} · {fmt(pkg.arrivalTime)}
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
{pkg.coachConfiguration && (
|
{pkg.coachConfiguration && (
|
||||||
<InfoRow icon={<Train className="w-4 h-4 text-primary" />} label="Coach Config">
|
<InfoRow
|
||||||
|
icon={<Train className="w-4 h-4 text-primary" />}
|
||||||
|
label="Coach Config"
|
||||||
|
>
|
||||||
{pkg.coachConfiguration.trim()}
|
{pkg.coachConfiguration.trim()}
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
)}
|
)}
|
||||||
<InfoRow icon={<Shield className="w-4 h-4 text-primary" />} label="Valid Period">
|
<InfoRow
|
||||||
|
icon={<Shield className="w-4 h-4 text-primary" />}
|
||||||
|
label="Valid Period"
|
||||||
|
>
|
||||||
{fmt(pkg.validFrom, { month: "short", day: "numeric" })} –{" "}
|
{fmt(pkg.validFrom, { month: "short", day: "numeric" })} –{" "}
|
||||||
{fmt(pkg.validUntil, { month: "short", day: "numeric", year: "numeric" })}
|
{fmt(pkg.validUntil, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -859,7 +1209,10 @@ export default function PackageDetailPage() {
|
|||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
<PriceTiersPanel
|
<PriceTiersPanel
|
||||||
tiers={pkg.priceTiers}
|
tiers={pkg.priceTiers}
|
||||||
onBookNow={(coachTypeId) => { setSelectedCoachTypeId(coachTypeId); setPassengerModalOpen(true); }}
|
onBookNow={(coachTypeId) => {
|
||||||
|
setSelectedCoachTypeId(coachTypeId);
|
||||||
|
setPassengerModalOpen(true);
|
||||||
|
}}
|
||||||
isRoundTrip={isRoundTripPkg}
|
isRoundTrip={isRoundTripPkg}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -870,7 +1223,10 @@ export default function PackageDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<PriceTiersPanel
|
<PriceTiersPanel
|
||||||
tiers={pkg.priceTiers}
|
tiers={pkg.priceTiers}
|
||||||
onBookNow={(coachTypeId) => { setSelectedCoachTypeId(coachTypeId); setPassengerModalOpen(true); }}
|
onBookNow={(coachTypeId) => {
|
||||||
|
setSelectedCoachTypeId(coachTypeId);
|
||||||
|
setPassengerModalOpen(true);
|
||||||
|
}}
|
||||||
isRoundTrip={isRoundTripPkg}
|
isRoundTrip={isRoundTripPkg}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user