mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1054 from Tria-plc/alpha
Add nationality selection on package
This commit is contained in:
BIN
apps/edr-passenger-web/portal/public/packages/package.jpeg
Normal file
BIN
apps/edr-passenger-web/portal/public/packages/package.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@@ -95,7 +95,7 @@ interface PackageDetail {
|
||||
busTransferRoute: string | null;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
journeyType: 'ONE_WAY' | 'ROUND_TRIP';
|
||||
journeyType: "ONE_WAY" | "ROUND_TRIP";
|
||||
priceTiers: PriceTier[];
|
||||
outboundSchedule: Schedule;
|
||||
returnSchedule: Schedule | null;
|
||||
@@ -109,17 +109,28 @@ interface PackageDetail {
|
||||
function fmt(iso: string, opts?: Intl.DateTimeFormatOptions): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("en-US", {
|
||||
weekday: "short", month: "short", day: "numeric",
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...opts,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
});
|
||||
} catch { return iso; }
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true, timeZone: "Africa/Addis_Ababa" });
|
||||
} catch { return iso; }
|
||||
return new Date(iso).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function durationLabel(minutes: number): string {
|
||||
@@ -138,7 +149,13 @@ function formatPrice(minor: number, currency: string): string {
|
||||
|
||||
// ─── 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 arr = new Date(schedule.arrivalAt);
|
||||
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="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" />
|
||||
<span className="text-sm font-bold text-gray-800 dark:text-white">{label}</span>
|
||||
<span className="ml-auto text-xs text-gray-400">{schedule.train.number}</span>
|
||||
<span className="text-sm font-bold text-gray-800 dark:text-white">
|
||||
{label}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-gray-400">
|
||||
{schedule.train.number}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
@@ -156,11 +177,15 @@ function JourneyCard({ schedule, label }: { schedule: Schedule; label: string })
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Origin */}
|
||||
<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">
|
||||
{schedule.originStation.code}
|
||||
</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>
|
||||
|
||||
{/* 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" />
|
||||
<ArrowRight className="w-3 h-3 text-primary absolute -right-1" />
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-400">{schedule.stopsCount} stops</span>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{schedule.stopsCount} stops
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<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">
|
||||
{schedule.destinationStation.code}
|
||||
</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>
|
||||
|
||||
{/* Times */}
|
||||
<div className="flex items-end justify-between mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<div>
|
||||
<p className="text-lg font-bold text-primary">{fmtTime(schedule.departureAt)}</p>
|
||||
<p className="text-xs text-gray-400">{fmt(schedule.departureAt, { weekday: "short", month: "short", day: "numeric" })}</p>
|
||||
<p className="text-lg font-bold text-primary">
|
||||
{fmtTime(schedule.departureAt)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{fmt(schedule.departureAt, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-bold text-primary">
|
||||
{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 className="text-xs text-gray-400">{fmt(schedule.arrivalAt, { weekday: "short", month: "short", day: "numeric" })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,7 +262,16 @@ function groupTiersByCoachType(tiers: PriceTier[]): Array<{
|
||||
minPrice: number;
|
||||
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) {
|
||||
const ct = tier.seatClass?.coachType;
|
||||
const key = ct?.id ?? `__ungrouped__${tier.seatType}`;
|
||||
@@ -223,8 +279,8 @@ function groupTiersByCoachType(tiers: PriceTier[]): Array<{
|
||||
map.set(key, {
|
||||
coachTypeId: ct?.id ?? key,
|
||||
coachTypeName: ct?.name ?? tier.label ?? tier.seatType,
|
||||
coachTypeCode: ct?.code ?? '',
|
||||
coachTypeType: ct?.type ?? 'passenger',
|
||||
coachTypeCode: ct?.code ?? "",
|
||||
coachTypeType: ct?.type ?? "passenger",
|
||||
tiers: [],
|
||||
});
|
||||
}
|
||||
@@ -233,20 +289,20 @@ function groupTiersByCoachType(tiers: PriceTier[]): Array<{
|
||||
return Array.from(map.values()).map((g) => ({
|
||||
...g,
|
||||
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) {
|
||||
const lower = coachTypeType.toLowerCase();
|
||||
if (lower.includes('sleeper')) return Star;
|
||||
if (lower.includes('bed') || lower.includes('sleep')) return Bed;
|
||||
if (lower.includes("sleeper")) return Star;
|
||||
if (lower.includes("bed") || lower.includes("sleep")) return Bed;
|
||||
return Armchair;
|
||||
}
|
||||
|
||||
// Capitalise first letter of each word, replace underscores with spaces
|
||||
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({
|
||||
@@ -265,14 +321,18 @@ function PriceTiersPanel({
|
||||
if (!tiers?.length) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
{groups.map((group, index) => {
|
||||
const CoachIcon = getCoachIcon(group.coachTypeType);
|
||||
@@ -283,37 +343,58 @@ function PriceTiersPanel({
|
||||
key={group.coachTypeId}
|
||||
role="button"
|
||||
tabIndex={allSoldOut ? -1 : 0}
|
||||
onClick={() => !allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)}
|
||||
onClick={() =>
|
||||
!allSoldOut &&
|
||||
setSelectedId(isSelected ? null : group.coachTypeId)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (!allSoldOut && (e.key === 'Enter' || e.key === ' ')) {
|
||||
if (!allSoldOut && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
setSelectedId(isSelected ? null : group.coachTypeId);
|
||||
}
|
||||
}}
|
||||
className={`group relative w-full p-2 rounded-2xl border-2 text-left transition-all duration-200 ${allSoldOut
|
||||
? 'border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed'
|
||||
className={`group relative w-full p-2 rounded-2xl border-2 text-left transition-all duration-200 ${
|
||||
allSoldOut
|
||||
? "border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed"
|
||||
: 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-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` }}
|
||||
? "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"
|
||||
}`}
|
||||
style={{
|
||||
animation: `fade-in-up 0.3s ease-out ${index * 0.08}s both`,
|
||||
}}
|
||||
>
|
||||
{/* Radio indicator */}
|
||||
{!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'
|
||||
}`}>
|
||||
{isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<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
|
||||
? 'bg-primary/15 dark:bg-primary/25 shadow-inner'
|
||||
: '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
|
||||
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all ${
|
||||
isSelected
|
||||
? "bg-primary/15 dark:bg-primary/25 shadow-inner"
|
||||
: "bg-gray-100 dark:bg-gray-700 group-hover:bg-primary/10"
|
||||
}`}
|
||||
>
|
||||
<CoachIcon
|
||||
className={`w-4 h-4 transition-colors ${
|
||||
isSelected
|
||||
? "text-primary"
|
||||
: "text-gray-600 dark:text-gray-400 group-hover:text-primary"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -321,15 +402,26 @@ function PriceTiersPanel({
|
||||
{formatCoachTypeLabel(group.coachTypeType)}
|
||||
</p>
|
||||
{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">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">From</span>
|
||||
<span className={`text-sm font-bold tracking-tight ${isSelected ? 'text-primary' : 'text-gray-900 dark:text-white'
|
||||
}`}>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
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)}
|
||||
</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>
|
||||
@@ -343,17 +435,26 @@ function PriceTiersPanel({
|
||||
return (
|
||||
<div
|
||||
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 ? (
|
||||
<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">
|
||||
<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 className="text-xs text-gray-500 dark:text-gray-400 font-medium">{tier.currency}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -372,7 +473,10 @@ function PriceTiersPanel({
|
||||
{isSelected && !allSoldOut && (
|
||||
<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]"
|
||||
>
|
||||
Book Now <ArrowRight className="w-4 h-4" />
|
||||
@@ -406,7 +510,13 @@ function PassengerCountModal({
|
||||
}: {
|
||||
tier: PriceTier;
|
||||
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;
|
||||
error: string | null;
|
||||
priceMultiplier: number;
|
||||
@@ -414,51 +524,121 @@ function PassengerCountModal({
|
||||
}) {
|
||||
const [adultCount, setAdultCount] = useState(1);
|
||||
const [childCount, setChildCount] = useState(0);
|
||||
const [departureStationId, setDepartureStationId] = useState('');
|
||||
const [departureStationId, setDepartureStationId] = useState("");
|
||||
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 freeChildren = Math.min(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 (
|
||||
<>
|
||||
<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="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">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">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">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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-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>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">
|
||||
Coach type
|
||||
</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 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 }) => (
|
||||
<div key={label} className="flex items-center justify-between">
|
||||
<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>
|
||||
</div>
|
||||
<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}
|
||||
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>
|
||||
<span className="w-6 text-center text-base font-bold text-gray-900 dark:text-white">{value}</span>
|
||||
<button type="button" onClick={() => set(value + 1)}
|
||||
<span className="w-6 text-center text-base font-bold text-gray-900 dark:text-white">
|
||||
{value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set(value + 1)}
|
||||
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>
|
||||
</div>
|
||||
@@ -467,22 +647,64 @@ function PassengerCountModal({
|
||||
|
||||
{freeChildren > 0 && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<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-base font-extrabold text-primary">{formatPrice(totalMinor, tier.currency)}</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
Total{priceMultiplier === 2 ? " (round-trip) from:" : ""}
|
||||
</span>
|
||||
<span className="text-base font-extrabold text-primary">
|
||||
{formatPrice(totalMinor, tier.currency)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
<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">
|
||||
@@ -490,30 +712,69 @@ function PassengerCountModal({
|
||||
</label>
|
||||
<select
|
||||
value={departureStationId}
|
||||
onChange={(e) => { 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'
|
||||
}`}
|
||||
onChange={(e) => {
|
||||
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>
|
||||
{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>
|
||||
<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>
|
||||
|
||||
{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={() => {
|
||||
if (!departureStationId) { setShowStationError(true); return; }
|
||||
const station = stations.find(s => s.id === departureStationId);
|
||||
onConfirm(adultCount, childCount, departureStationId, station?.name ?? '');
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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}
|
||||
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" /></>}
|
||||
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" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -528,14 +789,30 @@ export default function PackageDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
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 [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],
|
||||
queryFn: async () =>
|
||||
(await apiClient.get<PackageDetail>(`/packages/${id}`)) as PackageDetail,
|
||||
@@ -549,15 +826,26 @@ export default function PackageDetailPage() {
|
||||
|
||||
// For the passenger modal, use the cheapest available tier in the selected coach type group
|
||||
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
|
||||
const representativeTier = selectedGroup?.tiers
|
||||
.filter((t) => t.availableSeats > 0)
|
||||
.sort((a, b) => a.priceMinor - b.priceMinor)[0] ?? selectedGroup?.tiers[0] ?? null;
|
||||
const representativeTier =
|
||||
selectedGroup?.tiers
|
||||
.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;
|
||||
setBookingContextLoading(true);
|
||||
setBookingContextError(null);
|
||||
@@ -577,10 +865,13 @@ export default function PackageDetailPage() {
|
||||
origin: s.originStation?.name ?? "",
|
||||
destination: s.destinationStation?.name ?? "",
|
||||
originStationId: s.originStationId ?? s.originStation?.id ?? "",
|
||||
destinationStationId: s.destinationStationId ?? s.destinationStation?.id ?? "",
|
||||
destinationStationId:
|
||||
s.destinationStationId ?? s.destinationStation?.id ?? "",
|
||||
departureTime: s.departureAt,
|
||||
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),
|
||||
baseFareChild: 0,
|
||||
displayCurrency: representativeTier.currency,
|
||||
@@ -590,19 +881,27 @@ export default function PackageDetailPage() {
|
||||
selectedCoachTypeId: ctx.coachTypeId ?? "",
|
||||
selectedCoachTypeCode: ctx.coachTypeCode ?? "",
|
||||
selectedCoachTypeName: ctx.coachTypeName ?? "",
|
||||
coachTypes: Array.isArray(ctx.coachTypes) ? ctx.coachTypes : groups.map((g) => ({
|
||||
coachId: g.coachTypeId,
|
||||
coachTypeId: g.coachTypeId,
|
||||
coachTypeName: g.coachTypeName,
|
||||
coachTypeCode: g.coachTypeCode,
|
||||
classes: g.tiers.map((t) => ({ name: t.label, baseFareMinor: t.priceMinor })),
|
||||
})),
|
||||
coachTypes: Array.isArray(ctx.coachTypes)
|
||||
? ctx.coachTypes
|
||||
: groups.map((g) => ({
|
||||
coachId: g.coachTypeId,
|
||||
coachTypeId: g.coachTypeId,
|
||||
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.
|
||||
// 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.
|
||||
const outboundDestId = ctx.outboundSchedule.destinationStationId ?? ctx.outboundSchedule.destinationStation?.id ?? "";
|
||||
const outboundDestId =
|
||||
ctx.outboundSchedule.destinationStationId ??
|
||||
ctx.outboundSchedule.destinationStation?.id ??
|
||||
"";
|
||||
|
||||
const outboundSched = {
|
||||
...toSchedule(ctx.outboundSchedule),
|
||||
@@ -618,7 +917,16 @@ export default function PackageDetailPage() {
|
||||
tripType: isRoundTrip ? "ROUND_TRIP" : "ONE_WAY",
|
||||
adultCount,
|
||||
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) {
|
||||
@@ -643,8 +951,16 @@ export default function PackageDetailPage() {
|
||||
name: "",
|
||||
// Free children travel without a seat — give them a synthetic DOB that
|
||||
// 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) : "",
|
||||
nationality: "ETHIOPIAN",
|
||||
dateOfBirth: isFreeChild
|
||||
? 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,
|
||||
};
|
||||
}),
|
||||
@@ -653,12 +969,20 @@ export default function PackageDetailPage() {
|
||||
// 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
|
||||
// (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");
|
||||
} catch (err: any) {
|
||||
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 {
|
||||
setBookingContextLoading(false);
|
||||
@@ -681,7 +1005,9 @@ export default function PackageDetailPage() {
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<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
|
||||
onClick={() => router.back()}
|
||||
className="text-sm text-primary font-medium flex items-center gap-1 mx-auto hover:underline"
|
||||
@@ -701,7 +1027,10 @@ export default function PackageDetailPage() {
|
||||
{passengerModalOpen && representativeTier && (
|
||||
<PassengerCountModal
|
||||
tier={representativeTier}
|
||||
onClose={() => { setPassengerModalOpen(false); setBookingContextError(null); }}
|
||||
onClose={() => {
|
||||
setPassengerModalOpen(false);
|
||||
setBookingContextError(null);
|
||||
}}
|
||||
onConfirm={handleBookNow}
|
||||
loading={bookingContextLoading}
|
||||
error={bookingContextError}
|
||||
@@ -713,7 +1042,7 @@ export default function PackageDetailPage() {
|
||||
{/* Hero */}
|
||||
<div className="relative h-56 md:h-80 bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)] overflow-hidden">
|
||||
<Image
|
||||
src="/packages/kulubi.jpeg"
|
||||
src="/packages/package.jpeg"
|
||||
alt={pkg.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
@@ -751,7 +1080,9 @@ export default function PackageDetailPage() {
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
{destination.name.trim()}
|
||||
{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>
|
||||
)}
|
||||
@@ -763,7 +1094,6 @@ export default function PackageDetailPage() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* ── Main content ── */}
|
||||
<div className="lg:col-span-2 space-y-5">
|
||||
|
||||
{/* Description */}
|
||||
{pkg.description && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 border border-gray-100 dark:border-gray-800">
|
||||
@@ -778,12 +1108,18 @@ export default function PackageDetailPage() {
|
||||
|
||||
{/* Outbound journey */}
|
||||
{pkg.outboundSchedule && (
|
||||
<JourneyCard schedule={pkg.outboundSchedule} label="Outbound Journey" />
|
||||
<JourneyCard
|
||||
schedule={pkg.outboundSchedule}
|
||||
label="Outbound Journey"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Return journey */}
|
||||
{pkg.returnSchedule && (
|
||||
<JourneyCard schedule={pkg.returnSchedule} label="Return Journey" />
|
||||
<JourneyCard
|
||||
schedule={pkg.returnSchedule}
|
||||
label="Return Journey"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bus transfer */}
|
||||
@@ -814,23 +1150,42 @@ export default function PackageDetailPage() {
|
||||
Travel Information
|
||||
</h2>
|
||||
<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)}
|
||||
</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)}
|
||||
</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)}
|
||||
</InfoRow>
|
||||
{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()}
|
||||
</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.validUntil, { month: "short", day: "numeric", year: "numeric" })}
|
||||
{fmt(pkg.validUntil, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</InfoRow>
|
||||
</div>
|
||||
</div>
|
||||
@@ -859,7 +1214,10 @@ export default function PackageDetailPage() {
|
||||
<div className="lg:hidden">
|
||||
<PriceTiersPanel
|
||||
tiers={pkg.priceTiers}
|
||||
onBookNow={(coachTypeId) => { setSelectedCoachTypeId(coachTypeId); setPassengerModalOpen(true); }}
|
||||
onBookNow={(coachTypeId) => {
|
||||
setSelectedCoachTypeId(coachTypeId);
|
||||
setPassengerModalOpen(true);
|
||||
}}
|
||||
isRoundTrip={isRoundTripPkg}
|
||||
/>
|
||||
</div>
|
||||
@@ -870,7 +1228,10 @@ export default function PackageDetailPage() {
|
||||
<div>
|
||||
<PriceTiersPanel
|
||||
tiers={pkg.priceTiers}
|
||||
onBookNow={(coachTypeId) => { setSelectedCoachTypeId(coachTypeId); setPassengerModalOpen(true); }}
|
||||
onBookNow={(coachTypeId) => {
|
||||
setSelectedCoachTypeId(coachTypeId);
|
||||
setPassengerModalOpen(true);
|
||||
}}
|
||||
isRoundTrip={isRoundTripPkg}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,7 @@ interface HolidayPackage {
|
||||
includedServices?: string[];
|
||||
busTransferIncluded?: boolean;
|
||||
busTransferRoute?: string | null;
|
||||
journeyType?: 'ONE_WAY' | 'ROUND_TRIP';
|
||||
journeyType?: "ONE_WAY" | "ROUND_TRIP";
|
||||
returnSchedule?: Schedule | null;
|
||||
outboundSchedule?: Schedule;
|
||||
priceTiers: PriceTier[];
|
||||
@@ -65,7 +65,8 @@ interface HolidayPackage {
|
||||
function fmtDate(iso: string, opts?: Intl.DateTimeFormatOptions): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("en-US", {
|
||||
month: "short", day: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...opts,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
});
|
||||
@@ -91,7 +92,10 @@ function minPrice(
|
||||
): { amount: number; currency: string } | null {
|
||||
if (!tiers?.length) return null;
|
||||
const min = tiers.reduce((a, b) => (a.priceMinor < b.priceMinor ? a : b));
|
||||
return { amount: (min.priceMinor * multiplier) / 100, currency: min.currency };
|
||||
return {
|
||||
amount: (min.priceMinor * multiplier) / 100,
|
||||
currency: min.currency,
|
||||
};
|
||||
}
|
||||
|
||||
function fmtPrice(minor: number, currency: string): string {
|
||||
@@ -148,7 +152,7 @@ function AvailBar({ booked, total }: { booked: number; total: number }) {
|
||||
// ─── Featured Card (first package — full-width, image left) ───────────────────
|
||||
|
||||
function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
|
||||
const multiplier = pkg.journeyType === 'ROUND_TRIP' ? 2 : 1;
|
||||
const multiplier = pkg.journeyType === "ROUND_TRIP" ? 2 : 1;
|
||||
const price = minPrice(pkg.priceTiers, multiplier);
|
||||
const origin = pkg.outboundSchedule?.originStation;
|
||||
const dest = pkg.outboundSchedule?.destinationStation;
|
||||
@@ -161,7 +165,7 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
|
||||
{/* ── Left: Image ── */}
|
||||
<div className="relative md:w-[46%] h-64 md:h-auto flex-shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src="/packages/kulubi.jpeg"
|
||||
src="/packages/package.jpeg"
|
||||
alt={pkg.name}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-700 ease-out"
|
||||
@@ -189,7 +193,6 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Right: Content ── */}
|
||||
@@ -319,7 +322,7 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
|
||||
// ─── Regular Package Card ─────────────────────────────────────────────────────
|
||||
|
||||
function PackageCard({ pkg }: { pkg: HolidayPackage }) {
|
||||
const multiplier = pkg.journeyType === 'ROUND_TRIP' ? 2 : 1;
|
||||
const multiplier = pkg.journeyType === "ROUND_TRIP" ? 2 : 1;
|
||||
const price = minPrice(pkg.priceTiers, multiplier);
|
||||
const origin = pkg.outboundSchedule?.originStation;
|
||||
const dest = pkg.outboundSchedule?.destinationStation;
|
||||
|
||||
Reference in New Issue
Block a user