mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 20:38:17 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -22,6 +22,10 @@ export class SmsNotificationStrategy implements NotificationStrategy {
|
|||||||
|
|
||||||
this.logger.debug(`Sending SMS to ${recipient} via ${url}`);
|
this.logger.debug(`Sending SMS to ${recipient} via ${url}`);
|
||||||
|
|
||||||
|
// axios defaults to no timeout — a hanging gateway would block the caller
|
||||||
|
// (and any transaction it sits in) indefinitely. Always bound the wait.
|
||||||
|
const timeout = Number(this.configService.get<string>("SMS_TIMEOUT_MS") ?? 8000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
url,
|
url,
|
||||||
@@ -34,6 +38,7 @@ export class SmsNotificationStrategy implements NotificationStrategy {
|
|||||||
callbackUrl: "",
|
callbackUrl: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
timeout,
|
||||||
headers: {
|
headers: {
|
||||||
accept: "*/*",
|
accept: "*/*",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -860,6 +860,25 @@ export class WarehouseInventoryService {
|
|||||||
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
|
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
|
||||||
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
|
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
|
||||||
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
|
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
|
||||||
|
/** Sent after the transaction commits so the gateway never blocks the receive. */
|
||||||
|
const pendingNotifications: Array<{
|
||||||
|
owner: {
|
||||||
|
phone?: string | null;
|
||||||
|
ownerName?: string | null;
|
||||||
|
bookingReference?: string | null;
|
||||||
|
grnNumber: string;
|
||||||
|
direction?: string | null;
|
||||||
|
warehouseId?: string | null;
|
||||||
|
};
|
||||||
|
booking: {
|
||||||
|
companyId?: string | null;
|
||||||
|
reference?: string | null;
|
||||||
|
hasFirstMile?: boolean;
|
||||||
|
hasLastMile?: boolean;
|
||||||
|
customerTruckAssignedAt?: string | null;
|
||||||
|
};
|
||||||
|
bookingId: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await this.validateLocation(manager, {
|
await this.validateLocation(manager, {
|
||||||
@@ -1032,21 +1051,33 @@ export class WarehouseInventoryService {
|
|||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.notifyOwnerInventoryReceived({
|
// Queued, not sent here: an SMS/email round-trip inside the transaction
|
||||||
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
|
// holds capacity/location locks open for the whole gateway latency.
|
||||||
ownerName: truckEntrance?.ownerName ?? booking.customer,
|
pendingNotifications.push({
|
||||||
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
|
owner: {
|
||||||
grnNumber,
|
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
|
||||||
direction: dto.direction,
|
ownerName: truckEntrance?.ownerName ?? booking.customer,
|
||||||
warehouseId: dto.warehouseId,
|
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
|
||||||
|
grnNumber,
|
||||||
|
direction: dto.direction,
|
||||||
|
warehouseId: dto.warehouseId,
|
||||||
|
},
|
||||||
|
booking,
|
||||||
|
bookingId,
|
||||||
});
|
});
|
||||||
|
|
||||||
result.receivedCount += 1;
|
result.receivedCount += 1;
|
||||||
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
||||||
void this.notifyTruckAssignmentNeeded(booking, bookingId);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fan out after commit, un-awaited: the receive response must not wait on the
|
||||||
|
// SMS gateway. Both notifiers swallow their own errors.
|
||||||
|
for (const pending of pendingNotifications) {
|
||||||
|
void this.notifyOwnerInventoryReceived(pending.owner);
|
||||||
|
void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,100 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
|
import {
|
||||||
import { ChevronRight, PackageCheck, Ship } from "lucide-react";
|
Badge,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
SegmentedControl,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { ChevronRight, FileSignature, PackageCheck, Ship } from "lucide-react";
|
||||||
|
|
||||||
import { PageContainer } from "@/components/page/PageContainer";
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||||
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||||
|
|
||||||
|
type QueueTab = "contracts" | "shipments";
|
||||||
|
|
||||||
|
const prettyStatus = (s?: string | null) =>
|
||||||
|
(s ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/^\w/, (c) => c.toUpperCase());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL Djibouti clearance queues:
|
||||||
|
* - Contracts: ONE_TIME customs contracts in phased clearance (legacy flow).
|
||||||
|
* - Shipments: GENERAL-contract bookings in per-booking clearance awaiting a DJ
|
||||||
|
* action (DO collection after ET finalizes pre-clearance, RO for exports,
|
||||||
|
* loading milestones). Managed like the one-time flow, but per booking.
|
||||||
|
*/
|
||||||
export default function GlDjiboutiClearanceListPage() {
|
export default function GlDjiboutiClearanceListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [tab, setTab] = useState<QueueTab>("shipments");
|
||||||
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
|
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
|
||||||
const { data: bookingQueue, isLoading: bookingsLoading } =
|
const { data: bookingQueue, isLoading: bookingsLoading } =
|
||||||
useBookingDjClearanceQueue();
|
useBookingDjClearanceQueue();
|
||||||
|
|
||||||
const contractItems = contractQueue?.items ?? [];
|
const contractItems = contractQueue?.items ?? [];
|
||||||
const bookingItems = bookingQueue ?? [];
|
const bookingItems = bookingQueue ?? [];
|
||||||
|
const isLoading = tab === "contracts" ? contractsLoading : bookingsLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<Stack gap="lg">
|
||||||
title="GL Djibouti — Clearance"
|
<PageHeader
|
||||||
subtitle="Customs contracts and shipment bookings handed off to Djibouti GL."
|
title="GL Djibouti — Clearance"
|
||||||
/>
|
subtitle="Customs contracts and shipment bookings handed off to Djibouti GL."
|
||||||
{contractsLoading || bookingsLoading ? (
|
/>
|
||||||
<Group justify="center" py={60}>
|
|
||||||
<Loader color="edr-green" />
|
<SegmentedControl
|
||||||
</Group>
|
value={tab}
|
||||||
) : (
|
onChange={(v) => setTab(v as QueueTab)}
|
||||||
<Stack gap="sm">
|
radius="md"
|
||||||
{contractItems.length === 0 && bookingItems.length === 0 ? (
|
data={[
|
||||||
<Text c="dimmed" ta="center" py="xl">
|
{
|
||||||
No Djibouti customs work yet.
|
value: "shipments",
|
||||||
</Text>
|
label: (
|
||||||
) : (
|
<Group gap={6} wrap="nowrap">
|
||||||
<>
|
<PackageCheck size={15} />
|
||||||
{contractItems.map((c) => (
|
<Box visibleFrom="sm">Shipments</Box>
|
||||||
|
<Badge size="sm" radius="sm" variant="light" color="edr-green">
|
||||||
|
{bookingItems.length}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "contracts",
|
||||||
|
label: (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<FileSignature size={15} />
|
||||||
|
<Box visibleFrom="sm">Contracts</Box>
|
||||||
|
<Badge size="sm" radius="sm" variant="light" color="gray">
|
||||||
|
{contractItems.length}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py={60}>
|
||||||
|
<Loader color="edr-green" />
|
||||||
|
</Group>
|
||||||
|
) : tab === "contracts" ? (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{contractItems.length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
|
No Djibouti customs contracts yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
contractItems.map((c) => (
|
||||||
<Card
|
<Card
|
||||||
key={c.id}
|
key={c.id}
|
||||||
withBorder
|
withBorder
|
||||||
@@ -49,7 +109,7 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Text fw={700}>{c.reference}</Text>
|
<Text fw={700}>{c.reference}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{c.tradeDirection} · {c.status}
|
{c.tradeDirection} · {prettyStatus(c.status)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -61,15 +121,24 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))
|
||||||
{bookingItems.map((b) => (
|
)}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{bookingItems.length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
|
No shipment bookings awaiting a Djibouti action.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
bookingItems.map((b) => (
|
||||||
<Card
|
<Card
|
||||||
key={b.id}
|
key={b.id}
|
||||||
withBorder
|
withBorder
|
||||||
radius="md"
|
radius="md"
|
||||||
padding="md"
|
padding="md"
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
onClick={() => navigate(`/dashboard/clearance/${b.id}`)}
|
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${b.id}`)}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
@@ -80,7 +149,8 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Text fw={700}>{b.reference}</Text>
|
<Text fw={700}>{b.reference}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{b.tradeDirection} · {b.status}
|
{b.tradeDirection} · {prettyStatus(b.status)}
|
||||||
|
{b.company?.name ? ` · ${b.company.name}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -92,11 +162,11 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))
|
||||||
</>
|
)}
|
||||||
)}
|
</Stack>
|
||||||
</Stack>
|
)}
|
||||||
)}
|
</Stack>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export default function PassengersPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || 'N/A' },
|
{ key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || p.passenger?.user?.phone || '—' },
|
||||||
{ key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' },
|
{ key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' },
|
||||||
{ key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' },
|
{ key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' },
|
||||||
{ key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' },
|
{ key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' },
|
||||||
|
|||||||
@@ -166,7 +166,10 @@ export default function TariffRatesPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'coachType', label: 'Coach Type',
|
key: 'coachType', label: 'Coach Type',
|
||||||
render: (c: any) => <span className="text-sm">{c.coachType?.name || c.coachTypeId}</span>,
|
render: (c: any) => {
|
||||||
|
const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId);
|
||||||
|
return <span className="text-sm">{ct ? `${ct.code} — ${ct.name}` : c.coachTypeId}</span>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'bedPosition', label: 'Bed Position',
|
key: 'bedPosition', label: 'Bed Position',
|
||||||
|
|||||||
@@ -343,15 +343,12 @@ export default function TicketsPage() {
|
|||||||
key: 'contact',
|
key: 'contact',
|
||||||
label: 'Contact',
|
label: 'Contact',
|
||||||
render: (ticket: any) => {
|
render: (ticket: any) => {
|
||||||
const phone = ticket.booking?.passenger?.phone || 'N/A';
|
const phone = ticket.booking?.contactPhone || ticket.booking?.passenger?.phone || '—';
|
||||||
const email = ticket.booking?.passenger?.email || 'N/A';
|
const email = ticket.booking?.contactEmail || ticket.booking?.passenger?.email || '—';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{phone}</div>
|
<div className="font-medium">{phone}</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-xs text-muted-foreground truncate" title={email}>{email}</div>
|
||||||
<div className="text-xs text-muted-foreground truncate" title={email}>{email}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ export const passengersApi = {
|
|||||||
if (filters?.role) params.append('role', filters.role);
|
if (filters?.role) params.append('role', filters.role);
|
||||||
if (filters?.page) params.append('page', filters.page.toString());
|
if (filters?.page) params.append('page', filters.page.toString());
|
||||||
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());
|
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());
|
||||||
|
if ((filters as any)?.gender) params.append('gender', (filters as any).gender);
|
||||||
|
if ((filters as any)?.nationality) params.append('nationality', (filters as any).nationality);
|
||||||
|
if ((filters as any)?.dateFrom) params.append('dateFrom', (filters as any).dateFrom);
|
||||||
|
if ((filters as any)?.dateTo) params.append('dateTo', (filters as any).dateTo);
|
||||||
return apiClient.get<PaginatedResponse<Passenger.IPassenger>>(`/passengers?${params.toString()}`);
|
return apiClient.get<PaginatedResponse<Passenger.IPassenger>>(`/passengers?${params.toString()}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -21,4 +24,8 @@ export const passengersApi = {
|
|||||||
update: (id: string, data: Partial<Passenger.IPassenger>) => {
|
update: (id: string, data: Partial<Passenger.IPassenger>) => {
|
||||||
return apiClient.patch<Passenger.IPassenger>(`/passengers/${id}`, data);
|
return apiClient.patch<Passenger.IPassenger>(`/passengers/${id}`, data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
delete: (id: string, cascade = false) => {
|
||||||
|
return apiClient.delete(`/passengers/${id}${cascade ? '?cascade=true' : ''}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -72,10 +72,10 @@ export default function PaymentPage() {
|
|||||||
// split equally across both legs. This guarantees leg totals are consistent with the
|
// split equally across both legs. This guarantees leg totals are consistent with the
|
||||||
// per-passenger breakdown rows and the overall reviewed total.
|
// per-passenger breakdown rows and the overall reviewed total.
|
||||||
const outboundBaseFare = isRoundTrip
|
const outboundBaseFare = isRoundTrip
|
||||||
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0)
|
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.outboundFareMinor ?? Math.round(f.fareMinor / 2))), 0)
|
||||||
: 0;
|
: 0;
|
||||||
const inboundBaseFare = isRoundTrip
|
const inboundBaseFare = isRoundTrip
|
||||||
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0)
|
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.inboundFareMinor ?? Math.round(f.fareMinor / 2))), 0)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display
|
// reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display
|
||||||
@@ -307,11 +307,11 @@ export default function PaymentPage() {
|
|||||||
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
|
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Outbound</span>
|
<span>Outbound</span>
|
||||||
<span>{formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
|
<span>{formatFare(reviewed?.outboundFareMinor ?? Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Return</span>
|
<span>Return</span>
|
||||||
<span>{formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
|
<span>{formatFare(reviewed?.inboundFareMinor ?? Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -293,7 +293,17 @@ export default function ResultsPage() {
|
|||||||
|
|
||||||
// For round trip inbound, proceed with both schedules
|
// For round trip inbound, proceed with both schedules
|
||||||
if (isRoundTrip && !isOutbound) {
|
if (isRoundTrip && !isOutbound) {
|
||||||
setInboundSchedule(scheduleData);
|
// Mirror the outbound's coachTypes (fares) onto the inbound schedule so the
|
||||||
|
// return seat selection page shows the same prices as the outbound leg.
|
||||||
|
const inboundScheduleData = outboundScheduleData
|
||||||
|
? {
|
||||||
|
...scheduleData,
|
||||||
|
baseFareAdult: outboundScheduleData.baseFareAdult,
|
||||||
|
baseFareChild: outboundScheduleData.baseFareChild,
|
||||||
|
coachTypes: outboundScheduleData.coachTypes,
|
||||||
|
}
|
||||||
|
: scheduleData;
|
||||||
|
setInboundSchedule(inboundScheduleData);
|
||||||
setSelectedSchedule(outboundScheduleData); // Set primary as outbound
|
setSelectedSchedule(outboundScheduleData); // Set primary as outbound
|
||||||
} else {
|
} else {
|
||||||
// For one-way
|
// For one-way
|
||||||
|
|||||||
@@ -445,7 +445,9 @@ export default function ReviewPage() {
|
|||||||
const fareMinor = isPackageBooking
|
const fareMinor = isPackageBooking
|
||||||
? (isFreeChild ? 0 : (seatFare ?? pkgFallback))
|
? (isFreeChild ? 0 : (seatFare ?? pkgFallback))
|
||||||
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
|
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
|
||||||
return { fareMinor, isFree: isFreeChild };
|
const outboundFareMinor = isRoundTrip ? ((p as any).outboundSeatFareMinor ?? undefined) : undefined;
|
||||||
|
const inboundFareMinor = isRoundTrip ? ((p as any).inboundSeatFareMinor ?? undefined) : undefined;
|
||||||
|
return { fareMinor, isFree: isFreeChild, outboundFareMinor, inboundFareMinor };
|
||||||
});
|
});
|
||||||
setReviewedTotal(computedTotal, passengerFares);
|
setReviewedTotal(computedTotal, passengerFares);
|
||||||
|
|
||||||
@@ -560,6 +562,14 @@ export default function ReviewPage() {
|
|||||||
const isFreeChild = isPackageBooking
|
const isFreeChild = isPackageBooking
|
||||||
? isPkgFreeChild(i)
|
? isPkgFreeChild(i)
|
||||||
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
|
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
|
||||||
|
|
||||||
|
// Per-leg fares for round trips
|
||||||
|
const outboundFare: number | null = isRoundTrip
|
||||||
|
? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).outboundSeatFareMinor ?? null))
|
||||||
|
: null;
|
||||||
|
const inboundFare: number | null = isRoundTrip
|
||||||
|
? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).inboundSeatFareMinor ?? null))
|
||||||
|
: null;
|
||||||
const seatFare = getPassengerSeatFare(p);
|
const seatFare = getPassengerSeatFare(p);
|
||||||
const passengerTotal = isPackageBooking
|
const passengerTotal = isPackageBooking
|
||||||
? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare)))
|
? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare)))
|
||||||
@@ -582,6 +592,19 @@ export default function ReviewPage() {
|
|||||||
{formatFare(passengerTotal, displayCurrency)}
|
{formatFare(passengerTotal, displayCurrency)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Round-trip: show outbound + inbound breakdown */}
|
||||||
|
{isRoundTrip && !isFreeChild && (
|
||||||
|
<div className="mt-1 space-y-0.5 pl-2">
|
||||||
|
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>↗ Outbound</span>
|
||||||
|
<span>{outboundFare != null ? formatFare(outboundFare, displayCurrency) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>↙ Return</span>
|
||||||
|
<span>{inboundFare != null ? formatFare(inboundFare, displayCurrency) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -451,16 +451,15 @@ export default function SeatsPage() {
|
|||||||
// the new seat map data has finished loading.
|
// the new seat map data has finished loading.
|
||||||
const applyCoachTypeSwitch = (coach: any, matchedType: any) => {
|
const applyCoachTypeSwitch = (coach: any, matchedType: any) => {
|
||||||
const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId);
|
const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId);
|
||||||
const firstClass = matchedType.classes?.[0];
|
|
||||||
const newCoachTypeId = matchedType.coachTypeId || matchedType.coachId;
|
const newCoachTypeId = matchedType.coachTypeId || matchedType.coachId;
|
||||||
const updatedSchedule = {
|
const updatedSchedule = {
|
||||||
...(currentSchedule as any),
|
...(currentSchedule as any),
|
||||||
selectedCoachTypeId: newCoachTypeId,
|
selectedCoachTypeId: newCoachTypeId,
|
||||||
selectedCoachTypeCode: matchedType.coachTypeCode || coach.type || "",
|
selectedCoachTypeCode: matchedType.coachTypeCode || coach.type || "",
|
||||||
selectedCoachTypeName: matchedType.coachTypeName || coach.coachTypeName || coach.typeName || "",
|
selectedCoachTypeName: matchedType.coachTypeName || coach.coachTypeName || coach.typeName || "",
|
||||||
selectedSeatClass: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
|
selectedSeatClass: matchedType.coachTypeName || coach.coachTypeName || "",
|
||||||
selectedSeatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
|
selectedSeatClassName: matchedType.coachTypeName || coach.coachTypeName || "",
|
||||||
seatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "",
|
seatClassName: matchedType.coachTypeName || coach.coachTypeName || "",
|
||||||
baseFareAdult: newFare ?? (currentSchedule as any)?.baseFareAdult,
|
baseFareAdult: newFare ?? (currentSchedule as any)?.baseFareAdult,
|
||||||
baseFareChild: newFare ?? (currentSchedule as any)?.baseFareChild,
|
baseFareChild: newFare ?? (currentSchedule as any)?.baseFareChild,
|
||||||
};
|
};
|
||||||
@@ -921,7 +920,7 @@ export default function SeatsPage() {
|
|||||||
const positionLabel = newSeat?.bedPosition
|
const positionLabel = newSeat?.bedPosition
|
||||||
? `${newSeat.bedPosition.charAt(0).toUpperCase()}${newSeat.bedPosition.slice(1)} berth`
|
? `${newSeat.bedPosition.charAt(0).toUpperCase()}${newSeat.bedPosition.slice(1)} berth`
|
||||||
: "This seat";
|
: "This seat";
|
||||||
const legMultiplier = isRoundTrip ? 2 : 1;
|
const legMultiplier = isPackageBooking && isRoundTrip ? 2 : 1;
|
||||||
|
|
||||||
setModalState({
|
setModalState({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
@@ -1328,15 +1327,16 @@ export default function SeatsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRoundTrip) {
|
if (isRoundTrip) {
|
||||||
if (!outboundSchedule || !inboundSchedule || !passengers.length) {
|
if (!outboundSchedule || !inboundSchedule || !passengers.length) {
|
||||||
router.push("/booking/search");
|
router.push(isPackageBooking ? "/booking/passengers" : "/booking/search");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!selectedSchedule || !passengers.length) {
|
if (!selectedSchedule || !passengers.length) {
|
||||||
router.push("/booking/search");
|
router.push(isPackageBooking ? "/booking/passengers" : "/booking/search");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
isRoundTrip,
|
isRoundTrip,
|
||||||
|
isPackageBooking,
|
||||||
selectedSchedule,
|
selectedSchedule,
|
||||||
outboundSchedule,
|
outboundSchedule,
|
||||||
inboundSchedule,
|
inboundSchedule,
|
||||||
@@ -1712,7 +1712,7 @@ export default function SeatsPage() {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
isRoundTrip
|
isRoundTrip
|
||||||
? !outboundSchedule || !inboundSchedule || !passengers.length
|
? !outboundSchedule || (!isPackageBooking && !inboundSchedule) || !passengers.length
|
||||||
: !selectedSchedule || !passengers.length
|
: !selectedSchedule || !passengers.length
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ function groupTiersByCoachType(tiers: PriceTier[]): Array<{
|
|||||||
if (!map.has(key)) {
|
if (!map.has(key)) {
|
||||||
map.set(key, {
|
map.set(key, {
|
||||||
coachTypeId: ct?.id ?? key,
|
coachTypeId: ct?.id ?? key,
|
||||||
coachTypeName: ct?.name ?? tier.seatType,
|
coachTypeName: ct?.name ?? tier.label ?? tier.seatType,
|
||||||
coachTypeCode: ct?.code ?? '',
|
coachTypeCode: ct?.code ?? '',
|
||||||
coachTypeType: ct?.type ?? 'passenger',
|
coachTypeType: ct?.type ?? 'passenger',
|
||||||
tiers: [],
|
tiers: [],
|
||||||
@@ -270,90 +270,121 @@ function PriceTiersPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800 space-y-3">
|
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800">
|
||||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">Select Coach Type</h2>
|
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-4">Choose Coach Type</h2>
|
||||||
{groups.map((group) => {
|
<div className="grid grid-cols-1 gap-4">
|
||||||
const CoachIcon = getCoachIcon(group.coachTypeType);
|
{groups.map((group, index) => {
|
||||||
const allSoldOut = group.tiers.every((t) => t.availableSeats === 0);
|
const CoachIcon = getCoachIcon(group.coachTypeType);
|
||||||
const isSelected = selectedId === group.coachTypeId;
|
const allSoldOut = group.tiers.every((t) => t.availableSeats === 0);
|
||||||
return (
|
const isSelected = selectedId === group.coachTypeId;
|
||||||
<div
|
return (
|
||||||
key={group.coachTypeId}
|
<div
|
||||||
className={`rounded-xl border-2 overflow-hidden transition-colors ${
|
key={group.coachTypeId}
|
||||||
allSoldOut
|
role="button"
|
||||||
? 'border-gray-200 dark:border-gray-700 opacity-50'
|
tabIndex={allSoldOut ? -1 : 0}
|
||||||
: isSelected
|
onClick={() => !allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)}
|
||||||
? 'border-primary'
|
onKeyDown={(e) => {
|
||||||
: 'border-gray-200 dark:border-gray-700 cursor-pointer hover:border-primary/50'
|
if (!allSoldOut && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
}`}
|
e.preventDefault();
|
||||||
onClick={() => !allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)}
|
setSelectedId(isSelected ? null : group.coachTypeId);
|
||||||
>
|
}
|
||||||
{/* Coach type header */}
|
}}
|
||||||
<div className="flex items-center gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800/60">
|
className={`group relative w-full p-2 rounded-2xl border-2 text-left transition-all duration-200 ${allSoldOut
|
||||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
? 'border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed'
|
||||||
isSelected ? 'bg-primary' : 'bg-primary/10'
|
: isSelected
|
||||||
}`}>
|
? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02] cursor-pointer'
|
||||||
<CoachIcon className={`w-5 h-5 ${isSelected ? 'text-white' : 'text-primary'}`} />
|
: 'border-gray-200 dark:border-gray-700 shadow-sm hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50 cursor-pointer'
|
||||||
</div>
|
}`}
|
||||||
<div className="flex-1 min-w-0">
|
style={{ animation: `fade-in-up 0.3s ease-out ${index * 0.08}s both` }}
|
||||||
<p className="text-sm font-bold text-gray-900 dark:text-white">
|
>
|
||||||
{formatCoachTypeLabel(group.coachTypeType)}
|
{/* Radio indicator */}
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
From {formatPrice(group.minPrice * priceMultiplier, group.currency)}
|
|
||||||
{allSoldOut && <span className="ml-2 text-red-500 font-semibold">· Sold out</span>}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{!allSoldOut && (
|
{!allSoldOut && (
|
||||||
<div className={`w-5 h-5 rounded-full border-2 flex-shrink-0 flex items-center justify-center ${
|
<span className={`absolute top-4 right-4 w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-all ${isSelected ? 'border-primary' : 'border-gray-300 dark:border-gray-600 group-hover:border-primary/50'
|
||||||
isSelected ? 'border-primary bg-primary' : 'border-gray-300 dark:border-gray-600'
|
}`}>
|
||||||
}`}>
|
{isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}
|
||||||
{isSelected && <Check className="w-3 h-3 text-white" />}
|
</span>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* All available classes for this coach type */}
|
<div className="flex flex-col">
|
||||||
<div className="px-4 py-3 space-y-2">
|
<div className="flex items-start gap-2 pr-2">
|
||||||
{group.tiers.map((tier) => {
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all ${isSelected
|
||||||
const soldOut = tier.availableSeats === 0;
|
? 'bg-primary/15 dark:bg-primary/25 shadow-inner'
|
||||||
return (
|
: 'bg-gray-100 dark:bg-gray-700 group-hover:bg-primary/10'
|
||||||
<div
|
}`}>
|
||||||
key={tier.id}
|
<CoachIcon className={`w-4 h-4 transition-colors ${isSelected ? 'text-primary' : 'text-gray-600 dark:text-gray-400 group-hover:text-primary'
|
||||||
className={`flex items-start gap-2 py-1.5 ${soldOut ? 'opacity-50' : ''}`}
|
}`} />
|
||||||
>
|
</div>
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-primary flex-shrink-0 mt-1.5" />
|
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300">{tier.seatType.trim()}</p>
|
<p className="text-sm font-bold text-gray-700 dark:text-gray-300 tracking-wider">
|
||||||
<p className="text-xs">
|
{formatCoachTypeLabel(group.coachTypeType)}
|
||||||
<span className="font-bold text-primary">{formatPrice(tier.priceMinor * priceMultiplier, tier.currency)}</span>
|
</p>
|
||||||
{soldOut ? (
|
{allSoldOut && (
|
||||||
<span className="ml-2 font-bold text-red-500">Sold out</span>
|
<span className="text-xs font-bold text-red-500 mt-0.5 block">Sold out</span>
|
||||||
) : (
|
)}
|
||||||
<span className="ml-2 text-gray-400">{tier.availableSeats} left</span>
|
<div className="mt-1 flex items-baseline gap-1">
|
||||||
)}
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">From</span>
|
||||||
</p>
|
<span className={`text-sm font-bold tracking-tight ${isSelected ? 'text-primary' : 'text-gray-900 dark:text-white'
|
||||||
|
}`}>
|
||||||
|
{((group.minPrice * priceMultiplier) / 100).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">{group.currency}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Book Now — only when this group is selected */}
|
{/* Class options — always visible, matching results page style */}
|
||||||
{isSelected && !allSoldOut && (
|
{group.tiers.length > 0 && (
|
||||||
<div className="px-4 pb-4">
|
<div className="mt-2 pt-2 border-t border-gray-200/60 dark:border-gray-700/60">
|
||||||
<button
|
<div className="space-y-1.5">
|
||||||
type="button"
|
{group.tiers.map((tier) => {
|
||||||
onClick={(e) => { e.stopPropagation(); onBookNow(group.coachTypeId); }}
|
const soldOut = tier.availableSeats === 0;
|
||||||
className="w-full py-2.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-md flex items-center justify-center gap-2"
|
return (
|
||||||
>
|
<div
|
||||||
Book Now <ArrowRight className="w-4 h-4" />
|
key={tier.id}
|
||||||
</button>
|
className={`flex flex-col py-1 px-1 rounded-lg bg-gray-50/80 dark:bg-gray-800/40 ${soldOut ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{tier.seatType.trim()}</span>
|
||||||
|
{soldOut ? (
|
||||||
|
<span className="text-xs font-bold text-red-500 mt-0.5">Sold out</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-baseline gap-1 mt-0.5">
|
||||||
|
<span className="text-sm font-bold tabular-nums text-primary">
|
||||||
|
{((tier.priceMinor * priceMultiplier) / 100).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">{tier.currency}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSelected && !allSoldOut && (
|
||||||
|
<p className="mt-4 pt-3 border-t border-gray-200/60 dark:border-gray-700/60 text-xs text-gray-400 dark:text-gray-500 italic text-center">
|
||||||
|
Click to select this coach
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSelected && !allSoldOut && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onBookNow(group.coachTypeId); }}
|
||||||
|
className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all shadow-md shadow-primary/30 hover:shadow-lg active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
Book Now <ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes fade-in-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -407,7 +438,7 @@ function PassengerCountModal({
|
|||||||
|
|
||||||
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10">
|
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10">
|
||||||
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Coach type</p>
|
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Coach type</p>
|
||||||
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}</p>
|
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.seatClass?.coachType?.name ?? tier.label.trim()}</p>
|
||||||
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · from {formatPrice(minPriceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)</p>
|
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · from {formatPrice(minPriceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -416,26 +447,26 @@ function PassengerCountModal({
|
|||||||
{ label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: (v: number) => { setAdultCount(v); const newMax = Math.min(v * PKG_CHILDREN_PER_ADULT, v + Math.max(0, remaining - v)); setChildCount(c => Math.min(c, newMax)); } },
|
{ label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: (v: number) => { setAdultCount(v); const newMax = Math.min(v * PKG_CHILDREN_PER_ADULT, v + Math.max(0, remaining - v)); setChildCount(c => Math.min(c, newMax)); } },
|
||||||
{ label: "Children", sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 1st per adult FREE (no seat)`, value: childCount, min: 0, max: Math.min(adultCount * PKG_CHILDREN_PER_ADULT, adultCount + Math.max(0, remaining - adultCount)), set: setChildCount },
|
{ label: "Children", sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 1st per adult FREE (no seat)`, value: childCount, min: 0, max: Math.min(adultCount * PKG_CHILDREN_PER_ADULT, adultCount + Math.max(0, remaining - adultCount)), set: setChildCount },
|
||||||
].map(({ label, sub, value, min, max, set }) => (
|
].map(({ label, sub, value, min, max, set }) => (
|
||||||
<div key={label} className="flex items-center justify-between">
|
<div key={label} className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
|
||||||
<p className="text-xs text-gray-400">{sub}</p>
|
<p className="text-xs text-gray-400">{sub}</p>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button type="button" onClick={() => set(Math.max(min, value - 1))}
|
|
||||||
disabled={value <= min}
|
|
||||||
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<span className="w-6 text-center text-base font-bold text-gray-900 dark:text-white">{value}</span>
|
|
||||||
<button type="button" onClick={() => set(value + 1)}
|
|
||||||
disabled={value >= max}
|
|
||||||
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="flex items-center gap-3">
|
||||||
|
<button type="button" onClick={() => set(Math.max(min, value - 1))}
|
||||||
|
disabled={value <= min}
|
||||||
|
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="w-6 text-center text-base font-bold text-gray-900 dark:text-white">{value}</span>
|
||||||
|
<button type="button" onClick={() => set(value + 1)}
|
||||||
|
disabled={value >= max}
|
||||||
|
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{freeChildren > 0 && (
|
{freeChildren > 0 && (
|
||||||
<div className="flex items-center justify-between text-xs text-green-600 dark:text-green-400">
|
<div className="flex items-center justify-between text-xs text-green-600 dark:text-green-400">
|
||||||
@@ -457,15 +488,14 @@ function PassengerCountModal({
|
|||||||
|
|
||||||
{/* Departure Station */}
|
{/* Departure Station */}
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center gap-1.5 text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5 border-t pt-3">
|
<label className="flex items-center gap-1.5 text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5 border-t pt-3">
|
||||||
Departure Station
|
Departure Station
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={departureStationId}
|
value={departureStationId}
|
||||||
onChange={(e) => { setDepartureStationId(e.target.value); setShowStationError(false); }}
|
onChange={(e) => { setDepartureStationId(e.target.value); setShowStationError(false); }}
|
||||||
className={`w-full rounded-xl border bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/40 ${
|
className={`w-full rounded-xl border bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/40 ${showStationError && !departureStationId ? 'border-red-400 dark:border-red-500' : 'border-gray-200 dark:border-gray-700'
|
||||||
showStationError && !departureStationId ? 'border-red-400 dark:border-red-500' : 'border-gray-200 dark:border-gray-700'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<option value="">Select your boarding station</option>
|
<option value="">Select your boarding station</option>
|
||||||
{stations.map((s) => (
|
{stations.map((s) => (
|
||||||
@@ -480,10 +510,10 @@ function PassengerCountModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<button type="button" onClick={() => {
|
<button type="button" onClick={() => {
|
||||||
if (!departureStationId) { setShowStationError(true); return; }
|
if (!departureStationId) { setShowStationError(true); return; }
|
||||||
const station = stations.find(s => s.id === departureStationId);
|
const station = stations.find(s => s.id === departureStationId);
|
||||||
onConfirm(adultCount, childCount, departureStationId, station?.name ?? '');
|
onConfirm(adultCount, childCount, departureStationId, station?.name ?? '');
|
||||||
}}
|
}}
|
||||||
disabled={loading || adultCount + childCount < 1}
|
disabled={loading || adultCount + childCount < 1}
|
||||||
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-60 text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2">
|
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-60 text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2">
|
||||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> Loading...</> : <>Continue <ArrowRight className="w-4 h-4" /></>}
|
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> Loading...</> : <>Continue <ArrowRight className="w-4 h-4" /></>}
|
||||||
@@ -523,8 +553,10 @@ export default function PackageDetailPage() {
|
|||||||
// For the passenger modal, use the cheapest available tier in the selected coach type group
|
// For the passenger modal, use the cheapest available tier in the selected coach type group
|
||||||
const groups = pkg ? groupTiersByCoachType(pkg.priceTiers) : [];
|
const groups = pkg ? groupTiersByCoachType(pkg.priceTiers) : [];
|
||||||
const selectedGroup = groups.find((g) => g.coachTypeId === selectedCoachTypeId);
|
const selectedGroup = groups.find((g) => g.coachTypeId === selectedCoachTypeId);
|
||||||
// Representative tier for the modal header (cheapest available)
|
// Representative tier for the modal: cheapest available in the selected group
|
||||||
const representativeTier = selectedGroup?.tiers.find((t) => t.availableSeats > 0) ?? selectedGroup?.tiers[0] ?? null;
|
const representativeTier = selectedGroup?.tiers
|
||||||
|
.filter((t) => t.availableSeats > 0)
|
||||||
|
.sort((a, b) => a.priceMinor - b.priceMinor)[0] ?? selectedGroup?.tiers[0] ?? null;
|
||||||
|
|
||||||
const isRoundTripPkg = pkg?.journeyType === 'ROUND_TRIP';
|
const isRoundTripPkg = pkg?.journeyType === 'ROUND_TRIP';
|
||||||
|
|
||||||
@@ -745,9 +777,9 @@ export default function PackageDetailPage() {
|
|||||||
<p className="text-xs font-bold text-gray-800 dark:text-white mt-0.5">
|
<p className="text-xs font-bold text-gray-800 dark:text-white mt-0.5">
|
||||||
{pkg.priceTiers.length
|
{pkg.priceTiers.length
|
||||||
? formatPrice(
|
? formatPrice(
|
||||||
Math.min(...pkg.priceTiers.map((t) => t.priceMinor)) * (isRoundTripPkg ? 2 : 1),
|
Math.min(...pkg.priceTiers.map((t) => t.priceMinor)) * (isRoundTripPkg ? 2 : 1),
|
||||||
pkg.priceTiers[0].currency,
|
pkg.priceTiers[0].currency,
|
||||||
)
|
)
|
||||||
: "—"}
|
: "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ interface BookingState {
|
|||||||
reviewedTotalMinor: number | null;
|
reviewedTotalMinor: number | null;
|
||||||
// Per-passenger fare breakdown computed on the review page — guarantees line items
|
// Per-passenger fare breakdown computed on the review page — guarantees line items
|
||||||
// on the payment page sum to exactly reviewedTotalMinor.
|
// on the payment page sum to exactly reviewedTotalMinor.
|
||||||
reviewedPassengerFares: Array<{ fareMinor: number; isFree: boolean }> | null;
|
reviewedPassengerFares: Array<{ fareMinor: number; isFree: boolean; outboundFareMinor?: number; inboundFareMinor?: number }> | null;
|
||||||
|
|
||||||
setSearchCriteria: (criteria: SearchCriteria) => void;
|
setSearchCriteria: (criteria: SearchCriteria) => void;
|
||||||
setSelectedSchedule: (schedule: SelectedSchedule) => void;
|
setSelectedSchedule: (schedule: SelectedSchedule) => void;
|
||||||
@@ -143,7 +143,7 @@ interface BookingState {
|
|||||||
setCreateAccount: (create: boolean) => void;
|
setCreateAccount: (create: boolean) => void;
|
||||||
setPassengerId: (id: string | null) => void;
|
setPassengerId: (id: string | null) => void;
|
||||||
setPackageContext: (packageId: string, priceTierId: string, priceMinor: number, packageName?: string, departureStationId?: string, departureStationName?: string) => void;
|
setPackageContext: (packageId: string, priceTierId: string, priceMinor: number, packageName?: string, departureStationId?: string, departureStationName?: string) => void;
|
||||||
setReviewedTotal: (totalMinor: number, passengerFares: Array<{ fareMinor: number; isFree: boolean }>) => void;
|
setReviewedTotal: (totalMinor: number, passengerFares: Array<{ fareMinor: number; isFree: boolean; outboundFareMinor?: number; inboundFareMinor?: number }>) => void;
|
||||||
clearBooking: () => void;
|
clearBooking: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
6680
docs/qa/edr-freight-qa-test-plan.pdf
Normal file
6680
docs/qa/edr-freight-qa-test-plan.pdf
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user