Merge pull request #636 from Tria-plc/alpha

Fix voucher and payment on booking detail page
This commit is contained in:
robiman
2026-07-11 16:26:13 +03:00
committed by GitHub
6 changed files with 560 additions and 336 deletions

View File

@@ -119,10 +119,13 @@ export default function ConfirmationPage() {
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
// The server-confirmed settled amount/currency (what was actually charged) is
// authoritative — prefer it over the ETB booking fare once it's available.
// authoritative — prefer it over the ETB booking fare once available. Shown exactly
// as returned by the API (no /100, no per-passenger split) on every passenger's
// voucher — see fareIsMajorUnits below.
const settledAmountMinor = _booking?.payment?.amountMinor;
const settledCurrency = _booking?.payment?.currency;
const voucherCurrency = settledCurrency || "ETB";
const hasSettledAmount = settledAmountMinor != null && !!settledCurrency;
const voucherCurrency = hasSettledAmount ? settledCurrency! : "ETB";
const createdAt = _booking?.createdAt || new Date().toISOString();
const status = _booking?.status || "CONFIRMED";
@@ -154,21 +157,6 @@ export default function ConfirmationPage() {
return Math.round(totalFare / passengers.length);
};
// Real conversion happened (payment settled in something other than ETB) — scale each
// passenger's ETB fare proportionally into the settled currency, rather than showing
// ETB-denominated numbers next to a foreign currency label.
const etbFares = passengers.map((_, idx) => getEtbFare(idx));
const etbTotal = etbFares.reduce((sum, f) => sum + f, 0);
const needsConversion =
settledAmountMinor != null &&
settledCurrency &&
settledCurrency !== "ETB" &&
etbTotal > 0;
const getVoucherFare = (idx: number): number => {
if (!needsConversion) return etbFares[idx];
return Math.round(etbFares[idx] * (settledAmountMinor! / etbTotal));
};
const outbound = {
trainNumber: activeSchedule?.trainNumber || "N/A",
trainName: "EDR Express",
@@ -234,8 +222,9 @@ export default function ConfirmationPage() {
outboundSchedule: outbound,
inboundSchedule: inbound,
isRoundTrip,
fareMinor: getVoucherFare(i),
fareMinor: hasSettledAmount ? settledAmountMinor! : getEtbFare(i),
currency: voucherCurrency,
fareIsMajorUnits: hasSettledAmount,
createdAt,
});
}

View File

@@ -18,6 +18,7 @@ import {
Smartphone,
Loader2,
ChevronLeft,
X,
} from "lucide-react";
import { format } from "date-fns";
import { formatTime, getTimePeriod } from "@/utils/format";
@@ -53,6 +54,11 @@ function BookingDetailContent() {
string | null
>(null);
const [paymentError, setPaymentError] = useState<string | null>(null);
// The mobile Pay trigger opens this modal instead of living in a `fixed bottom-0`
// bar — that bar kept getting covered by the phone's own home-indicator/gesture
// nav bar. The modal's footer button is a normal in-flow flex item instead, so it
// can't end up pinned underneath system chrome.
const [paymentModalOpen, setPaymentModalOpen] = useState(false);
const [copiedPNR, setCopiedPNR] = useState(false);
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
@@ -486,7 +492,7 @@ function BookingDetailContent() {
if (isPendingPayment && !isExpired) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-6 lg:pb-10">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">
@@ -676,6 +682,11 @@ function BookingDetailContent() {
setSelectedMethodCurrency(
method.currency ?? null,
);
// Mobile only — desktop's Pay button lives inline in the
// sidebar OrderSummary, not behind a modal.
if (window.innerWidth < 1024) {
setPaymentModalOpen(true);
}
}}
disabled={
paymentMutation.isPending ||
@@ -735,57 +746,93 @@ function BookingDetailContent() {
</div>
</div>
{/* Mobile sticky bottom bar */}
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
<div className="flex items-center justify-between mb-2.5">
<span className="text-sm text-gray-600 dark:text-gray-400">
Total
</span>
<span className="text-lg font-bold text-primary flex items-center gap-1.5">
{awaitingAmount ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<>
{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}
</>
)}
</span>
{/* Mobile payment dialog — the amount + Back/Pay buttons live here now,
opened by picking a payment method above. Centered dialog-box style
(not a full-screen sheet) so it can't end up pinned under system chrome. */}
{paymentModalOpen && (
<div
className="lg:hidden fixed inset-0 z-[100] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="payment-modal-title"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setPaymentModalOpen(false)}
/>
{/* Dialog */}
<div className="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl max-w-sm w-full animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between px-5 pt-5">
<h2
id="payment-modal-title"
className="text-base font-bold text-gray-900 dark:text-gray-100"
>
Confirm payment
</h2>
<button
type="button"
onClick={() => setPaymentModalOpen(false)}
aria-label="Close"
className="p-1.5 -mr-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 pt-3 pb-1">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-600 dark:text-gray-400">
Total
</span>
<span className="text-xl font-bold text-primary flex items-center gap-1.5">
{awaitingAmount ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
{confirmedCurrency}{" "}
{(totalAmountDisplay ?? 0).toFixed(2)}
</>
)}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{selectedPaymentMethod
? `via ${selectedPaymentMethod.displayName}`
: "Select a payment method to continue."}
</p>
{paymentError && (
<p className="text-red-600 dark:text-red-400 text-xs mt-2">
{paymentError}
</p>
)}
</div>
<div className="px-5 pt-3 pb-5">
<button
onClick={handlePayment}
disabled={
!selectedMethod || paymentMutation.isPending || awaitingAmount
}
className="btn-primary w-full py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
{paymentMutation.isPending ? (
<span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span>
) : awaitingAmount ? (
<span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Calculating...
</span>
) : (
`Pay ${confirmedCurrency} ${(totalAmountDisplay ?? 0).toFixed(2)}`
)}
</button>
</div>
</div>
</div>
{paymentError && (
<p className="text-red-600 dark:text-red-400 text-xs mb-2">
{paymentError}
</p>
)}
<div className="flex gap-3">
<button
onClick={() => router.push("/booking/lookup")}
disabled={paymentMutation.isPending}
className="btn-secondary flex-1 py-2.5 flex items-center justify-center gap-2"
>
<ChevronLeft className="w-4 h-4" />
Back
</button>
<button
onClick={handlePayment}
disabled={
!selectedMethod || paymentMutation.isPending || awaitingAmount
}
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
{paymentMutation.isPending ? (
<span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span>
) : awaitingAmount ? (
<span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Calculating...
</span>
) : (
`Pay ${confirmedCurrency} ${(totalAmountDisplay ?? 0).toFixed(2)}`
)}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -372,7 +372,7 @@ export default function PaymentPage() {
);
return (
<div className="booking-page pb-28 lg:pb-10">
<div className="booking-page pb-[calc(7rem+env(safe-area-inset-bottom))] lg:pb-10">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<h1 className="section-title">Complete payment</h1>
@@ -487,7 +487,7 @@ export default function PaymentPage() {
</div>
{/* Mobile sticky bottom bar */}
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] z-40 shadow-lg">
<div className="flex items-center justify-between mb-2.5">
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
<span className="text-lg font-bold text-primary flex items-center gap-1.5">

View File

@@ -25,14 +25,16 @@ import { useEffect, useRef, useState, useCallback } from "react";
import ModernDatePicker from "@/components/ModernDatePicker";
function useDarkMode() {
const [dark, setDark] = useState(() =>
typeof window !== 'undefined' && document.documentElement.classList.contains('dark')
const [dark, setDark] = useState(
() =>
typeof window !== "undefined" &&
document.documentElement.classList.contains("dark"),
);
useEffect(() => {
const obs = new MutationObserver(() =>
setDark(document.documentElement.classList.contains('dark'))
setDark(document.documentElement.classList.contains("dark")),
);
obs.observe(document.documentElement, { attributeFilter: ['class'] });
obs.observe(document.documentElement, { attributeFilter: ["class"] });
return () => obs.disconnect();
}, []);
return dark;
@@ -344,7 +346,9 @@ function PassengerModal({
{nationalityError && (
<p className="text-xs text-red-500 mb-2">{nationalityError}</p>
)}
<div className={`grid grid-cols-3 gap-2 ${nationalityError ? "mt-1" : "mt-2"}`}>
<div
className={`grid grid-cols-3 gap-2 ${nationalityError ? "mt-1" : "mt-2"}`}
>
{natOptions.map((opt) => (
<button
key={opt.value}
@@ -556,6 +560,10 @@ export default function SearchPage() {
// doesn't paint anything until the target route's JS has loaded, which
// otherwise reads as a dead click.
const [isSearching, setIsSearching] = useState(false);
// Mobile only: collapsed view shows just From/To + Search; tapping Search opens this
// modal with the full field set (trip type, dates, passengers/nationality) instead of
// showing everything inline on the page.
const [mobileSearchModalOpen, setMobileSearchModalOpen] = useState(false);
// Warms the results route's JS chunk ahead of time so clicking Search
// doesn't have to wait for it to download/compile on top of the actual
@@ -728,7 +736,9 @@ export default function SearchPage() {
const onInvalid = (formErrors: typeof errors) => {
setHasInteracted(true);
setIsSearching(false);
const hasOtherErrors = Object.keys(formErrors).some((k) => k !== "nationality");
const hasOtherErrors = Object.keys(formErrors).some(
(k) => k !== "nationality",
);
if (formErrors.nationality && !hasOtherErrors) {
setPassengerModalOpen(true);
}
@@ -748,7 +758,167 @@ export default function SearchPage() {
// No default nationality anymore — only render a flag once one is actually picked, rather
// than falling through to the "Other" 🌍 flag and implying a selection that hasn't happened.
const nationalityFlag = (nat?: string) =>
nat === "ETHIOPIAN" ? "🇪🇹" : nat === "DJIBOUTIAN" ? "🇩🇯" : nat === "OTHER" ? "🌍" : null;
nat === "ETHIOPIAN"
? "🇪🇹"
: nat === "DJIBOUTIAN"
? "🇩🇯"
: nat === "OTHER"
? "🌍"
: null;
// Shared between the desktop layout and the mobile search modal — kept out of the
// collapsed mobile view (see mobileSearchModalOpen).
const renderTripTypeTabs = () => (
<div className="mb-4">
<div className="inline-flex rounded-xl bg-gray-100 dark:bg-gray-800 p-1 w-full md:w-auto">
<button
type="button"
onClick={() => setValue("tripType", "ONE_WAY")}
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
tripType === "ONE_WAY"
? "bg-white dark:bg-gray-900 text-primary shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
One Way
</button>
<button
type="button"
onClick={() => setValue("tripType", "ROUND_TRIP")}
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
tripType === "ROUND_TRIP"
? "bg-white dark:bg-gray-900 text-primary shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
Round Trip
</button>
</div>
</div>
);
// From/To fields — rendered both in the mobile collapsed view (so a station can be
// picked without opening the full modal) and inside the modal itself (prefilled with
// whatever was already picked). Tapping either still opens StationModal directly.
// stacked: single column for the collapsed first view; the modal keeps the 2-column grid.
const renderStationFields = (stacked = false) => (
<div className={stacked ? "flex flex-col gap-3" : "grid grid-cols-2 gap-3"}>
<div className="space-y-1.5 min-w-0">
<div className="h-5 flex items-center">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
From
</label>
</div>
<button
type="button"
onClick={() => {
setHasInteracted(true);
window.scrollTo({
top: 0,
behavior: "instant" as ScrollBehavior,
});
setStationModal("origin");
}}
className="w-full"
>
<div
className={`flex items-center gap-2 px-2.5 py-3 border-2 rounded-xl transition-all ${
hasInteracted && errors.originStationId
? "border-red-400"
: originId
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700"
}`}
style={{ backgroundColor: originId ? undefined : undefined }}
>
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
<span
style={{
color: originStation
? dark
? "#ffffff"
: "#111827"
: dark
? "#6b7280"
: "#9ca3af",
}}
className={`text-sm truncate ${originStation ? "font-semibold" : ""}`}
>
{originStation?.name ??
(stacked ? "Select departure station" : "Departure")}
</span>
</div>
</button>
{hasInteracted && errors.originStationId && (
<p className="text-xs text-red-500">
{errors.originStationId.message}
</p>
)}
</div>
<div className="space-y-1.5 min-w-0">
<div className="h-5 flex items-center justify-between">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
To
</label>
<button
type="button"
onClick={handleSwap}
disabled={!originId || !destId}
aria-label="Swap origin and destination"
className="flex items-center justify-center gap-1 text-xs text-primary font-medium disabled:opacity-30 p-0 h-5 w-5"
>
<ArrowLeftRight
className={`w-3.5 h-3.5 transition-transform duration-300 ${swapping ? "rotate-180" : ""}`}
/>
</button>
</div>
<button
type="button"
onClick={() => {
setHasInteracted(true);
window.scrollTo({
top: 0,
behavior: "instant" as ScrollBehavior,
});
setStationModal("destination");
}}
className="w-full"
>
<div
className={`flex items-center gap-2 px-2.5 py-3 border-2 rounded-xl transition-all ${
hasInteracted && errors.destinationStationId
? "border-red-400"
: destId
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700"
}`}
>
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
<span
style={{
color: destStation
? dark
? "#ffffff"
: "#111827"
: dark
? "#6b7280"
: "#9ca3af",
}}
className={`text-sm truncate ${destStation ? "font-semibold" : ""}`}
>
{destStation?.name ??
(stacked ? "Select destination station" : "Destination")}
</span>
</div>
</button>
{hasInteracted && errors.destinationStationId && (
<p className="text-xs text-red-500">
{errors.destinationStationId.message}
</p>
)}
</div>
</div>
);
return (
<div className="bg-gray-50 dark:bg-gray-950">
@@ -765,7 +935,9 @@ export default function SearchPage() {
clearErrors("nationality");
}}
onClose={() => setPassengerModalOpen(false)}
nationalityError={showNationalityError ? errors.nationality?.message : undefined}
nationalityError={
showNationalityError ? errors.nationality?.message : undefined
}
/>
)}
@@ -870,241 +1042,182 @@ export default function SearchPage() {
)}
<div className="p-4 md:p-5">
{/* Trip Type Tabs */}
<div className="mb-4">
<div className="inline-flex rounded-xl bg-gray-100 dark:bg-gray-800 p-1 w-full md:w-auto">
<button
type="button"
onClick={() => setValue("tripType", "ONE_WAY")}
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
tripType === "ONE_WAY"
? "bg-white dark:bg-gray-900 text-primary shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
One Way
</button>
<button
type="button"
onClick={() => setValue("tripType", "ROUND_TRIP")}
className={`flex-1 md:flex-none px-6 py-2.5 rounded-lg text-sm font-semibold transition-all ${
tripType === "ROUND_TRIP"
? "bg-white dark:bg-gray-900 text-primary shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
}`}
>
Round Trip
</button>
</div>
{/* Trip Type Tabs — always visible on desktop; on mobile only inside the
search modal (hidden in the collapsed view). */}
<div
className={`${mobileSearchModalOpen ? "block" : "hidden"} md:block`}
>
{renderTripTypeTabs()}
</div>
{/* Mobile: stacked, but From/To and Date/Return Date pair up into two
columns each to save vertical space (station names/dates truncate
rather than wrap) — same fields, same behavior, just denser. */}
<div className="flex flex-col gap-3 md:hidden">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5 min-w-0">
<div className="h-5 flex items-center">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
From
</label>
</div>
<button
type="button"
onClick={() => {
setHasInteracted(true);
window.scrollTo({
top: 0,
behavior: "instant" as ScrollBehavior,
});
setStationModal("origin");
}}
className="w-full"
>
<div
className={`flex items-center gap-2 px-2.5 py-3 border-2 rounded-xl transition-all ${
hasInteracted && errors.originStationId
? "border-red-400"
: originId
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700"
}`}
style={{ backgroundColor: originId ? undefined : undefined }}
>
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
<span
style={{ color: originStation ? (dark ? '#ffffff' : '#111827') : (dark ? '#6b7280' : '#9ca3af') }}
className={`text-sm truncate ${originStation ? 'font-semibold' : ''}`}
>
{originStation?.name ?? "Departure"}
</span>
</div>
</button>
{hasInteracted && errors.originStationId && (
<p className="text-xs text-red-500">
{errors.originStationId.message}
</p>
)}
</div>
<div className="space-y-1.5 min-w-0">
<div className="h-5 flex items-center justify-between">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
To
</label>
<button
type="button"
onClick={handleSwap}
disabled={!originId || !destId}
aria-label="Swap origin and destination"
className="flex items-center justify-center gap-1 text-xs text-primary font-medium disabled:opacity-30 p-0 h-5 w-5"
>
<ArrowLeftRight
className={`w-3.5 h-3.5 transition-transform duration-300 ${swapping ? "rotate-180" : ""}`}
/>
</button>
</div>
<button
type="button"
onClick={() => {
setHasInteracted(true);
window.scrollTo({
top: 0,
behavior: "instant" as ScrollBehavior,
});
setStationModal("destination");
}}
className="w-full"
>
<div
className={`flex items-center gap-2 px-2.5 py-3 border-2 rounded-xl transition-all ${
hasInteracted && errors.destinationStationId
? "border-red-400"
: destId
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700"
}`}
>
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
<span
style={{ color: destStation ? (dark ? '#ffffff' : '#111827') : (dark ? '#6b7280' : '#9ca3af') }}
className={`text-sm truncate ${destStation ? 'font-semibold' : ''}`}
>
{destStation?.name ?? "Destination"}
</span>
</div>
</button>
{hasInteracted && errors.destinationStationId && (
<p className="text-xs text-red-500">
{errors.destinationStationId.message}
</p>
)}
</div>
</div>
<div className={tripType === "ROUND_TRIP" ? "grid grid-cols-2 gap-3" : ""}>
<div className="space-y-1.5 min-w-0">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Date
</label>
<div>
<ModernDatePicker
value={
departureDate
? new Date(departureDate + "T00:00:00")
: undefined
}
onChange={(date) => {
setValue(
"departureDate",
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
);
trigger("departureDate");
}}
minDate={new Date()}
placeholder="Departure date"
error={!!errors.departureDate}
/>
</div>
{errors.departureDate && (
<p className="text-xs text-red-500">
{errors.departureDate.message}
</p>
)}
</div>
{tripType === "ROUND_TRIP" && (
<div className="space-y-1.5 min-w-0">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Return Date
</label>
<div>
<ModernDatePicker
value={
returnDate
? new Date(returnDate + "T00:00:00")
: undefined
}
onChange={(date) => {
setValue(
"returnDate",
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
);
trigger("returnDate");
}}
minDate={
departureDate
? new Date(departureDate + "T00:00:00")
: new Date()
}
placeholder="Return date"
error={!!errors.returnDate}
/>
</div>
{errors.returnDate && (
<p className="text-xs text-red-500">
{errors.returnDate.message}
</p>
)}
</div>
)}
</div>
{/* Pax + Nationality combined trigger */}
{/* Mobile collapsed view — just From/To + Search. Tapping a station still
opens StationModal directly; tapping Search opens the full modal below
instead of submitting (validation happens inside that modal). */}
<div
className={`${mobileSearchModalOpen ? "hidden" : "flex"} flex-col gap-3 md:hidden`}
>
{renderStationFields(true)}
<button
type="button"
onClick={() => setPassengerModalOpen(true)}
className={`w-full flex items-center justify-between px-3.5 py-3 border-2 rounded-xl bg-white dark:bg-gray-800 ${
showNationalityError ? "border-red-400" : "border-gray-200 dark:border-gray-700"
}`}
onClick={() => setMobileSearchModalOpen(true)}
className="btn-primary w-full text-sm flex items-center justify-center gap-2"
>
<span className="flex items-center gap-2 text-sm font-medium" style={{ color: dark ? '#ffffff' : '#111827' }}>
<Users className="w-4 h-4 text-primary" />
{totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
{nationalityFlag(watch("nationality"))
? ` · ${nationalityFlag(watch("nationality"))}`
: " · Nationality"}
</span>
<ChevronDown className="w-4 h-4 text-primary" />
</button>
{showNationalityError && (
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
)}
<button
type="submit"
disabled={isLoading || isSearching}
className="btn-primary w-full text-sm flex items-center justify-center gap-2 disabled:opacity-80"
>
{isSearching ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Searching...
</>
) : (
<>
<Search className="w-5 h-5" />
Search
</>
)}
<Search className="w-5 h-5" />
Search
</button>
</div>
{/* Mobile search modal — full field set (trip type, From/To, dates,
passengers/nationality). Its Search button is the real form submit —
same onSubmit/onInvalid/validation as before, just relocated here. */}
{mobileSearchModalOpen && (
<div
className="fixed inset-0 z-[100] bg-white dark:bg-gray-900 flex flex-col md:hidden animate-slide-up"
role="dialog"
aria-modal="true"
aria-labelledby="mobile-search-modal-title"
>
<div className="flex items-center gap-3 px-4 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
<button
type="button"
onClick={() => setMobileSearchModalOpen(false)}
className="w-10 h-10 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Close"
>
<X className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
<h2
id="mobile-search-modal-title"
className="text-lg font-semibold text-gray-900 dark:text-white"
>
Search Trains
</h2>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
{renderTripTypeTabs()}
{renderStationFields()}
<div
className={
tripType === "ROUND_TRIP"
? "grid grid-cols-2 gap-3"
: ""
}
>
<div className="space-y-1.5 min-w-0">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Date
</label>
<div>
<ModernDatePicker
value={
departureDate
? new Date(departureDate + "T00:00:00")
: undefined
}
onChange={(date) => {
setValue(
"departureDate",
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
);
trigger("departureDate");
}}
minDate={new Date()}
placeholder="Departure date"
error={!!errors.departureDate}
/>
</div>
{errors.departureDate && (
<p className="text-xs text-red-500">
{errors.departureDate.message}
</p>
)}
</div>
{tripType === "ROUND_TRIP" && (
<div className="space-y-1.5 min-w-0">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Return Date
</label>
<div>
<ModernDatePicker
value={
returnDate
? new Date(returnDate + "T00:00:00")
: undefined
}
onChange={(date) => {
setValue(
"returnDate",
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
);
trigger("returnDate");
}}
minDate={
departureDate
? new Date(departureDate + "T00:00:00")
: new Date()
}
placeholder="Return date"
error={!!errors.returnDate}
/>
</div>
{errors.returnDate && (
<p className="text-xs text-red-500">
{errors.returnDate.message}
</p>
)}
</div>
)}
</div>
{/* Pax + Nationality combined trigger */}
<button
type="button"
onClick={() => setPassengerModalOpen(true)}
className={`w-full flex items-center justify-between px-3.5 py-3 border-2 rounded-xl bg-white dark:bg-gray-800 ${
showNationalityError
? "border-red-400"
: "border-gray-200 dark:border-gray-700"
}`}
>
<span
className="flex items-center gap-2 text-sm font-medium"
style={{ color: dark ? "#ffffff" : "#111827" }}
>
<Users className="w-4 h-4 text-primary" />
{totalPassengers}{" "}
{totalPassengers === 1 ? "Passenger" : "Passengers"}
{nationalityFlag(watch("nationality"))
? ` · ${nationalityFlag(watch("nationality"))}`
: " · Nationality"}
</span>
<ChevronDown className="w-4 h-4 text-primary" />
</button>
{showNationalityError && (
<p className="text-xs text-red-500">
{errors.nationality?.message}
</p>
)}
<button
type="submit"
disabled={isLoading || isSearching}
className="btn-primary w-full text-sm flex items-center justify-center gap-2 disabled:opacity-80"
>
{isSearching ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Searching...
</>
) : (
<>
<Search className="w-5 h-5" />
Search
</>
)}
</button>
</div>
</div>
)}
{/* Desktop: dynamic layout based on trip type */}
<div className={`hidden md:block`}>
{tripType === "ONE_WAY" ? (
@@ -1219,12 +1332,17 @@ export default function SearchPage() {
type="button"
onClick={() => setPassengerModalOpen(true)}
className={`w-full flex items-center justify-between px-3 py-3.5 border-2 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all ${
showNationalityError ? "border-red-400" : "border-gray-200 dark:border-gray-700"
showNationalityError
? "border-red-400"
: "border-gray-200 dark:border-gray-700"
}`}
>
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
<Users className="w-4 h-4 text-primary flex-shrink-0" />
{totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
{totalPassengers}{" "}
{totalPassengers === 1
? "Passenger"
: "Passengers"}
{nationalityFlag(watch("nationality"))
? ` · ${nationalityFlag(watch("nationality"))}`
: " · Nationality"}
@@ -1232,7 +1350,9 @@ export default function SearchPage() {
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
</button>
{showNationalityError && (
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
<p className="text-xs text-red-500">
{errors.nationality?.message}
</p>
)}
</div>
{/* Search */}
@@ -1259,7 +1379,9 @@ export default function SearchPage() {
<div className="flex items-end gap-2">
{/* From */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
From
</label>
<StationDropdown
stations={stations}
value={originId}
@@ -1273,11 +1395,17 @@ export default function SearchPage() {
clearErrors("originStationId");
clearErrors("destinationStationId");
}}
error={hasInteracted ? errors.originStationId?.message : undefined}
error={
hasInteracted
? errors.originStationId?.message
: undefined
}
onOpen={scrollWidgetIntoView}
/>
{hasInteracted && errors.originStationId && (
<p className="text-xs text-red-500">{errors.originStationId.message}</p>
<p className="text-xs text-red-500">
{errors.originStationId.message}
</p>
)}
</div>
{/* Swap */}
@@ -1291,7 +1419,9 @@ export default function SearchPage() {
</button>
{/* To */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
To
</label>
<StationDropdown
stations={stations}
value={destId}
@@ -1304,20 +1434,35 @@ export default function SearchPage() {
if (s.id) saveRecent(s.id);
clearErrors("destinationStationId");
}}
error={hasInteracted ? errors.destinationStationId?.message : undefined}
error={
hasInteracted
? errors.destinationStationId?.message
: undefined
}
onOpen={scrollWidgetIntoView}
/>
{hasInteracted && errors.destinationStationId && (
<p className="text-xs text-red-500">{errors.destinationStationId.message}</p>
<p className="text-xs text-red-500">
{errors.destinationStationId.message}
</p>
)}
</div>
{/* Departure Date */}
<div className="w-40 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Departure</label>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Departure
</label>
<ModernDatePicker
value={departureDate ? new Date(departureDate + "T00:00:00") : undefined}
value={
departureDate
? new Date(departureDate + "T00:00:00")
: undefined
}
onChange={(date) => {
setValue("departureDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
setValue(
"departureDate",
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
);
trigger("departureDate");
trigger("returnDate");
}}
@@ -1325,44 +1470,72 @@ export default function SearchPage() {
placeholder="Departure date"
/>
{errors.departureDate && (
<p className="text-xs text-red-500">{errors.departureDate.message}</p>
<p className="text-xs text-red-500">
{errors.departureDate.message}
</p>
)}
</div>
{/* Return Date */}
<div className="w-40 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Return</label>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Return
</label>
<ModernDatePicker
value={returnDate ? new Date(returnDate + "T00:00:00") : undefined}
value={
returnDate
? new Date(returnDate + "T00:00:00")
: undefined
}
onChange={(date) => {
setValue("returnDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
setValue(
"returnDate",
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
);
trigger("returnDate");
}}
minDate={departureDate ? new Date(departureDate + "T00:00:00") : new Date()}
minDate={
departureDate
? new Date(departureDate + "T00:00:00")
: new Date()
}
placeholder="Return date"
/>
{errors.returnDate && (
<p className="text-xs text-red-500">{errors.returnDate.message}</p>
<p className="text-xs text-red-500">
{errors.returnDate.message}
</p>
)}
</div>
{/* Passengers */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Passengers</label>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Passengers
</label>
<button
type="button"
onClick={() => setPassengerModalOpen(true)}
className={`w-full flex items-center justify-between px-3 py-3.5 border-2 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all ${
showNationalityError ? "border-red-400" : "border-gray-200 dark:border-gray-700"
showNationalityError
? "border-red-400"
: "border-gray-200 dark:border-gray-700"
}`}
>
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
<Users className="w-4 h-4 text-primary flex-shrink-0" />
{totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
{nationalityFlag(watch("nationality")) ? ` · ${nationalityFlag(watch("nationality"))}` : " · Nationality"}
{totalPassengers}{" "}
{totalPassengers === 1
? "Passenger"
: "Passengers"}
{nationalityFlag(watch("nationality"))
? ` · ${nationalityFlag(watch("nationality"))}`
: " · Nationality"}
</span>
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
</button>
{showNationalityError && (
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
<p className="text-xs text-red-500">
{errors.nationality?.message}
</p>
)}
</div>
{/* Search */}
@@ -1386,7 +1559,6 @@ export default function SearchPage() {
</div>
)}
</div>
</div>
</div>
</form>

View File

@@ -1,4 +1,4 @@
import type { Metadata } from 'next';
import type { Metadata, Viewport } from 'next';
import { headers } from 'next/headers';
import './globals.css';
import { Providers } from './providers';
@@ -13,6 +13,15 @@ export const metadata: Metadata = {
description: 'Book train tickets on the Ethio-Djibouti Railway',
};
// viewportFit: 'cover' lets fixed bottom bars (e.g. the payment page's Pay
// button) read env(safe-area-inset-bottom) so they pad above the home
// indicator / gesture nav bar instead of being covered by it.
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
};
export default function RootLayout({
children,
}: {

View File

@@ -27,6 +27,10 @@ interface PassengerVoucherData {
fareMinor: number;
currency: string;
createdAt: string;
// True when fareMinor is already a display-ready amount (e.g. the settled
// payment.amountMinor straight from the API) and must NOT be divided by 100 — as
// opposed to the normal case where fareMinor is genuine minor units (cents).
fareIsMajorUnits?: boolean;
}
// ─── palette ───────────────────────────────────────────────────────────────
@@ -327,7 +331,7 @@ function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number,
// ─── fare summary ──────────────────────────────────────────────────────────
function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number): number {
function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number, fareIsMajorUnits = false): number {
const cardH = 20;
doc.setFillColor(...BRAND_SOFT);
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'F');
@@ -338,7 +342,8 @@ function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: num
doc.text('✓ PAID', margin + padX, y + 15);
doc.setTextColor(...BRAND); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
doc.text(`${currency} ${(fareMinor / 100).toFixed(2)}`, pageWidth - margin - padX, y + 13, { align: 'right' });
const displayAmount = fareIsMajorUnits ? fareMinor : fareMinor / 100;
doc.text(`${currency} ${displayAmount.toFixed(2)}`, pageWidth - margin - padX, y + 13, { align: 'right' });
return y + cardH + 8;
}
@@ -398,7 +403,7 @@ async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData):
}
y = drawPassengerDetails(doc, data, y, margin, pageW);
y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW);
y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW, data.fareIsMajorUnits);
drawInstructions(doc, y, margin, pageW);
drawFooter(doc, data.createdAt);
}
@@ -432,11 +437,12 @@ interface VoucherData {
}
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
// The settled payment amount, when available, is shown exactly as returned by the API
// (no /100, no per-passenger split) on every passenger's voucher — see fareIsMajorUnits.
const settledAmountMinor = booking.payment?.amountMinor;
const settledCurrency = booking.payment?.currency;
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
const voucherCurrency = useSettledAmount ? settledCurrency! : booking.currency;
const totalForSplit = useSettledAmount ? settledAmountMinor! : booking.totalMinor;
// Separate file per passenger, saved back-to-back with no macrotask (setTimeout) between
// them — a setTimeout delay here would push later saves outside the click's synchronous
@@ -459,8 +465,9 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
status: booking.status,
outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass },
isRoundTrip: false,
fareMinor: Math.round(totalForSplit / booking.passengers.length),
fareMinor: useSettledAmount ? settledAmountMinor! : Math.round(booking.totalMinor / booking.passengers.length),
currency: voucherCurrency,
fareIsMajorUnits: useSettledAmount,
createdAt: booking.createdAt,
});
}