mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -19,7 +19,7 @@ import {
|
|||||||
ApiResponse,
|
ApiResponse,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { SeatsService } from "./seats.service";
|
import { SeatsService } from "./seats.service";
|
||||||
import { HoldSeatsDto } from "./seats.dto";
|
import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto";
|
||||||
import { JwtGuard } from "../../common/jwt.guard";
|
import { JwtGuard } from "../../common/jwt.guard";
|
||||||
import { IamGuard } from "../../common/iam-adapter";
|
import { IamGuard } from "../../common/iam-adapter";
|
||||||
|
|
||||||
@@ -182,6 +182,27 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
|||||||
return this.service.releaseHold(holdId);
|
return this.service.releaseHold(holdId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("release")
|
||||||
|
@SetMetadata('isPublic', true)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Release a seat hold by holdId (portal-server use only)",
|
||||||
|
description:
|
||||||
|
"Frees a previously-created hold's seats immediately instead of waiting for it to " +
|
||||||
|
"expire — used when a guest or logged-in user changes their seat selection, so the " +
|
||||||
|
"stale hold doesn't linger and block that seat for other travellers.\n\n" +
|
||||||
|
"This is a public endpoint (no JWT), like POST /seats/hold, since guest sessions have " +
|
||||||
|
"no login to authenticate with. It must ONLY ever be called from the passenger portal's " +
|
||||||
|
"own Next.js server (a server-side route handler), never directly from browser code — " +
|
||||||
|
"calling it straight from client JS would let anyone script mass hold-cancellation " +
|
||||||
|
"against other travellers' in-progress seat selections. The portal's server-side proxy " +
|
||||||
|
"is what keeps this endpoint's existence out of the browser's network requests.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "Hold released" })
|
||||||
|
@ApiResponse({ status: 404, description: "Hold not found" })
|
||||||
|
releaseSeatById(@Body() dto: ReleaseHoldDto) {
|
||||||
|
return this.service.releaseHold(dto.holdId);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Seat Block / Unblock ───────────────────────────────────────────────────
|
// ── Seat Block / Unblock ───────────────────────────────────────────────────
|
||||||
@Post(":seatId/block")
|
@Post(":seatId/block")
|
||||||
@UseGuards(IamGuard)
|
@UseGuards(IamGuard)
|
||||||
|
|||||||
@@ -48,3 +48,8 @@ export class HoldSeatsDto {
|
|||||||
@Type(() => PassengerSeatDto)
|
@Type(() => PassengerSeatDto)
|
||||||
passengers: PassengerSeatDto[];
|
passengers: PassengerSeatDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ReleaseHoldDto {
|
||||||
|
@ApiProperty({ example: 'hold-uuid', description: 'SeatHold UUID to release' })
|
||||||
|
@IsString() holdId: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Server-side proxy for POST /seats/release on the passenger API.
|
||||||
|
//
|
||||||
|
// The browser calls THIS route (same-origin, /api/seats/release-hold) instead of the
|
||||||
|
// backend's public release endpoint directly. Route handlers run on the Next.js server, not
|
||||||
|
// in the browser, so the actual backend call — and its URL — never appears in client-side
|
||||||
|
// JS or network requests a user could copy and script against other travellers' holds.
|
||||||
|
// Keeping this indirection is the whole point: it doesn't add cryptographic protection (the
|
||||||
|
// backend endpoint is still public), it just keeps the release capability out of the
|
||||||
|
// browser's reach so it can't be trivially discovered and abused from client code.
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let holdId: string | undefined;
|
||||||
|
try {
|
||||||
|
({ holdId } = await request.json());
|
||||||
|
} catch {
|
||||||
|
return Response.json({ message: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!holdId || typeof holdId !== "string") {
|
||||||
|
return Response.json({ message: "holdId is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const backendResponse = await fetch(`${API_URL}/seats/release`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ holdId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await backendResponse.json().catch(() => null);
|
||||||
|
return Response.json(data, { status: backendResponse.status });
|
||||||
|
} catch {
|
||||||
|
return Response.json({ message: "Failed to reach booking service" }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,9 @@ const searchSchema = z
|
|||||||
returnDate: z.string().optional(),
|
returnDate: z.string().optional(),
|
||||||
adultCount: z.number().min(1).max(9),
|
adultCount: z.number().min(1).max(9),
|
||||||
childCount: z.number().min(0).max(9),
|
childCount: z.number().min(0).max(9),
|
||||||
nationality: z.enum(["ETHIOPIAN", "DJIBOUTIAN", "OTHER"]),
|
nationality: z.enum(["ETHIOPIAN", "DJIBOUTIAN", "OTHER"], {
|
||||||
|
errorMap: () => ({ message: "Please select your nationality" }),
|
||||||
|
}),
|
||||||
promoCode: z.string().optional(),
|
promoCode: z.string().optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
@@ -226,6 +228,7 @@ function PassengerModal({
|
|||||||
onChangeChild,
|
onChangeChild,
|
||||||
onChangeNationality,
|
onChangeNationality,
|
||||||
onClose,
|
onClose,
|
||||||
|
nationalityError,
|
||||||
}: {
|
}: {
|
||||||
adultCount: number;
|
adultCount: number;
|
||||||
childCount: number;
|
childCount: number;
|
||||||
@@ -234,6 +237,7 @@ function PassengerModal({
|
|||||||
onChangeChild: (n: number) => void;
|
onChangeChild: (n: number) => void;
|
||||||
onChangeNationality: (v: string) => void;
|
onChangeNationality: (v: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
nationalityError?: string;
|
||||||
}) {
|
}) {
|
||||||
const rows = [
|
const rows = [
|
||||||
{
|
{
|
||||||
@@ -323,10 +327,13 @@ function PassengerModal({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="border-t border-gray-100 dark:border-gray-800 -mx-5 pt-5 px-5">
|
<div className="border-t border-gray-100 dark:border-gray-800 -mx-5 pt-5 px-5">
|
||||||
<p className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
|
<p className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
Nationality
|
Nationality
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
{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"}`}>
|
||||||
{natOptions.map((opt) => (
|
{natOptions.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
@@ -335,7 +342,9 @@ function PassengerModal({
|
|||||||
className={`py-2.5 px-2 rounded-xl border-2 text-xs font-semibold transition-all ${
|
className={`py-2.5 px-2 rounded-xl border-2 text-xs font-semibold transition-all ${
|
||||||
nationality === opt.value
|
nationality === opt.value
|
||||||
? "border-primary bg-primary/5 text-primary"
|
? "border-primary bg-primary/5 text-primary"
|
||||||
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300"
|
: nationalityError
|
||||||
|
? "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}
|
{opt.label}
|
||||||
@@ -586,7 +595,10 @@ export default function SearchPage() {
|
|||||||
tripType: "ONE_WAY",
|
tripType: "ONE_WAY",
|
||||||
adultCount: 1,
|
adultCount: 1,
|
||||||
childCount: 0,
|
childCount: 0,
|
||||||
nationality: "ETHIOPIAN",
|
// No default nationality — the user must explicitly pick one. Left blank (not a valid
|
||||||
|
// enum member) so the zod schema's errorMap flags it if they try to search without
|
||||||
|
// selecting it.
|
||||||
|
nationality: "" as any,
|
||||||
departureDate: "",
|
departureDate: "",
|
||||||
promoCode: "",
|
promoCode: "",
|
||||||
},
|
},
|
||||||
@@ -716,10 +728,34 @@ export default function SearchPage() {
|
|||||||
router.push(`/booking/results?${params}`);
|
router.push(`/booking/results?${params}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Validate the rest of the form first — only once every other field is already valid do
|
||||||
|
// we surface the nationality error (opening the modal directly rather than leaving an
|
||||||
|
// inline error to hunt for). Otherwise nationality's error would show at the same time as
|
||||||
|
// origin/destination/date errors, which is noisier than fixing things one step at a time.
|
||||||
|
const onInvalid = (formErrors: typeof errors) => {
|
||||||
|
setHasInteracted(true);
|
||||||
|
const hasOtherErrors = Object.keys(formErrors).some((k) => k !== "nationality");
|
||||||
|
if (formErrors.nationality && !hasOtherErrors) {
|
||||||
|
setPassengerModalOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getStationById = (id: string) => stations.find((s) => s.id === id);
|
const getStationById = (id: string) => stations.find((s) => s.id === id);
|
||||||
const originStation = getStationById(originId);
|
const originStation = getStationById(originId);
|
||||||
const destStation = getStationById(destId);
|
const destStation = getStationById(destId);
|
||||||
|
|
||||||
|
// Mirrors onInvalid's ordering: don't flag nationality (border/message/modal) while other
|
||||||
|
// fields still have errors of their own to fix first.
|
||||||
|
const showNationalityError =
|
||||||
|
hasInteracted &&
|
||||||
|
!!errors.nationality &&
|
||||||
|
!Object.keys(errors).some((k) => k !== "nationality");
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-50 dark:bg-gray-950">
|
<div className="bg-gray-50 dark:bg-gray-950">
|
||||||
{/* Passenger modal (mobile) */}
|
{/* Passenger modal (mobile) */}
|
||||||
@@ -730,8 +766,12 @@ export default function SearchPage() {
|
|||||||
nationality={watch("nationality")}
|
nationality={watch("nationality")}
|
||||||
onChangeAdult={(n) => setValue("adultCount", n)}
|
onChangeAdult={(n) => setValue("adultCount", n)}
|
||||||
onChangeChild={(n) => setValue("childCount", n)}
|
onChangeChild={(n) => setValue("childCount", n)}
|
||||||
onChangeNationality={(v) => setValue("nationality", v as any)}
|
onChangeNationality={(v) => {
|
||||||
|
setValue("nationality", v as any);
|
||||||
|
clearErrors("nationality");
|
||||||
|
}}
|
||||||
onClose={() => setPassengerModalOpen(false)}
|
onClose={() => setPassengerModalOpen(false)}
|
||||||
|
nationalityError={showNationalityError ? errors.nationality?.message : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -818,7 +858,7 @@ export default function SearchPage() {
|
|||||||
ref={widgetRef}
|
ref={widgetRef}
|
||||||
>
|
>
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit, onInvalid)}>
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-white/20 overflow-visible">
|
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-white/20 overflow-visible">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 px-5 py-3 bg-red-50 text-red-600 text-sm border-b border-red-100 rounded-t-2xl">
|
<div className="flex items-center gap-2 px-5 py-3 bg-red-50 text-red-600 text-sm border-b border-red-100 rounded-t-2xl">
|
||||||
@@ -1002,19 +1042,22 @@ export default function SearchPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPassengerModalOpen(true)}
|
onClick={() => setPassengerModalOpen(true)}
|
||||||
className="w-full flex items-center justify-between px-3.5 py-3 border-2 border-gray-200 rounded-xl bg-white"
|
className={`w-full flex items-center justify-between px-3.5 py-3 border-2 rounded-xl bg-white ${
|
||||||
|
showNationalityError ? "border-red-400" : "border-gray-200"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
<span className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||||
<Users className="w-4 h-4 text-primary" />
|
<Users className="w-4 h-4 text-primary" />
|
||||||
{totalPassengers} Pax ·{" "}
|
{totalPassengers} Pax
|
||||||
{watch("nationality") === "ETHIOPIAN"
|
{nationalityFlag(watch("nationality"))
|
||||||
? "🇪🇹"
|
? ` · ${nationalityFlag(watch("nationality"))}`
|
||||||
: watch("nationality") === "DJIBOUTIAN"
|
: " · Select nationality"}
|
||||||
? "🇩🇯"
|
|
||||||
: "🌍"}
|
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="w-4 h-4 text-primary" />
|
<ChevronDown className="w-4 h-4 text-primary" />
|
||||||
</button>
|
</button>
|
||||||
|
{showNationalityError && (
|
||||||
|
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -1141,19 +1184,22 @@ export default function SearchPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPassengerModalOpen(true)}
|
onClick={() => setPassengerModalOpen(true)}
|
||||||
className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 rounded-xl bg-white hover:border-gray-300 transition-all"
|
className={`w-full flex items-center justify-between px-3 py-3.5 border-2 rounded-xl bg-white hover:border-gray-300 transition-all ${
|
||||||
|
showNationalityError ? "border-red-400" : "border-gray-200"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 truncate">
|
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 truncate">
|
||||||
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
{totalPassengers} Pax ·{" "}
|
{totalPassengers} Pax
|
||||||
{watch("nationality") === "ETHIOPIAN"
|
{nationalityFlag(watch("nationality"))
|
||||||
? "🇪🇹"
|
? ` · ${nationalityFlag(watch("nationality"))}`
|
||||||
: watch("nationality") === "DJIBOUTIAN"
|
: " · Select nationality"}
|
||||||
? "🇩🇯"
|
|
||||||
: "🌍"}
|
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
|
{showNationalityError && (
|
||||||
|
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<button
|
<button
|
||||||
@@ -1395,19 +1441,24 @@ export default function SearchPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPassengerModalOpen(true)}
|
onClick={() => setPassengerModalOpen(true)}
|
||||||
className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all"
|
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"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
|
<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" />
|
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
{totalPassengers} Pax ·{" "}
|
{totalPassengers} Pax
|
||||||
{watch("nationality") === "ETHIOPIAN"
|
{nationalityFlag(watch("nationality"))
|
||||||
? "🇪🇹"
|
? ` · ${nationalityFlag(watch("nationality"))}`
|
||||||
: watch("nationality") === "DJIBOUTIAN"
|
: " · Select nationality"}
|
||||||
? "🇩🇯"
|
|
||||||
: "🌍"}
|
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
|
{showNationalityError && (
|
||||||
|
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Search Button */}
|
{/* Search Button */}
|
||||||
<div className="flex-shrink-0 space-y-1">
|
<div className="flex-shrink-0 space-y-1">
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useBookingStore } from "@/lib/booking-store";
|
import { useBookingStore } from "@/lib/booking-store";
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { apiClient } from "@/lib/api-client";
|
||||||
import { useState, useEffect, useCallback, useMemo, useRef, memo } from "react";
|
import { useState, useEffect, useCallback, useMemo, useRef, memo } from "react";
|
||||||
@@ -181,7 +180,6 @@ export default function SeatsPage() {
|
|||||||
packageDepartureStationName,
|
packageDepartureStationName,
|
||||||
setPackageContext,
|
setPackageContext,
|
||||||
} = useBookingStore();
|
} = useBookingStore();
|
||||||
const { isAuthenticated } = useAuthStore();
|
|
||||||
// Maps passenger index -> assigned seat id. A passenger can only get a seat while
|
// Maps passenger index -> assigned seat id. A passenger can only get a seat while
|
||||||
// they are the "active" passenger, which prevents bulk/batch selection across passengers.
|
// they are the "active" passenger, which prevents bulk/batch selection across passengers.
|
||||||
const [passengerSeatMap, setPassengerSeatMap] = useState<Record<number, string>>({});
|
const [passengerSeatMap, setPassengerSeatMap] = useState<Record<number, string>>({});
|
||||||
@@ -209,6 +207,10 @@ export default function SeatsPage() {
|
|||||||
confirmText: "OK",
|
confirmText: "OK",
|
||||||
});
|
});
|
||||||
const [autoAssigningReturn, setAutoAssigningReturn] = useState(false);
|
const [autoAssigningReturn, setAutoAssigningReturn] = useState(false);
|
||||||
|
// Mobile summary bottom-sheet starts collapsed to a slim bar (badge + Continue button) so
|
||||||
|
// it doesn't cover the seat map — the full passenger list/progress bar only shows once the
|
||||||
|
// user explicitly expands it.
|
||||||
|
const [mobileSummaryExpanded, setMobileSummaryExpanded] = useState(false);
|
||||||
|
|
||||||
const isRoundTrip = searchCriteria?.tripType === "ROUND_TRIP";
|
const isRoundTrip = searchCriteria?.tripType === "ROUND_TRIP";
|
||||||
const isPackageBooking = !!packageName;
|
const isPackageBooking = !!packageName;
|
||||||
@@ -946,9 +948,9 @@ export default function SeatsPage() {
|
|||||||
// already held (valid, unexpired), skip the API call entirely and reuse that hold — this
|
// already held (valid, unexpired), skip the API call entirely and reuse that hold — this
|
||||||
// is what stops "back, then Continue again" from stacking up a second hold on the same
|
// is what stops "back, then Continue again" from stacking up a second hold on the same
|
||||||
// seats. If the user genuinely picked different seats than what was previously held,
|
// seats. If the user genuinely picked different seats than what was previously held,
|
||||||
// best-effort release the stale hold first (authenticated sessions only — the release
|
// release the stale hold first (best-effort — an already-expired/released hold shouldn't
|
||||||
// endpoint requires a login) before holding the new selection, so at most one hold for
|
// block picking the new seat(s)) before holding the new selection, so at most one hold for
|
||||||
// this leg is ever active at a time.
|
// this leg is ever active at a time — for both guest and authenticated sessions.
|
||||||
const ensureLegHold = async (seatIdsForHold: string[]) => {
|
const ensureLegHold = async (seatIdsForHold: string[]) => {
|
||||||
const selectionMatchesExistingHold =
|
const selectionMatchesExistingHold =
|
||||||
isCurrentLegHoldValid &&
|
isCurrentLegHoldValid &&
|
||||||
@@ -958,12 +960,17 @@ export default function SeatsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCurrentLegHoldValid && isAuthenticated && currentLegHoldId) {
|
if (isCurrentLegHoldValid && currentLegHoldId) {
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(`/seats/hold/${currentLegHoldId}`);
|
// Routed through the portal's own server-side proxy (not called on the backend
|
||||||
|
// directly) so the release capability never appears in client-side network calls.
|
||||||
|
await fetch("/api/seats/release-hold", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ holdId: currentLegHoldId }),
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort — an expired/already-released hold, or a guest session that can't
|
// Best-effort — an expired/already-released hold shouldn't block picking the new seat(s).
|
||||||
// call this endpoint, shouldn't block picking the new seat(s).
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1786,23 +1793,29 @@ export default function SeatsPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Summary card content — shared between sidebar and mobile modal
|
// Summary content, split into pieces so the mobile bottom-sheet can show a compact
|
||||||
const SummaryContent = () => (
|
// header + always-reachable Continue button by default, and only reveal the full
|
||||||
|
// passenger list (SummaryDetails) when the user explicitly expands it — otherwise it
|
||||||
|
// permanently covers most of the seat map on small screens.
|
||||||
|
const SummaryHeader = () => (
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h3 className="font-bold text-gray-900 dark:text-white">
|
||||||
|
Selection Summary
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||||||
|
allSeatsAssigned
|
||||||
|
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||||
|
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{assignedCount}/{seatEligibleIndices.length} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SummaryDetails = () => (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<h3 className="font-bold text-gray-900 dark:text-white">
|
|
||||||
Selection Summary
|
|
||||||
</h3>
|
|
||||||
<span
|
|
||||||
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
|
||||||
allSeatsAssigned
|
|
||||||
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
|
||||||
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{assignedCount}/{seatEligibleIndices.length} selected
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||||
{allSeatsAssigned
|
{allSeatsAssigned
|
||||||
? "All seats selected — ready to continue"
|
? "All seats selected — ready to continue"
|
||||||
@@ -1910,27 +1923,49 @@ export default function SeatsPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
<button
|
const SummaryContinueButton = () => (
|
||||||
onClick={handleContinue}
|
<button
|
||||||
disabled={!allSeatsAssigned || holdMutation.isPending || autoAssigningReturn}
|
onClick={handleContinue}
|
||||||
className="w-full py-3 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold text-sm rounded-xl transition-all shadow-lg"
|
disabled={!allSeatsAssigned || holdMutation.isPending || autoAssigningReturn}
|
||||||
>
|
className="w-full py-2.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold text-sm rounded-xl transition-all shadow-lg"
|
||||||
{autoAssigningReturn
|
>
|
||||||
? "Assigning return seats..."
|
{autoAssigningReturn
|
||||||
: holdMutation.isPending
|
? "Assigning return seats..."
|
||||||
? "Holding seats..."
|
: holdMutation.isPending
|
||||||
: isRoundTrip && currentJourneyType === "outbound" && !isPackageBooking
|
? "Holding seats..."
|
||||||
? "Continue to Return Seats"
|
: isRoundTrip && currentJourneyType === "outbound" && !isPackageBooking
|
||||||
: "Continue"}
|
? "Continue to Return Seats"
|
||||||
</button>
|
: "Continue"}
|
||||||
<button
|
</button>
|
||||||
onClick={handleAutoAssign}
|
);
|
||||||
disabled={holdMutation.isPending || allSeatsAssigned}
|
|
||||||
className="w-full mt-2 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-gray-700 dark:text-gray-300 font-semibold text-sm rounded-xl transition-all"
|
const SummaryAutoAssignButton = () => (
|
||||||
>
|
<button
|
||||||
Auto Assign Seats
|
onClick={handleAutoAssign}
|
||||||
</button>
|
disabled={holdMutation.isPending || allSeatsAssigned}
|
||||||
|
className="w-full mt-2 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-gray-700 dark:text-gray-300 font-semibold text-sm rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
Auto Assign Seats
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SummaryActions = () => (
|
||||||
|
<>
|
||||||
|
<SummaryContinueButton />
|
||||||
|
<SummaryAutoAssignButton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Full summary card — used as-is in the desktop sidebar, which has room to show
|
||||||
|
// everything at once.
|
||||||
|
const SummaryContent = () => (
|
||||||
|
<>
|
||||||
|
<SummaryHeader />
|
||||||
|
<SummaryDetails />
|
||||||
|
<SummaryActions />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1998,15 +2033,41 @@ export default function SeatsPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile summary bottom-sheet — always visible so the active passenger is clear */}
|
{/* Mobile summary bottom-sheet — collapsed to a single slim row by default so it
|
||||||
|
barely dents the seat map; tap it to expand the full passenger list + auto-assign.
|
||||||
|
The Continue button always stays visible either way. */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-x-0 bottom-0 z-50 lg:hidden bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl p-5 max-h-[70vh] overflow-y-auto"
|
className={`fixed inset-x-0 bottom-0 z-50 lg:hidden bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl overflow-hidden flex flex-col ${
|
||||||
|
mobileSummaryExpanded ? "max-h-[70vh]" : ""
|
||||||
|
}`}
|
||||||
style={{
|
style={{
|
||||||
animation: "seats-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)",
|
animation: "seats-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full mx-auto mb-4" />
|
<button
|
||||||
<SummaryContent />
|
type="button"
|
||||||
|
onClick={() => setMobileSummaryExpanded((v) => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-5 py-2.5 flex-shrink-0"
|
||||||
|
aria-label={mobileSummaryExpanded ? "Collapse seat selection summary" : "Expand seat selection summary"}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{assignedCount}/{seatEligibleIndices.length} seats selected
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-4 h-4 text-gray-400 transition-transform ${mobileSummaryExpanded ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{mobileSummaryExpanded && (
|
||||||
|
<div className="px-5 overflow-y-auto flex-1 min-h-0">
|
||||||
|
<SummaryDetails />
|
||||||
|
<SummaryAutoAssignButton />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="px-5 pb-4 pt-2 flex-shrink-0 border-t border-gray-100 dark:border-gray-800">
|
||||||
|
<SummaryContinueButton />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<style>{`@keyframes seats-slide-up{from{transform:translateY(100%)}to{transform:translateY(0)}}`}</style>
|
<style>{`@keyframes seats-slide-up{from{transform:translateY(100%)}to{transform:translateY(0)}}`}</style>
|
||||||
|
|
||||||
@@ -2326,8 +2387,9 @@ export default function SeatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile spacer so bottom-sheet doesn't cover last seat */}
|
{/* Mobile spacer so the bottom-sheet doesn't cover the last seat — sized to
|
||||||
<div className="h-48 lg:hidden" />
|
match the sheet's current (collapsed/expanded) height. */}
|
||||||
|
<div className={`lg:hidden ${mobileSummaryExpanded ? "h-[70vh]" : "h-24"}`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
return this.config.get<string>("telebirr.timeoutExpress") ?? "15m";
|
return this.config.get<string>("telebirr.timeoutExpress") ?? "15m";
|
||||||
}
|
}
|
||||||
private get privateKey(): string {
|
private get privateKey(): string {
|
||||||
return `-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC/ZcoOng1sJZ4CegopQVCw3HYqqVRLEudgT+dDpS8fRVy7zBgqZunju2VRCQuHeWs7yWgc9QGd4/8kRSLY+jlvKNeZ60yWcqEY+eKyQMmcjOz2Sn41fcVNgF+HV3DGiV4b23B6BCMjnpEFIb9d99/TsjsFSc7gCPgfl2yWDxE/Y1B2tVE6op2qd63YsMVFQGdre/CQYvFJENpQaBLMq4hHyBDgluUXlF0uA1X7UM0ZjbFC6ZIB/Hn1+pl5Ua8dKYrkVaecolmJT/s7c/+/1JeN+ja8luBoONsoODt2mTeVJHLF9Y3oh5rI+IY8HukIZJ1U6O7/JcjH3aRJTZagXUS9AgMBAAECggEBALBIBx8JcWFfEDZFwuAWeUQ7+VX3mVx/770kOuNx24HYt718D/HV0avfKETHqOfA7AQnz42EF1Yd7Rux1ZO0e3unSVRJhMO4linT1XjJ9ScMISAColWQHk3wY4va/FLPqG7N4L1w3BBtdjIc0A2zRGLNcFDBlxl/CVDHfcqD3CXdLukm/friX6TvnrbTyfAFicYgu0+UtDvfxTL3pRL3u3WTkDvnFK5YXhoazLctNOFrNiiIpCW6dJ7WRYRXuXhz7C0rENHyBtJ0zura1WD5oDbRZ8ON4v1KV4QofWiTFXJpbDgZdEeJJmFmt5HIi+Ny3P5n31WwZpRMHGeHrV23//0CgYEA+2/gYjYWOW3JgMDLX7r8fGPTo1ljkOUHuH98H/a/lE3wnnKKx+2ngRNZX4RfvNG4LLeWTz9plxR2RAqqOTbX8fj/NA/sS4mru9zvzMY1925FcX3WsWKBgKlLryl0vPScq4ejMLSCmypGz4VgLMYZqT4NYIkU2Lo1G1MiDoLy0CcCgYEAwt77exynUhM7AlyjhAA2wSINXLKsdFFF1u976x9kVhOfmbAutfMJPEQWb2WXaOJQMvMpgg2rU5aVsyEcuHsRH/2zatrxrGqLqgxaiqPz4ELINIh1iYK/hdRpr1vATHoebOv1wt8/9qxITNKtQTgQbqYci3KV1lPsOrBAB5S57nsCgYAvw+cagS/jpQmcngOEoh8I+mXgKEET64517DIGWHe4kr3dO+FFbc5eZPCbhqgxVJ3qUM4LK/7BJq/46RXBXLvVSfohR80Z5INtYuFjQ1xJLveeQcuhUxdK+95W3kdBBi8lHtVPkVsmYvekwK+ukcuaLSGZbzE4otcn47kajKHYDQKBgDbQyIbJ+ZsRw8CXVHu2H7DWJlIUBIS3s+CQ/xeVfgDkhjmSIKGX2to0AOeW+S9MseiTE/L8a1wY+MUppE2UeK26DLUbH24zjlPoI7PqCJjl0DFOzVlACSXZKV1lfsNEeriC61/EstZtgezyOkAlSCIH4fGr6tAeTU349Bnt0RtvAoGBAObgxjeH6JGpdLz1BbMj8xUHuYQkbxNeIPhH29CySn0vfhwg9VxAtIoOhvZeCfnsCRTj9OZjepCeUqDiDSoFznglrKhfeKUndHjvg+9kiae92iI6qJudPCHMNwP8wMSphkxUqnXFR3lr9A765GA980818UWZdrhrjLKtIIZdh+X1\n-----END PRIVATE KEY-----`
|
return this.config.get<string>("telebirr.privateKey") ?? "";
|
||||||
}
|
}
|
||||||
private get publicKey(): string {
|
private get publicKey(): string {
|
||||||
return this.config.get<string>("telebirr.publicKey") ?? "";
|
return this.config.get<string>("telebirr.publicKey") ?? "";
|
||||||
|
|||||||
Reference in New Issue
Block a user