Project Initialization

This commit is contained in:
Muluhabt
2026-05-12 15:17:16 +03:00
parent 33fa742e8a
commit 3b8b6979db
259 changed files with 15962 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/edr-passenger-web/backoffice/package.json ./apps/edr-passenger-web/backoffice/
COPY packages ./packages
RUN pnpm install --frozen-lockfile --filter @edr/passenger-backoffice...
FROM deps AS build
COPY apps/edr-passenger-web/backoffice ./apps/edr-passenger-web/backoffice
RUN pnpm --filter @edr/passenger-backoffice build
FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/apps/edr-passenger-web/backoffice/dist /usr/share/nginx/html
EXPOSE 5184
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,18 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/edr-passenger-web/portal/package.json ./apps/edr-passenger-web/portal/
COPY packages ./packages
RUN pnpm install --frozen-lockfile --filter @edr/passenger-portal...
FROM deps AS build
COPY apps/edr-passenger-web/portal ./apps/edr-passenger-web/portal
RUN pnpm --filter @edr/passenger-portal build
FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/apps/edr-passenger-web/portal/dist /usr/share/nginx/html
EXPOSE 5174
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:3002

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EDR Passenger Backoffice</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,39 @@
{
"name": "@edr/passenger-backoffice",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5184",
"build": "tsc -b && vite build",
"preview": "vite preview --port 5184",
"lint": "eslint src",
"test": "vitest run",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@edr/types": "workspace:*",
"@edr/ui-common": "workspace:*",
"@tanstack/react-query": "^5.59.0",
"axios": "^1.7.7",
"clsx": "^2.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@edr/eslint-config": "workspace:*",
"@edr/tsconfig": "workspace:*",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.5.4",
"vite": "^5.4.8",
"vitest": "^2.1.2"
}
}

View File

@@ -0,0 +1,29 @@
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom';
import { DashboardLayout, type SidebarItem } from '@edr/ui-common';
import DashboardPage from './pages/dashboard/DashboardPage';
const sidebarItems: SidebarItem[] = [
{ label: 'Dashboard', href: '/' },
];
const App = () => {
const navigate = useNavigate();
const location = useLocation();
return (
<DashboardLayout
title="EDR Passenger Backoffice"
sidebarItems={sidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</DashboardLayout>
);
};
export default App;

View File

@@ -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(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,10 @@
const DashboardPage = () => {
return (
<div className="p-6">
<h1 className="text-2xl font-semibold">EDR Passenger Backoffice</h1>
<p className="mt-2 text-gray-600">Backoffice coming soon.</p>
</div>
);
};
export default DashboardPage;

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,9 @@
{
"extends": "@edr/tsconfig/react.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"useDefineForClassFields": true,
"skipLibCheck": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -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"]
}

View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5184,
host: '0.0.0.0',
},
test: {
environment: 'jsdom',
globals: true,
},
});

View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:3002

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EDR Passenger Portal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,39 @@
{
"name": "@edr/passenger-portal",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5174",
"build": "tsc -b && vite build",
"preview": "vite preview --port 5174",
"lint": "eslint src",
"test": "vitest run",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@edr/types": "workspace:*",
"@edr/ui-common": "workspace:*",
"@tanstack/react-query": "^5.59.0",
"axios": "^1.7.7",
"clsx": "^2.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@edr/eslint-config": "workspace:*",
"@edr/tsconfig": "workspace:*",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.5.4",
"vite": "^5.4.8",
"vitest": "^2.1.2"
}
}

View File

@@ -0,0 +1,47 @@
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom';
import { DashboardLayout, type SidebarItem } from '@edr/ui-common';
import TicketsPage from './pages/tickets/TicketsPage';
import TicketDetailPage from './pages/tickets/TicketDetailPage';
import BookTicketPage from './pages/tickets/BookTicketPage';
import SchedulesPage from './pages/schedules/SchedulesPage';
import ScheduleDetailPage from './pages/schedules/ScheduleDetailPage';
import StationsPage from './pages/stations/StationsPage';
import PassengersPage from './pages/passengers/PassengersPage';
import DashboardPage from './pages/dashboard/DashboardPage';
const sidebarItems: SidebarItem[] = [
{ label: 'Dashboard', href: '/' },
{ label: 'Tickets', href: '/tickets' },
{ label: 'Schedules', href: '/schedules' },
{ label: 'Stations', href: '/stations' },
{ label: 'Passengers', href: '/passengers' },
];
const App = () => {
const navigate = useNavigate();
const location = useLocation();
return (
<DashboardLayout
title="EDR Passenger"
sidebarItems={sidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/tickets" element={<TicketsPage />} />
<Route path="/tickets/new" element={<BookTicketPage />} />
<Route path="/tickets/:id" element={<TicketDetailPage />} />
<Route path="/schedules" element={<SchedulesPage />} />
<Route path="/schedules/:id" element={<ScheduleDetailPage />} />
<Route path="/stations" element={<StationsPage />} />
<Route path="/passengers" element={<PassengersPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</DashboardLayout>
);
};
export default App;

View File

@@ -0,0 +1,81 @@
import { FormEvent, useState } from 'react';
import { Button, FormField } from '@edr/ui-common';
export interface ScheduleFormPayload {
trainCode: string;
originStationId: string;
destinationStationId: string;
departureTime: string;
arrivalTime: string;
basePrice: number;
}
export interface ScheduleFormProps {
onSubmit: (payload: ScheduleFormPayload) => void;
isSubmitting?: boolean;
}
const ScheduleForm = ({ onSubmit, isSubmitting }: ScheduleFormProps) => {
const [trainCode, setTrainCode] = useState('');
const [originStationId, setOriginStationId] = useState('');
const [destinationStationId, setDestinationStationId] = useState('');
const [departureTime, setDepartureTime] = useState('');
const [arrivalTime, setArrivalTime] = useState('');
const [basePrice, setBasePrice] = useState('0');
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
onSubmit({
trainCode,
originStationId,
destinationStationId,
departureTime,
arrivalTime,
basePrice: Number(basePrice),
});
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<FormField label="Train code" value={trainCode} onChange={(e) => setTrainCode(e.target.value)} required />
<FormField
label="Origin station ID"
value={originStationId}
onChange={(e) => setOriginStationId(e.target.value)}
required
/>
<FormField
label="Destination station ID"
value={destinationStationId}
onChange={(e) => setDestinationStationId(e.target.value)}
required
/>
<FormField
label="Departure"
type="datetime-local"
value={departureTime}
onChange={(e) => setDepartureTime(e.target.value)}
required
/>
<FormField
label="Arrival"
type="datetime-local"
value={arrivalTime}
onChange={(e) => setArrivalTime(e.target.value)}
required
/>
<FormField
label="Base price"
type="number"
value={basePrice}
onChange={(e) => setBasePrice(e.target.value)}
min="0"
/>
<Button type="submit" isLoading={isSubmitting}>
Publish schedule
</Button>
</form>
);
};
export default ScheduleForm;

View File

@@ -0,0 +1,28 @@
import type { Passenger } from '@edr/types';
import { Table, type TableColumn } from '@edr/ui-common';
export interface ScheduleTableProps {
schedules: Passenger.ISchedule[];
}
const columns: TableColumn<Passenger.ISchedule>[] = [
{ key: 'trainCode', header: 'Train' },
{ key: 'status', header: 'Status' },
{
key: 'departureTime',
header: 'Departure',
render: (row) => new Date(row.departureTime).toLocaleString(),
},
{
key: 'arrivalTime',
header: 'Arrival',
render: (row) => new Date(row.arrivalTime).toLocaleString(),
},
{ key: 'basePrice', header: 'Base price', render: (row) => row.basePrice.toFixed(2) },
];
const ScheduleTable = ({ schedules }: ScheduleTableProps) => (
<Table columns={columns} data={schedules} rowKey={(row) => row.id} emptyMessage="No schedules yet" />
);
export default ScheduleTable;

View File

@@ -0,0 +1,37 @@
import clsx from 'clsx';
import type { Passenger } from '@edr/types';
export interface SeatSelectorProps {
seats: Passenger.ISeat[];
selectedSeatId?: string;
onSelect: (seatId: string) => void;
}
const SeatSelector = ({ seats, selectedSeatId, onSelect }: SeatSelectorProps) => (
<div className="grid grid-cols-6 gap-2">
{seats.map((seat) => {
const isAvailable = seat.status === 'AVAILABLE';
const isSelected = seat.id === selectedSeatId;
return (
<button
key={seat.id}
type="button"
disabled={!isAvailable}
onClick={() => onSelect(seat.id)}
className={clsx(
'rounded-md border px-2 py-2 text-xs font-medium',
isSelected
? 'border-blue-600 bg-blue-50 text-blue-700'
: isAvailable
? 'border-gray-200 bg-white text-gray-800 hover:border-blue-400'
: 'cursor-not-allowed border-gray-200 bg-gray-100 text-gray-400',
)}
>
{seat.seatNumber}
</button>
);
})}
</div>
);
export default SeatSelector;

View File

@@ -0,0 +1,50 @@
import { FormEvent, useState } from 'react';
import { Button, FormField } from '@edr/ui-common';
import type { CreateTicketPayload } from '../../services/tickets.service';
export interface TicketFormProps {
onSubmit: (payload: CreateTicketPayload) => void;
isSubmitting?: boolean;
}
const TicketForm = ({ onSubmit, isSubmitting }: TicketFormProps) => {
const [reference, setReference] = useState('');
const [passengerId, setPassengerId] = useState('');
const [scheduleId, setScheduleId] = useState('');
const [seatId, setSeatId] = useState('');
const [pricePaid, setPricePaid] = useState('0');
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
onSubmit({
reference,
passengerId,
scheduleId,
seatId,
pricePaid: Number(pricePaid),
issuedAt: new Date().toISOString(),
});
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<FormField label="Reference" value={reference} onChange={(e) => setReference(e.target.value)} required />
<FormField label="Passenger ID" value={passengerId} onChange={(e) => setPassengerId(e.target.value)} required />
<FormField label="Schedule ID" value={scheduleId} onChange={(e) => setScheduleId(e.target.value)} required />
<FormField label="Seat ID" value={seatId} onChange={(e) => setSeatId(e.target.value)} required />
<FormField
label="Price paid"
type="number"
value={pricePaid}
onChange={(e) => setPricePaid(e.target.value)}
min="0"
/>
<Button type="submit" isLoading={isSubmitting}>
Issue ticket
</Button>
</form>
);
};
export default TicketForm;

View File

@@ -0,0 +1,24 @@
import type { Passenger } from '@edr/types';
import { Table, type TableColumn } from '@edr/ui-common';
export interface TicketTableProps {
tickets: Passenger.ITicket[];
}
const columns: TableColumn<Passenger.ITicket>[] = [
{ key: 'reference', header: 'Reference' },
{ key: 'passengerId', header: 'Passenger' },
{ key: 'status', header: 'Status' },
{
key: 'issuedAt',
header: 'Issued',
render: (row) => new Date(row.issuedAt).toLocaleString(),
},
{ key: 'pricePaid', header: 'Price', render: (row) => row.pricePaid.toFixed(2) },
];
const TicketTable = ({ tickets }: TicketTableProps) => (
<Table columns={columns} data={tickets} rowKey={(row) => row.id} emptyMessage="No tickets yet" />
);
export default TicketTable;

View File

@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { schedulesService } from '../services/schedules.service';
export const useSchedules = () =>
useQuery({
queryKey: ['schedules'],
queryFn: schedulesService.list,
});
export const useSchedule = (id: string) =>
useQuery({
queryKey: ['schedules', id],
queryFn: () => schedulesService.get(id),
enabled: Boolean(id),
});

View File

@@ -0,0 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import { stationsService } from '../services/stations.service';
export const useStations = () =>
useQuery({
queryKey: ['stations'],
queryFn: stationsService.list,
});

View File

@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { ticketsService } from '../services/tickets.service';
export const useTickets = () =>
useQuery({
queryKey: ['tickets'],
queryFn: ticketsService.list,
});
export const useTicket = (id: string) =>
useQuery({
queryKey: ['tickets', id],
queryFn: () => ticketsService.get(id),
enabled: Boolean(id),
});

View File

@@ -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(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,10 @@
const DashboardPage = () => (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold text-gray-900">Passenger Dashboard</h1>
<p className="text-sm text-gray-600">
Daily ridership, ticket sales, occupancy by class, and schedule status go here.
</p>
</div>
);
export default DashboardPage;

View File

@@ -0,0 +1,10 @@
const PassengersPage = () => (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold text-gray-900">Passengers</h1>
<p className="text-sm text-gray-600">
Passenger directory. Connect to <code>/passengers</code> when ready.
</p>
</div>
);
export default PassengersPage;

View File

@@ -0,0 +1,29 @@
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 <div className="text-sm text-gray-500">Loading</div>;
if (!data) return <div className="text-sm text-red-600">Schedule not found.</div>;
return (
<div className="flex flex-col gap-3">
<h1 className="text-2xl font-semibold text-gray-900">Schedule {data.trainCode}</h1>
<dl className="grid grid-cols-2 gap-2 text-sm">
<dt className="text-gray-500">Status</dt>
<dd className="text-gray-900">{data.status}</dd>
<dt className="text-gray-500">Departure</dt>
<dd className="text-gray-900">{new Date(data.departureTime).toLocaleString()}</dd>
<dt className="text-gray-500">Arrival</dt>
<dd className="text-gray-900">{new Date(data.arrivalTime).toLocaleString()}</dd>
<dt className="text-gray-500">Base price</dt>
<dd className="text-gray-900">{data.basePrice.toFixed(2)}</dd>
</dl>
</div>
);
};
export default ScheduleDetailPage;

View File

@@ -0,0 +1,16 @@
import ScheduleTable from '../../components/schedules/ScheduleTable';
import { useSchedules } from '../../hooks/useSchedules';
const SchedulesPage = () => {
const { data, isLoading } = useSchedules();
const items = data ?? [];
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold text-gray-900">Schedules</h1>
{isLoading ? <div className="text-sm text-gray-500">Loading</div> : <ScheduleTable schedules={items} />}
</div>
);
};
export default SchedulesPage;

View File

@@ -0,0 +1,27 @@
import { Table, type TableColumn } from '@edr/ui-common';
import type { Passenger } from '@edr/types';
import { useStations } from '../../hooks/useStations';
const columns: TableColumn<Passenger.IStation>[] = [
{ key: 'code', header: 'Code' },
{ key: 'name', header: 'Name' },
{ key: 'city', header: 'City' },
{ key: 'country', header: 'Country' },
];
const StationsPage = () => {
const { data, isLoading } = useStations();
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold text-gray-900">Stations</h1>
{isLoading ? (
<div className="text-sm text-gray-500">Loading</div>
) : (
<Table columns={columns} data={data ?? []} rowKey={(row) => row.id} emptyMessage="No stations" />
)}
</div>
);
};
export default StationsPage;

View File

@@ -0,0 +1,27 @@
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 (
<div className="max-w-lg">
<h1 className="mb-4 text-2xl font-semibold text-gray-900">Issue ticket</h1>
<TicketForm onSubmit={mutation.mutate} isSubmitting={mutation.isPending} />
</div>
);
};
export default BookTicketPage;

View File

@@ -0,0 +1,33 @@
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 <div className="text-sm text-gray-500">Loading</div>;
if (!data) return <div className="text-sm text-red-600">Ticket not found.</div>;
return (
<div className="flex flex-col gap-3">
<h1 className="text-2xl font-semibold text-gray-900">Ticket {data.reference}</h1>
<dl className="grid grid-cols-2 gap-2 text-sm">
<dt className="text-gray-500">Passenger ID</dt>
<dd className="text-gray-900">{data.passengerId}</dd>
<dt className="text-gray-500">Schedule ID</dt>
<dd className="text-gray-900">{data.scheduleId}</dd>
<dt className="text-gray-500">Seat ID</dt>
<dd className="text-gray-900">{data.seatId}</dd>
<dt className="text-gray-500">Status</dt>
<dd className="text-gray-900">{data.status}</dd>
<dt className="text-gray-500">Issued</dt>
<dd className="text-gray-900">{new Date(data.issuedAt).toLocaleString()}</dd>
<dt className="text-gray-500">Price paid</dt>
<dd className="text-gray-900">{data.pricePaid.toFixed(2)}</dd>
</dl>
</div>
);
};
export default TicketDetailPage;

View File

@@ -0,0 +1,24 @@
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 (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-gray-900">Tickets</h1>
<Link to="/tickets/new">
<Button>Issue ticket</Button>
</Link>
</div>
{isLoading ? <div className="text-sm text-gray-500">Loading</div> : <TicketTable tickets={items} />}
</div>
);
};
export default TicketsPage;

View File

@@ -0,0 +1,14 @@
import type { Passenger } from '@edr/types';
import { api } from '../utils/api';
export const schedulesService = {
list: async (): Promise<Passenger.ISchedule[]> => {
const { data } = await api.get('/schedules');
return data.data;
},
get: async (id: string): Promise<Passenger.ISchedule> => {
const { data } = await api.get(`/schedules/${id}`);
return data.data;
},
};

View File

@@ -0,0 +1,10 @@
import type { Passenger } from '@edr/types';
import { api } from '../utils/api';
export const stationsService = {
list: async (): Promise<Passenger.IStation[]> => {
const { data } = await api.get('/stations');
return data.data;
},
};

View File

@@ -0,0 +1,30 @@
import type { Passenger, PaginatedResponse } from '@edr/types';
import { api } from '../utils/api';
export interface CreateTicketPayload {
reference: string;
passengerId: string;
scheduleId: string;
seatId: string;
pricePaid: number;
issuedAt: string;
}
export const ticketsService = {
list: async (): Promise<PaginatedResponse<Passenger.ITicket>> => {
const { data } = await api.get('/tickets');
return data.data;
},
get: async (id: string): Promise<Passenger.ITicket> => {
const { data } = await api.get(`/tickets/${id}`);
return data.data;
},
create: async (payload: CreateTicketPayload): Promise<Passenger.ITicket> => {
const { data } = await api.post('/tickets', payload);
return data.data;
},
cancel: async (id: string): Promise<void> => {
await api.delete(`/tickets/${id}`);
},
};

View File

@@ -0,0 +1,12 @@
import { create } from 'zustand';
interface AppState {
// TODO: integrate @edr/auth — currentUser will be sourced from the auth package
isSidebarOpen: boolean;
toggleSidebar: () => void;
}
export const useAppStore = create<AppState>((set) => ({
isSidebarOpen: true,
toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
}));

View File

@@ -0,0 +1,6 @@
export type { Passenger } from '@edr/types';
export interface NavItem {
href: string;
label: string;
}

View File

@@ -0,0 +1,9 @@
import axios from 'axios';
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
});
// TODO: integrate @edr/auth — add a request interceptor here that attaches
// the bearer token from the auth package and a response interceptor that
// triggers a refresh on 401.

View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "@edr/tsconfig/react.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"useDefineForClassFields": true,
"skipLibCheck": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -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"]
}

View File

@@ -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,
},
});