Update group booking and package

This commit is contained in:
Roba Boru
2026-08-27 22:54:59 +03:00
parent 5c2100e76d
commit 1603ff8211
35 changed files with 1170 additions and 377 deletions

View File

@@ -59,6 +59,7 @@
"helmet": "^8.0.0",
"jose": "^5.10.0",
"minio": "7.1.3",
"multer": "^2.1.1",
"pg": "^8.21.0",
"qrcode": "^1.5.3",
"reflect-metadata": "^0.2.2",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TrainSchedule" ADD COLUMN IF NOT EXISTS "isGroupBookingOnly" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TravelPackage" ADD COLUMN IF NOT EXISTS "imageUrl" TEXT;

View File

@@ -372,6 +372,7 @@ model TrainSchedule {
carbonRating String @default("A")
notes String?
isPackageOnly Boolean @default(false)
isGroupBookingOnly Boolean @default(false)
train Train @relation(fields: [trainId], references: [id])
route Route? @relation(fields: [routeId], references: [id])
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
@@ -1529,6 +1530,7 @@ model TravelPackage {
code String @unique
name String
description String?
imageUrl String?
status PackageStatus @default(DRAFT)
outboundScheduleId String
returnScheduleId String

View File

@@ -4,9 +4,11 @@
import "dotenv/config";
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Logger, ValidationPipe, VersioningType } from "@nestjs/common";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import helmet from "helmet";
import { join } from "path";
import { AppModule } from "./app.module";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor";
@@ -23,10 +25,20 @@ if (process.env.NODE_ENV === 'production' && process.env.WAAFI_INSECURE_TLS ===
async function bootstrap() {
// rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
const app = await NestFactory.create(AppModule, { rawBody: true });
const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true });
// Security headers
app.use(helmet());
// Security headers. crossOriginResourcePolicy defaults to 'same-origin' in helmet, which
// would make browsers refuse to actually render package images (served from this origin)
// inside <img> tags on the portal/backoffice (different origins) even though the request
// itself succeeds — relaxed to 'cross-origin' since this API already serves all its JSON to
// those exact same origins per the CORS allowlist below; nothing new is being exposed.
app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
// Serves apps/edr-passenger-api/public/* at the site root — package images live at
// public/uploads/packages/<file>, reachable as GET /uploads/packages/<file>. Local-disk
// storage is a deliberate, explicit stopgap (see packages.service.ts's uploadImage) rather
// than this app's usual MinIO-backed upload pattern (see modules/support's attachments).
app.useStaticAssets(join(__dirname, "..", "public"));
// URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under
// `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay

View File

@@ -368,26 +368,22 @@ export class BookingsController {
summary: "Create a group booking — staff bulk/group reservation, one PNR for the whole group",
description: `Staff-only entry point for bulk/group bookings (e.g. tour groups booked via an uploaded passenger list and auto-assigned seats from POST /seats/auto-assign-hold).
Same body shape as POST /bookings/guest (CreateGuestBookingDto) and the same underlying pipeline — fare engine, ADULT/CHILD age pricing — just gated to staff and always ONE_WAY.
Same body shape as POST /bookings/guest (CreateGuestBookingDto) and the same underlying pipeline — fare engine, ADULT/CHILD age pricing — just gated to staff. Supports ONE_WAY (default) and ROUND_TRIP via \`bookingType\`; for ROUND_TRIP, supply \`returnScheduleId\`/\`returnHoldId\`/\`returnOriginStationId\`/\`returnDestinationStationId\`/\`returnSeatClassId\` and each passenger's \`returnSeatId\`, exactly as POST /bookings/guest does.
Skips Verifayda national-ID verification: the roster comes from a staff-uploaded spreadsheet, not a live Fayda identity flow, so there is nothing to verify an ID number against. Passenger fields (name, DOB, nationality) are trusted exactly as uploaded.
Deliberately does NOT forward the staff caller's identity into booking creation: the acting staff member is not a Passenger, so the underlying guest-booking flow (which tries to resolve an authenticated caller as an existing Passenger profile) would reject the request. The booking is created exactly like a guest booking — a fresh passenger record, contact info from the first passenger in the list — with staff authorization enforced only at this route.
If booking creation fails after the seats were already held, the hold is released immediately so the seats don't sit locked for the rest of the hold TTL.`,
If booking creation fails after the seats were already held, every hold involved (outbound and, for ROUND_TRIP, return) is released immediately so the seats don't sit locked for the rest of the hold TTL.`,
})
@ApiResponse({ status: 201, description: "Group booking created successfully with fareBreakdown" })
@ApiResponse({ status: 400, description: "Missing required seat IDs" })
async createGroup(@Body() dto: CreateGuestBookingDto) {
try {
return await this.guestService.createGuestBooking({ ...dto, bookingType: "ONE_WAY", skipIdentityVerification: true });
return await this.guestService.createGuestBooking({ ...dto, bookingType: dto.bookingType || "ONE_WAY", skipIdentityVerification: true });
} catch (err) {
try {
await this.seatsService.releaseHold(dto.holdId);
} catch (releaseErr) {
// Best-effort — the hold may already be gone (e.g. it expired mid-request). The
// original booking-creation error is what the caller actually needs to see.
}
const holdIdsToRelease = [dto.holdId, dto.returnHoldId].filter((id): id is string => !!id);
await Promise.allSettled(holdIdsToRelease.map((id) => this.seatsService.releaseHold(id)));
throw err;
}
}

View File

@@ -392,7 +392,10 @@ export class GuestBookingService {
contactEmail: contact.contactEmail,
contactPhone: contact.contactPhone,
seats: {
create: passengersWithFares.map((p) => ({
// A free child (no seatId — see passengersWithFares above) has nothing to connect to;
// `connect: { id: undefined }` throws PrismaClientValidationError immediately if this
// filter is missing, so it's never optional here despite the map below looking safe.
create: passengersWithFares.filter((p) => p.seatId).map((p) => ({
seat: { connect: { id: p.seatId } },
scheduleId: dto.scheduleId,
passengerName: p.passengerName,
@@ -763,7 +766,7 @@ export class GuestBookingService {
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
if (passenger.idDocumentNumber) {
if (passenger.idDocumentNumber && !dto.skipIdentityVerification) {
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
passengerName = verification.passengerData?.fullName || passengerName;

View File

@@ -0,0 +1,44 @@
import { BadRequestException } from '@nestjs/common';
import { diskStorage } from 'multer';
import { randomUUID } from 'crypto';
import { extname, join } from 'path';
import { mkdirSync } from 'fs';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
/** Multipart field name carrying the package image file. */
export const PACKAGE_IMAGE_FIELD = 'image';
/** Local-disk stopgap (see packages.service.ts) — not this app's usual MinIO-backed upload pattern. */
export const PACKAGE_IMAGE_UPLOAD_DIR = join(__dirname, '..', '..', '..', 'public', 'uploads', 'packages');
export const PACKAGE_IMAGE_MAX_BYTES = 5 * 1024 * 1024; // 5MB
const ALLOWED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
export const packageImageMulterOptions: MulterOptions = {
storage: diskStorage({
destination: (_req, _file, cb) => {
// mkdir on every request rather than once at module load — this directory is
// gitignored (see public/uploads/packages/.gitignore) so a fresh checkout/deploy
// won't have it yet, and recursive mkdir on an already-existing dir is a no-op.
mkdirSync(PACKAGE_IMAGE_UPLOAD_DIR, { recursive: true });
cb(null, PACKAGE_IMAGE_UPLOAD_DIR);
},
// Unique filename so two packages (or two uploads for the same package) never collide —
// never trust or reuse the original filename.
filename: (_req, file, cb) => {
cb(null, `${randomUUID()}${extname(file.originalname).toLowerCase()}`);
},
}),
limits: {
fileSize: PACKAGE_IMAGE_MAX_BYTES,
files: 1,
},
fileFilter: (_req, file, cb) => {
if (!ALLOWED_MIME_TYPES.has(file.mimetype)) {
cb(new BadRequestException('Image must be JPEG, PNG, WEBP, or GIF.'), false);
return;
}
cb(null, true);
},
};

View File

@@ -1,8 +1,10 @@
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, UseInterceptors, UploadedFile, Request, Query, BadRequestException } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiConsumes, ApiBody } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { PackagesService } from './packages.service';
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto, PackageBookingContextDto } from './packages.dto';
import { PACKAGE_IMAGE_FIELD, packageImageMulterOptions } from './package-image-upload.options';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
@@ -148,6 +150,29 @@ export class PackagesController {
return this.service.remove(id, cascade === 'true');
}
@Post(':id/image')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@UseInterceptors(FileInterceptor(PACKAGE_IMAGE_FIELD, packageImageMulterOptions))
@ApiConsumes('multipart/form-data')
@ApiBody({ schema: { type: 'object', properties: { [PACKAGE_IMAGE_FIELD]: { type: 'string', format: 'binary' } } } })
@ApiOperation({
summary: 'Upload or replace a package image (admin)',
description: 'JPEG/PNG/WEBP/GIF, max 5MB. Replaces and deletes the previous image file if one exists — works the same whether the package currently has an image or not, so this one route covers both the initial upload and later replacement.',
})
uploadImage(@Param('id') id: string, @UploadedFile() file?: Express.Multer.File) {
if (!file) throw new BadRequestException('No image file provided.');
return this.service.uploadImage(id, file);
}
@Delete(':id/image')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a package image without deleting the package (admin)' })
removeImage(@Param('id') id: string) {
return this.service.removeImage(id);
}
@Patch(':id/activate')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CurrencyService } from '../currency/currency.service';
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto';
@@ -7,6 +7,9 @@ import { BookingsService } from '../bookings/bookings.service';
import { GuestBookingService } from '../bookings/guest-booking.service';
import { AuditService } from '../../common/audit.service';
import { computePaymentDeadline, CUTOFF_MINUTES } from '../../common/utils/payment-deadline.utils';
import { PACKAGE_IMAGE_UPLOAD_DIR } from './package-image-upload.options';
import { join } from 'path';
import { unlink } from 'fs/promises';
/** Package-specific fare rules */
const PKG_MAX_ADULTS = 5;
@@ -46,6 +49,8 @@ function generateRef(): string {
@Injectable()
export class PackagesService {
private readonly logger = new Logger(PackagesService.name);
constructor(
private readonly prisma: PrismaService,
private readonly currencyService: CurrencyService,
@@ -325,6 +330,55 @@ export class PackagesService {
return updated;
}
/** Public URL prefix main.ts's app.useStaticAssets serves public/uploads/packages under. */
private readonly PACKAGE_IMAGE_URL_PREFIX = '/uploads/packages/';
private packageImagePublicUrl(filename: string): string {
const base = (process.env.APP_PUBLIC_URL || `http://localhost:${process.env.PORT || 4000}`).replace(/\/$/, '');
return `${base}${this.PACKAGE_IMAGE_URL_PREFIX}${filename}`;
}
/** Best-effort delete of the file backing a package's current imageUrl — never throws, since a
* missing file (already deleted, moved, or from before this feature existed) shouldn't block
* the DB update that's actually replacing/clearing the field. */
private async deletePackageImageFile(imageUrl: string | null): Promise<void> {
if (!imageUrl) return;
const idx = imageUrl.indexOf(this.PACKAGE_IMAGE_URL_PREFIX);
if (idx === -1) return; // not a file this app manages (e.g. an external URL) — nothing to delete
const filename = imageUrl.slice(idx + this.PACKAGE_IMAGE_URL_PREFIX.length);
if (!filename || filename.includes('/') || filename.includes('..')) return; // defensive: never touch paths outside the uploads dir
try {
await unlink(join(PACKAGE_IMAGE_UPLOAD_DIR, filename));
} catch (err: any) {
if (err?.code !== 'ENOENT') this.logger.warn(`Failed to delete package image file ${filename}: ${err?.message ?? err}`);
}
}
/** Multer's diskStorage has already written the file to PACKAGE_IMAGE_UPLOAD_DIR by the time
* this runs (see package-image-upload.options.ts) — this just points the package at it and
* cleans up whatever it's replacing. */
async uploadImage(id: string, file: Express.Multer.File) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id }, select: { imageUrl: true } });
if (!pkg) throw new NotFoundException('Package not found');
const imageUrl = this.packageImagePublicUrl(file.filename);
const updated = await this.prisma.travelPackage.update({ where: { id }, data: { imageUrl } });
await this.deletePackageImageFile(pkg.imageUrl);
await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { imageUrl } });
return updated;
}
async removeImage(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id }, select: { imageUrl: true } });
if (!pkg) throw new NotFoundException('Package not found');
if (!pkg.imageUrl) return this.prisma.travelPackage.findUnique({ where: { id } });
const updated = await this.prisma.travelPackage.update({ where: { id }, data: { imageUrl: null } });
await this.deletePackageImageFile(pkg.imageUrl);
await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { imageUrl: null } });
return updated;
}
async addTier(packageId: string, dto: CreatePriceTierDto) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id: packageId } });
if (!pkg) throw new NotFoundException('Package not found');
@@ -369,6 +423,7 @@ export class PackagesService {
await this.prisma.packageInquiry.deleteMany({ where: { packageId: id } });
await this.prisma.packagePriceTier.deleteMany({ where: { packageId: id } });
await this.prisma.travelPackage.delete({ where: { id } });
await this.deletePackageImageFile(pkg.imageUrl);
await this.auditService.log({ action: 'DELETE', entityType: 'Package', entityId: id });
return { deleted: true };
}

View File

@@ -1,4 +1,4 @@
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator';
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, IsBoolean, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
@@ -56,6 +56,9 @@ export class CreateScheduleDto {
@ApiPropertyOptional({ type: [String], description: 'Coach UUIDs to assign, in consist order. Overrides the route coach template if provided. A schedule must end up with at least one coach.' })
@IsOptional() @IsArray() @IsString({ each: true })
coachIds?: string[];
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() @IsBoolean() isPackageOnly?: boolean;
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for staff group bookings)' }) @IsOptional() @IsBoolean() isGroupBookingOnly?: boolean;
}
export class UpdateScheduleDto {
@@ -64,6 +67,7 @@ export class UpdateScheduleDto {
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean;
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for staff group bookings)' }) @IsOptional() @IsBoolean() isGroupBookingOnly?: boolean;
}
export class UpdateStopTimeDto {

View File

@@ -263,6 +263,8 @@ export class SchedulesService {
arrivalAt: arr,
durationMinutes: Math.round((arr.getTime() - dep.getTime()) / 60_000),
stopsCount: Math.max(0, route.stops.length - 2),
isPackageOnly: dto.isPackageOnly ?? false,
isGroupBookingOnly: dto.isGroupBookingOnly ?? false,
},
include: { train: true, originStation: true, destinationStation: true },
});
@@ -1000,6 +1002,7 @@ export class SchedulesService {
if (dto.status) updateData.status = dto.status;
if (dto.isPackageOnly !== undefined) updateData.isPackageOnly = dto.isPackageOnly;
if (dto.isGroupBookingOnly !== undefined) updateData.isGroupBookingOnly = dto.isGroupBookingOnly;
if (Object.keys(updateData).length > 0) {
await this.prisma.trainSchedule.update({ where: { id }, data: updateData });

View File

@@ -27,6 +27,13 @@ export class SearchTripsDto {
@ApiPropertyOptional({ example: '2026-06-20', description: 'Return date (YYYY-MM-DD) — required for ROUND_TRIP, must be after outbound date' })
@IsOptional() @IsDateString() returnDate?: string;
@ApiPropertyOptional({
example: 'PORTAL',
enum: ['PORTAL', 'GROUP_BOOKING'],
description: 'Calling surface. Omit or PORTAL for normal ticket search (default) — only sees schedules with isGroupBookingOnly=false. GROUP_BOOKING sees only schedules with isGroupBookingOnly=true — the two are an exclusive partition, not additive; each channel sees a disjoint set of schedules.',
})
@IsOptional() @IsEnum(['PORTAL', 'GROUP_BOOKING']) channel?: string;
}
export class AvailableDatesQueryDto {

View File

@@ -82,6 +82,10 @@ export class SearchService {
) {}
async searchTrips(dto: SearchTripsDto) {
// GROUP_BOOKING is the staff-only bulk-booking wizard's own calling surface — isGroupBookingOnly
// is an exclusive partition, not additive: this channel sees ONLY schedules explicitly created
// for group booking, and the normal ticket channel (the default, PORTAL) sees only the rest.
const forGroupBooking = dto.channel === "GROUP_BOOKING";
const [direct, transit] = await Promise.all([
this.searchSchedules(
dto.originStationId,
@@ -90,6 +94,7 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
),
this.searchTransitOptions(
dto.originStationId,
@@ -98,6 +103,7 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
),
]);
@@ -112,8 +118,9 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
),
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date),
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date, forGroupBooking),
]);
return {
journeyType: "ONE_WAY",
@@ -133,6 +140,7 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
),
this.searchTransitOptions(
dto.destinationStationId,
@@ -141,6 +149,7 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
),
]);
@@ -172,6 +181,7 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
)
: Promise.resolve([]),
inbound.length === 0
@@ -182,13 +192,14 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
)
: Promise.resolve([]),
outbound.length === 0
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date)
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date, forGroupBooking)
: Promise.resolve(undefined),
inbound.length === 0
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate)
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate, forGroupBooking)
: Promise.resolve(undefined),
]);
return {
@@ -223,6 +234,7 @@ export class SearchService {
adultCount: number,
childCount?: number,
nationality?: string,
forGroupBooking = false,
) {
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
@@ -241,6 +253,10 @@ export class SearchService {
const baseWhere: Prisma.TrainScheduleWhereInput = {
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
// Group Booking's search is exclusive, not additive: staff only ever see schedules
// explicitly created for group booking, never the normal passenger-facing ones, and the
// portal never sees group-only ones. Each channel is a strict partition of the other.
isGroupBookingOnly: forGroupBooking,
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
};
@@ -310,6 +326,7 @@ export class SearchService {
adultCount: number,
childCount?: number,
nationality?: string,
forGroupBooking = false,
) {
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
@@ -329,6 +346,8 @@ export class SearchService {
// statuses, not booking-closed signals (see comment on searchAlternatives' baseWhere).
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
// Exclusive partition — see the comment on searchAlternatives' baseWhere.
isGroupBookingOnly: forGroupBooking,
departureAt: { gte: date, lt: nextDay },
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
@@ -363,6 +382,7 @@ export class SearchService {
originStationId: string,
destinationStationId: string,
dateStr: string,
forGroupBooking = false,
): Promise<Passenger.ISearchEmptyReason> {
const [origin, destination] = await Promise.all([
this.prisma.station.findUnique({ where: { id: originStationId }, select: { name: true } }),
@@ -393,6 +413,7 @@ export class SearchService {
select: {
status: true,
isPackageOnly: true,
isGroupBookingOnly: true,
departureAt: true,
route: {
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
@@ -409,13 +430,20 @@ export class SearchService {
if (sameDayForPair.length === 0) return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
// 3. Schedules exist that date — narrow to ones that would otherwise be bookable
// (right status, not package-only, has at least one coach assigned).
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s));
// (right status, not package-only, not group-booking-only unless this IS a group-booking
// search, has at least one coach assigned).
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s, forGroupBooking));
if (bookable.length === 0) {
if (sameDayForPair.every((s) => s.status === "CANCELLED"))
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
if (sameDayForPair.every((s) => s.isPackageOnly))
return withCode(Passenger.SearchEmptyReasonCode.PackageOnly);
// isGroupBookingOnly is an exclusive partition (see isBookableSchedule) — this same reason
// code covers both directions: the portal finding only group-reserved schedules, and Group
// Booking finding only normal ones (nothing set up for it on this date). The frontend picks
// the right copy per caller.
if (sameDayForPair.every((s) => s.isGroupBookingOnly !== forGroupBooking))
return withCode(Passenger.SearchEmptyReasonCode.GroupBookingOnly);
return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
}
@@ -483,11 +511,22 @@ export class SearchService {
/** Bounds the stop-time fallback scan — connectivity is a yes/no, not a survey. */
private readonly ROUTE_EXISTS_SCHEDULE_SCAN_LIMIT = 200;
/** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */
private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean {
/**
* Status/package/group-booking/coach bookability only — ignores date, cutoff, and seat-level
* availability. `forGroupBooking` defaults false so existing single-arg callers (e.g.
* getAvailableDates, the portal's calendar) keep hiding group-booking-only schedules.
* isGroupBookingOnly is an exclusive partition, not an additive one: a schedule is bookable
* for a given channel only when its flag exactly matches that channel (normal schedules for
* the portal, group-only schedules for Group Booking — never both from one channel).
*/
private isBookableSchedule(
s: { status: string; isPackageOnly: boolean; isGroupBookingOnly: boolean; coachAssignments: { id: string }[] },
forGroupBooking = false,
): boolean {
return (
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
!s.isPackageOnly &&
s.isGroupBookingOnly === forGroupBooking &&
s.coachAssignments.length > 0
);
}
@@ -542,6 +581,7 @@ export class SearchService {
departureAt: true,
status: true,
isPackageOnly: true,
isGroupBookingOnly: true,
route: {
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
},
@@ -581,6 +621,7 @@ export class SearchService {
adultCount: number,
childCount?: number,
nationality?: string,
forGroupBooking = false,
) {
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
@@ -599,6 +640,8 @@ export class SearchService {
// BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere.
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
// Exclusive partition — see the comment on searchAlternatives' baseWhere.
isGroupBookingOnly: forGroupBooking,
departureAt: { gte: dayStart, lt: dayEnd },
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
@@ -609,6 +652,7 @@ export class SearchService {
where: {
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
isGroupBookingOnly: forGroupBooking,
departureAt: { gte: dayStart, lt: leg2WindowEnd },
coachAssignments: { some: {} },
},

View File

@@ -191,10 +191,12 @@ This makes it clear which segment of the route each seat is held for, enabling s
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only",
description: `Picks the requested number of available seats of the given class (preferring a contiguous row) and holds them in one step, so the caller never shows an assignment it could lose to a race before the passenger data is submitted.
description: `Picks the requested number of available seats of the given class (filling Lower berths first, then Middle, then Upper, ascending seat number within each tier) and holds them in one step, so the caller never shows an assignment it could lose to a race before the passenger data is submitted.
No manual seat selection — this is for bulk/group booking flows where staff upload a passenger list rather than picking seats on a seat map. Returns the same hold shape as POST /seats/hold.
For a round-trip group booking, call this twice — once per leg — passing \`journeyDirection: 'OUTBOUND'\`/\`'RETURN'\` so a same-schedule turnaround round trip isn't mistaken for a double-hold conflict.
Throws 409 with no partial hold created if fewer than the requested seats are available in that class.`,
})
@ApiResponse({ status: 201, description: "Seats auto-assigned and held" })
@@ -207,6 +209,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
dto.destinationStationId,
dto.seatClassName,
passengerCount,
dto.journeyDirection,
);
}

View File

@@ -67,6 +67,13 @@ export class AutoAssignHoldDto {
@ApiPropertyOptional({ example: 1, minimum: 0, description: 'Number of child passengers to assign seats for.' })
@IsOptional() @IsInt() @Min(0) childCount?: number;
@ApiPropertyOptional({
enum: JourneyDirection,
example: JourneyDirection.OUTBOUND,
description: 'Round-trip leg direction — OUTBOUND or RETURN. Omit for a plain one-way group booking (the hold defaults to ONE_WAY), preserving current behavior.',
})
@IsOptional() @IsEnum(JourneyDirection) journeyDirection?: JourneyDirection;
}
export class ReleaseHoldDto {

View File

@@ -920,6 +920,7 @@ export class SeatsService {
destinationStationId: string,
seatClassName: string,
passengerCount: number,
journeyDirection?: JourneyDirection,
) {
const seatIds = await this.autoAssignSeats(scheduleId, passengerCount, seatClassName);
// Scope the synthetic passengerId to this attempt (not just its row index) — a fixed
@@ -933,6 +934,7 @@ export class SeatsService {
scheduleId,
originStationId,
destinationStationId,
journeyDirection,
passengers,
} as HoldSeatsDto);
}

View File

@@ -23,7 +23,7 @@ import {
type SupportedPaymentMethod,
} from '@/lib/api/group-booking';
import { buildPassengerTemplate } from '@/lib/export/passenger-template';
import { countByType, parsePassengerExcel, type ParsedPassengerRow } from '@/lib/import/passenger-excel';
import { countByType, parsePassengerExcel, resolveFreeChildIndexes, type ParsedPassengerRow } from '@/lib/import/passenger-excel';
import ActionButton from '@/components/ui/ActionButton';
import DatePicker from '@/components/ui/DatePicker';
import Skeleton from '@/components/ui/Skeleton';
@@ -69,6 +69,11 @@ function emptySearchMessage(reason: SearchEmptyReason | undefined): string {
return `Every departure from ${o} to ${d} on this date was cancelled.`;
case 'PACKAGE_ONLY':
return `Departures on this date are reserved for travel packages, not regular ticketing.`;
case 'GROUP_BOOKING_ONLY':
// isGroupBookingOnly is an exclusive partition — this page only ever sees group-booking
// schedules, so an empty result here means a regular (non-group) train runs on this date
// but nothing has been set up for group booking specifically.
return `A regular train runs from ${o} to ${d} on this date, but no schedule has been set up for group booking yet — ask fleet/schedule management to create one, or try another date.`;
case 'CHECKIN_CLOSED':
return `Check-in has already closed for every departure on this date.`;
case 'FULLY_BOOKED':
@@ -246,6 +251,8 @@ function GroupBookingPageContent() {
const [originStationId, setOriginStationId] = useState('');
const [destinationStationId, setDestinationStationId] = useState('');
const [travelDate, setTravelDate] = useState('');
const [tripType, setTripType] = useState<'ONE_WAY' | 'ROUND_TRIP'>('ONE_WAY');
const [returnDate, setReturnDate] = useState('');
const [adultCount, setAdultCount] = useState<number>(1);
const [childCount, setChildCount] = useState<number>(0);
const [searchTouched, setSearchTouched] = useState(false);
@@ -265,7 +272,7 @@ function GroupBookingPageContent() {
const totalPassengers = (adultCount || 0) + (childCount || 0);
const searchValid = !!originStationId && !!destinationStationId && originStationId !== destinationStationId
&& !!travelDate && totalPassengers > 0;
&& !!travelDate && totalPassengers > 0 && (tripType === 'ONE_WAY' || !!returnDate);
const searchMutation = useMutation({
mutationFn: () =>
@@ -275,14 +282,19 @@ function GroupBookingPageContent() {
date: travelDate,
adultCount: adultCount || 0,
childCount: childCount || 0,
journeyType: 'ONE_WAY',
journeyType: tripType,
returnDate: tripType === 'ROUND_TRIP' ? returnDate : undefined,
nationality: fareTier === 'LOCAL' ? 'Ethiopian' : 'Other',
channel: 'GROUP_BOOKING',
}),
});
const runSearch = () => {
setSearchTouched(true);
if (!searchValid) return;
setResultsPhase('outbound');
setSelectedReturnSchedule(null);
setSelectedReturnClass(null);
setStep('results');
searchMutation.mutate();
};
@@ -290,13 +302,23 @@ function GroupBookingPageContent() {
// ── Step 2: results / class selection ───────────────────────────────────
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleResult | null>(null);
const [selectedClass, setSelectedClass] = useState<SelectedClass | null>(null);
// Round trip only — the outbound/return picks happen as two phases of this same step,
// mirroring the passenger portal's results page (search once, pick outbound, then return).
const [resultsPhase, setResultsPhase] = useState<'outbound' | 'return'>('outbound');
const [selectedReturnSchedule, setSelectedReturnSchedule] = useState<ScheduleResult | null>(null);
const [selectedReturnClass, setSelectedReturnClass] = useState<SelectedClass | null>(null);
const seatClassId = useMemo(() => {
if (!selectedClass) return null;
return seatClassOptions.find((sc) => sc.name === selectedClass.className)?.id ?? null;
}, [selectedClass, seatClassOptions]);
const chooseClass = (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => {
const returnSeatClassId = useMemo(() => {
if (!selectedReturnClass) return null;
return seatClassOptions.find((sc) => sc.name === selectedReturnClass.className)?.id ?? null;
}, [selectedReturnClass, seatClassOptions]);
const chooseOutboundClass = (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => {
setSelectedSchedule(schedule);
setSelectedClass({
scheduleId: schedule.scheduleId,
@@ -306,6 +328,26 @@ function GroupBookingPageContent() {
displayCurrency: cls.displayCurrency,
available: totalAvailable,
});
if (tripType === 'ROUND_TRIP') {
// Force re-confirming the return leg if staff changes their mind on outbound later.
setSelectedReturnSchedule(null);
setSelectedReturnClass(null);
setResultsPhase('return');
} else {
setStep('passengers');
}
};
const chooseReturnClass = (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => {
setSelectedReturnSchedule(schedule);
setSelectedReturnClass({
scheduleId: schedule.scheduleId,
className: cls.name,
category,
fareMinor: cls.baseFareMinor,
displayCurrency: cls.displayCurrency,
available: totalAvailable,
});
setStep('passengers');
};
@@ -322,6 +364,16 @@ function GroupBookingPageContent() {
const countMismatch = passengerRows.length > 0 && (uploadedAdults !== adultCount || uploadedChildren !== childCount);
const passengersValid = passengerRows.length > 0 && fileErrors.length === 0 && !rowsHaveErrors && !countMismatch;
// One-way only — the portal's own "1 free child per adult, no seat" rule (fare-utils.ts's
// isFirstChild). Round trip can't offer this: createGuestRoundTripBooking hard-requires a
// returnSeatId on every passenger, so every child there still needs a real seat both ways.
const freeChildFlags = useMemo(
() => (tripType === 'ONE_WAY' ? resolveFreeChildIndexes(passengerRows, adultCount) : passengerRows.map(() => false)),
[passengerRows, adultCount, tripType],
);
const freeChildrenCount = freeChildFlags.filter(Boolean).length;
const paidChildrenCount = uploadedChildren - freeChildrenCount;
const downloadTemplate = async () => {
if (!selectedSchedule || !selectedClass) return;
const blob = await buildPassengerTemplate({
@@ -370,25 +422,52 @@ function GroupBookingPageContent() {
// ── Step 4: auto-assign + hold ───────────────────────────────────────────
const [assignError, setAssignError] = useState<string | null>(null);
const [hold, setHold] = useState<AutoAssignHoldResponse | null>(null);
const [returnHold, setReturnHold] = useState<AutoAssignHoldResponse | null>(null);
const autoAssignMutation = useMutation({
mutationFn: () => {
mutationFn: async () => {
if (!selectedSchedule || !selectedClass) throw new Error('No schedule/class selected');
return groupBookingApi.autoAssignHold({
// One-way's free children (see freeChildFlags above) need no seat at all — only ask for
// seats covering adults + paid children. Round trip can't offer that (every passenger
// needs both a seatId and a returnSeatId), so it still requests one seat per child.
const outboundHold = await groupBookingApi.autoAssignHold({
scheduleId: selectedSchedule.scheduleId,
originStationId: selectedSchedule.origin.id,
destinationStationId: selectedSchedule.destination.id,
seatClassName: selectedClass.className,
adultCount,
childCount,
childCount: tripType === 'ONE_WAY' ? paidChildrenCount : childCount,
journeyDirection: tripType === 'ROUND_TRIP' ? 'OUTBOUND' : undefined,
});
if (tripType !== 'ROUND_TRIP') return { outboundHold, returnHold: null };
if (!selectedReturnSchedule || !selectedReturnClass) throw new Error('No return schedule/class selected');
try {
const returnHoldResp = await groupBookingApi.autoAssignHold({
scheduleId: selectedReturnSchedule.scheduleId,
originStationId: selectedReturnSchedule.origin.id,
destinationStationId: selectedReturnSchedule.destination.id,
seatClassName: selectedReturnClass.className,
adultCount,
childCount,
journeyDirection: 'RETURN',
});
return { outboundHold, returnHold: returnHoldResp };
} catch (err) {
// Return leg failed after outbound already succeeded — release the outbound hold
// immediately instead of leaving it locked for the rest of the hold TTL.
await groupBookingApi.releaseHold(outboundHold.holdId).catch(() => {});
throw err;
}
},
onSuccess: (data) => {
setHold(data);
onSuccess: ({ outboundHold, returnHold: returnHoldResp }) => {
setHold(outboundHold);
setReturnHold(returnHoldResp);
setAssignError(null);
setStep('confirm');
},
onError: (err: any) => {
setHold(null);
setReturnHold(null);
setAssignError(err?.response?.data?.message ?? err?.message ?? 'Not enough seats are available for this class.');
},
});
@@ -399,14 +478,23 @@ function GroupBookingPageContent() {
autoAssignMutation.mutate();
};
// Pairs each validated passenger row (upload order) with its auto-assigned seat (same order).
// Pairs each validated passenger row (upload order) with its auto-assigned seat(s). A free
// child (freeChildFlags[i]) consumes no seat at all — it's skipped when walking the hold's
// seat list, so seat N goes to the Nth non-free passenger, not the Nth row.
const seatAssignments = useMemo(() => {
if (!hold) return [];
return passengerRows.map((row, i) => ({
row,
seat: hold.passengers[i]?.seat ?? null,
}));
}, [hold, passengerRows]);
let seatIdx = 0;
return passengerRows.map((row, i) => {
const isFree = freeChildFlags[i];
const seat = isFree ? null : (hold.passengers[seatIdx++]?.seat ?? null);
return {
row,
seat,
returnSeat: returnHold?.passengers[i]?.seat ?? null,
isFree,
};
});
}, [hold, returnHold, passengerRows, freeChildFlags]);
// ── Step 5: create booking ───────────────────────────────────────────────
const [bookingError, setBookingError] = useState<string | null>(null);
@@ -417,15 +505,29 @@ function GroupBookingPageContent() {
if (!selectedSchedule || !selectedClass || !hold || !seatClassId) {
throw new Error('Missing schedule, class, or hold — go back and try again.');
}
if (tripType === 'ROUND_TRIP' && (!selectedReturnSchedule || !selectedReturnClass || !returnHold || !returnSeatClassId)) {
throw new Error('Missing return schedule, class, or hold — go back and try again.');
}
return groupBookingApi.createGroupBooking({
scheduleId: selectedSchedule.scheduleId,
holdId: hold.holdId,
originStationId: selectedSchedule.origin.id,
destinationStationId: selectedSchedule.destination.id,
seatClassId,
bookingType: 'ONE_WAY',
passengers: seatAssignments.map(({ row, seat }) => ({
seatId: seat!.id,
bookingType: tripType,
...(tripType === 'ROUND_TRIP' ? {
returnScheduleId: selectedReturnSchedule!.scheduleId,
returnHoldId: returnHold!.holdId,
returnOriginStationId: selectedReturnSchedule!.origin.id,
returnDestinationStationId: selectedReturnSchedule!.destination.id,
returnSeatClassId: returnSeatClassId!,
} : {}),
passengers: seatAssignments.map(({ row, seat, returnSeat, isFree }) => ({
// Free children (ONE_WAY only) have no seat at all — omit seatId entirely,
// matching the portal's own convention (guest-booking.service.ts treats a missing
// seatId as "unseated = free (0)").
...(isFree ? {} : { seatId: seat!.id }),
...(tripType === 'ROUND_TRIP' ? { returnSeatId: returnSeat!.id } : {}),
passengerName: row.fullName,
dateOfBirth: row.dateOfBirth,
idDocumentType: row.idDocumentType as any,
@@ -444,8 +546,9 @@ function GroupBookingPageContent() {
setStep('success');
},
onError: (err: any) => {
// The hold was released server-side on failure — a retry needs a fresh one.
// Both holds were released server-side on failure — a retry needs fresh ones.
setHold(null);
setReturnHold(null);
setBookingError(err?.response?.data?.message ?? err?.message ?? 'Could not create the booking.');
},
});
@@ -456,12 +559,16 @@ function GroupBookingPageContent() {
};
// ── Step 6: pay ───────────────────────────────────────────────────────────
const { data: paymentMethods = [] } = useQuery<SupportedPaymentMethod[]>({
const { data: paymentMethodsData } = useQuery<SupportedPaymentMethod[]>({
queryKey: ['payment-methods'],
queryFn: () => groupBookingApi.getPaymentMethods(),
enabled: step === 'success',
staleTime: 5 * 60 * 1000,
});
// Defensive: never let a malformed/unexpected response shape (e.g. an unwrap mismatch, or a
// proxy/error page returned in place of JSON) crash the page with a raw TypeError — an empty
// list here just shows "Loading payment options…" a beat longer instead.
const paymentMethods = Array.isArray(paymentMethodsData) ? paymentMethodsData : [];
const enabledPaymentMethods = paymentMethods.filter((m) => m.enabled);
const [selectedPaymentType, setSelectedPaymentType] = useState<string | null>(null);
@@ -506,8 +613,12 @@ function GroupBookingPageContent() {
setStep('search');
setSelectedSchedule(null);
setSelectedClass(null);
setResultsPhase('outbound');
setSelectedReturnSchedule(null);
setSelectedReturnClass(null);
clearUpload();
setHold(null);
setReturnHold(null);
setAssignError(null);
setBooking(null);
setBookingError(null);
@@ -532,16 +643,31 @@ function GroupBookingPageContent() {
{/* Selection summary bar — visible from Step 2 onward */}
{selectedSchedule && selectedClass && step !== 'search' && step !== 'results' && (
<div className="card flex items-center justify-between flex-wrap gap-3 animate-fade-up">
<div className="flex items-center gap-3 text-sm flex-wrap">
<div className="rounded-lg bg-primary/10 p-1.5 shrink-0">
<Train className="h-4 w-4 text-primary" />
<div className="space-y-1.5">
<div className="flex items-center gap-3 text-sm flex-wrap">
<div className="rounded-lg bg-primary/10 p-1.5 shrink-0">
<Train className="h-4 w-4 text-primary" />
</div>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{tripType === 'ROUND_TRIP' ? 'Outbound' : 'Trip'}</span>
<span className="font-semibold">{selectedSchedule.trainNumber}</span>
<span className="text-muted-foreground">{selectedSchedule.origin.name} {selectedSchedule.destination.name}</span>
<span className="text-muted-foreground">· {formatDateTime(selectedSchedule.departureAt)}</span>
<span className="text-muted-foreground">· {selectedClass.category}</span>
<span className="text-muted-foreground">· {fareTier === 'LOCAL' ? 'Local' : 'International'} rates</span>
<span className="text-muted-foreground">· {adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? ` + ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}</span>
</div>
<span className="font-semibold">{selectedSchedule.trainNumber}</span>
<span className="text-muted-foreground">{selectedSchedule.origin.name} {selectedSchedule.destination.name}</span>
<span className="text-muted-foreground">· {formatDateTime(selectedSchedule.departureAt)}</span>
<span className="text-muted-foreground">· {selectedClass.category}</span>
<span className="text-muted-foreground">· {fareTier === 'LOCAL' ? 'Local' : 'International'} rates</span>
<span className="text-muted-foreground">· {adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? ` + ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}</span>
{tripType === 'ROUND_TRIP' && selectedReturnSchedule && selectedReturnClass && (
<div className="flex items-center gap-3 text-sm flex-wrap">
<div className="rounded-lg bg-primary/10 p-1.5 shrink-0 invisible">
<Train className="h-4 w-4" />
</div>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Return</span>
<span className="font-semibold">{selectedReturnSchedule.trainNumber}</span>
<span className="text-muted-foreground">{selectedReturnSchedule.origin.name} {selectedReturnSchedule.destination.name}</span>
<span className="text-muted-foreground">· {formatDateTime(selectedReturnSchedule.departureAt)}</span>
<span className="text-muted-foreground">· {selectedReturnClass.category}</span>
</div>
)}
</div>
<button type="button" onClick={startOver} className="text-xs text-primary hover:underline shrink-0">
Start over
@@ -559,6 +685,24 @@ function GroupBookingPageContent() {
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Search Availability</h2>
</div>
<div className="flex gap-2">
{(['ONE_WAY', 'ROUND_TRIP'] as const).map((tt) => (
<button
key={tt}
type="button"
onClick={() => { setTripType(tt); if (tt === 'ONE_WAY') setReturnDate(''); }}
className={cn(
'flex-1 rounded-lg border px-3 py-2 text-sm transition-colors',
tripType === tt
? 'border-primary bg-primary/5 text-foreground font-medium'
: 'border-border text-muted-foreground hover:border-primary/50',
)}
>
{tt === 'ONE_WAY' ? 'One Way' : 'Round Trip'}
</button>
))}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<div>
<label className="label">Origin</label>
@@ -610,6 +754,21 @@ function GroupBookingPageContent() {
</div>
</div>
{tripType === 'ROUND_TRIP' && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<div>
<label className="label">Return Date</label>
<DatePicker
value={returnDate}
onChange={setReturnDate}
placeholder="Pick a return date"
minDate={travelDate ? new Date(`${travelDate}T00:00:00`) : new Date(new Date().setHours(0, 0, 0, 0))}
className="w-full [&>button]:w-full"
/>
</div>
</div>
)}
<div>
<label className="label">Fare Tier</label>
<div className="flex gap-2">
@@ -648,7 +807,9 @@ function GroupBookingPageContent() {
? 'Enter at least one adult or child.'
: originStationId && originStationId === destinationStationId
? 'Origin and destination must be different.'
: 'Fill in origin, destination, and travel date.'}
: tripType === 'ROUND_TRIP' && travelDate && !returnDate
? 'Pick a return date.'
: 'Fill in origin, destination, and travel date.'}
</p>
)}
@@ -661,10 +822,35 @@ function GroupBookingPageContent() {
{/* ── Step 2: Results ────────────────────────────────────────────── */}
{step === 'results' && (
<div className="space-y-4 animate-fade-up">
<button type="button" onClick={() => setStep('search')} className="flex items-center gap-1 text-xs text-primary hover:underline">
<ArrowLeft className="h-3.5 w-3.5" /> Back to search
<button
type="button"
onClick={() => tripType === 'ROUND_TRIP' && resultsPhase === 'return' ? setResultsPhase('outbound') : setStep('search')}
className="flex items-center gap-1 text-xs text-primary hover:underline"
>
<ArrowLeft className="h-3.5 w-3.5" /> {tripType === 'ROUND_TRIP' && resultsPhase === 'return' ? 'Back to outbound' : 'Back to search'}
</button>
{tripType === 'ROUND_TRIP' && (
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{resultsPhase === 'outbound' ? 'Step 2a — Choose the outbound trip' : 'Step 2b — Choose the return trip'}
</p>
)}
{tripType === 'ROUND_TRIP' && resultsPhase === 'return' && selectedSchedule && selectedClass && (
<div className="card flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3 text-sm flex-wrap">
<CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400 shrink-0" />
<span className="font-semibold">{selectedSchedule.trainNumber}</span>
<span className="text-muted-foreground">{selectedSchedule.origin.name} {selectedSchedule.destination.name}</span>
<span className="text-muted-foreground">· {formatDateTime(selectedSchedule.departureAt)}</span>
<span className="text-muted-foreground">· {selectedClass.category}</span>
</div>
<button type="button" onClick={() => setResultsPhase('outbound')} className="text-xs text-primary hover:underline shrink-0">
Change outbound
</button>
</div>
)}
{searchMutation.isPending && (
<div className="space-y-4">
{Array.from({ length: 2 }).map((_, i) => (
@@ -696,28 +882,59 @@ function GroupBookingPageContent() {
</div>
)}
{searchMutation.isSuccess && searchMutation.data.outbound.length === 0 && (
<div className="card py-12 text-center text-muted-foreground">
<Train className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-foreground font-medium">No schedules found for this search.</p>
<p className="text-xs mt-1">{emptySearchMessage(searchMutation.data.outboundReason)}</p>
</div>
)}
{searchMutation.isSuccess && resultsPhase === 'outbound' && (
<>
{searchMutation.data.outbound.length === 0 && (
<div className="card py-12 text-center text-muted-foreground">
<Train className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-foreground font-medium">No schedules found for this search.</p>
<p className="text-xs mt-1">{emptySearchMessage(searchMutation.data.outboundReason)}</p>
</div>
)}
{searchMutation.isSuccess && (searchMutation.data.alternativeOutbound?.length ?? 0) > 0 && (
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5" /> Nearby schedules for the same route
</p>
{searchMutation.data!.alternativeOutbound!.map((schedule) => (
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseClass} />
{(searchMutation.data.alternativeOutbound?.length ?? 0) > 0 && (
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5" /> Nearby schedules for the same route
</p>
{searchMutation.data.alternativeOutbound!.map((schedule) => (
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseOutboundClass} />
))}
</div>
)}
{searchMutation.data.outbound.map((schedule) => (
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseOutboundClass} />
))}
</div>
</>
)}
{(searchMutation.data?.outbound ?? []).map((schedule) => (
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseClass} />
))}
{searchMutation.isSuccess && tripType === 'ROUND_TRIP' && resultsPhase === 'return' && (
<>
{(searchMutation.data.inbound?.length ?? 0) === 0 && (
<div className="card py-12 text-center text-muted-foreground">
<Train className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-foreground font-medium">No return schedules found for this search.</p>
<p className="text-xs mt-1">{emptySearchMessage(searchMutation.data.inboundReason)}</p>
</div>
)}
{(searchMutation.data.alternativeInbound?.length ?? 0) > 0 && (
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5" /> Nearby return schedules for the same route
</p>
{searchMutation.data.alternativeInbound!.map((schedule) => (
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseReturnClass} />
))}
</div>
)}
{(searchMutation.data.inbound ?? []).map((schedule) => (
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseReturnClass} />
))}
</>
)}
</div>
)}
@@ -735,10 +952,20 @@ function GroupBookingPageContent() {
</div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Passenger Information</h2>
</div>
<p className="text-sm text-muted-foreground mb-4 ml-9">
<p className="text-sm text-muted-foreground mb-1 ml-9">
Download the template, fill in one row per passenger, then upload the completed file.
Need exactly <strong className="text-foreground">{totalPassengers}</strong> passenger{totalPassengers === 1 ? '' : 's'} ({adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? `, ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}).
</p>
{tripType === 'ONE_WAY' && childCount > 0 && (
<p className="text-xs text-muted-foreground mb-4 ml-9">
The first {Math.min(childCount, adultCount)} child{Math.min(childCount, adultCount) === 1 ? '' : 'ren'} under 5 (by row order) travel{Math.min(childCount, adultCount) === 1 ? 's' : ''} free with no assigned seat, matching the passenger portal's policy — one free child per adult. Any additional children get a seat and pay the child fare.
</p>
)}
{tripType === 'ROUND_TRIP' && childCount > 0 && (
<p className="text-xs text-muted-foreground mb-4 ml-9">
Round trip requires a seat for every child on both legs — the free-child policy only applies to one-way bookings.
</p>
)}
<div className="ml-9 flex items-center gap-3 flex-wrap mb-4">
<ActionButton icon={Download} variant="secondary" onClick={downloadTemplate}>
@@ -810,12 +1037,19 @@ function GroupBookingPageContent() {
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{passengerRows.map((row) => (
{passengerRows.map((row, i) => (
<tr key={row.rowNumber} className={row.errors.length > 0 ? 'bg-red-50/60 dark:bg-red-950/20' : undefined}>
<td className="px-4 py-2 text-xs text-muted-foreground">{row.rowNumber}</td>
<td className="px-4 py-2 whitespace-nowrap">{row.fullName || ''}</td>
<td className="px-4 py-2 whitespace-nowrap text-muted-foreground">{row.dateOfBirth || ''}</td>
<td className="px-4 py-2 whitespace-nowrap">{row.passengerType || '—'}</td>
<td className="px-4 py-2 whitespace-nowrap">
{row.passengerType || ''}
{freeChildFlags[i] && (
<span className="ml-1.5 inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/30 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-700 dark:text-emerald-400">
Free · no seat
</span>
)}
</td>
<td className="px-4 py-2 whitespace-nowrap text-muted-foreground">{row.idDocumentType || ''}</td>
<td className="px-4 py-2 whitespace-nowrap text-muted-foreground">{row.nationality || ''}</td>
<td className="px-4 py-2 text-xs">
@@ -876,20 +1110,53 @@ function GroupBookingPageContent() {
</div>
<p className="text-xs text-muted-foreground mb-4">Read-only — seats are assigned by the system, not selected manually.</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{seatAssignments.map(({ row, seat }) => (
{seatAssignments.map(({ row, seat, isFree }) => (
<div key={row.rowNumber} className="flex items-center gap-3 rounded-lg border border-border p-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 font-mono text-xs font-bold text-primary">
{seat ? (seat.seatNumber ?? seat.label ?? '?') : '—'}
<div className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg font-mono text-xs font-bold',
isFree ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' : 'bg-primary/10 text-primary',
)}>
{isFree ? 'Free' : seat ? (seat.seatNumber ?? seat.label ?? '?') : ''}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{row.fullName}</p>
<p className="text-xs text-muted-foreground">{row.passengerType} · {seat?.coach ?? '—'}</p>
<p className="text-xs text-muted-foreground">{row.passengerType} · {isFree ? 'no seat' : (seat?.coach ?? '')}</p>
</div>
</div>
))}
</div>
</div>
{tripType === 'ROUND_TRIP' && returnHold && (
<div className="card">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-primary/10 p-1.5">
<ArmchairIcon className="h-4 w-4 text-primary" />
</div>
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Return Seats Assigned Automatically
</h3>
</div>
<span className="text-xs text-muted-foreground">Held for {Math.floor(returnHold.ttlSeconds / 60)}m {returnHold.ttlSeconds % 60}s</span>
</div>
<p className="text-xs text-muted-foreground mb-4">Read-only — seats are assigned by the system, not selected manually.</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{seatAssignments.map(({ row, returnSeat }) => (
<div key={row.rowNumber} className="flex items-center gap-3 rounded-lg border border-border p-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 font-mono text-xs font-bold text-primary">
{returnSeat ? (returnSeat.seatNumber ?? returnSeat.label ?? '?') : ''}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{row.fullName}</p>
<p className="text-xs text-muted-foreground">{row.passengerType} · {returnSeat?.coach ?? ''}</p>
</div>
</div>
))}
</div>
</div>
)}
{bookingError && (
<div className="rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/30 p-3 text-sm text-red-700 dark:text-red-300 flex items-center justify-between gap-3">
<span className="flex items-center gap-2"><AlertTriangle className="h-4 w-4 shrink-0" /> {bookingError}</span>
@@ -899,7 +1166,16 @@ function GroupBookingPageContent() {
<div className="card flex items-center justify-between flex-wrap gap-3">
<div className="text-sm text-muted-foreground">
{totalPassengers} passenger{totalPassengers === 1 ? '' : 's'} · {selectedClass.category} · <span className="font-semibold text-foreground">{formatCurrency(selectedClass.fareMinor * totalPassengers, selectedClass.displayCurrency)}</span> estimated total
{totalPassengers} passenger{totalPassengers === 1 ? '' : 's'}
{tripType === 'ONE_WAY' && freeChildrenCount > 0 && <> ({freeChildrenCount} free)</>} · {selectedClass.category}
{tripType === 'ROUND_TRIP' && selectedReturnClass && <> / {selectedReturnClass.category}</>} ·{' '}
<span className="font-semibold text-foreground">
{formatCurrency(
selectedClass.fareMinor * (tripType === 'ONE_WAY' ? adultCount + paidChildrenCount : totalPassengers)
+ (selectedReturnClass?.fareMinor ?? 0) * totalPassengers,
selectedClass.displayCurrency,
)}
</span> estimated total
</div>
<ActionButton
icon={CheckCircle2}
@@ -947,7 +1223,10 @@ function GroupBookingPageContent() {
</div>
<div>
<p className="text-xs text-muted-foreground">Coach / Class</p>
<p className="font-semibold">{selectedClass?.category ?? '—'}</p>
<p className="font-semibold">
{selectedClass?.category ?? ''}
{tripType === 'ROUND_TRIP' && selectedReturnClass && <> / {selectedReturnClass.category}</>}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Adults / Children</p>
@@ -955,7 +1234,7 @@ function GroupBookingPageContent() {
</div>
<div>
<p className="text-xs text-muted-foreground">Total Passengers</p>
<p className="font-semibold">{booking.seats.length}</p>
<p className="font-semibold">{totalPassengers}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Total Fare</p>
@@ -964,12 +1243,38 @@ function GroupBookingPageContent() {
</div>
</div>
{tripType === 'ROUND_TRIP' && selectedReturnSchedule && (
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-3">Return Trip</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<p className="text-xs text-muted-foreground">Train</p>
<p className="font-semibold">{selectedReturnSchedule.trainNumber} · {selectedReturnSchedule.trainName}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Origin → Destination</p>
<p className="font-semibold">{selectedReturnSchedule.origin.name} → {selectedReturnSchedule.destination.name}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Departure → Arrival</p>
<p className="font-semibold">{formatDateTime(selectedReturnSchedule.departureAt)} → {formatDateTime(selectedReturnSchedule.arrivalAt)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Class</p>
<p className="font-semibold">{selectedReturnClass?.category ?? ''}</p>
</div>
</div>
</div>
)}
<div className="card p-0">
<div className="px-4 pt-4 pb-3">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Passenger List</h3>
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{tripType === 'ROUND_TRIP' ? 'Outbound Passenger List' : 'Passenger List'}
</h3>
</div>
<div className="grid grid-cols-3 gap-3 px-4 pb-4">
{booking.seats.map((s, i) => (
{booking.seats.filter((s) => s.leg !== 2).map((s, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border border-border p-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 font-mono text-xs font-bold text-primary">
{s.seat?.seatNumber ?? '?'}
@@ -982,9 +1287,45 @@ function GroupBookingPageContent() {
</div>
</div>
))}
{/* Free children have no BookingSeat row at all — list them separately so they
don't silently disappear from the confirmation. */}
{seatAssignments.filter((a) => a.isFree).map(({ row }) => (
<div key={row.rowNumber} className="flex items-center gap-3 rounded-lg border border-border p-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 font-mono text-xs font-bold text-emerald-700 dark:text-emerald-400">
Free
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{row.fullName}</p>
<p className="text-xs text-muted-foreground">CHILD · no seat (free)</p>
</div>
</div>
))}
</div>
</div>
{tripType === 'ROUND_TRIP' && (
<div className="card p-0">
<div className="px-4 pt-4 pb-3">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Return Passenger List</h3>
</div>
<div className="grid grid-cols-3 gap-3 px-4 pb-4">
{booking.seats.filter((s) => s.leg === 2).map((s, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border border-border p-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 font-mono text-xs font-bold text-primary">
{s.seat?.seatNumber ?? '?'}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{s.passengerName}</p>
<p className="text-xs text-muted-foreground">
{s.passengerCategory} · {seatTypeLabel(s.seat?.bedPosition)} · Seat {s.seat?.seatNumber ?? '—'} · Coach {s.seat?.coach?.number}
</p>
</div>
</div>
))}
</div>
</div>
)}
{/* ── Pay now ─────────────────────────────────────────────────── */}
{!paymentResult && (
<div className="card">

View File

@@ -9,6 +9,7 @@ import {
TicketCheck, Users, TrendingUp, ShieldCheck, MailCheck,
} from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
import { getErrorMessage } from '@/lib/api-client';
const EDR_GREEN = 'rgb(20, 113, 76)';
@@ -52,11 +53,11 @@ export default function LoginPage() {
await login(identifier.trim(), password);
router.push('/dashboard');
} catch (err: any) {
const msg = err.message || err.response?.data?.message || '';
if (msg === 'ACCESS_DENIED') {
const rawMessage = err.response?.data?.message;
if (rawMessage === 'ACCESS_DENIED') {
setError('This account does not have back-office access. Contact your administrator.');
} else {
setError(err.response?.data?.message || msg || 'Invalid credentials. Please try again.');
setError(getErrorMessage(err, 'Invalid credentials. Please try again.'));
}
} finally {
setLoading(false);
@@ -71,11 +72,11 @@ export default function LoginPage() {
await iamAuthApi.forgotPassword(forgotIdentifier.trim());
setForgotSent(true);
} catch (err: any) {
const msg = err.response?.data?.message || err.message || '';
const rawMessage = err.response?.data?.message;
setForgotError(
msg === 'user_not_found'
rawMessage === 'user_not_found'
? 'No account found with that email or phone number.'
: msg || 'Failed to send the reset link. Please try again.'
: getErrorMessage(err, 'Failed to send the reset link. Please try again.')
);
} finally {
setForgotLoading(false);

View File

@@ -10,6 +10,7 @@ import Modal from '@/components/ui/Modal';
import Pagination from '@/components/ui/Pagination';
import { packagesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { getErrorMessage } from '@/lib/api-client';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
@@ -116,7 +117,7 @@ export default function PackageBookingsPage() {
<div className="card">
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
Error: {(error as any)?.response?.data?.message || (error as any)?.message || String(error)}
Error: {getErrorMessage(error)}
</div>
)}
<div className="flex flex-wrap gap-3 mb-4">

View File

@@ -2,15 +2,21 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, CheckCircle, Eye, Layers, Trash2 } from 'lucide-react';
import { Plus, Edit, CheckCircle, Eye, Layers, Trash2, ImagePlus, ImageOff, X } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal';
import { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api';
import { getErrorMessage } from '@/lib/api-client';
import { formatDateTime, formatCurrency } from '@/lib/utils';
// Mirrors the backend's own limits (packages/package-image-upload.options.ts) so a bad file is
// rejected instantly client-side instead of round-tripping to the server first.
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const toLocal = (iso?: string) => {
if (!iso) return '';
const d = new Date(iso);
@@ -48,6 +54,15 @@ export default function PackagesPage() {
const [deletePackageConfirm, setDeletePackageConfirm] = useState<any>(null);
const [deletePackageError, setDeletePackageError] = useState<string | null>(null);
const [deletePackageCascade, setDeletePackageCascade] = useState(false);
// Image upload: `imageFile`/`imagePreviewUrl` track a newly-selected-but-not-yet-uploaded file
// (local object URL preview); `existingImageUrl` is the package's current server-side image
// when editing, shown until/unless the admin picks a replacement.
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
const [existingImageUrl, setExistingImageUrl] = useState<string | null>(null);
const [imageError, setImageError] = useState<string | null>(null);
const [imageUploadError, setImageUploadError] = useState<string | null>(null);
const [removeImageConfirm, setRemoveImageConfirm] = useState<any>(null);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
@@ -141,6 +156,23 @@ export default function PackagesPage() {
onError: (e: any) => setTierError(e?.response?.data?.message || e?.message || 'Failed to delete tier'),
});
const uploadImageMutation = useMutation({
mutationFn: ({ id, file }: { id: string; file: File }) => packagesApi.uploadImage(id, file),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setImageUploadError(null); },
onError: (e: any) => setImageUploadError(getErrorMessage(e, 'Failed to upload package image')),
});
const removeImageMutation = useMutation({
mutationFn: (id: string) => packagesApi.removeImage(id),
onSuccess: (updated: any) => {
queryClient.invalidateQueries({ queryKey: ['packages'] });
setRemoveImageConfirm(null);
setExistingImageUrl(updated?.imageUrl ?? null);
setViewPackage((prev: any) => (prev && prev.id === updated?.id ? { ...prev, imageUrl: null } : prev));
},
onError: (e: any) => setImageUploadError(getErrorMessage(e, 'Failed to remove package image')),
});
const openEditTier = (tier: any) => {
setEditingTier(tier);
setTierForm({ seatClassId: tier.seatClassId ?? '', seatType: tier.seatType, label: tier.label, priceMinor: String(tier.priceMinor), availableSeats: String(tier.availableSeats) });
@@ -163,13 +195,29 @@ export default function PackagesPage() {
}
};
// Deliberately does not touch imageUploadError — that's shown in a page-level banner (outside
// this modal) precisely because it can still be set after the modal has already auto-closed
// (see handleSubmit), and clearing it here would wipe it out before the user ever sees it.
const resetImageSelection = () => {
setImageFile(null);
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
setImagePreviewUrl(null);
setImageError(null);
};
const openCreate = () => {
setForm(emptyForm);
setEditingId(null);
resetImageSelection();
setImageUploadError(null);
setExistingImageUrl(null);
setModalMode('create');
};
const openEdit = (pkg: any) => {
resetImageSelection();
setImageUploadError(null);
setExistingImageUrl(pkg.imageUrl ?? null);
setForm({
code: pkg.code ?? '',
name: pkg.name ?? '',
@@ -216,11 +264,23 @@ export default function PackagesPage() {
validUntil: form.validUntil,
priceTiers: [],
};
// The image is uploaded as a separate follow-up call (the DTO here carries no image field —
// see packages.service.ts's uploadImage) so it must run after the package itself exists.
let targetId = editingId;
if (modalMode === 'edit' && editingId) {
await updateMutation.mutateAsync({ id: editingId, data: payload });
} else {
await createMutation.mutateAsync(payload);
const created = await createMutation.mutateAsync(payload);
targetId = created?.id ?? null;
}
if (imageFile && targetId) {
try {
await uploadImageMutation.mutateAsync({ id: targetId, file: imageFile });
} catch {
// surfaced via imageUploadError banner — the package itself was already saved successfully
}
}
resetImageSelection();
};
const field = (key: keyof typeof form) => ({
@@ -229,6 +289,24 @@ export default function PackagesPage() {
setForm((f) => ({ ...f, [key]: e.target.value })),
});
const handleImageFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = ''; // allow re-selecting the same file after a validation error
if (!file) return;
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
setImageError('Image must be JPEG, PNG, WEBP, or GIF.');
return;
}
if (file.size > MAX_IMAGE_BYTES) {
setImageError(`Image must be ${Math.round(MAX_IMAGE_BYTES / (1024 * 1024))}MB or smaller.`);
return;
}
setImageError(null);
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
setImageFile(file);
setImagePreviewUrl(URL.createObjectURL(file));
};
const scheduleLabel = (s: any) => {
const from = s.originStation?.name ?? s.originStationId ?? '?';
const to = s.destinationStation?.name ?? s.destinationStationId ?? '?';
@@ -237,7 +315,19 @@ export default function PackagesPage() {
};
const columns = [
{ key: 'code', label: 'Package',
{
key: 'image', label: '',
render: (p: any) => (
p.imageUrl ? (
<img src={p.imageUrl} alt="" className="h-10 w-10 rounded object-cover border border-border" />
) : (
<div className="h-10 w-10 rounded border border-border bg-muted flex items-center justify-center">
<ImageOff className="h-4 w-4 text-muted-foreground" />
</div>
)
),
},
{ key: 'code', label: 'Package',
render: (pkg: any) => (
<div className="text-sm">
<div>{pkg.code}</div>
@@ -299,7 +389,7 @@ export default function PackagesPage() {
},
];
const isPending = createMutation.isPending || updateMutation.isPending;
const isPending = createMutation.isPending || updateMutation.isPending || uploadImageMutation.isPending;
const allItems: any[] = data?.items || [];
const filteredItems = allItems.filter((p) => {
@@ -320,6 +410,18 @@ export default function PackagesPage() {
<ActionButton icon={Plus} onClick={openCreate}>New Package</ActionButton>
</div>
{/* The package itself may already be saved and this modal closed by the time an image
upload/removal fails (see handleSubmit) — surfaced here rather than inside the modal
so it's never silently lost. */}
{imageUploadError && (
<div className="flex items-center justify-between rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-300">
<span>{imageUploadError}</span>
<button type="button" className="ml-3 shrink-0" onClick={() => setImageUploadError(null)}>
<X className="h-4 w-4" />
</button>
</div>
)}
<div className="card">
<div className="mb-4 space-y-3">
<div className="flex flex-wrap gap-3">
@@ -366,6 +468,14 @@ export default function PackagesPage() {
<Modal isOpen={!!viewPackage} onClose={() => setViewPackage(null)} title="Package Details" size="lg">
{viewPackage && (
<div className="space-y-4 text-sm">
{viewPackage.imageUrl ? (
<img src={viewPackage.imageUrl} alt={viewPackage.name} className="w-full max-h-56 rounded-lg object-cover border border-border" />
) : (
<div className="w-full h-32 rounded-lg border border-dashed border-border bg-muted flex items-center justify-center gap-2 text-muted-foreground">
<ImageOff className="h-5 w-5" />
<span>No image</span>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div><span className="label">Code</span><p className="font-mono font-semibold">{viewPackage.code}</p></div>
<div><span className="label">Status</span><p>{viewPackage.status}</p></div>
@@ -549,10 +659,23 @@ export default function PackagesPage() {
error={tierError ?? undefined}
/>
{/* Remove Image Confirmation */}
<ConfirmDialog
isOpen={!!removeImageConfirm}
onClose={() => setRemoveImageConfirm(null)}
onConfirm={() => removeImageMutation.mutate(removeImageConfirm.id)}
title="Remove Package Image"
message={`Remove the image for "${removeImageConfirm?.name}"? The package itself will not be deleted.`}
confirmText="Remove Image"
isDanger
isLoading={removeImageMutation.isPending}
error={imageUploadError ?? undefined}
/>
{/* Create / Edit Modal */}
<Modal
isOpen={modalMode !== null}
onClose={() => setModalMode(null)}
onClose={() => { setModalMode(null); resetImageSelection(); }}
title={modalMode === 'edit' ? 'Edit Package' : 'New Package'}
size="lg"
>
@@ -571,6 +694,40 @@ export default function PackagesPage() {
<textarea className="input" rows={2} placeholder="Optional description" {...field('description')} />
</div>
<div className="col-span-2">
<label className="label">Package Image</label>
<div className="flex items-start gap-4">
{imagePreviewUrl ? (
<img src={imagePreviewUrl} alt="Preview" className="h-24 w-24 rounded-lg object-cover border border-border" />
) : existingImageUrl ? (
<img src={existingImageUrl} alt="Current" className="h-24 w-24 rounded-lg object-cover border border-border" />
) : (
<div className="h-24 w-24 rounded-lg border border-dashed border-border bg-muted flex items-center justify-center">
<ImagePlus className="h-6 w-6 text-muted-foreground" />
</div>
)}
<div className="flex-1 space-y-2">
<input type="file" accept="image/jpeg,image/png,image/webp,image/gif" className="input" onChange={handleImageFileChange} />
<p className="text-xs text-muted-foreground">JPEG, PNG, WEBP, or GIF. Max 5MB.</p>
{imageError && <p className="text-xs text-red-600 dark:text-red-400">{imageError}</p>}
{imageFile && (
<button type="button" className="text-xs text-primary underline" onClick={resetImageSelection}>
<X className="h-3 w-3 inline -mt-0.5 mr-0.5" />Clear selected file
</button>
)}
{!imageFile && modalMode === 'edit' && existingImageUrl && (
<button
type="button"
className="text-xs text-red-600 dark:text-red-400 underline block"
onClick={() => { setImageUploadError(null); setRemoveImageConfirm({ id: editingId, name: form.name }); }}
>
Remove current image
</button>
)}
</div>
</div>
</div>
<div>
<label className="label">Origin Station *</label>
<select className="input" required {...field('originStationId')}>
@@ -660,7 +817,7 @@ export default function PackagesPage() {
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton type="button" variant="secondary" onClick={() => setModalMode(null)}>Cancel</ActionButton>
<ActionButton type="button" variant="secondary" onClick={() => { setModalMode(null); resetImageSelection(); }}>Cancel</ActionButton>
<ActionButton type="submit" loading={isPending}>
{modalMode === 'edit' ? 'Update Package' : 'Create Package'}
</ActionButton>

View File

@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Eye, EyeOff, ArrowRight, ArrowLeft, Loader2, CheckCircle2 } from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
import { getErrorMessage } from '@/lib/api-client';
function ResetPasswordForm() {
const router = useRouter();
@@ -41,8 +42,7 @@ function ResetPasswordForm() {
setSuccess(true);
setTimeout(() => router.push('/login'), 2000);
} catch (err: any) {
const msg = err.response?.data?.message || err.message || '';
setError(msg || 'Failed to reset password. The link may have expired — request a new one from the sign-in page.');
setError(getErrorMessage(err, 'Failed to reset password. The link may have expired — request a new one from the sign-in page.'));
} finally {
setLoading(false);
}

View File

@@ -30,9 +30,22 @@ interface Schedule {
destinationStation?: { id: string; name: string };
coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>;
isPackageOnly?: boolean;
isGroupBookingOnly?: boolean;
liveStatus?: { delayMinutes: number } | null;
}
/** Mutually-exclusive UI view over the two independent isPackageOnly/isGroupBookingOnly flags
* the API actually stores — same pair, just presented as one choice instead of two checkboxes. */
type ScheduleVisibility = 'NORMAL' | 'PACKAGE_ONLY' | 'GROUP_ONLY';
function visibilityOf(isPackageOnly?: boolean, isGroupBookingOnly?: boolean): ScheduleVisibility {
if (isGroupBookingOnly) return 'GROUP_ONLY';
if (isPackageOnly) return 'PACKAGE_ONLY';
return 'NORMAL';
}
function visibilityFlags(v: ScheduleVisibility): { isPackageOnly: boolean; isGroupBookingOnly: boolean } {
return { isPackageOnly: v === 'PACKAGE_ONLY', isGroupBookingOnly: v === 'GROUP_ONLY' };
}
interface Train {
id: string;
name: string;
@@ -84,7 +97,7 @@ function SchedulesPageContent() {
const [bulkCoachRows, setBulkCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false });
const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({
@@ -122,6 +135,7 @@ function SchedulesPageContent() {
status: 'SCHEDULED',
coachIds: [] as string[],
isPackageOnly: false,
isGroupBookingOnly: false,
});
const [filters, setFilters] = useState({
@@ -188,7 +202,7 @@ function SchedulesPageContent() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['schedules'] });
setShowAddModal(false);
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false });
setAddCoachRows([]);
setError(null);
},
@@ -288,6 +302,8 @@ function SchedulesPageContent() {
routeId: addForm.routeId,
departureAt: dep.toISOString(),
arrivalAt: arr.toISOString(),
isPackageOnly: addForm.isPackageOnly,
isGroupBookingOnly: addForm.isGroupBookingOnly,
...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }),
});
};
@@ -312,6 +328,7 @@ function SchedulesPageContent() {
arrivalAt: eatLocalToISO(editForm.arrivalAt),
status: editForm.status,
isPackageOnly: editForm.isPackageOnly,
isGroupBookingOnly: editForm.isGroupBookingOnly,
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
coachId,
positionNumber: idx + 1,
@@ -356,6 +373,7 @@ function SchedulesPageContent() {
status: schedule.status,
coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [],
isPackageOnly: schedule.isPackageOnly ?? false,
isGroupBookingOnly: schedule.isGroupBookingOnly ?? false,
});
setError(null);
setShowEditModal(true);
@@ -494,6 +512,9 @@ function SchedulesPageContent() {
{schedule.isPackageOnly && (
<span className="edr-badge edr-badge-warning">PKG</span>
)}
{schedule.isGroupBookingOnly && (
<span className="edr-badge edr-badge-warning">GROUP</span>
)}
</div>
),
},
@@ -778,7 +799,7 @@ function SchedulesPageContent() {
<Modal
isOpen={showAddModal}
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false }); setAddCoachRows([]); setError(null); }}
title="Add Schedule"
size="xl"
>
@@ -817,6 +838,37 @@ function SchedulesPageContent() {
/>
</div>
<div className="border-t pt-4">
<label className="label mb-2">Visibility</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{([
{ value: 'NORMAL', title: 'Normal', desc: 'Bookable through the passenger portal like any other schedule' },
{ value: 'PACKAGE_ONLY', title: 'Package Only', desc: 'Hide from public search — reserved for package bookings' },
{ value: 'GROUP_ONLY', title: 'Group Booking Only', desc: 'Hide from public search — reserved for staff group bookings' },
] as const).map((opt) => {
const selected = visibilityOf(addForm.isPackageOnly, addForm.isGroupBookingOnly) === opt.value;
return (
<label
key={opt.value}
className={`flex items-start gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${selected ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}`}
>
<input
type="radio"
name="add-visibility"
className="w-4 h-4 mt-0.5"
checked={selected}
onChange={() => setAddForm({ ...addForm, ...visibilityFlags(opt.value) })}
/>
<span className="text-sm">
<span className="font-medium block">{opt.title}</span>
<span className="block text-xs text-muted-foreground">{opt.desc}</span>
</span>
</label>
);
})}
</div>
</div>
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-2">
<label className="label mb-0">Coaches</label>
@@ -875,7 +927,7 @@ function SchedulesPageContent() {
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton type="button" variant="secondary" onClick={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}>Cancel</ActionButton>
<ActionButton type="button" variant="secondary" onClick={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false }); setAddCoachRows([]); setError(null); }}>Cancel</ActionButton>
<ActionButton type="submit" loading={createScheduleMutation.isPending}>Create Schedule</ActionButton>
</div>
</form>
@@ -1155,18 +1207,35 @@ function SchedulesPageContent() {
</select>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg border border-border">
<input
type="checkbox"
id="isPackageOnly"
checked={editForm.isPackageOnly}
onChange={(e) => setEditForm({ ...editForm, isPackageOnly: e.target.checked })}
className="w-4 h-4 rounded"
/>
<label htmlFor="isPackageOnly" className="text-sm cursor-pointer">
<span className="font-medium">Package Only</span>
<span className="block text-xs text-muted-foreground">Hide from public search reserved for package bookings</span>
</label>
<div>
<label className="label mb-2">Visibility</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{([
{ value: 'NORMAL', title: 'Normal', desc: 'Bookable through the passenger portal like any other schedule' },
{ value: 'PACKAGE_ONLY', title: 'Package Only', desc: 'Hide from public search — reserved for package bookings' },
{ value: 'GROUP_ONLY', title: 'Group Booking Only', desc: 'Hide from public search — reserved for staff group bookings' },
] as const).map((opt) => {
const selected = visibilityOf(editForm.isPackageOnly, editForm.isGroupBookingOnly) === opt.value;
return (
<label
key={opt.value}
className={`flex items-start gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${selected ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}`}
>
<input
type="radio"
name="edit-visibility"
className="w-4 h-4 mt-0.5"
checked={selected}
onChange={() => setEditForm({ ...editForm, ...visibilityFlags(opt.value) })}
/>
<span className="text-sm">
<span className="font-medium block">{opt.title}</span>
<span className="block text-xs text-muted-foreground">{opt.desc}</span>
</span>
</label>
);
})}
</div>
</div>
<div>

View File

@@ -2,6 +2,30 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.';
const NETWORK_ERROR_MESSAGE = 'Could not reach the server. Please check your connection and try again.';
/**
* Extracts a user-facing message from a failed request. Prefers a real backend-provided message
* (joining a NestJS validation array into one line); otherwise falls back to a friendly generic
* message — never raw client/network text like "Request failed with status code 500" or
* "Network Error", which is what axios puts in `error.message` when there's nothing better.
* Every page in this app should use this (or rely on the response interceptor below, which
* normalizes the same error in place) instead of reading `err.message` directly.
*/
export function getErrorMessage(error: unknown, fallback: string = GENERIC_ERROR_MESSAGE): string {
const err = error as any;
const raw = err?.response?.data?.message;
if (Array.isArray(raw) && raw.length > 0) {
const joined = raw.filter((m: unknown) => typeof m === 'string' && m.trim()).join('; ');
if (joined) return joined;
} else if (typeof raw === 'string' && raw.trim()) {
return raw;
}
if (err?.isAxiosError && !err.response) return NETWORK_ERROR_MESSAGE;
return fallback;
}
class ApiClient {
private client: AxiosInstance;
@@ -30,6 +54,20 @@ class ApiClient {
window.location.href = '/login';
}
}
// Normalize in place so every existing `err?.response?.data?.message || err?.message ||
// '<fallback>'` call site across the app picks up a friendly message automatically,
// instead of raw axios/network text or an unjoined NestJS validation array.
try {
const friendly = getErrorMessage(error);
if (error.response?.data && typeof error.response.data === 'object') {
error.response.data.message = friendly;
}
error.message = friendly;
} catch {
// Best-effort — never let normalization itself break the original rejection.
}
return Promise.reject(error);
},
);

View File

@@ -8,9 +8,15 @@ export interface SearchTripsRequest {
date: string;
adultCount: number;
childCount?: number;
journeyType: 'ONE_WAY';
journeyType: 'ONE_WAY' | 'ROUND_TRIP';
/** Required when journeyType is ROUND_TRIP. */
returnDate?: string;
/** Drives which fare tier (Local vs International) gets quoted — see fareTier in the page component. */
nationality?: string;
/** Always 'GROUP_BOOKING' for this app — search returns ONLY schedules marked
* isGroupBookingOnly (an exclusive partition, not additive): normal passenger-facing
* schedules never show up here, and group-only schedules never show up in the portal. */
channel?: 'PORTAL' | 'GROUP_BOOKING';
}
export interface ScheduleClassOption {
@@ -50,6 +56,7 @@ export type SearchEmptyReasonCode =
| 'NO_SCHEDULE_ON_DATE'
| 'CANCELLED'
| 'PACKAGE_ONLY'
| 'GROUP_BOOKING_ONLY'
| 'CHECKIN_CLOSED'
| 'FULLY_BOOKED';
@@ -67,6 +74,11 @@ export interface SearchTripsResponse {
outboundReason?: SearchEmptyReason;
/** Nearby schedules for the same station pair on a different date, offered when `outbound` is empty. */
alternativeOutbound?: ScheduleResult[];
/** Return-leg schedules — present when the request's journeyType was ROUND_TRIP. */
inbound?: ScheduleResult[];
requestedReturnDate?: string;
inboundReason?: SearchEmptyReason;
alternativeInbound?: ScheduleResult[];
}
// ── Seat classes (GET /seat-classes) ───────────────────────────────────────
@@ -85,6 +97,8 @@ export interface AutoAssignHoldRequest {
seatClassName: string;
adultCount: number;
childCount?: number;
/** Round-trip leg tag — omit for a one-way booking. */
journeyDirection?: 'OUTBOUND' | 'RETURN';
}
export interface HeldPassengerSeat {
@@ -110,7 +124,8 @@ export interface AutoAssignHoldResponse {
// ── Group booking creation (POST /bookings/group) ──────────────────────────
export interface GroupBookingPassengerInput {
seatId: string;
/** Omit for a free child (ONE_WAY only) — matches guest-booking.dto.ts's own optional seatId. */
seatId?: string;
passengerName: string;
dateOfBirth: string;
idDocumentType: 'NATIONAL_ID' | 'PASSPORT' | 'DRIVING_LICENSE' | 'OTHER';
@@ -120,6 +135,8 @@ export interface GroupBookingPassengerInput {
nationality?: string;
phone?: string;
email?: string;
/** Return-leg seat ID — required when the booking is ROUND_TRIP. */
returnSeatId?: string;
}
export interface CreateGroupBookingRequest {
@@ -128,14 +145,23 @@ export interface CreateGroupBookingRequest {
originStationId: string;
destinationStationId: string;
seatClassId: string;
bookingType: 'ONE_WAY';
bookingType: 'ONE_WAY' | 'ROUND_TRIP';
passengers: GroupBookingPassengerInput[];
/** ROUND_TRIP only. */
returnScheduleId?: string;
returnHoldId?: string;
returnOriginStationId?: string;
returnDestinationStationId?: string;
/** Falls back to seatClassId on the backend if omitted. */
returnSeatClassId?: string;
}
export interface GroupBookingSeat {
seatId: string;
passengerName: string;
passengerCategory: 'ADULT' | 'CHILD';
/** 1 = outbound leg, 2 = return leg. Absent on a plain ONE_WAY booking. */
leg?: number;
seat: { seatNumber: string; bedPosition?: string | null; coach: { number: string } };
}
@@ -209,10 +235,23 @@ export const groupBookingApi = {
autoAssignHold: (dto: AutoAssignHoldRequest) =>
apiClient.post<AutoAssignHoldResponse>('/seats/auto-assign-hold', dto),
/** Best-effort early release — e.g. freeing an outbound hold when the return leg's auto-assign fails. */
releaseHold: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
createGroupBooking: (dto: CreateGroupBookingRequest) =>
apiClient.post<CreateGroupBookingResponse>('/bookings/group', dto),
getPaymentMethods: () => apiClient.get<SupportedPaymentMethod[]>('/payments/methods'),
// Guards against a non-array response the same way dashboardApi.getPaymentMethods /
// paymentsApi.getMethods already do elsewhere in this app — never lets a bad/unexpected
// response shape reach a caller expecting a plain array.
getPaymentMethods: async (): Promise<SupportedPaymentMethod[]> => {
try {
const response = await apiClient.get<SupportedPaymentMethod[]>('/payments/methods');
return Array.isArray(response) ? response : [];
} catch {
return [];
}
},
initiatePayment: (dto: InitiatePaymentRequest) =>
apiClient.post<InitiatePaymentResponse>('/payments/initiate', dto),

View File

@@ -475,6 +475,15 @@ export const packagesApi = {
addTier: (packageId: string, data: any) => apiClient.post<any>(`/packages/${packageId}/tiers`, data),
updateTier: (tierId: string, data: any) => apiClient.patch<any>(`/packages/tiers/${tierId}`, data),
deleteTier: (tierId: string) => apiClient.delete(`/packages/tiers/${tierId}`),
// `apiClient` pins Content-Type: application/json on every request — clearing it (rather than
// setting multipart/form-data by hand, which omits the boundary) is what lets the browser
// generate a proper boundary of its own. Same pattern as features/support/supportApi.ts.
uploadImage: (id: string, file: File) => {
const form = new FormData();
form.append('image', file);
return apiClient.post<any>(`/packages/${id}/image`, form, { headers: { 'Content-Type': undefined } });
},
removeImage: (id: string) => apiClient.delete<any>(`/packages/${id}/image`),
};
// Package Inquiries API

View File

@@ -2,6 +2,16 @@ import ExcelJS from 'exceljs';
const VALID_ID_TYPES = ['NATIONAL_ID', 'PASSPORT', 'DRIVING_LICENSE', 'OTHER'];
/** Exact mirror of guest-booking.service.ts's calculateAge — calendar-based, not a 365.25-day
* approximation, so this file's CHILD/ADULT determination never disagrees with the backend's. */
function calculateAge(dateOfBirth: Date): number {
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
const monthDiff = today.getMonth() - dateOfBirth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dateOfBirth.getDate())) age--;
return age;
}
/**
* Mirrors guest-booking.service.ts's per-passenger nationality inference exactly (NATIONAL_ID
* always forces 'Ethiopian'; PASSPORT falls back to Djiboutian/Other by passport country), so a
@@ -25,6 +35,8 @@ export interface ParsedPassengerRow {
fullName: string;
dateOfBirth: string; // normalized YYYY-MM-DD, empty if invalid/missing
passengerType: 'Adult' | 'Child' | '';
/** Backend-authoritative category from DOB alone (age < 5), regardless of the Type column. */
isChildByAge: boolean;
idDocumentType: string;
idDocumentNumber: string;
passportNumber: string;
@@ -145,7 +157,7 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
if (!fullName) errors.push('Full Name is required');
let dateOfBirth = '';
let ageYears: number | null = null;
let isChildByAge = false;
if (!dobRaw) {
errors.push('Date of Birth is required');
} else {
@@ -156,7 +168,7 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
errors.push('Date of Birth cannot be in the future');
} else {
dateOfBirth = parsed.toISOString().split('T')[0];
ageYears = (Date.now() - parsed.getTime()) / (365.25 * 24 * 60 * 60 * 1000);
isChildByAge = calculateAge(parsed) < 5;
}
}
@@ -168,8 +180,8 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
// The backend computes ADULT/CHILD from date of birth alone (under 5 = Child), regardless
// of this column — flag a mismatch so the uploader notices before it surprises them later.
if (passengerType && ageYears !== null) {
const impliedType = ageYears < 5 ? 'Child' : 'Adult';
if (passengerType && dateOfBirth) {
const impliedType = isChildByAge ? 'Child' : 'Adult';
if (impliedType !== passengerType) {
warnings.push(`Date of Birth implies ${impliedType}, but Passenger Type is set to ${passengerType} — seats/fare are priced by age, not this column`);
}
@@ -202,6 +214,7 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
fullName,
dateOfBirth,
passengerType,
isChildByAge,
idDocumentType: docTypeRaw,
idDocumentNumber: docNumber,
passportNumber,
@@ -223,7 +236,23 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
export function countByType(rows: ParsedPassengerRow[]): { adults: number; children: number } {
return {
adults: rows.filter((r) => r.passengerType === 'Adult').length,
children: rows.filter((r) => r.passengerType === 'Child').length,
adults: rows.filter((r) => !r.isChildByAge).length,
children: rows.filter((r) => r.isChildByAge).length,
};
}
/**
* Mirrors the passenger portal's isFirstChild rule exactly (fare-utils.ts): the first
* `adultCount` children in passenger order travel free with no assigned seat; any child
* beyond that gets a real seat and pays the child fare. Returns one boolean per row, true
* where that row is a free, unseated child.
*/
export function resolveFreeChildIndexes(rows: ParsedPassengerRow[], adultCount: number): boolean[] {
let childrenSeen = 0;
return rows.map((row) => {
if (!row.isChildByAge) return false;
const free = childrenSeen < adultCount;
childrenSeen++;
return free;
});
}

View File

@@ -1,3 +1,20 @@
// Package images are served by edr-passenger-api's own origin (public/uploads/packages via
// app.useStaticAssets — see apps/edr-passenger-api/src/main.ts), a different origin than this
// app, so next/image needs it explicitly whitelisted or it 400s every package image at runtime.
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
let apiImagePattern;
try {
const apiUrl = new URL(API_URL);
apiImagePattern = {
protocol: apiUrl.protocol.replace(':', ''),
hostname: apiUrl.hostname,
port: apiUrl.port || '',
pathname: '/uploads/**',
};
} catch {
apiImagePattern = { protocol: 'http', hostname: 'localhost', port: '4000', pathname: '/uploads/**' };
}
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
@@ -5,6 +22,7 @@ const nextConfig = {
transpilePackages: ['@edr/types', '@edr/ui-common'],
images: {
unoptimized: false,
remotePatterns: [apiImagePattern],
},
};

View File

@@ -20,7 +20,7 @@ const COUNTRIES = [
'Bolivia','Bosnia and Herzegovina','Botswana','Brazil','Brunei','Bulgaria','Burkina Faso','Burundi','Cabo Verde','Cambodia',
'Cameroon','Canada','Central African Republic','Chad','Chile','China','Colombia','Comoros','Congo','Costa Rica',
'Croatia','Cuba','Cyprus','Czech Republic','Denmark','Dominica','Dominican Republic','Ecuador','Egypt',
'El Salvador','Equatorial Guinea','Eritrea','Estonia','Eswatini','Fiji','Finland','France','Gabon',
'El Salvador','Equatorial Guinea','Eritrea','Estonia','Eswatini','Ethiopia','Fiji','Finland','France','Gabon',
'Gambia','Georgia','Germany','Ghana','Greece','Grenada','Guatemala','Guinea','Guinea-Bissau','Guyana',
'Haiti','Honduras','Hungary','Iceland','India','Indonesia','Iran','Iraq','Ireland','Israel',
'Italy','Jamaica','Japan','Jordan','Kazakhstan','Kenya','Kiribati','Kuwait','Kyrgyzstan','Laos',

View File

@@ -93,6 +93,12 @@ function emptyReasonCopy(
message: `The only train between ${originStationName} and ${destinationStationName} on ${date} is bookable as part of a travel package, not as a standalone ticket. Please choose another date below.`,
showAlternatives: true,
};
case Passenger.SearchEmptyReasonCode.GroupBookingOnly:
return {
title: "Reserved for a group booking",
message: `The only train between ${originStationName} and ${destinationStationName} on ${date} is reserved for a group booking, not individual tickets. Please choose another date below.`,
showAlternatives: true,
};
case Passenger.SearchEmptyReasonCode.NoScheduleOnDate:
default:
return {

View File

@@ -8,7 +8,7 @@ import { apiClient } from '@/lib/api-client';
import { format } from 'date-fns';
import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format';
import { useState, useEffect, useRef } from 'react';
import { ChevronLeft } from 'lucide-react';
import { ChevronLeft, Loader2 } from 'lucide-react';
import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
// Helper function to decode JWT token and extract passengerId
@@ -54,6 +54,17 @@ export default function ReviewPage() {
const [fareBreakdown, setFareBreakdown] = useState<any>(null);
const [returnFareBreakdown, setReturnFareBreakdown] = useState<any>(null);
const [computedTotal, setComputedTotal] = useState<number>(0);
// createBookingMutation.isPending only covers the mutation's own network call, but
// handleConfirm does real async work (seat-class lookup, passengerId resolution) before ever
// calling it — during that window the button showed no loading state and stayed clickable,
// letting a double-click race through and create two bookings for the same seat hold. This
// covers the whole handleConfirm run, not just the mutation's slice of it.
const [isSubmitting, setIsSubmitting] = useState(false);
// setIsSubmitting alone isn't enough to stop a fast double-click: React state updates aren't
// synchronous, so a second click event dispatched before the first setIsSubmitting(true) has
// actually re-rendered the button as disabled would still slip through. A ref updates
// immediately, closing that gap regardless of render timing.
const isSubmittingRef = useRef(false);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
@@ -228,6 +239,9 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
});
const handleConfirm = async () => {
if (isSubmittingRef.current || createBookingMutation.isPending) return;
isSubmittingRef.current = true;
setIsSubmitting(true);
try {
const { searchCriteria } = useBookingStore.getState();
@@ -287,6 +301,8 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
if (!seatClassId) {
alert('Unable to determine seat class. Please go back and re-select your seats.');
isSubmittingRef.current = false;
setIsSubmitting(false);
return;
}
@@ -489,8 +505,16 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
bookingData.reviewedTotalMinor = computedTotal;
await createBookingMutation.mutateAsync(bookingData);
// Deliberately not resetting isSubmitting here: every path that reaches this point is
// about to navigate away (router.push, either here or inside onSuccess's setTimeout above).
// Clearing it now would flip the button back to its idle label for the gap between the
// booking actually being created and the navigation landing — exactly the "did it get
// stuck?" flash this state exists to prevent. It only needs resetting on a genuine failure,
// handled in the catch block below, so the user can retry.
} catch (error) {
alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.');
isSubmittingRef.current = false;
setIsSubmitting(false);
}
};
@@ -691,10 +715,16 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
)}
<button
onClick={handleConfirm}
disabled={createBookingMutation.isPending}
className="btn-primary w-full"
disabled={isSubmitting || createBookingMutation.isPending}
className="btn-primary w-full disabled:opacity-50 disabled:cursor-not-allowed"
>
{createBookingMutation.isPending ? 'Creating booking...' : `Confirm ${isAuthenticated ? '' : 'and pay'}`}
{isSubmitting || createBookingMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Creating booking...
</span>
) : (
`Confirm ${isAuthenticated ? '' : 'and pay'}`
)}
</button>
<button onClick={() => router.back()} className="btn-secondary w-full flex items-center justify-center gap-2">
<ChevronLeft className="w-4 h-4" />
@@ -1032,10 +1062,16 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
</button>
<button
onClick={handleConfirm}
disabled={createBookingMutation.isPending}
className="btn-primary flex-1 py-2.5"
disabled={isSubmitting || createBookingMutation.isPending}
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
{createBookingMutation.isPending ? 'Creating...' : `Confirm ${isAuthenticated ? '' : '& pay'}`}
{isSubmitting || createBookingMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Creating...
</span>
) : (
`Confirm ${isAuthenticated ? '' : '& pay'}`
)}
</button>
</div>
</div>

View File

@@ -83,6 +83,7 @@ interface PackageDetail {
code: string;
name: string;
description: string | null;
imageUrl?: string | null;
status: string;
boardingTime: string;
departureTime: string;
@@ -1042,7 +1043,7 @@ export default function PackageDetailPage() {
{/* 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/package.jpeg"
src={pkg.imageUrl || "/packages/package.jpeg"}
alt={pkg.name}
fill
className="object-cover"

View File

@@ -42,6 +42,7 @@ interface HolidayPackage {
code: string;
name: string;
description?: string | null;
imageUrl?: string | null;
status: string;
departureTime: string;
validFrom: string;
@@ -165,7 +166,7 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
{/* ── Left: Image ── */}
<div className="relative md:w-[46%] h-64 md:h-auto flex-shrink-0 overflow-hidden">
<Image
src="/packages/package.jpeg"
src={pkg.imageUrl || "/packages/package.jpeg"}
alt={pkg.name}
fill
className="object-cover group-hover:scale-105 transition-transform duration-700 ease-out"
@@ -330,11 +331,21 @@ function PackageCard({ pkg }: { pkg: HolidayPackage }) {
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 */}
{/* Image / Gradient fallback */}
<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>
{pkg.imageUrl ? (
<Image
src={pkg.imageUrl}
alt={pkg.name}
fill
className="object-cover group-hover:scale-105 transition-transform duration-700 ease-out"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
) : (
<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">

View File

@@ -38,9 +38,9 @@ export enum ScheduleStatus {
/**
* Why a /search leg (outbound or inbound) came back with zero bookable schedules.
* Priority order applied by the API when classifying: NoRoute > NoScheduleOnDate >
* Cancelled > PackageOnly > CheckinClosed > FullyBooked (see search.service.ts
* classifyEmptySearch). The frontend uses this to show a specific empty-state
* message instead of a generic "no trains available".
* Cancelled > PackageOnly > GroupBookingOnly > CheckinClosed > FullyBooked (see
* search.service.ts classifyEmptySearch). The frontend uses this to show a specific
* empty-state message instead of a generic "no trains available".
*/
export enum SearchEmptyReasonCode {
/** No route (in either direction) ever connects these two stations. */
@@ -51,6 +51,8 @@ export enum SearchEmptyReasonCode {
Cancelled = "CANCELLED",
/** Every schedule for this pair on this date is package-only (excluded from ticket search). */
PackageOnly = "PACKAGE_ONLY",
/** Every schedule for this pair on this date is reserved for staff group bookings (excluded from normal ticket search). */
GroupBookingOnly = "GROUP_BOOKING_ONLY",
/** A bookable schedule exists, but its check-in cutoff has already passed for every option. */
CheckinClosed = "CHECKIN_CLOSED",
/** A bookable, still-open schedule exists but has no seats left for the requested party. */

285
pnpm-lock.yaml generated
View File

@@ -604,7 +604,7 @@ importers:
version: 5.101.0(react@19.2.6)
'@tria-plc/iamui':
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
'@vis.gl/react-google-maps':
specifier: ^1.8.3
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -874,6 +874,9 @@ importers:
minio:
specifier: 7.1.3
version: 7.1.3
multer:
specifier: ^2.1.1
version: 2.1.1
pg:
specifier: ^8.21.0
version: 8.21.0
@@ -13081,11 +13084,11 @@ snapshots:
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -13120,7 +13123,7 @@ snapshots:
'@babel/helper-optimise-call-expression': 7.29.7
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
semver: 6.3.1
transitivePeerDependencies:
- supports-color
@@ -13129,14 +13132,7 @@ snapshots:
'@babel/helper-member-expression-to-functions@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-module-imports@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -13151,9 +13147,9 @@ snapshots:
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-module-imports': 7.29.7
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -13168,13 +13164,13 @@ snapshots:
'@babel/core': 7.29.7
'@babel/helper-member-expression-to-functions': 7.29.7
'@babel/helper-optimise-call-expression': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
'@babel/helper-skip-transparent-expression-wrappers@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -13327,18 +13323,6 @@ snapshots:
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@babel/traverse@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
'@babel/helper-globals': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/types': 7.29.7
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
'@babel/traverse@7.29.7(supports-color@5.5.0)':
dependencies:
'@babel/code-frame': 7.29.7
@@ -13823,7 +13807,7 @@ snapshots:
'@emotion/babel-plugin@11.13.5':
dependencies:
'@babel/helper-module-imports': 7.29.7
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/runtime': 7.29.7
'@emotion/hash': 0.9.2
'@emotion/memoize': 0.9.0
@@ -13989,7 +13973,7 @@ snapshots:
'@eslint/eslintrc@2.1.4':
dependencies:
ajv: 6.15.0
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
espree: 9.6.1
globals: 13.24.0
ignore: 5.3.2
@@ -14149,7 +14133,7 @@ snapshots:
'@humanwhocodes/config-array@0.13.0':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -15748,7 +15732,7 @@ snapshots:
'@puppeteer/browsers@2.13.2':
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
extract-zip: 2.0.1
progress: 2.0.3
proxy-agent: 6.5.0
@@ -17822,7 +17806,7 @@ snapshots:
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
token-types: 6.1.2
transitivePeerDependencies:
- supports-color
@@ -18109,130 +18093,6 @@ snapshots:
- utf-8-validate
- vite
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)':
dependencies:
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
'@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6))
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': 7.17.8(react@19.2.6)
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6)
'@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf/renderer': 4.5.1(react@19.2.6)
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
'@tabler/icons-react': 3.44.0(react@19.2.6)
'@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
'@tanstack/react-query': 5.101.0(react@19.2.6)
'@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6)
'@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3)
'@types/dompurify': 3.2.0
'@types/node': 24.13.1
'@types/tinymce': 4.6.9
axios: 1.17.0
class-variance-authority: 0.7.1
clsx: 2.1.1
cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
date-fns: 3.6.0
dayjs: 1.11.21
dompurify: 3.4.8
ethiopian-calendar-date-converter: 2.1.6
ethiopian-calendar-new: 1.1.0
file-type: 18.7.0
framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
html2canvas: 1.4.1
i18next: 25.10.10(typescript@5.9.3)
i18next-browser-languagedetector: 8.2.1
jquery: 3.7.1
js-cookie: 3.0.8
jspdf: 3.0.4
lodash: 4.18.1
lucide-react: 0.513.0(react@19.2.6)
mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d)
next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
path: 0.12.7
pdf-lib: 1.17.1
qs: 6.15.2
react: 19.2.6
react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6)
react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
react-dropzone: 14.4.1(react@19.2.6)
react-hook-form: 7.77.0(react@19.2.6)
react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react-icons: 5.6.0(react@19.2.6)
react-image-crop: 11.0.10(react@19.2.6)
react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6)
react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1)
react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)
rollup-plugin-visualizer: 7.0.1(rollup@4.61.1)
socket.io-client: 4.8.3
sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
tailwind-merge: 3.6.0
tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0)
tailwindcss: 4.3.0
tailwindcss-animate: 1.0.7(tailwindcss@4.3.0)
tinymce: 7.9.3
url: 0.11.4
vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
xlsx: 0.18.5
zod: 3.25.76
transitivePeerDependencies:
- '@babel/core'
- '@emotion/is-prop-valid'
- '@mui/icons-material'
- '@mui/material'
- '@mui/x-date-pickers'
- '@types/prop-types'
- '@types/react'
- '@types/react-dom'
- bufferutil
- debug
- pdfjs-dist
- prop-types
- react-is
- react-native
- redux
- rolldown
- rollup
- supports-color
- typescript
- utf-8-validate
- vite
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3
@@ -18630,7 +18490,7 @@ snapshots:
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.60.1
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
eslint: 8.57.1
typescript: 5.9.3
transitivePeerDependencies:
@@ -18640,7 +18500,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
'@typescript-eslint/types': 8.60.1
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -18659,7 +18519,7 @@ snapshots:
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
'@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
eslint: 8.57.1
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
@@ -18674,7 +18534,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/visitor-keys': 8.60.1
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
minimatch: 10.2.5
semver: 7.8.2
tinyglobby: 0.2.17
@@ -18963,7 +18823,7 @@ snapshots:
agent-base@6.0.2:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -19472,16 +19332,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0):
dependencies:
'@babel/helper-annotate-as-pure': 7.29.7
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
picomatch: 4.0.4
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
transitivePeerDependencies:
- supports-color
babel-polyfill@6.26.0:
dependencies:
babel-runtime: 6.26.0
@@ -19637,7 +19487,7 @@ snapshots:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
@@ -20686,7 +20536,7 @@ snapshots:
engine.io-client@6.6.5:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
engine.io-parser: 5.2.3
ws: 8.20.1
xmlhttprequest-ssl: 2.1.2
@@ -20706,7 +20556,7 @@ snapshots:
base64id: 2.0.0
cookie: 0.7.2
cors: 2.8.6
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
engine.io-parser: 5.2.3
ws: 8.21.0
transitivePeerDependencies:
@@ -20937,7 +20787,7 @@ snapshots:
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
eslint: 8.57.1
get-tsconfig: 4.14.0
is-bun-module: 2.0.0
@@ -21065,7 +20915,7 @@ snapshots:
ajv: 6.15.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
doctrine: 3.0.0
escape-string-regexp: 4.0.0
eslint-scope: 7.2.2
@@ -21302,7 +21152,7 @@ snapshots:
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
@@ -21355,7 +21205,7 @@ snapshots:
extract-zip@2.0.1:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
get-stream: 5.2.0
yauzl: 2.10.0
optionalDependencies:
@@ -21510,7 +21360,7 @@ snapshots:
finalhandler@2.1.1:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
@@ -21758,7 +21608,7 @@ snapshots:
dependencies:
basic-ftp: 5.3.1
data-uri-to-buffer: 6.0.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -22067,7 +21917,7 @@ snapshots:
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -22080,14 +21930,14 @@ snapshots:
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -22527,7 +22377,7 @@ snapshots:
istanbul-lib-source-maps@4.0.1:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
istanbul-lib-coverage: 3.2.2
source-map: 0.6.1
transitivePeerDependencies:
@@ -23187,7 +23037,7 @@ snapshots:
dependencies:
chalk: 5.6.2
commander: 13.1.0
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
execa: 8.0.1
lilconfig: 3.1.3
listr2: 8.3.3
@@ -23874,7 +23724,7 @@ snapshots:
micromark@4.0.2:
dependencies:
'@types/debug': 4.1.13
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
decode-named-character-reference: 1.3.0
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
@@ -24400,7 +24250,7 @@ snapshots:
dependencies:
'@tootallnate/quickjs-emscripten': 0.23.0
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
get-uri: 6.0.5
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
@@ -24750,7 +24600,7 @@ snapshots:
proxy-agent@6.5.0:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
lru-cache: 7.18.3
@@ -24779,7 +24629,7 @@ snapshots:
dependencies:
'@puppeteer/browsers': 2.13.2
chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
devtools-protocol: 0.0.1608973
typed-query-selector: 2.12.2
webdriver-bidi-protocol: 0.4.1
@@ -25032,15 +24882,6 @@ snapshots:
- '@babel/core'
- react-is
react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
dependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
transitivePeerDependencies:
- '@babel/core'
- react-is
react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
dependencies:
date-fns: 3.6.0
@@ -25651,7 +25492,7 @@ snapshots:
router@2.2.0:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
@@ -25773,7 +25614,7 @@ snapshots:
send@1.2.1:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
@@ -25989,7 +25830,7 @@ snapshots:
socket.io-adapter@2.5.8:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
ws: 8.21.0
transitivePeerDependencies:
- bufferutil
@@ -25999,7 +25840,7 @@ snapshots:
socket.io-client@4.8.3:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
engine.io-client: 6.6.5
socket.io-parser: 4.2.6
transitivePeerDependencies:
@@ -26010,7 +25851,7 @@ snapshots:
socket.io-parser@4.2.6:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -26019,7 +25860,7 @@ snapshots:
accepts: 1.3.8
base64id: 2.0.0
cors: 2.8.6
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
engine.io: 6.6.9
socket.io-adapter: 2.5.8
socket.io-parser: 4.2.6
@@ -26031,7 +25872,7 @@ snapshots:
socks-proxy-agent@8.0.5:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
socks: 2.8.9
transitivePeerDependencies:
- supports-color
@@ -26326,24 +26167,6 @@ snapshots:
transitivePeerDependencies:
- '@babel/core'
styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
dependencies:
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@emotion/is-prop-valid': 1.4.0
'@emotion/stylis': 0.8.5
'@emotion/unitless': 0.7.5
babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0)
css-to-react-native: 3.2.0
hoist-non-react-statics: 3.3.2
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-is: 19.2.7
shallowequal: 1.1.0
supports-color: 5.5.0
transitivePeerDependencies:
- '@babel/core'
styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
dependencies:
client-only: 0.0.1
@@ -26369,7 +26192,7 @@ snapshots:
dependencies:
component-emitter: 1.3.1
cookiejar: 2.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
fast-safe-stringify: 2.1.1
form-data: 4.0.5
formidable: 3.5.4
@@ -26882,7 +26705,7 @@ snapshots:
app-root-path: 3.1.0
buffer: 6.0.3
dayjs: 1.11.21
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
dedent: 1.7.2(babel-plugin-macros@3.1.0)
dotenv: 16.6.1
glob: 10.5.0
@@ -26906,7 +26729,7 @@ snapshots:
app-root-path: 3.1.0
buffer: 6.0.3
dayjs: 1.11.21
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
dedent: 1.7.2(babel-plugin-macros@3.1.0)
dotenv: 16.6.1
glob: 10.5.0
@@ -27267,7 +27090,7 @@ snapshots:
vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0):
dependencies:
cac: 6.7.14
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
es-module-lexer: 1.7.0
pathe: 1.1.2
vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
@@ -27285,7 +27108,7 @@ snapshots:
vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0):
dependencies:
cac: 6.7.14
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
es-module-lexer: 1.7.0
pathe: 1.1.2
vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)
@@ -27332,7 +27155,7 @@ snapshots:
'@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9
chai: 5.3.3
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 1.1.2
@@ -27368,7 +27191,7 @@ snapshots:
'@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9
chai: 5.3.3
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 1.1.2