mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -3,6 +3,7 @@ NODE_ENV=development
|
||||
PORT=4000
|
||||
|
||||
# Database (Prisma) — owns the `passenger` schema in edr_database
|
||||
# Production: append ?sslmode=require&connection_limit=10&pool_timeout=20 to enforce SSL and connection pooling
|
||||
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_database?schema=passenger
|
||||
|
||||
# Database (TypeORM / @tria-plc IAM) — shared `iam` schema in the SAME edr_database.
|
||||
@@ -32,14 +33,14 @@ FRONTEND_URL=http://localhost:5174
|
||||
BACK_OFFICE_URL=http://localhost:5184
|
||||
|
||||
# JWT (legacy passenger auth — being replaced by IAM)
|
||||
JWT_SECRET=edr-platform-secret-change-in-production
|
||||
# REQUIRED in production — use a random 32+ character string (e.g. openssl rand -hex 32)
|
||||
JWT_SECRET=<change-me-min-32-chars>
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# @tria-plc IAM token contract — the package's JwtGuard/verifyToken + AuthService sign/verify with
|
||||
# these. MUST match the IAM issuer's secret in shared deployments. (Expiry strings use jsonwebtoken/ms.)
|
||||
JWT_ACCESS_TOKEN_SECRET=dev-iam-access-secret-change-me
|
||||
# @tria-plc IAM token contract — REQUIRED in production. MUST match the IAM issuer's secret.
|
||||
JWT_ACCESS_TOKEN_SECRET=<change-me-min-32-chars>
|
||||
JWT_ACCESS_TOKEN_EXPIRES=1h
|
||||
JWT_REFRESH_TOKEN_SECRET=dev-iam-refresh-secret-change-me
|
||||
JWT_REFRESH_TOKEN_SECRET=<change-me-min-32-chars>
|
||||
JWT_REFRESH_TOKEN_EXPIRES=7d
|
||||
|
||||
# SendGrid
|
||||
@@ -157,7 +158,7 @@ FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
|
||||
FAYDA_CLAIMS_LOCALES=en am
|
||||
FAYDA_SESSION_TTL_MINUTES=10
|
||||
|
||||
GITHUB_PACKAGE_TOKEN=
|
||||
GITHUB_PACKAGE_TOKEN=<your-github-packages-token>
|
||||
|
||||
# --- Notification broker (RabbitMQ) -----------------------------------------------------------------
|
||||
# Set RABBITMQ_ENABLED=false to skip connection entirely (dev without a local broker).
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable, Inject, Optional } from '@nestjs/common';
|
||||
import { Injectable, Inject, Logger, Optional } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
private readonly logger = new Logger(AuditService.name);
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@Optional() @Inject(REQUEST) private request?: any,
|
||||
@@ -34,7 +35,7 @@ export class AuditService {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to log audit event:', error);
|
||||
this.logger.error('Failed to log audit event:', error);
|
||||
// Don't throw - audit logging should not break main operations
|
||||
}
|
||||
}
|
||||
@@ -74,11 +75,20 @@ export class AuditService {
|
||||
where.entityType = filters.entityType;
|
||||
}
|
||||
|
||||
return this.prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
const limit = Math.min(filters.limit ?? 50, 200);
|
||||
const offset = filters.offset ?? 0;
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
this.prisma.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return { data, total, limit, offset };
|
||||
}
|
||||
|
||||
async getLog(id: string) {
|
||||
|
||||
@@ -41,9 +41,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
this.logger.error(
|
||||
`${request.method} ${request.url} -> ${status}`,
|
||||
exception instanceof Error ? exception.stack : JSON.stringify(exception),
|
||||
);
|
||||
console.error('Full error details:', exception);
|
||||
} else {
|
||||
); } else {
|
||||
this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -6,6 +6,7 @@ type TranslationMap = Record<string, any>;
|
||||
|
||||
@Injectable()
|
||||
export class I18nService {
|
||||
private readonly logger = new Logger(I18nService.name);
|
||||
private translations: Map<string, TranslationMap> = new Map();
|
||||
private readonly supportedLocales = ['en', 'am', 'fr', 'om'];
|
||||
private readonly defaultLocale = 'en';
|
||||
@@ -21,7 +22,7 @@ export class I18nService {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
this.translations.set(locale, JSON.parse(content));
|
||||
} catch (err) {
|
||||
console.warn(`Failed to load translation file for locale: ${locale}`);
|
||||
this.logger.warn(`Failed to load translation file for locale: ${locale}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,7 +548,7 @@ export class PassengerAuthService {
|
||||
|
||||
await this.dataSource.query(`DELETE FROM iam.users WHERE id = $1`, [iamUserId]);
|
||||
} catch (err) {
|
||||
console.error('[PassengerAuthService] IAM compensating cleanup failed for', email, (err as Error).message);
|
||||
this.logger.error(`[PassengerAuthService] IAM compensating cleanup failed for ${email}`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +317,10 @@ export class FleetService {
|
||||
}
|
||||
|
||||
getTrains() {
|
||||
return this.prisma.train.findMany({ include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } } });
|
||||
return this.prisma.train.findMany({
|
||||
where: { isActive: true },
|
||||
include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } },
|
||||
});
|
||||
}
|
||||
|
||||
createTrain(dto: CreateTrainDto) {
|
||||
@@ -462,6 +465,7 @@ export class FleetService {
|
||||
return this.prisma.coach.update({
|
||||
where: { id },
|
||||
data: {
|
||||
number: dto.number,
|
||||
arrangement: dto.arrangement,
|
||||
capacity: dto.capacity,
|
||||
status: dto.status,
|
||||
@@ -540,6 +544,7 @@ export class FleetService {
|
||||
]);
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
if (!coach) throw new NotFoundException('Coach not found');
|
||||
if (coach.status !== 'ACTIVE') throw new BadRequestException('Coach is not active');
|
||||
return this.prisma.coachAssignment.create({ data: dto });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Controller, Get, HttpStatus, Res } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { Response } from 'express';
|
||||
|
||||
@ApiTags('Health')
|
||||
@Controller('health')
|
||||
@@ -20,17 +21,17 @@ export class HealthController {
|
||||
@Get('ready')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Readiness probe — checks database connectivity' })
|
||||
async readiness() {
|
||||
async readiness(@Res() res: Response) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
return {
|
||||
return res.status(HttpStatus.OK).json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
checks: { database: { status: 'ok', latencyMs: Date.now() - start } },
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
return res.status(HttpStatus.SERVICE_UNAVAILABLE).json({
|
||||
status: 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
checks: {
|
||||
@@ -40,7 +41,7 @@ export class HealthController {
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
this.logger.log("connected to SMS service");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error happened at SMS service", err);
|
||||
this.logger.error('Error happened at SMS service', err);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
@@ -25,6 +25,7 @@ type IamUserRow = {
|
||||
|
||||
@Injectable()
|
||||
export class PassengersService {
|
||||
private readonly logger = new Logger(PassengersService.name);
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
@@ -373,7 +374,7 @@ export class PassengersService {
|
||||
verifiedData = verification.passengerData;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Fayda verification failed, using manual data:', error);
|
||||
this.logger.warn('Fayda verification failed, using manual data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
@@ -6,6 +6,7 @@ import { GenerateReportDto, ReportType } from './reports.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
private readonly logger = new Logger(ReportsService.name);
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@InjectDataSource() private dataSource: DataSource,
|
||||
@@ -60,8 +61,6 @@ export class ReportsService {
|
||||
include: { paymentIntent: true }
|
||||
});
|
||||
|
||||
console.log(`[Reports] Revenue Report: Found ${bookings.length} bookings between ${dateFrom} and ${dateTo}`);
|
||||
|
||||
const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
|
||||
const byPaymentMethod = bookings.reduce((acc, b) => {
|
||||
const method = b.paymentIntent?.method ?? 'UNKNOWN';
|
||||
|
||||
@@ -100,10 +100,15 @@ export class SchedulesService {
|
||||
const arr = parseEthiopianTime(dto.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id: dto.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
const [train, route] = await Promise.all([
|
||||
this.prisma.train.findUnique({ where: { id: dto.trainId } }),
|
||||
this.prisma.route.findUnique({
|
||||
where: { id: dto.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
}),
|
||||
]);
|
||||
if (!train) throw new NotFoundException('Train not found');
|
||||
if (!train.isActive) throw new BadRequestException('Train is not active');
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
if (!route.active) throw new BadRequestException('Route is not active');
|
||||
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
|
||||
@@ -247,10 +252,15 @@ export class SchedulesService {
|
||||
const arr = parseEthiopianTime(dto.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id: dto.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
const [train, route] = await Promise.all([
|
||||
this.prisma.train.findUnique({ where: { id: dto.trainId } }),
|
||||
this.prisma.route.findUnique({
|
||||
where: { id: dto.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
}),
|
||||
]);
|
||||
if (!train) throw new NotFoundException('Train not found');
|
||||
if (!train.isActive) throw new BadRequestException('Train is not active');
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
if (!route.active) throw new BadRequestException('Route is not active');
|
||||
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
|
||||
@@ -540,6 +550,8 @@ export class SchedulesService {
|
||||
const coachIds = coaches.map(c => c.coachId);
|
||||
const existingCoaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } });
|
||||
if (existingCoaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found');
|
||||
const inactiveCoach = existingCoaches.find(c => c.status !== 'ACTIVE');
|
||||
if (inactiveCoach) throw new BadRequestException(`Coach ${inactiveCoach.number} is not active`);
|
||||
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
|
||||
|
||||
|
||||
@@ -6,12 +6,16 @@ export class SeatClassesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
listSeatClasses() {
|
||||
return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' } });
|
||||
return this.prisma.seatClass.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getSeatClass(id: string) {
|
||||
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||
if (!sc) throw new NotFoundException('SeatClass not found');
|
||||
if (!sc.isActive) throw new NotFoundException('SeatClass is not active');
|
||||
return sc;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { EnhancedSeatsService } from './enhanced-seats.service';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
@@ -6,6 +6,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
@Injectable()
|
||||
export class TripProgressService {
|
||||
private readonly logger = new Logger(TripProgressService.name);
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private enhancedSeatsService: EnhancedSeatsService,
|
||||
@@ -154,10 +155,10 @@ export class TripProgressService {
|
||||
try {
|
||||
const result = await this.enhancedSeatsService.expireHolds();
|
||||
if (result.expiredHolds > 0) {
|
||||
console.log(`Expired ${result.expiredHolds} holds, released ${result.releasedSeats.length} seats`);
|
||||
this.logger.log(`Expired ${result.expiredHolds} holds, released ${result.releasedSeats.length} seats`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error expiring holds:', error);
|
||||
this.logger.error('Error expiring holds:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,11 @@ export class StationsService {
|
||||
where.countryCode = filters.country;
|
||||
}
|
||||
|
||||
// Default to operational stations only; allow explicit override (e.g. back-office)
|
||||
if (filters.operational !== undefined && filters.operational !== '') {
|
||||
where.isOperational = filters.operational === 'true';
|
||||
} else {
|
||||
where.isOperational = true;
|
||||
}
|
||||
|
||||
return this.prisma.station.findMany({
|
||||
@@ -46,6 +49,7 @@ export class StationsService {
|
||||
async findOne(id: string) {
|
||||
const s = await this.prisma.station.findUnique({ where: { id } });
|
||||
if (!s) throw new NotFoundException('Station not found');
|
||||
if (!s.isOperational) throw new NotFoundException('Station is not operational');
|
||||
return s;
|
||||
}
|
||||
|
||||
|
||||
@@ -99,18 +99,11 @@ export class TicketsService {
|
||||
let guestEmail = null;
|
||||
const matchingProfile = t.booking?.passenger?.travelerProfiles?.find((tp: any) => tp.fullName === t.passengerName);
|
||||
|
||||
// DEBUG: Log to see what we're getting
|
||||
this.logger.debug(`Ticket ${t.id}: passengerName=${t.passengerName}, profiles count=${t.booking?.passenger?.travelerProfiles?.length || 0}, matchingProfile=${!!matchingProfile}`);
|
||||
if (matchingProfile) {
|
||||
this.logger.debug(`Matching profile notes: ${matchingProfile.notes}`);
|
||||
}
|
||||
|
||||
if (matchingProfile?.notes) {
|
||||
try {
|
||||
const notesData = JSON.parse(matchingProfile.notes);
|
||||
guestPhone = notesData.phone || null;
|
||||
guestEmail = notesData.email || null;
|
||||
this.logger.debug(`Extracted from notes: phone=${guestPhone}, email=${guestEmail}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to parse notes JSON: ${err}`);
|
||||
}
|
||||
@@ -120,13 +113,11 @@ export class TicketsService {
|
||||
if (!guestPhone) guestPhone = t.booking?.contactPhone;
|
||||
if (!guestEmail) guestEmail = t.booking?.contactEmail;
|
||||
|
||||
this.logger.debug(`Final values: phone=${guestPhone}, email=${guestEmail}`);
|
||||
|
||||
const passengerInfo = iam
|
||||
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
|
||||
: { fullName: 'Guest', email: guestEmail, phone: guestPhone };
|
||||
|
||||
this.logger.debug(`Final passenger info: ${JSON.stringify(passengerInfo)}`);
|
||||
|
||||
return {
|
||||
id: t.id,
|
||||
@@ -596,8 +587,7 @@ export class TicketsService {
|
||||
}
|
||||
|
||||
private async fireBoardingPassNotification(booking: any, ticket: any, leg: string | null) {
|
||||
// TODO: Implement notification logic
|
||||
console.log(`Boarding pass notification for booking ${booking.bookingRef}, leg: ${leg}`);
|
||||
this.logger.log(`Boarding pass notification for booking ${booking.bookingRef}, leg: ${leg}`);
|
||||
}
|
||||
|
||||
async getValidationLogs(ticketId: string) {
|
||||
@@ -687,4 +677,4 @@ export class TicketsService {
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
return this.prisma.ticket.update({ where: { id }, data: { status: 'ACTIVE' } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@ NEXT_PUBLIC_API_URL=https://your-api-domain.com
|
||||
NEXT_PUBLIC_IAM_ENABLED=false
|
||||
NEXT_PUBLIC_IAM_API_URL=https://iam.tria-plc.com/api
|
||||
|
||||
# GitHub Packages Token
|
||||
GITHUB_PACKAGE_TOKEN=$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
|
||||
# GitHub Packages Token (required to install @tria-plc/* private packages)
|
||||
GITHUB_PACKAGE_TOKEN=<your-github-packages-token>
|
||||
|
||||
@@ -144,11 +144,10 @@ export default function CoachesPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('coaches');
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
const [seatMapPreview, setSeatMapPreview] = useState<any>(null);
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
|
||||
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Coach Types Queries
|
||||
@@ -221,14 +220,6 @@ export default function CoachesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const generateSeatMapMutation = useMutation({
|
||||
mutationFn: fleetApi.generateSeatMap,
|
||||
onSuccess: (data) => {
|
||||
setSeatMapPreview(data);
|
||||
setShowPreviewModal(true);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCoachTypeSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
@@ -275,27 +266,6 @@ export default function CoachesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewSeatMap = async () => {
|
||||
const form = document.querySelector('form') as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
const bedCategory = formData.get('bedCategory') as string;
|
||||
const capacity = parseInt(formData.get('capacity') as string);
|
||||
|
||||
if (!bedCategory || !capacity) {
|
||||
alert('Please select a bed category and enter capacity to preview seat map');
|
||||
return;
|
||||
}
|
||||
|
||||
const bedsPerRoom = bedCategory === 'VIP_BED' ? 4 : 6;
|
||||
const roomsPerCoach = Math.ceil(capacity / bedsPerRoom);
|
||||
|
||||
await generateSeatMapMutation.mutateAsync({
|
||||
coachCount: 1,
|
||||
roomsPerCoach,
|
||||
roomType: bedCategory,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (item: any, isCoachType: boolean) => {
|
||||
setDeleteConfirm({ isOpen: true, item: { ...item, isCoachType } });
|
||||
};
|
||||
@@ -779,23 +749,25 @@ export default function CoachesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Conditionally show bed fields only for Economy and Regular coach types */}
|
||||
{(() => {
|
||||
const selectedCoachType = coachTypesArray.find((ct: any) => ct.id === (selectedCoachTypeId || editingItem?.coachTypeId));
|
||||
const isEconomyOrRegular = selectedCoachType &&
|
||||
(selectedCoachType.name?.toLowerCase().includes('economy') ||
|
||||
selectedCoachType.name?.toLowerCase().includes('regular') ||
|
||||
selectedCoachType.type?.toLowerCase().includes('economy') ||
|
||||
selectedCoachType.type?.toLowerCase().includes('regular'));
|
||||
|
||||
return isEconomyOrRegular ? (
|
||||
const isBedType = selectedCoachType &&
|
||||
(selectedCoachType.name?.toLowerCase().includes('bed') ||
|
||||
selectedCoachType.name?.toLowerCase().includes('sleeper') ||
|
||||
selectedCoachType.type?.toLowerCase().includes('sleeper'));
|
||||
|
||||
const derivedBedCategory = editingItem?.isCoach && selectedCoachType
|
||||
? (selectedCoachType.name?.toLowerCase().includes('vip') ? 'VIP_BED' : 'ECONOMY_BED')
|
||||
: (editingItem?.bedCategory || '');
|
||||
|
||||
return isBedType ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="label">Bed Category</label>
|
||||
<select
|
||||
name="bedCategory"
|
||||
className="input"
|
||||
defaultValue={editingItem?.bedCategory || ''}
|
||||
defaultValue={derivedBedCategory}
|
||||
>
|
||||
<option value="">Select bed category</option>
|
||||
<option value="ECONOMY_BED">Economy Bed</option>
|
||||
@@ -832,9 +804,9 @@ export default function CoachesPage() {
|
||||
type="text"
|
||||
name="arrangement"
|
||||
className="input"
|
||||
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement || '2+2'}
|
||||
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement }
|
||||
required
|
||||
placeholder="e.g., 2+2, 3+2"
|
||||
placeholder="e.g., 3+2"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
For regular seats: columns separated by +
|
||||
@@ -850,7 +822,7 @@ export default function CoachesPage() {
|
||||
defaultValue={editingItem?.capacity || editingItem?.totalUnits || ''}
|
||||
required
|
||||
min="1"
|
||||
placeholder="60"
|
||||
placeholder="e.g., 60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -860,10 +832,10 @@ export default function CoachesPage() {
|
||||
type="number"
|
||||
name="sequence"
|
||||
className="input"
|
||||
defaultValue={editingItem?.sequence || 1}
|
||||
defaultValue={editingItem?.sequence }
|
||||
min="1"
|
||||
required
|
||||
placeholder="1"
|
||||
placeholder="e.g., 1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Position in train consist
|
||||
@@ -885,14 +857,6 @@ export default function CoachesPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handlePreviewSeatMap}
|
||||
loading={generateSeatMapMutation.isPending}
|
||||
>
|
||||
Preview Bed Layout
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@@ -915,57 +879,7 @@ export default function CoachesPage() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Seat Map Preview Modal */}
|
||||
<Modal
|
||||
isOpen={showPreviewModal}
|
||||
onClose={() => {
|
||||
setShowPreviewModal(false);
|
||||
setSeatMapPreview(null);
|
||||
}}
|
||||
title="Bed Layout Preview"
|
||||
size="lg"
|
||||
>
|
||||
{seatMapPreview && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/50 p-4 rounded-lg">
|
||||
<h4 className="font-semibold mb-2">Configuration</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>Room Type: <span className="font-medium">{seatMapPreview.roomType}</span></div>
|
||||
<div>Rooms per Coach: <span className="font-medium">{seatMapPreview.roomsPerCoach}</span></div>
|
||||
<div>Beds per Room: <span className="font-medium">{seatMapPreview.bedsPerRoom}</span></div>
|
||||
<div>Total Beds: <span className="font-medium">{seatMapPreview.totalBeds}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold">Bed Layout Sample (First Few Rooms)</h4>
|
||||
<div className="bg-gray-50 p-4 rounded border max-h-64 overflow-y-auto">
|
||||
{seatMapPreview.seats?.slice(0, 24).map((seat: any, idx: number) => (
|
||||
<div key={idx} className="text-xs mb-1 font-mono">
|
||||
{seat.seat_id} - Room: {seat.room_id} - {seat.position} {seat.bed_type}
|
||||
</div>
|
||||
))}
|
||||
{seatMapPreview.seats?.length > 24 && (
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
... and {seatMapPreview.seats.length - 24} more beds
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
setShowPreviewModal(false);
|
||||
setSeatMapPreview(null);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,12 +55,6 @@ function DashboardPageContent() {
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const { data: topAgents, isLoading: agentsLoading } = useQuery({
|
||||
queryKey: ['top-agents'],
|
||||
queryFn: () => dashboardApi.getTopAgents(5),
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({
|
||||
queryKey: ['upcoming-trips'],
|
||||
queryFn: () => dashboardApi.getUpcomingTrips(5),
|
||||
@@ -109,13 +103,6 @@ function DashboardPageContent() {
|
||||
{ key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) },
|
||||
];
|
||||
|
||||
const agentColumns = [
|
||||
{ key: 'name', label: 'Agent Name', render: (item: any) => item.name || item.fullName },
|
||||
{ key: 'bookings', label: 'Bookings', render: (item: any) => item.bookingsCount || item.bookings || 0 },
|
||||
{ key: 'revenue', label: 'Revenue', render: (item: any) => formatCurrency(item.totalRevenue || item.revenue || 0, 'ETB') },
|
||||
{ key: 'commission', label: 'Commission', render: (item: any) => formatCurrency(item.commission || 0, 'ETB') },
|
||||
];
|
||||
|
||||
const tripColumns = [
|
||||
{ key: 'trainName', label: 'Train', render: (item: any) => item.trainName || item.train?.name },
|
||||
{ key: 'route', label: 'Route', render: (item: any) => `${item.originStation?.name || item.origin?.name} → ${item.destinationStation?.name || item.destination?.name}` },
|
||||
@@ -231,19 +218,6 @@ function DashboardPageContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top Agents */}
|
||||
<div className="card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Top Performing Agents
|
||||
</h2>
|
||||
<DataTable
|
||||
data={topAgents || []}
|
||||
columns={agentColumns}
|
||||
loading={agentsLoading}
|
||||
emptyMessage="No agent performance data available"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ export default function OperationalReportsPage() {
|
||||
refetch();
|
||||
setShowGenerateModal(false);
|
||||
} catch (error) {
|
||||
console.error('Error generating report:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ export default function PaymentMethodsPage() {
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Update failed:', error);
|
||||
setSuccessMessage('Failed to update payment method');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
},
|
||||
@@ -131,8 +130,6 @@ export default function PaymentMethodsPage() {
|
||||
processingTime: formData.processingTime,
|
||||
};
|
||||
|
||||
console.log('Submitting data:', submitData);
|
||||
|
||||
if (selectedMethod) {
|
||||
updateMutation.mutate({ id: selectedMethod.id, ...submitData });
|
||||
} else {
|
||||
|
||||
@@ -53,8 +53,6 @@ export default function SettingsPage() {
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'payment', label: 'Payment' },
|
||||
{ id: 'integrations', label: 'Integrations' },
|
||||
{ id: 'configurations', label: 'Configurations' },
|
||||
];
|
||||
|
||||
@@ -109,81 +107,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'payment' && (
|
||||
<div className="space-y-6">
|
||||
<div className="card">
|
||||
<h3 className="mb-4 text-lg font-semibold text-foreground">Payment Providers</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Telebirr</p>
|
||||
<p className="text-sm text-muted-foreground">Mobile payment provider</p>
|
||||
</div>
|
||||
<label className="relative inline-flex cursor-pointer items-center">
|
||||
<input type="checkbox" className="peer sr-only" defaultChecked />
|
||||
<div className="peer h-6 w-11 rounded-full bg-gray-200 dark:bg-gray-700 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-primary peer-checked:after:translate-x-full peer-checked:after:border-white"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">CBE Birr</p>
|
||||
<p className="text-sm text-muted-foreground">Bank payment provider</p>
|
||||
</div>
|
||||
<label className="relative inline-flex cursor-pointer items-center">
|
||||
<input type="checkbox" className="peer sr-only" defaultChecked />
|
||||
<div className="peer h-6 w-11 rounded-full bg-gray-200 dark:bg-gray-700 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-primary peer-checked:after:translate-x-full peer-checked:after:border-white"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'integrations' && (
|
||||
<div className="space-y-6">
|
||||
<div className="card">
|
||||
<h3 className="mb-4 text-lg font-semibold text-foreground">Verifayda 2.0 Integration</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label">API URL</label>
|
||||
<input type="text" className="input" defaultValue="https://api.verifayda.gov.et/v2" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">API Key</label>
|
||||
<input type="password" className="input" defaultValue="••••••••••••" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="verifayda-enabled" defaultChecked />
|
||||
<label htmlFor="verifayda-enabled" className="text-sm text-foreground">
|
||||
Enable Verifayda verification
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="mb-4 text-lg font-semibold text-foreground">Corporate IAM Integration</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label">IAM API URL</label>
|
||||
<input type="text" className="input" defaultValue="https://iam.tria-plc.com/api" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">API Key</label>
|
||||
<input type="password" className="input" defaultValue="••••••••••••" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="iam-enabled" />
|
||||
<label htmlFor="iam-enabled" className="text-sm text-foreground">
|
||||
Enable IAM authentication
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{activeTab === 'configurations' && (
|
||||
<div className="card space-y-6">
|
||||
<h3 className="text-lg font-semibold text-foreground">Rate Limiting (requests / minute / IP)</h3>
|
||||
|
||||
@@ -49,8 +49,7 @@ export default function ActionButton({
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await onClick();
|
||||
} catch (error) {
|
||||
console.error('Action failed:', error);
|
||||
} catch (error) {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,14 @@ export const dashboardApi = {
|
||||
const bookingsTotal = bookingsRes?.meta?.total || 0;
|
||||
const passengersTotal = passengersRes?.meta?.total || 0;
|
||||
|
||||
// Calculate revenue from bookings
|
||||
const allBookingsRes = await apiClient.get<any>('/bookings?pageSize=100');
|
||||
const allBookings = Array.isArray(allBookingsRes) ? allBookingsRes : allBookingsRes?.items || [];
|
||||
const totalRevenue = allBookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0);
|
||||
// Calculate revenue from confirmed bookings only
|
||||
const confirmedRes = await apiClient.get<any>('/bookings?pageSize=1000&status=CONFIRMED');
|
||||
const boardedRes = await apiClient.get<any>('/bookings?pageSize=1000&status=BOARDED');
|
||||
const paidBookings = [
|
||||
...(Array.isArray(confirmedRes) ? confirmedRes : confirmedRes?.items || []),
|
||||
...(Array.isArray(boardedRes) ? boardedRes : boardedRes?.items || []),
|
||||
];
|
||||
const totalRevenue = paidBookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0);
|
||||
|
||||
// Calculate average occupancy (placeholder - would need dedicated endpoint)
|
||||
const occupancyRate = Math.floor(Math.random() * 100); // Replace with actual data
|
||||
@@ -29,10 +33,9 @@ export const dashboardApi = {
|
||||
totalTripsToday: 0,
|
||||
activeTrips: 0,
|
||||
cancelledBookings: 0,
|
||||
averageTicketPrice: allBookings.length > 0 ? totalRevenue / allBookings.length : 0,
|
||||
averageTicketPrice: paidBookings.length > 0 ? Math.round(totalRevenue / paidBookings.length) : 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dashboard stats:', error);
|
||||
return {
|
||||
totalBookings: 0,
|
||||
totalRevenue: 0,
|
||||
@@ -51,7 +54,6 @@ export const dashboardApi = {
|
||||
const response = await apiClient.get<RevenueData[]>(`/dashboard/revenue?days=${days}`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch revenue chart:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -82,7 +84,6 @@ export const dashboardApi = {
|
||||
paymentIntent: booking.paymentIntent,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch recent bookings:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -92,7 +93,6 @@ export const dashboardApi = {
|
||||
const response = await apiClient.get<any[]>(`/agents/top?limit=${limit}`);
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch top agents:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -102,7 +102,6 @@ export const dashboardApi = {
|
||||
const response = await apiClient.get<any[]>(`/dashboard/occupancy?days=${days}`);
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch occupancy trend:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -112,7 +111,6 @@ export const dashboardApi = {
|
||||
const response = await apiClient.get<any[]>(`/schedules/upcoming?limit=${limit}`);
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch upcoming trips:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -122,7 +120,6 @@ export const dashboardApi = {
|
||||
const response = await apiClient.get<any[]>('/dashboard/payment-methods');
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch payment methods:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
@@ -137,7 +134,6 @@ export const dashboardApi = {
|
||||
loyaltyPoints: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch passenger stats:', error);
|
||||
return {
|
||||
totalPassengers: 0,
|
||||
newPassengersToday: 0,
|
||||
@@ -157,7 +153,6 @@ export const dashboardApi = {
|
||||
totalAmount: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch transaction summary:', error);
|
||||
return {
|
||||
totalTransactions: 0,
|
||||
successfulTransactions: 0,
|
||||
@@ -176,7 +171,6 @@ export const dashboardApi = {
|
||||
activePayments: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch live metrics:', error);
|
||||
return {
|
||||
onlineUsers: 0,
|
||||
activeBookings: 0,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { PaginatedResponse } from '@edr/types';
|
||||
export const routesApi = {
|
||||
getAll: async () => {
|
||||
const response = await apiClient.get<any>('/routes');
|
||||
console.log('Routes API response:', response);
|
||||
// Handle both direct array and wrapped response
|
||||
if (Array.isArray(response)) {
|
||||
return { items: response };
|
||||
@@ -24,7 +23,6 @@ export const routesApi = {
|
||||
},
|
||||
|
||||
create: (data: any) => {
|
||||
console.log('Creating route with data:', JSON.stringify(data, null, 2));
|
||||
return apiClient.post<Route>('/routes', data);
|
||||
},
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# API Configuration
|
||||
NEXT_PUBLIC_API_URL=https://your-api-domain.com
|
||||
|
||||
# Allowlisted destination hosts for the /go D-Money redirect bounce page (comma-separated).
|
||||
# The /go?url=... page only forwards to https hosts that match one of these (host or subdomain).
|
||||
NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS=d-money.dj
|
||||
|
||||
# GitHub Packages Token
|
||||
GITHUB_PACKAGE_TOKEN=$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
|
||||
# GitHub Packages Token (required to install @tria-plc/* private packages)
|
||||
GITHUB_PACKAGE_TOKEN=<your-github-packages-token>
|
||||
BIN
apps/edr-passenger-web/portal/public/packages/kulubi.jpeg
Normal file
BIN
apps/edr-passenger-web/portal/public/packages/kulubi.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -681,7 +681,7 @@ export default function PassengersPage() {
|
||||
|
||||
try {
|
||||
const response: any = await apiClient.post('/fayda/verification/start', {
|
||||
purpose: 'PURCHASE',
|
||||
purpose: 'VERIFY',
|
||||
platform: 'WEB',
|
||||
saveToAccount: index === 0 && isAuthenticated,
|
||||
});
|
||||
|
||||
@@ -1532,107 +1532,6 @@ export default function SearchPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Promotions Section */}
|
||||
<div className="bg-white dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<span className="text-lg">🎁</span>
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
Offers & Promotions
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{/* Wide promo card */}
|
||||
<div className="md:col-span-2 relative rounded-2xl overflow-hidden min-h-[220px] group cursor-pointer">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)]" />
|
||||
<div
|
||||
className="absolute inset-0 opacity-10"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(45deg, transparent, transparent 20px, rgba(255,255,255,0.3) 20px, rgba(255,255,255,0.3) 21px)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/30 to-transparent" />
|
||||
<div className="relative z-10 p-7 flex flex-col justify-between h-full min-h-[220px]">
|
||||
<div>
|
||||
<span className="inline-block px-3 py-1 bg-white/20 text-white text-xs font-semibold rounded-full mb-3 backdrop-blur-sm">
|
||||
Limited Time
|
||||
</span>
|
||||
<h3 className="text-2xl font-extrabold text-white leading-tight mb-2">
|
||||
20% Off Weekend
|
||||
<br />
|
||||
Travel
|
||||
</h3>
|
||||
<p className="text-white/70 text-sm max-w-xs">
|
||||
Book any weekend journey and save 20%. Valid for all seat
|
||||
classes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<span className="text-white/60 text-xs">
|
||||
Valid until 31 Dec 2024
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-white text-sm font-semibold group-hover:gap-3 transition-all">
|
||||
Book now <ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Narrow promo cards */}
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="relative rounded-2xl overflow-hidden min-h-[100px] group cursor-pointer">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-amber-500 to-orange-600" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/20 to-transparent" />
|
||||
<div className="relative z-10 p-5 flex flex-col justify-between h-full min-h-[100px]">
|
||||
<div>
|
||||
<span className="inline-block px-2.5 py-0.5 bg-white/25 text-white text-xs font-semibold rounded-full mb-2 backdrop-blur-sm">
|
||||
New
|
||||
</span>
|
||||
<h3 className="text-lg font-bold text-white leading-tight">
|
||||
Family Package
|
||||
</h3>
|
||||
<p className="text-white/75 text-xs mt-1">
|
||||
4 tickets for the price of 3
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end mt-3">
|
||||
<span className="flex items-center gap-1 text-white text-xs font-semibold group-hover:gap-2 transition-all">
|
||||
Learn more <ArrowRight className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative rounded-2xl overflow-hidden min-h-[100px] group cursor-pointer">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-600 to-indigo-700" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/20 to-transparent" />
|
||||
<div className="relative z-10 p-5 flex flex-col justify-between h-full min-h-[100px]">
|
||||
<div>
|
||||
<span className="inline-block px-2.5 py-0.5 bg-white/25 text-white text-xs font-semibold rounded-full mb-2 backdrop-blur-sm">
|
||||
Student
|
||||
</span>
|
||||
<h3 className="text-lg font-bold text-white leading-tight">
|
||||
Student Discount
|
||||
</h3>
|
||||
<p className="text-white/75 text-xs mt-1">
|
||||
15% off with valid student ID
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end mt-3">
|
||||
<span className="flex items-center gap-1 text-white text-xs font-semibold group-hover:gap-2 transition-all">
|
||||
Learn more <ArrowRight className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
|
||||
913
apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx
Normal file
913
apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx
Normal file
@@ -0,0 +1,913 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ChevronLeft,
|
||||
Calendar,
|
||||
MapPin,
|
||||
Users,
|
||||
Clock,
|
||||
Train,
|
||||
Bus,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
Tag,
|
||||
Shield,
|
||||
X,
|
||||
CheckCircle2,
|
||||
Phone,
|
||||
Mail,
|
||||
User,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Station {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
city: string;
|
||||
countryCode: string;
|
||||
}
|
||||
|
||||
interface TrainInfo {
|
||||
id: string;
|
||||
number: string;
|
||||
name: string;
|
||||
operatorName: string;
|
||||
}
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
departureAt: string;
|
||||
arrivalAt: string;
|
||||
durationMinutes: number;
|
||||
status: string;
|
||||
stopsCount: number;
|
||||
originStation: Station;
|
||||
destinationStation: Station;
|
||||
train: TrainInfo;
|
||||
}
|
||||
|
||||
interface PriceTier {
|
||||
id: string;
|
||||
packageId: string;
|
||||
seatType: string;
|
||||
label: string;
|
||||
priceMinor: number;
|
||||
currency: string;
|
||||
availableSeats: number;
|
||||
bookedSeats: number;
|
||||
}
|
||||
|
||||
interface PackageDetail {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
boardingTime: string;
|
||||
departureTime: string;
|
||||
arrivalTime: string;
|
||||
totalCapacity: number;
|
||||
bookedCount: number;
|
||||
includedServices: string[];
|
||||
coachConfiguration: string;
|
||||
busTransferIncluded: boolean;
|
||||
busTransferRoute: string | null;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
priceTiers: PriceTier[];
|
||||
outboundSchedule: Schedule;
|
||||
returnSchedule: Schedule | null;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmt(iso: string, opts?: Intl.DateTimeFormatOptions): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("en-US", opts ?? {
|
||||
weekday: "short", month: "short", day: "numeric",
|
||||
});
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
function durationLabel(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return m ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
|
||||
function stripBullet(s: string): string {
|
||||
return s.replace(/^[••\-\t\s]+/, "").trim();
|
||||
}
|
||||
|
||||
function formatPrice(minor: number, currency: string): string {
|
||||
return `${currency} ${(minor / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
// ─── Inquiry form ────────────────────────────────────────────────────────────
|
||||
|
||||
const inquirySchema = z.object({
|
||||
travelerCount: z
|
||||
.number({ invalid_type_error: "Enter a valid number" })
|
||||
.int("Must be a whole number")
|
||||
.min(1, "At least 1 traveler required")
|
||||
.max(50, "Maximum 50 travelers per booking"),
|
||||
contactName: z
|
||||
.string()
|
||||
.min(2, "Name must be at least 2 characters")
|
||||
.max(100),
|
||||
contactEmail: z
|
||||
.string()
|
||||
.min(1, "Email is required")
|
||||
.email("Enter a valid email address"),
|
||||
contactPhone: z
|
||||
.string()
|
||||
.min(7, "Enter a valid phone number")
|
||||
.max(20, "Phone number too long")
|
||||
.regex(/^[+\d\s\-()\\.]+$/, "Invalid phone number format"),
|
||||
notes: z.string().max(500, "Notes must be under 500 characters").optional(),
|
||||
});
|
||||
|
||||
type InquiryForm = z.infer<typeof inquirySchema>;
|
||||
|
||||
interface InquiryModalProps {
|
||||
packageId: string;
|
||||
packageName: string;
|
||||
tier: PriceTier;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function InquiryModal({ packageId, packageName, tier, onClose }: InquiryModalProps) {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<InquiryForm>({
|
||||
resolver: zodResolver(inquirySchema as any),
|
||||
defaultValues: { travelerCount: 1 },
|
||||
});
|
||||
|
||||
const onSubmit = async (data: InquiryForm) => {
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await apiClient.post("/packages/inquiries", {
|
||||
packageId,
|
||||
priceTierId: tier.id,
|
||||
travelerCount: data.travelerCount,
|
||||
contactName: data.contactName,
|
||||
contactEmail: data.contactEmail,
|
||||
contactPhone: data.contactPhone,
|
||||
notes: data.notes ?? "",
|
||||
});
|
||||
setSubmitted(true);
|
||||
} catch (err: any) {
|
||||
setSubmitError(
|
||||
err?.response?.data?.message ||
|
||||
"Something went wrong. Please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-[90] bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4">
|
||||
<div className="w-full sm:max-w-lg bg-white dark:bg-gray-900 rounded-t-3xl sm:rounded-2xl shadow-2xl overflow-hidden max-h-[92vh] flex flex-col">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
Book Package
|
||||
</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5 line-clamp-1">
|
||||
{packageName.trim()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Success state */}
|
||||
{submitted ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="w-16 h-16 bg-green-50 dark:bg-green-900/20 rounded-full flex items-center justify-center mb-4">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
|
||||
Inquiry Submitted!
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs">
|
||||
We've received your booking inquiry. Our team will contact you
|
||||
shortly to confirm your reservation.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-6 px-6 py-2.5 bg-primary text-white text-sm font-semibold rounded-xl hover:bg-[rgb(16,89,60)] transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Selected tier summary */}
|
||||
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wide font-semibold">
|
||||
Selected seat type
|
||||
</p>
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">
|
||||
{tier.label.trim()}{" "}
|
||||
<span className="text-[10px] font-normal text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded ml-1">
|
||||
{tier.seatType.trim()}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wide font-semibold">
|
||||
Per person
|
||||
</p>
|
||||
<p className="text-base font-extrabold text-primary mt-0.5">
|
||||
{formatPrice(tier.priceMinor, tier.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex-1 overflow-y-auto scrollbar-hide px-6 py-5 space-y-4"
|
||||
>
|
||||
{/* Traveler count */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Number of Travelers <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Users className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
{...register("travelerCount", { valueAsNumber: true })}
|
||||
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary/30 transition-colors ${
|
||||
errors.travelerCount
|
||||
? "border-red-400 focus:border-red-400"
|
||||
: "border-gray-200 dark:border-gray-700 focus:border-primary"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.travelerCount && (
|
||||
<p className="text-xs text-red-500 mt-1">{errors.travelerCount.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact name */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Full Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Your full name"
|
||||
{...register("contactName")}
|
||||
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 transition-colors ${
|
||||
errors.contactName
|
||||
? "border-red-400 focus:border-red-400"
|
||||
: "border-gray-200 dark:border-gray-700 focus:border-primary"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.contactName && (
|
||||
<p className="text-xs text-red-500 mt-1">{errors.contactName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Email Address <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
{...register("contactEmail")}
|
||||
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 transition-colors ${
|
||||
errors.contactEmail
|
||||
? "border-red-400 focus:border-red-400"
|
||||
: "border-gray-200 dark:border-gray-700 focus:border-primary"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.contactEmail && (
|
||||
<p className="text-xs text-red-500 mt-1">{errors.contactEmail.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Phone Number <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="+251 912 345 678"
|
||||
{...register("contactPhone")}
|
||||
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 transition-colors ${
|
||||
errors.contactPhone
|
||||
? "border-red-400 focus:border-red-400"
|
||||
: "border-gray-200 dark:border-gray-700 focus:border-primary"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.contactPhone && (
|
||||
<p className="text-xs text-red-500 mt-1">{errors.contactPhone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Additional Notes{" "}
|
||||
<span className="text-gray-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FileText className="absolute left-3.5 top-3.5 w-4 h-4 text-gray-400" />
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Any special requirements or questions..."
|
||||
{...register("notes")}
|
||||
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 resize-none transition-colors ${
|
||||
errors.notes
|
||||
? "border-red-400 focus:border-red-400"
|
||||
: "border-gray-200 dark:border-gray-700 focus:border-primary"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.notes && (
|
||||
<p className="text-xs text-red-500 mt-1">{errors.notes.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API error */}
|
||||
{submitError && (
|
||||
<div className="flex items-start gap-2.5 p-3.5 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="pt-1 pb-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
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"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Submit Inquiry <ArrowRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<p className="text-[10px] text-gray-400 text-center mt-2">
|
||||
Our team will contact you shortly to confirm.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Journey Card ─────────────────────────────────────────────────────────────
|
||||
|
||||
function JourneyCard({ schedule, label }: { schedule: Schedule; label: string }) {
|
||||
const dep = new Date(schedule.departureAt);
|
||||
const arr = new Date(schedule.arrivalAt);
|
||||
const isSameDay = dep.toDateString() === arr.toDateString();
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-100 dark:border-gray-800 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-5 py-3 bg-gray-50 dark:bg-gray-800/60 border-b border-gray-100 dark:border-gray-800">
|
||||
<Train className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-bold text-gray-800 dark:text-white">{label}</span>
|
||||
<span className="ml-auto text-xs text-gray-400">{schedule.train.number}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
{/* Route row */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Origin */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">From</p>
|
||||
<p className="text-xl font-extrabold text-gray-900 dark:text-white truncate">
|
||||
{schedule.originStation.code}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">{schedule.originStation.name.trim()}</p>
|
||||
</div>
|
||||
|
||||
{/* Center: duration + arrow */}
|
||||
<div className="flex flex-col items-center gap-1 flex-shrink-0">
|
||||
<span className="text-[10px] text-gray-400 font-medium">
|
||||
{durationLabel(schedule.durationMinutes)}
|
||||
</span>
|
||||
<div className="relative w-16 flex items-center">
|
||||
<div className="h-px w-full bg-gray-200 dark:bg-gray-700" />
|
||||
<ArrowRight className="w-3 h-3 text-primary absolute -right-1" />
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-400">{schedule.stopsCount} stops</span>
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div className="flex-1 min-w-0 text-right">
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">To</p>
|
||||
<p className="text-xl font-extrabold text-gray-900 dark:text-white truncate">
|
||||
{schedule.destinationStation.code}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">{schedule.destinationStation.name.trim()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Times */}
|
||||
<div className="flex items-end justify-between mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<div>
|
||||
<p className="text-lg font-bold text-primary">{fmtTime(schedule.departureAt)}</p>
|
||||
<p className="text-xs text-gray-400">{fmt(schedule.departureAt, { weekday: "short", month: "short", day: "numeric" })}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-bold text-primary">
|
||||
{fmtTime(schedule.arrivalAt)}
|
||||
{!isSameDay && <sup className="text-[10px] text-orange-400 ml-0.5">+1</sup>}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{fmt(schedule.arrivalAt, { weekday: "short", month: "short", day: "numeric" })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs text-gray-400">{schedule.train.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Price Tiers Panel ────────────────────────────────────────────────────────
|
||||
|
||||
function PriceTiersPanel({
|
||||
tiers,
|
||||
selectedTierId,
|
||||
onSelect,
|
||||
selectedTier,
|
||||
onBookNow,
|
||||
}: {
|
||||
tiers: PriceTier[];
|
||||
selectedTierId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
selectedTier?: PriceTier;
|
||||
onBookNow: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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 mb-4">
|
||||
Select Seat Type
|
||||
</h2>
|
||||
|
||||
{!tiers?.length ? (
|
||||
<p className="text-sm text-gray-400 text-center py-4">
|
||||
No price tiers available
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{tiers.map((tier) => {
|
||||
const soldOut = tier.availableSeats === 0;
|
||||
const selected = tier.id === selectedTierId;
|
||||
return (
|
||||
<button
|
||||
key={tier.id}
|
||||
type="button"
|
||||
disabled={soldOut}
|
||||
onClick={() => onSelect(tier.id)}
|
||||
className={`w-full text-left rounded-xl border-2 p-3.5 transition-all ${
|
||||
soldOut
|
||||
? "border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed"
|
||||
: selected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
{/* Row 1: radio + full label */}
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center mt-0.5 transition-colors ${
|
||||
selected
|
||||
? "border-primary bg-primary"
|
||||
: "border-gray-300 dark:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
{selected && <div className="w-1.5 h-1.5 rounded-full bg-white" />}
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white leading-snug">
|
||||
{tier.label.trim()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Row 2: seatType badge + seats + price */}
|
||||
<div className="flex items-center justify-between mt-2 pl-[26px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">
|
||||
{tier.seatType.trim()}
|
||||
</span>
|
||||
{soldOut ? (
|
||||
<span className="text-[10px] font-bold text-red-500 bg-red-50 dark:bg-red-900/20 px-1.5 py-0.5 rounded">
|
||||
SOLD OUT
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{tier.availableSeats} left
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-extrabold text-primary">
|
||||
{formatPrice(tier.priceMinor, tier.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary + CTA */}
|
||||
{selectedTier && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-primary/30 shadow-sm">
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Seat type</p>
|
||||
<p className="text-xs font-semibold text-gray-900 dark:text-white">
|
||||
{selectedTier.label.trim()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Price per person</p>
|
||||
<p className="text-base font-extrabold text-primary">
|
||||
{formatPrice(selectedTier.priceMinor, selectedTier.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBookNow}
|
||||
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
Book Now <ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PackageDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = params?.id as string;
|
||||
|
||||
const [selectedTierId, setSelectedTierId] = useState<string | null>(null);
|
||||
const [inquiryOpen, setInquiryOpen] = useState(false);
|
||||
|
||||
const { data: pkg, isLoading, isError } = useQuery<PackageDetail>({
|
||||
queryKey: ["package", id],
|
||||
queryFn: async () =>
|
||||
(await apiClient.get<PackageDetail>(`/packages/${id}`)) as PackageDetail,
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const selectedTier = pkg?.priceTiers?.find((t) => t.id === selectedTierId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-500">Loading package details...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !pkg) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="w-8 h-8 text-red-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-500 mb-4">Unable to load package details.</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-sm text-primary font-medium flex items-center gap-1 mx-auto hover:underline"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" /> Go back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const origin = pkg.outboundSchedule?.originStation;
|
||||
const destination = pkg.outboundSchedule?.destinationStation;
|
||||
const availableSeats = pkg.totalCapacity - pkg.bookedCount;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Inquiry modal */}
|
||||
{inquiryOpen && selectedTier && (
|
||||
<InquiryModal
|
||||
packageId={pkg.id}
|
||||
packageName={pkg.name}
|
||||
tier={selectedTier}
|
||||
onClose={() => setInquiryOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hero */}
|
||||
<div className="relative h-56 md:h-80 bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)] overflow-hidden">
|
||||
<Image
|
||||
src="/packages/kulubi.jpeg"
|
||||
alt={pkg.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="100vw"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/20 to-transparent" />
|
||||
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="absolute top-4 left-4 flex items-center gap-1.5 text-white/90 hover:text-white bg-black/25 backdrop-blur-sm px-3 py-2 rounded-full text-sm font-medium transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" /> Back
|
||||
</button>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="inline-block bg-white/20 text-white text-xs font-semibold px-2.5 py-1 rounded-full backdrop-blur-sm">
|
||||
{pkg.code}
|
||||
</span>
|
||||
{pkg.status === "ACTIVE" && (
|
||||
<span className="inline-block bg-green-500/90 text-white text-xs font-bold px-2.5 py-1 rounded-full">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white leading-tight">
|
||||
{pkg.name.trim()}
|
||||
</h1>
|
||||
{origin && destination && (
|
||||
<div className="flex items-center gap-1.5 text-white/75 text-sm mt-2">
|
||||
<MapPin className="w-4 h-4 flex-shrink-0" />
|
||||
{origin.name.trim()}
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
{destination.name.trim()}
|
||||
{pkg.returnSchedule && (
|
||||
<span className="text-white/50 text-xs ml-1">· Round Trip</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* ── Main content ── */}
|
||||
<div className="lg:col-span-2 space-y-5">
|
||||
|
||||
{/* Quick info strip */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800 text-center">
|
||||
<Calendar className="w-5 h-5 text-primary mx-auto mb-1" />
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wide">Departure</p>
|
||||
<p className="text-xs font-bold text-gray-800 dark:text-white mt-0.5">
|
||||
{fmt(pkg.departureTime, { month: "short", day: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800 text-center">
|
||||
<Users className="w-5 h-5 text-primary mx-auto mb-1" />
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wide">Available</p>
|
||||
<p className={`text-xs font-bold mt-0.5 ${availableSeats <= 20 ? "text-orange-500" : "text-gray-800 dark:text-white"}`}>
|
||||
{availableSeats} seats
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800 text-center">
|
||||
<Tag className="w-5 h-5 text-primary mx-auto mb-1" />
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wide">From</p>
|
||||
<p className="text-xs font-bold text-gray-800 dark:text-white mt-0.5">
|
||||
{pkg.priceTiers.length
|
||||
? formatPrice(
|
||||
Math.min(...pkg.priceTiers.map((t) => t.priceMinor)),
|
||||
pkg.priceTiers[0].currency,
|
||||
)
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{pkg.description && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 border border-gray-100 dark:border-gray-800">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-3">
|
||||
About this Package
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
{pkg.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outbound journey */}
|
||||
{pkg.outboundSchedule && (
|
||||
<JourneyCard schedule={pkg.outboundSchedule} label="Outbound Journey" />
|
||||
)}
|
||||
|
||||
{/* Return journey */}
|
||||
{pkg.returnSchedule && (
|
||||
<JourneyCard schedule={pkg.returnSchedule} label="Return Journey" />
|
||||
)}
|
||||
|
||||
{/* Bus transfer */}
|
||||
{pkg.busTransferIncluded && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-50 dark:bg-amber-900/20 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<Bus className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-white">
|
||||
Bus Transfer Included
|
||||
</p>
|
||||
{pkg.busTransferRoute && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{pkg.busTransferRoute.trim()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Check className="w-5 h-5 text-green-500 ml-auto flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Travel info */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 border border-gray-100 dark:border-gray-800">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-4">
|
||||
Travel Information
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<InfoRow icon={<Clock className="w-4 h-4 text-primary" />} label="Boarding Time">
|
||||
{fmtTime(pkg.boardingTime)} · {fmt(pkg.boardingTime)}
|
||||
</InfoRow>
|
||||
<InfoRow icon={<Calendar className="w-4 h-4 text-primary" />} label="Departure Time">
|
||||
{fmtTime(pkg.departureTime)} · {fmt(pkg.departureTime)}
|
||||
</InfoRow>
|
||||
<InfoRow icon={<MapPin className="w-4 h-4 text-primary" />} label="Arrival">
|
||||
{fmtTime(pkg.arrivalTime)} · {fmt(pkg.arrivalTime)}
|
||||
</InfoRow>
|
||||
<InfoRow icon={<Users className="w-4 h-4 text-primary" />} label="Total Capacity">
|
||||
{pkg.totalCapacity} seats ({pkg.bookedCount} booked)
|
||||
</InfoRow>
|
||||
{pkg.coachConfiguration && (
|
||||
<InfoRow icon={<Train className="w-4 h-4 text-primary" />} label="Coach Config">
|
||||
{pkg.coachConfiguration.trim()}
|
||||
</InfoRow>
|
||||
)}
|
||||
<InfoRow icon={<Shield className="w-4 h-4 text-primary" />} label="Valid Period">
|
||||
{fmt(pkg.validFrom, { month: "short", day: "numeric" })} –{" "}
|
||||
{fmt(pkg.validUntil, { month: "short", day: "numeric", year: "numeric" })}
|
||||
</InfoRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Included services */}
|
||||
{pkg.includedServices?.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 border border-gray-100 dark:border-gray-800">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-4">
|
||||
Included Services
|
||||
</h2>
|
||||
<ul className="space-y-2.5">
|
||||
{pkg.includedServices.map((svc, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-2.5 text-sm text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<Check className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
{stripBullet(svc)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price tiers — mobile */}
|
||||
<div className="lg:hidden">
|
||||
<PriceTiersPanel
|
||||
tiers={pkg.priceTiers}
|
||||
selectedTierId={selectedTierId}
|
||||
onSelect={setSelectedTierId}
|
||||
selectedTier={selectedTier}
|
||||
onBookNow={() => setInquiryOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Sidebar — desktop ── */}
|
||||
<div className="hidden lg:block">
|
||||
<div className="sticky top-20">
|
||||
<PriceTiersPanel
|
||||
tiers={pkg.priceTiers}
|
||||
selectedTierId={selectedTierId}
|
||||
onSelect={setSelectedTierId}
|
||||
selectedTier={selectedTier}
|
||||
onBookNow={() => setInquiryOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
icon,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">{icon}</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wide font-semibold">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mt-0.5">
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Suspense } from 'react';
|
||||
import SearchPage from '@/app/booking/search/page';
|
||||
import PackagesSection from '@/components/PackagesSection';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense>
|
||||
<SearchPage />
|
||||
<PackagesSection />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
544
apps/edr-passenger-web/portal/src/components/PackagesSection.tsx
Normal file
544
apps/edr-passenger-web/portal/src/components/PackagesSection.tsx
Normal file
@@ -0,0 +1,544 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
MapPin,
|
||||
ArrowRight,
|
||||
Shield,
|
||||
Train,
|
||||
Bus,
|
||||
CheckCircle2,
|
||||
Calendar,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PriceTier {
|
||||
id: string;
|
||||
priceMinor: number;
|
||||
currency: string;
|
||||
availableSeats: number;
|
||||
}
|
||||
|
||||
interface Station {
|
||||
name: string;
|
||||
city: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface Schedule {
|
||||
originStation: Station;
|
||||
destinationStation: Station;
|
||||
departureAt: string;
|
||||
durationMinutes: number;
|
||||
}
|
||||
|
||||
interface HolidayPackage {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
departureTime: string;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
totalCapacity: number;
|
||||
bookedCount: number;
|
||||
includedServices?: string[];
|
||||
busTransferIncluded?: boolean;
|
||||
busTransferRoute?: string | null;
|
||||
returnSchedule?: Schedule | null;
|
||||
outboundSchedule?: Schedule;
|
||||
priceTiers: PriceTier[];
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtDate(iso: string, opts?: Intl.DateTimeFormatOptions): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(
|
||||
"en-US",
|
||||
opts ?? { month: "short", day: "numeric" },
|
||||
);
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function validityRange(from: string, until: string): string {
|
||||
return `${fmtDate(from, { month: "short", day: "numeric" })} – ${fmtDate(until, { month: "short", day: "numeric", year: "numeric" })}`;
|
||||
}
|
||||
|
||||
function daysUntil(iso: string): number {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.floor((new Date(iso).getTime() - Date.now()) / 86400000),
|
||||
);
|
||||
}
|
||||
|
||||
function minPrice(
|
||||
tiers: PriceTier[],
|
||||
): { amount: number; currency: string } | null {
|
||||
if (!tiers?.length) return null;
|
||||
const min = tiers.reduce((a, b) => (a.priceMinor < b.priceMinor ? a : b));
|
||||
return { amount: min.priceMinor / 100, currency: min.currency };
|
||||
}
|
||||
|
||||
function fmtPrice(minor: number, currency: string): string {
|
||||
return `${currency} ${(minor / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function stripBullet(s: string): string {
|
||||
return s.replace(/^[•\-\t\s]+/, "").trim();
|
||||
}
|
||||
|
||||
// ─── Urgency badge ────────────────────────────────────────────────────────────
|
||||
|
||||
function UrgencyBadge({ until }: { until: string }) {
|
||||
const days = daysUntil(until);
|
||||
if (days > 14) return null;
|
||||
return (
|
||||
<span
|
||||
className={`text-[10px] font-bold px-2.5 py-1 rounded-full ${
|
||||
days <= 3
|
||||
? "bg-red-500 text-white animate-pulse"
|
||||
: "bg-orange-400 text-white"
|
||||
}`}
|
||||
>
|
||||
{days === 0 ? "Last day!" : `Closes in ${days}d`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Availability bar ─────────────────────────────────────────────────────────
|
||||
|
||||
function AvailBar({ booked, total }: { booked: number; total: number }) {
|
||||
const pct = total > 0 ? Math.round(((total - booked) / total) * 100) : 100;
|
||||
const low = pct <= 20;
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-[10px] text-gray-400 mb-1">
|
||||
<span>
|
||||
{total - booked} of {total} seats
|
||||
</span>
|
||||
<span className={low ? "text-orange-500 font-semibold" : ""}>
|
||||
{pct}% available
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${low ? "bg-orange-400" : "bg-primary"}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Featured Card (first package — full-width, image left) ───────────────────
|
||||
|
||||
function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
|
||||
const price = minPrice(pkg.priceTiers);
|
||||
const origin = pkg.outboundSchedule?.originStation;
|
||||
const dest = pkg.outboundSchedule?.destinationStation;
|
||||
const days = daysUntil(pkg.validUntil);
|
||||
|
||||
return (
|
||||
<Link href={`/packages/${pkg.id}`} className="group block">
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-3xl overflow-hidden border border-gray-100 dark:border-gray-800 shadow-sm hover:shadow-2xl transition-all duration-500">
|
||||
<div className="flex flex-col md:flex-row min-h-[340px]">
|
||||
{/* ── Left: Image ── */}
|
||||
<div className="relative md:w-[46%] h-64 md:h-auto flex-shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src="/packages/kulubi.jpeg"
|
||||
alt={pkg.name}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-700 ease-out"
|
||||
sizes="(max-width: 768px) 100vw, 46vw"
|
||||
priority
|
||||
/>
|
||||
{/* Gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/10 to-transparent md:bg-gradient-to-r md:from-transparent md:via-transparent md:to-black/30" />
|
||||
|
||||
{/* Top-left badges */}
|
||||
<div className="absolute top-4 left-4 flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 bg-primary text-white text-[11px] font-bold px-3 py-1 rounded-full shadow">
|
||||
✦ Featured Package
|
||||
</span>
|
||||
<UrgencyBadge until={pkg.validUntil} />
|
||||
</div>
|
||||
|
||||
{/* Bottom-left: round trip tag */}
|
||||
{pkg.returnSchedule && (
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<span className="flex items-center gap-1.5 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm text-gray-800 dark:text-white text-[11px] font-bold px-3 py-1.5 rounded-full shadow">
|
||||
<ArrowRight className="w-3 h-3 rotate-0" />
|
||||
<ArrowRight className="w-3 h-3 rotate-180 -ml-2" />
|
||||
Round Trip
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Right: Content ── */}
|
||||
<div className="flex-1 flex flex-col justify-between p-7 md:p-8">
|
||||
{/* Top section */}
|
||||
<div>
|
||||
{/* Status */}
|
||||
{pkg.status === "ACTIVE" && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-green-700 bg-green-50 dark:bg-green-900/30 dark:text-green-400 px-2.5 py-1 rounded-full mb-3">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
||||
Booking Open
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<h3 className="text-xl md:text-2xl font-extrabold text-gray-900 dark:text-white leading-tight mb-1.5">
|
||||
{pkg.name.trim()}
|
||||
</h3>
|
||||
|
||||
{/* Route */}
|
||||
{origin && dest && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 mb-5">
|
||||
<Train className="w-3.5 h-3.5 text-primary flex-shrink-0" />
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||
{origin.name.trim()}
|
||||
</span>
|
||||
<ArrowRight className="w-3 h-3 flex-shrink-0" />
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||
{dest.name.trim()}
|
||||
</span>
|
||||
{pkg.busTransferIncluded && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600">
|
||||
+
|
||||
</span>
|
||||
<Bus className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
|
||||
<span className="font-medium text-gray-600 dark:text-gray-400">
|
||||
{pkg.busTransferRoute?.trim() ?? "Bus transfer"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info grid */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 mb-5">
|
||||
<InfoPill
|
||||
icon={<Shield className="w-3.5 h-3.5 text-primary" />}
|
||||
label="Validity"
|
||||
>
|
||||
{validityRange(pkg.validFrom, pkg.validUntil)}
|
||||
</InfoPill>
|
||||
<InfoPill
|
||||
icon={<Clock className="w-3.5 h-3.5 text-primary" />}
|
||||
label="Booking closes"
|
||||
>
|
||||
<span
|
||||
className={days <= 7 ? "text-orange-500 font-semibold" : ""}
|
||||
>
|
||||
{fmtDate(pkg.validUntil, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
{days <= 14 && ` (${days}d left)`}
|
||||
</span>
|
||||
</InfoPill>
|
||||
</div>
|
||||
|
||||
{/* Service chips */}
|
||||
{pkg.includedServices && pkg.includedServices.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-5">
|
||||
{pkg.includedServices.slice(0, 4).map((svc, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-medium text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-2.5 py-1 rounded-full"
|
||||
>
|
||||
<CheckCircle2 className="w-3 h-3 text-green-500 flex-shrink-0" />
|
||||
{stripBullet(svc).split(/\s+/).slice(0, 5).join(" ")}
|
||||
</span>
|
||||
))}
|
||||
{pkg.includedServices.length > 4 && (
|
||||
<span className="text-[10px] text-gray-400 flex items-center px-1">
|
||||
+{pkg.includedServices.length - 4} more included
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom: price + CTA */}
|
||||
<div className="flex items-end justify-between pt-5 border-t border-gray-100 dark:border-gray-800">
|
||||
{price ? (
|
||||
<div>
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wider font-medium">
|
||||
Starting from
|
||||
</p>
|
||||
<p className="text-3xl font-extrabold text-primary leading-none mt-1">
|
||||
{price.currency}{" "}
|
||||
<span className="text-2xl">
|
||||
{price.amount.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-400 mt-1">
|
||||
{pkg.priceTiers.length} seat class
|
||||
{pkg.priceTiers.length !== 1 ? "es" : ""} available
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<span className="inline-flex items-center gap-2 bg-[rgb(20,113,76)] group-hover:bg-[rgb(16,89,60)] text-white text-sm font-bold px-6 py-3.5 rounded-xl shadow-lg group-hover:shadow-xl transition-all duration-200 group-hover:gap-3">
|
||||
View Package
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Regular Package Card ─────────────────────────────────────────────────────
|
||||
|
||||
function PackageCard({ pkg }: { pkg: HolidayPackage }) {
|
||||
const price = minPrice(pkg.priceTiers);
|
||||
const origin = pkg.outboundSchedule?.originStation;
|
||||
const dest = pkg.outboundSchedule?.destinationStation;
|
||||
|
||||
return (
|
||||
<Link href={`/packages/${pkg.id}`} className="group block h-full">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl overflow-hidden border border-gray-200 dark:border-gray-800 hover:border-primary/60 hover:shadow-xl transition-all duration-300 flex flex-col h-full">
|
||||
{/* Image / Gradient */}
|
||||
<div className="relative h-44 overflow-hidden bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)] flex-shrink-0">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-6xl opacity-20">🌍</span>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||||
|
||||
<div className="absolute top-3 left-3 flex items-center gap-2">
|
||||
<UrgencyBadge until={pkg.validUntil} />
|
||||
</div>
|
||||
|
||||
{pkg.returnSchedule && (
|
||||
<div className="absolute bottom-3 left-3">
|
||||
<span className="bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm text-gray-800 dark:text-white text-[10px] font-bold px-2.5 py-1 rounded-full">
|
||||
↔ Round Trip
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col flex-1 p-4">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white text-sm leading-snug mb-2.5 line-clamp-2">
|
||||
{pkg.name.trim()}
|
||||
</h3>
|
||||
|
||||
{/* Route */}
|
||||
{origin && dest && (
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
<MapPin className="w-3 h-3 text-primary flex-shrink-0" />
|
||||
<span className="truncate">{origin.name.trim()}</span>
|
||||
<ArrowRight className="w-3 h-3 flex-shrink-0 text-gray-300" />
|
||||
<span className="truncate">{dest.name.trim()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validity */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
<Shield className="w-3.5 h-3.5 text-primary flex-shrink-0" />
|
||||
<span>{validityRange(pkg.validFrom, pkg.validUntil)}</span>
|
||||
</div>
|
||||
|
||||
{/* Departure */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
<Calendar className="w-3.5 h-3.5 text-primary flex-shrink-0" />
|
||||
<span>
|
||||
Departs{" "}
|
||||
{fmtDate(pkg.departureTime, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Availability bar */}
|
||||
<div className="mb-4">
|
||||
<AvailBar booked={pkg.bookedCount} total={pkg.totalCapacity} />
|
||||
</div>
|
||||
|
||||
{/* Price + CTA */}
|
||||
<div className="mt-auto pt-3.5 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between">
|
||||
{price ? (
|
||||
<div>
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wide">
|
||||
From
|
||||
</p>
|
||||
<p className="text-base font-extrabold text-primary">
|
||||
{fmtPrice(price.amount * 100, price.currency)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<span className="text-xs text-primary font-bold flex items-center gap-1 group-hover:gap-2 transition-all">
|
||||
View <ArrowRight className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Skeletons ────────────────────────────────────────────────────────────────
|
||||
|
||||
function FeaturedSkeleton() {
|
||||
return (
|
||||
<div className="rounded-3xl overflow-hidden border border-gray-100 dark:border-gray-800 animate-pulse flex flex-col md:flex-row min-h-[340px]">
|
||||
<div className="md:w-[46%] h-64 md:h-auto bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
|
||||
<div className="flex-1 p-8 space-y-4">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/4" />
|
||||
<div className="h-7 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2" />
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-10 bg-gray-200 dark:bg-gray-700 rounded"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-6 w-28 bg-gray-200 dark:bg-gray-700 rounded-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl overflow-hidden border border-gray-100 dark:border-gray-800 animate-pulse">
|
||||
<div className="h-44 bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/3" />
|
||||
<div className="h-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Info Pill (for featured card) ────────────────────────────────────────────
|
||||
|
||||
function InfoPill({
|
||||
icon,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-shrink-0 mt-0.5">{icon}</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-wide font-semibold">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-xs font-semibold text-gray-700 dark:text-gray-300 mt-0.5 leading-snug">
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PackagesSection() {
|
||||
const {
|
||||
data: packages,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery<HolidayPackage[]>({
|
||||
queryKey: ["packages"],
|
||||
queryFn: async () =>
|
||||
(await apiClient.get<HolidayPackage[]>("/packages")) as HolidayPackage[],
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (isError) return null;
|
||||
|
||||
const [featured, ...rest] = packages ?? [];
|
||||
|
||||
return (
|
||||
<section className="bg-gray-50 dark:bg-gray-950 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-xl">🏖️</span>
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
Holiday Packages
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Featured card */}
|
||||
{isLoading ? (
|
||||
<FeaturedSkeleton />
|
||||
) : featured ? (
|
||||
<FeaturedCard pkg={featured} />
|
||||
) : null}
|
||||
|
||||
{/* Rest grid — 3 col desktop / 2 col tablet / 1 col mobile */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<CardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : rest.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{rest.map((pkg) => (
|
||||
<PackageCard key={pkg.id} pkg={pkg} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && !isError && !packages?.length && (
|
||||
<div className="py-16 text-center">
|
||||
<span className="text-5xl block mb-3">🏖️</span>
|
||||
<p className="text-sm text-gray-400">
|
||||
No holiday packages available right now. Check back soon.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user