mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Fix voucher and payment on booking detail page
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user