mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, Matches } from 'class-validator';
|
||||
|
||||
export class CreateAllocationRuleDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' })
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 100 })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity';
|
||||
|
||||
@@ -7,6 +7,7 @@ export class CreateWarehouseDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
@Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator';
|
||||
import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator';
|
||||
|
||||
import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
|
||||
|
||||
@@ -25,6 +25,7 @@ export class FeeRuleTierDto {
|
||||
export class CreateFeeRuleDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ enum: FEE_RULE_TYPES })
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
|
||||
import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options';
|
||||
import { extractErrorMessage, lettersOnly, statusOptions, warehouseTypeOptions } from './options';
|
||||
|
||||
interface CreateWarehouseModalProps {
|
||||
opened: boolean;
|
||||
@@ -120,7 +120,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
placeholder="Modjo Open Warehouse"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
|
||||
onChange={(e) => { const v = lettersOnly(e.currentTarget.value); setForm((f) => ({ ...f, name: v })); }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Code"
|
||||
|
||||
@@ -48,6 +48,9 @@ export const formatDate = (value: string | null | undefined) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Name fields (warehouse / fee rule / allocation rule) accept letters and spaces only — no numbers.
|
||||
export const lettersOnly = (value: string) => value.replace(/[^A-Za-z\s]/g, '');
|
||||
|
||||
export const extractErrorMessage = (error: unknown, fallback = 'Something went wrong') => {
|
||||
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
||||
const data = responseData && typeof responseData === 'object' ? (responseData as Record<string, unknown>) : undefined;
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { api } from '@/services/api';
|
||||
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||
import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options';
|
||||
|
||||
const FREIGHT = [
|
||||
{ value: 'CONTAINER', label: 'Container' },
|
||||
@@ -253,7 +253,7 @@ function AllocationRules() {
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
const value = lettersOnly(e.currentTarget.value);
|
||||
setForm((f) => ({ ...f, name: value }));
|
||||
}}
|
||||
/>
|
||||
@@ -569,7 +569,7 @@ function FeeRules() {
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
const value = lettersOnly(e.currentTarget.value);
|
||||
setForm((f) => ({ ...f, name: value }));
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
'use client';
|
||||
'use client';
|
||||
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -147,10 +147,16 @@ export default function ResultsPage() {
|
||||
|
||||
// For one-way, check if outbound has results
|
||||
// For round-trip, check if BOTH outbound and inbound have results
|
||||
const hasResults = isRoundTrip
|
||||
const hasResults = isRoundTrip
|
||||
? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
|
||||
: outboundSchedules.length > 0;
|
||||
|
||||
// One-way searches that come back with an empty outbound list may still include
|
||||
// date-shifted alternatives from the API — surface those instead of a dead end.
|
||||
const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0;
|
||||
const alternativeOutbound: Schedule[] = isOneWayNoOutbound ? (results.alternativeOutbound || []) : [];
|
||||
const requestedDate: string = (results && results.requestedDate) || searchData.date;
|
||||
|
||||
const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => {
|
||||
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } }));
|
||||
};
|
||||
@@ -217,7 +223,190 @@ export default function ResultsPage() {
|
||||
router.push('/booking/auth-check');
|
||||
};
|
||||
|
||||
const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false) => {
|
||||
// Shared "Choose Your Coach" drawer — used by both the normal results view and the
|
||||
// Alternative Travel Options fallback, so selecting an alternative opens the exact
|
||||
// same coach-type picker as a normal schedule.
|
||||
const renderClassModal = () => {
|
||||
if (!classModal) return null;
|
||||
|
||||
const scheduleId = classModal.scheduleId || classModal.id || '';
|
||||
const selectedCoachType = selectedCoachTypes[scheduleId];
|
||||
const isOutbound = (classModal as any).isOutbound;
|
||||
const coachTypes = classModal.coachTypes || [];
|
||||
|
||||
const getCoachIcon = (typeName: string) => {
|
||||
const lower = typeName.toLowerCase();
|
||||
if (lower.includes('soft') || lower.includes('vip')) return Star;
|
||||
if (lower.includes('bed')) return Bed;
|
||||
return Armchair;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99] bg-black/50 backdrop-blur-sm" onClick={() => setClassModal(null)} />
|
||||
<div className="fixed inset-y-0 right-0 z-[100] w-full sm:w-[680px] lg:w-[95vw] xl:w-[90vw] 2xl:w-[85vw] max-w-[1400px] bg-white dark:bg-gray-900 shadow-2xl flex flex-col"
|
||||
style={{ animation: 'drawer-slide-in 0.3s cubic-bezier(0.22,1,0.36,1)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Choose Your Coach</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-1.5">
|
||||
<Train className="w-3.5 h-3.5" />
|
||||
<span className="font-medium">{classModal.trainNumber}</span>
|
||||
<span className="text-gray-400">·</span>
|
||||
<span>{classModal.origin?.name} → {classModal.destination?.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClassModal(null)}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{coachTypes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2">
|
||||
{coachTypes.map((coachType: any, index: number) => {
|
||||
const isSelected = selectedCoachType?.id === coachType.coachTypeId;
|
||||
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
|
||||
const coachCurrency = 'ETB';
|
||||
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={coachType.coachId}
|
||||
onClick={() => handleSelectCoachType(scheduleId, coachType.coachTypeId, coachType.coachTypeCode, coachType.coachTypeName, coachType.classes?.[0]?.name || coachType.coachTypeName)}
|
||||
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
|
||||
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]'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50'
|
||||
}`}
|
||||
style={{ animation: `fade-in-up 0.3s ease-out ${index * 0.1}s both` }}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute top-4 right-4 w-7 h-7 bg-primary rounded-full flex items-center justify-center shadow-lg animate-scale-in">
|
||||
<Check className="w-4 h-4 text-white" strokeWidth={3} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-start gap-4 pr-2">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 transition-all ${
|
||||
isSelected
|
||||
? 'bg-primary/15 dark:bg-primary/25 shadow-inner'
|
||||
: 'bg-gray-100 dark:bg-gray-700 group-hover:bg-primary/10'
|
||||
}`}>
|
||||
<CoachIcon className={`w-6 h-6 transition-colors ${
|
||||
isSelected ? 'text-primary' : 'text-gray-600 dark:text-gray-400 group-hover:text-primary'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div>
|
||||
<h3 className="font-bold text-base text-gray-900 dark:text-white leading-tight">
|
||||
{coachType.coachTypeName}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">From</span>
|
||||
<span className={`text-2xl font-bold tracking-tight ${
|
||||
isSelected ? 'text-primary' : 'text-gray-900 dark:text-white'
|
||||
}`}>
|
||||
{(minPrice / 100).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">{coachCurrency}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{coachType.classes.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200/60 dark:border-gray-700/60">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-xs font-bold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
Class Options
|
||||
</p>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
{coachType.classes.length} available
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{coachType.classes.map((cls: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-50/80 dark:bg-gray-800/40 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<CoachIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{cls.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
|
||||
{(cls.baseFareMinor / 100).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
{coachCurrency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4">
|
||||
<Train className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">No coach types available for this journey</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 border-t border-gray-100 dark:border-gray-800 flex-shrink-0 bg-gray-50/50 dark:bg-gray-800/30 flex justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<button
|
||||
onClick={() => { if (selectedCoachType) { handleSelect(classModal, isOutbound); } }}
|
||||
disabled={!selectedCoachType}
|
||||
className="w-full flex items-center justify-center gap-2.5 px-6 py-3.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 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary/30 disabled:shadow-none hover:shadow-xl hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
<span>{isRoundTrip && isOutbound ? 'Continue to Return Journey' : 'Continue to Passenger Details'}</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
{!selectedCoachType && (
|
||||
<p className="text-center text-xs text-gray-500 dark:text-gray-400 mt-3 flex items-center justify-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-pulse" />
|
||||
Select a coach type to continue
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes drawer-slide-in{from{transform:translateX(100%)}to{transform:translateX(0)}}
|
||||
@keyframes fade-in-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
||||
@keyframes scale-in{from{transform:scale(0)}to{transform:scale(1)}}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false, isAlternative: boolean = false) => {
|
||||
const scheduleId = schedule.scheduleId || schedule.id || '';
|
||||
const selectedCoachType = selectedCoachTypes[scheduleId];
|
||||
|
||||
@@ -242,7 +431,22 @@ export default function ResultsPage() {
|
||||
const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString();
|
||||
|
||||
return (
|
||||
<div key={scheduleId} className="card">
|
||||
<div key={scheduleId} className={`card ${isAlternative ? 'border-2 border-amber-200 dark:border-amber-800/60' : ''}`}>
|
||||
{isAlternative && (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
Different date — {schedule.departureAt ? format(new Date(schedule.departureAt), 'EEEE, MMMM d, yyyy') : 'N/A'}
|
||||
</span>
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full ${
|
||||
schedule.hasAvailability
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
|
||||
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
|
||||
}`}>
|
||||
{schedule.hasAvailability ? 'Seats available' : 'Fully booked'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
@@ -310,9 +514,10 @@ export default function ResultsPage() {
|
||||
)}
|
||||
<button
|
||||
onClick={() => setClassModal({ ...schedule, isOutbound } as any)}
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2"
|
||||
disabled={isAlternative && schedule.hasAvailability === false}
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{selectedCoachType ? 'Change' : 'Select'}
|
||||
{isAlternative && schedule.hasAvailability === false ? 'Unavailable' : selectedCoachType ? 'Change' : 'Select'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -478,6 +683,54 @@ export default function ResultsPage() {
|
||||
}
|
||||
|
||||
if (!hasResults) {
|
||||
// ONE_WAY search with an explicit empty outbound list — surface any date-shifted
|
||||
// alternatives the API suggests instead of a dead-end "no trains found" screen.
|
||||
if (isOneWayNoOutbound) {
|
||||
const requestedDateLabel = requestedDate
|
||||
? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy')
|
||||
: 'your selected date';
|
||||
const hasAlternatives = alternativeOutbound.length > 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
{renderClassModal()}
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="card text-center mb-8 max-w-3xl mx-auto">
|
||||
<div className="w-20 h-20 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Calendar className="w-10 h-10 text-amber-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">No trains available</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
No trains are available on <span className="font-semibold text-gray-900 dark:text-gray-100">{requestedDateLabel}</span>. This
|
||||
may be due to no scheduled service or full capacity. {hasAlternatives
|
||||
? 'Please check the alternative options below or try a different date.'
|
||||
: 'Please try a different date.'}
|
||||
</p>
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">
|
||||
Change travel date
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasAlternatives && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">Alternative Travel Options</h3>
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400 mt-1">
|
||||
These trains run on different dates than requested — adjust your travel date to book one of them.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{alternativeOutbound.map((schedule) => renderScheduleCard(schedule, true, true))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
@@ -505,183 +758,7 @@ export default function ResultsPage() {
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
|
||||
{classModal && (() => {
|
||||
const scheduleId = classModal.scheduleId || classModal.id || '';
|
||||
const selectedCoachType = selectedCoachTypes[scheduleId];
|
||||
const isOutbound = (classModal as any).isOutbound;
|
||||
const coachTypes = classModal.coachTypes || [];
|
||||
|
||||
const getCoachIcon = (typeName: string) => {
|
||||
const lower = typeName.toLowerCase();
|
||||
if (lower.includes('soft') || lower.includes('vip')) return Star;
|
||||
if (lower.includes('bed')) return Bed;
|
||||
return Armchair;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99] bg-black/50 backdrop-blur-sm" onClick={() => setClassModal(null)} />
|
||||
<div className="fixed inset-y-0 right-0 z-[100] w-full sm:w-[680px] lg:w-[95vw] xl:w-[90vw] 2xl:w-[85vw] max-w-[1400px] bg-white dark:bg-gray-900 shadow-2xl flex flex-col"
|
||||
style={{ animation: 'drawer-slide-in 0.3s cubic-bezier(0.22,1,0.36,1)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Choose Your Coach</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-1.5">
|
||||
<Train className="w-3.5 h-3.5" />
|
||||
<span className="font-medium">{classModal.trainNumber}</span>
|
||||
<span className="text-gray-400">·</span>
|
||||
<span>{classModal.origin?.name} → {classModal.destination?.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClassModal(null)}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{coachTypes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2">
|
||||
{coachTypes.map((coachType: any, index: number) => {
|
||||
const isSelected = selectedCoachType?.id === coachType.coachTypeId;
|
||||
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
|
||||
const coachCurrency = 'ETB';
|
||||
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={coachType.coachId}
|
||||
onClick={() => handleSelectCoachType(scheduleId, coachType.coachTypeId, coachType.coachTypeCode, coachType.coachTypeName, coachType.classes?.[0]?.name || coachType.coachTypeName)}
|
||||
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
|
||||
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]'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50'
|
||||
}`}
|
||||
style={{ animation: `fade-in-up 0.3s ease-out ${index * 0.1}s both` }}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute top-4 right-4 w-7 h-7 bg-primary rounded-full flex items-center justify-center shadow-lg animate-scale-in">
|
||||
<Check className="w-4 h-4 text-white" strokeWidth={3} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-start gap-4 pr-2">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 transition-all ${
|
||||
isSelected
|
||||
? 'bg-primary/15 dark:bg-primary/25 shadow-inner'
|
||||
: 'bg-gray-100 dark:bg-gray-700 group-hover:bg-primary/10'
|
||||
}`}>
|
||||
<CoachIcon className={`w-6 h-6 transition-colors ${
|
||||
isSelected ? 'text-primary' : 'text-gray-600 dark:text-gray-400 group-hover:text-primary'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div>
|
||||
<h3 className="font-bold text-base text-gray-900 dark:text-white leading-tight">
|
||||
{coachType.coachTypeName}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">From</span>
|
||||
<span className={`text-2xl font-bold tracking-tight ${
|
||||
isSelected ? 'text-primary' : 'text-gray-900 dark:text-white'
|
||||
}`}>
|
||||
{(minPrice / 100).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">{coachCurrency}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{coachType.classes.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200/60 dark:border-gray-700/60">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-xs font-bold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
Class Options
|
||||
</p>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
{coachType.classes.length} available
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{coachType.classes.map((cls: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-50/80 dark:bg-gray-800/40 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<CoachIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{cls.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
|
||||
{(cls.baseFareMinor / 100).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
{coachCurrency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4">
|
||||
<Train className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">No coach types available for this journey</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 border-t border-gray-100 dark:border-gray-800 flex-shrink-0 bg-gray-50/50 dark:bg-gray-800/30 flex justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<button
|
||||
onClick={() => { if (selectedCoachType) { handleSelect(classModal, isOutbound); } }}
|
||||
disabled={!selectedCoachType}
|
||||
className="w-full flex items-center justify-center gap-2.5 px-6 py-3.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 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary/30 disabled:shadow-none hover:shadow-xl hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
<span>{isRoundTrip && isOutbound ? 'Continue to Return Journey' : 'Continue to Passenger Details'}</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
{!selectedCoachType && (
|
||||
<p className="text-center text-xs text-gray-500 dark:text-gray-400 mt-3 flex items-center justify-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-pulse" />
|
||||
Select a coach type to continue
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes drawer-slide-in{from{transform:translateX(100%)}to{transform:translateX(0)}}
|
||||
@keyframes fade-in-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
||||
@keyframes scale-in{from{transform:scale(0)}to{transform:scale(1)}}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{renderClassModal()}
|
||||
|
||||
{promoData && (
|
||||
<div className="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-start gap-3">
|
||||
|
||||
Reference in New Issue
Block a user