For International Travelers:
diff --git a/apps/edr-passenger-web/portal/src/app/login/page.tsx b/apps/edr-passenger-web/portal/src/app/login/page.tsx
index b4becfa40..8eacd9c10 100644
--- a/apps/edr-passenger-web/portal/src/app/login/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/login/page.tsx
@@ -9,8 +9,6 @@ import { useBookingStore } from '@/lib/booking-store';
import { useState } from 'react';
import { Train } from 'lucide-react';
-export const dynamic = 'force-dynamic';
-
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
diff --git a/apps/edr-passenger-web/portal/src/app/profile/page.tsx b/apps/edr-passenger-web/portal/src/app/profile/page.tsx
index 01a5dc45e..67d15f30a 100644
--- a/apps/edr-passenger-web/portal/src/app/profile/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/profile/page.tsx
@@ -5,9 +5,9 @@ import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { useTheme } from '@/components/ThemeProvider';
import {
- User, Settings, Ticket, Calendar, MapPin,
- Download, Trash2, Lock, Bell, CreditCard,
- MapPinned, Palette, CheckCircle,
+ User, Settings, Ticket, ChevronRight, Calendar, MapPin,
+ Download, Trash2, Lock, Bell, CreditCard, Globe,
+ MapPinned, Palette, CheckCircle, XCircle, Clock,
Eye, Edit, LogOut, X
} from 'lucide-react';
import { apiClient } from '@/lib/api-client';
@@ -84,11 +84,11 @@ export default function ProfilePage() {
}
}, [isInitialized, isAuthenticated, user, router, fetchProfile]);
- const { data: bookings, isLoading: loadingBookings } = useQuery
({
+ const { data: bookings, isLoading: loadingBookings } = useQuery({
queryKey: ['user-bookings'],
- queryFn: async (): Promise => {
+ queryFn: async () => {
try {
- return await apiClient.get('/bookings/my-bookings') as Booking[];
+ return await apiClient.get('/bookings/my-bookings');
} catch {
return [];
}
@@ -98,7 +98,7 @@ export default function ProfilePage() {
const updateProfileMutation = useMutation({
mutationFn: (data: any) => apiClient.patch('/auth/profile', data),
- onSuccess: (response: any) => {
+ onSuccess: (response) => {
const updatedData = response.data || response;
updateUser(updatedData);
setShowEditProfile(false);
@@ -143,7 +143,7 @@ export default function ProfilePage() {
const downloadDataMutation = useMutation({
mutationFn: () => apiClient.get('/auth/download-data'),
- onSuccess: (data: any) => {
+ onSuccess: (data) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
diff --git a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx
index 145b5daca..326570d97 100644
--- a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx
+++ b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx
@@ -1,13 +1,15 @@
'use client';
-import { Train, User, BookOpen, LogIn } from 'lucide-react';
+import { Train, LogOut, User, BookOpen, LogIn } from 'lucide-react';
import ThemeToggle from './ThemeToggle';
import Link from 'next/link';
import { useAuthStore } from '@/lib/auth-store';
+import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function AppHeader() {
const { user, isAuthenticated, initialize } = useAuthStore();
+ const router = useRouter();
useEffect(() => {
initialize();
diff --git a/apps/edr-passenger-web/portal/src/components/DualCalendarPicker.tsx b/apps/edr-passenger-web/portal/src/components/DualCalendarPicker.tsx
index 4c8a7f24f..cc437e251 100644
--- a/apps/edr-passenger-web/portal/src/components/DualCalendarPicker.tsx
+++ b/apps/edr-passenger-web/portal/src/components/DualCalendarPicker.tsx
@@ -29,7 +29,7 @@ export default function DualCalendarPicker({
}: DualCalendarPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const [calendarType, setCalendarType] = useState<'gregorian' | 'ethiopian'>('gregorian');
- const [, setCurrentDate] = useState(value || new Date());
+ const [currentDate, setCurrentDate] = useState(value || new Date());
const [viewMonth, setViewMonth] = useState(value?.getMonth() || new Date().getMonth());
const [viewYear, setViewYear] = useState(value?.getFullYear() || new Date().getFullYear());
diff --git a/apps/edr-passenger-web/portal/src/components/schedules/ScheduleTable.tsx b/apps/edr-passenger-web/portal/src/components/schedules/ScheduleTable.tsx
index 3d8b49242..420372a53 100644
--- a/apps/edr-passenger-web/portal/src/components/schedules/ScheduleTable.tsx
+++ b/apps/edr-passenger-web/portal/src/components/schedules/ScheduleTable.tsx
@@ -1,19 +1,11 @@
+import type { Passenger } from "@edr/types";
import { Table, type TableColumn } from "@edr/ui-common";
-interface ISchedule {
- id: string;
- trainCode: string;
- status: string;
- departureTime: string;
- arrivalTime: string;
- basePrice: number;
-}
-
export interface ScheduleTableProps {
- schedules: ISchedule[];
+ schedules: Passenger.ISchedule[];
}
-const columns: TableColumn[] = [
+const columns: TableColumn[] = [
{ key: "trainCode", header: "Train" },
{ key: "status", header: "Status" },
{
diff --git a/apps/edr-passenger-web/portal/src/lib/ethiopian-calendar.ts b/apps/edr-passenger-web/portal/src/lib/ethiopian-calendar.ts
index aad31a4c5..ee762d9a8 100644
--- a/apps/edr-passenger-web/portal/src/lib/ethiopian-calendar.ts
+++ b/apps/edr-passenger-web/portal/src/lib/ethiopian-calendar.ts
@@ -99,7 +99,7 @@ export function ethiopianToGregorian(ethDate: EthiopianDate): Date {
/**
* Get day of year from date (1-366)
*/
-export function getDayOfYear(date: Date): number {
+function getDayOfYear(date: Date): number {
const start = new Date(date.getFullYear(), 0, 0);
const diff = date.getTime() - start.getTime();
const oneDay = 1000 * 60 * 60 * 24;
@@ -109,7 +109,7 @@ export function getDayOfYear(date: Date): number {
/**
* Convert day of year to Date object
*/
-export function dayOfYearToDate(year: number, dayOfYear: number): Date {
+function dayOfYearToDate(year: number, dayOfYear: number): Date {
const date = new Date(year, 0);
date.setDate(dayOfYear);
return date;
diff --git a/apps/edr-passenger-web/portal/src/main.tsx b/apps/edr-passenger-web/portal/src/main.tsx
new file mode 100644
index 000000000..67557625f
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/main.tsx
@@ -0,0 +1,18 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import App from "./App";
+
+const queryClient = new QueryClient();
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+
+ ,
+);
diff --git a/apps/edr-passenger-web/portal/src/pages/dashboard/DashboardPage.tsx b/apps/edr-passenger-web/portal/src/pages/dashboard/DashboardPage.tsx
new file mode 100644
index 000000000..71bc2e539
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/pages/dashboard/DashboardPage.tsx
@@ -0,0 +1,13 @@
+const DashboardPage = () => (
+
+
+ Passenger Dashboard
+
+
+ Daily ridership, ticket sales, occupancy by class, and schedule status go
+ here.
+
+
+);
+
+export default DashboardPage;
diff --git a/apps/edr-passenger-web/portal/src/pages/passengers/PassengersPage.tsx b/apps/edr-passenger-web/portal/src/pages/passengers/PassengersPage.tsx
new file mode 100644
index 000000000..936b5f573
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/pages/passengers/PassengersPage.tsx
@@ -0,0 +1,10 @@
+const PassengersPage = () => (
+
+
Passengers
+
+ Passenger directory. Connect to /passengers when ready.
+
+
+);
+
+export default PassengersPage;
diff --git a/apps/edr-passenger-web/portal/src/pages/schedules/ScheduleDetailPage.tsx b/apps/edr-passenger-web/portal/src/pages/schedules/ScheduleDetailPage.tsx
new file mode 100644
index 000000000..304b3c020
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/pages/schedules/ScheduleDetailPage.tsx
@@ -0,0 +1,36 @@
+import { useParams } from "react-router-dom";
+
+import { useSchedule } from "@/hooks/useSchedules";
+
+const ScheduleDetailPage = () => {
+ const { id } = useParams<{ id: string }>();
+ const { data, isLoading } = useSchedule(id ?? "");
+
+ if (isLoading) return Loading…
;
+ if (!data)
+ return Schedule not found.
;
+
+ return (
+
+
+ Schedule {data.trainCode}
+
+
+ Status
+ {data.status}
+ Departure
+
+ {new Date(data.departureTime).toLocaleString()}
+
+ Arrival
+
+ {new Date(data.arrivalTime).toLocaleString()}
+
+ Base price
+ {data.basePrice.toFixed(2)}
+
+
+ );
+};
+
+export default ScheduleDetailPage;
diff --git a/apps/edr-passenger-web/portal/src/pages/schedules/SchedulesPage.tsx b/apps/edr-passenger-web/portal/src/pages/schedules/SchedulesPage.tsx
new file mode 100644
index 000000000..a51d63f91
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/pages/schedules/SchedulesPage.tsx
@@ -0,0 +1,20 @@
+import ScheduleTable from "../../components/schedules/ScheduleTable";
+import { useSchedules } from "../../hooks/useSchedules";
+
+const SchedulesPage = () => {
+ const { data, isLoading } = useSchedules();
+ const items = data ?? [];
+
+ return (
+
+
Schedules
+ {isLoading ? (
+
Loading…
+ ) : (
+
+ )}
+
+ );
+};
+
+export default SchedulesPage;
diff --git a/apps/edr-passenger-web/portal/src/pages/stations/StationsPage.tsx b/apps/edr-passenger-web/portal/src/pages/stations/StationsPage.tsx
new file mode 100644
index 000000000..53c5ae1fb
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/pages/stations/StationsPage.tsx
@@ -0,0 +1,32 @@
+import { Table, type TableColumn } from "@edr/ui-common";
+import type { Passenger } from "@edr/types";
+
+import { useStations } from "../../hooks/useStations";
+
+const columns: TableColumn[] = [
+ { key: "code", header: "Code" },
+ { key: "name", header: "Name" },
+ { key: "city", header: "City" },
+ { key: "country", header: "Country" },
+];
+
+const StationsPage = () => {
+ const { data, isLoading } = useStations();
+ return (
+
+
Stations
+ {isLoading ? (
+
Loading…
+ ) : (
+
row.id}
+ emptyMessage="No stations"
+ />
+ )}
+
+ );
+};
+
+export default StationsPage;
diff --git a/apps/edr-passenger-web/portal/src/pages/tickets/BookTicketPage.tsx b/apps/edr-passenger-web/portal/src/pages/tickets/BookTicketPage.tsx
new file mode 100644
index 000000000..8f02a9264
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/pages/tickets/BookTicketPage.tsx
@@ -0,0 +1,32 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useNavigate } from "react-router-dom";
+
+import TicketForm from "../../components/tickets/TicketForm";
+import { ticketsService } from "../../services/tickets.service";
+
+const BookTicketPage = () => {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+
+ const mutation = useMutation({
+ mutationFn: ticketsService.create,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["tickets"] });
+ navigate("/tickets");
+ },
+ });
+
+ return (
+
+
+ Issue ticket
+
+
+
+ );
+};
+
+export default BookTicketPage;
diff --git a/apps/edr-passenger-web/portal/src/pages/tickets/TicketDetailPage.tsx b/apps/edr-passenger-web/portal/src/pages/tickets/TicketDetailPage.tsx
new file mode 100644
index 000000000..d53614726
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/pages/tickets/TicketDetailPage.tsx
@@ -0,0 +1,38 @@
+import { useParams } from "react-router-dom";
+
+import { useTicket } from "../../hooks/useTickets";
+
+const TicketDetailPage = () => {
+ const { id } = useParams<{ id: string }>();
+ const { data, isLoading } = useTicket(id ?? "");
+
+ if (isLoading) return Loading…
;
+ if (!data)
+ return Ticket not found.
;
+
+ return (
+
+
+ Ticket {data.reference}
+
+
+ Passenger ID
+ {data.passengerId}
+ Schedule ID
+ {data.scheduleId}
+ Seat ID
+ {data.seatId}
+ Status
+ {data.status}
+ Issued
+
+ {new Date(data.issuedAt).toLocaleString()}
+
+ Price paid
+ {data.pricePaid.toFixed(2)}
+
+
+ );
+};
+
+export default TicketDetailPage;
diff --git a/apps/edr-passenger-web/portal/src/pages/tickets/TicketsPage.tsx b/apps/edr-passenger-web/portal/src/pages/tickets/TicketsPage.tsx
new file mode 100644
index 000000000..b33471fac
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/pages/tickets/TicketsPage.tsx
@@ -0,0 +1,28 @@
+import { Link } from "react-router-dom";
+import { Button } from "@edr/ui-common";
+
+import TicketTable from "../../components/tickets/TicketTable";
+import { useTickets } from "../../hooks/useTickets";
+
+const TicketsPage = () => {
+ const { data, isLoading } = useTickets();
+ const items = data?.items ?? [];
+
+ return (
+
+
+
Tickets
+
+ Issue ticket
+
+
+ {isLoading ? (
+
Loading…
+ ) : (
+
+ )}
+
+ );
+};
+
+export default TicketsPage;
diff --git a/apps/edr-passenger-web/portal/src/utils/api.ts b/apps/edr-passenger-web/portal/src/utils/api.ts
index d84d9a124..ca7c2031e 100644
--- a/apps/edr-passenger-web/portal/src/utils/api.ts
+++ b/apps/edr-passenger-web/portal/src/utils/api.ts
@@ -1,7 +1,7 @@
import axios from "axios";
export const api = axios.create({
- baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
+ baseURL: import.meta.env.VITE_API_URL,
});
// TODO: integrate @edr/auth — add a request interceptor here that attaches
diff --git a/apps/edr-passenger-web/portal/src/vite-env.d.ts b/apps/edr-passenger-web/portal/src/vite-env.d.ts
new file mode 100644
index 000000000..29c29a188
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/vite-env.d.ts
@@ -0,0 +1,9 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_API_URL: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/apps/edr-passenger-web/portal/tsconfig.app.json b/apps/edr-passenger-web/portal/tsconfig.app.json
new file mode 100644
index 000000000..73df43221
--- /dev/null
+++ b/apps/edr-passenger-web/portal/tsconfig.app.json
@@ -0,0 +1,9 @@
+{
+ "extends": "@edr/tsconfig/react.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "useDefineForClassFields": true,
+ "skipLibCheck": true
+ },
+ "include": ["src"]
+}
diff --git a/apps/edr-passenger-web/portal/tsconfig.node.json b/apps/edr-passenger-web/portal/tsconfig.node.json
new file mode 100644
index 000000000..181375c8f
--- /dev/null
+++ b/apps/edr-passenger-web/portal/tsconfig.node.json
@@ -0,0 +1,14 @@
+{
+ "extends": "@edr/tsconfig/base.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "skipLibCheck": true,
+ "allowSyntheticDefaultImports": true,
+ "noEmit": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/apps/edr-passenger-web/portal/vite.config.ts b/apps/edr-passenger-web/portal/vite.config.ts
new file mode 100644
index 000000000..324a8dfa7
--- /dev/null
+++ b/apps/edr-passenger-web/portal/vite.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5174,
+ host: "0.0.0.0",
+ },
+ // test: {
+ // environment: "jsdom",
+ // globals: true,
+ // },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 053d67038..8964fecde 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -222,7 +222,7 @@ importers:
version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3)
'@nestjs/testing':
specifier: ^11.1.19
- version: 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(@nestjs/platform-express@11.1.23)
+ version: 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.23)
'@types/bcrypt':
specifier: ^5.0.2
version: 5.0.2