Initial commit: booking-ewa v1.0.0
Embedded Web App for EV charging slot bookings. Express backend with JWT auth and AMPECO Public API proxy. React SPA with booking CRUD, availability checking, and runtime design token theming. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
API_BASE_URL=https://your-instance.charge.ampeco.tech/public-api
|
||||
API_TOKEN=your-api-token-here
|
||||
JWT_SECRET=your-jwt-secret-here
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
@@ -0,0 +1,162 @@
|
||||
# Booking EWA
|
||||
|
||||
Embedded Web App (EWA) for managing EV charging station bookings. The application provides a mobile-optimized UI for creating, viewing, updating, and cancelling charger bookings. It is designed to be embedded within a native mobile app via a JWT-based session handoff.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The application consists of two parts:
|
||||
|
||||
- **Backend** -- Express.js server (port 3001) that validates JWT tokens, manages sessions, and proxies API requests to the AMPECO public API.
|
||||
- **Frontend** -- React SPA (Vite dev server on port 5173) using Tailwind CSS and the `@ampeco/ewa-ui` design system. Uses `HashRouter` for client-side routing.
|
||||
|
||||
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full architecture document.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js >= 18
|
||||
- npm >= 9
|
||||
- Local sibling packages (linked via `file:` references in `package.json`):
|
||||
- `@ampeco/design-tokens` at `../design-tokens`
|
||||
- `@ampeco/ewa-ui` at `../ewa-ui`
|
||||
|
||||
## Setup
|
||||
|
||||
1. Clone the repository and ensure sibling packages are present:
|
||||
|
||||
```
|
||||
parent-directory/
|
||||
booking-ewa/ <-- this project
|
||||
design-tokens/ <-- @ampeco/design-tokens
|
||||
ewa-ui/ <-- @ampeco/ewa-ui
|
||||
```
|
||||
|
||||
2. Copy the example environment file and configure it:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. Edit `.env` with your values:
|
||||
|
||||
```
|
||||
API_BASE_URL=https://your-instance.charge.ampeco.tech/public-api
|
||||
API_TOKEN=your-api-token-here
|
||||
JWT_SECRET=your-jwt-secret-here
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
| Variable | Description |
|
||||
|----------------|----------------------------------------------------------------|
|
||||
| `API_BASE_URL` | Base URL of the AMPECO public API instance |
|
||||
| `API_TOKEN` | Bearer token for authenticating with the public API |
|
||||
| `JWT_SECRET` | Shared secret for signing/verifying HS256 JWT tokens |
|
||||
| `PORT` | Port for the Express backend server (default: `3001`) |
|
||||
| `NODE_ENV` | Set to `development` to enable dev fallback session and `/dev/jwt` endpoint |
|
||||
|
||||
4. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
5. Start the development servers:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This starts both the Vite dev server (http://localhost:5173) and the Express backend (http://localhost:3001) concurrently.
|
||||
|
||||
## Available Scripts
|
||||
|
||||
| Script | Description |
|
||||
|-----------------|-------------------------------------------------------------|
|
||||
| `npm run dev` | Start Vite frontend and Express backend in parallel |
|
||||
| `npm run build` | Build the frontend (Vite) and compile the backend (TypeScript) |
|
||||
| `npm run typecheck` | Run TypeScript type checking (no emit) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
booking-ewa/
|
||||
.env.example # Environment variable template
|
||||
index.html # Vite HTML entry point
|
||||
package.json # Dependencies and scripts
|
||||
vite.config.ts # Vite config (proxy /api and /dev to backend)
|
||||
tailwind.config.ts # Tailwind CSS with ewa-ui plugin
|
||||
postcss.config.js # PostCSS (Tailwind + Autoprefixer)
|
||||
tsconfig.json # Frontend TypeScript config
|
||||
|
||||
backend/
|
||||
tsconfig.json # Backend TypeScript config
|
||||
src/
|
||||
index.ts # Express server entry, JWT middleware, dev fallback
|
||||
jwt.ts # JWT verification and dev token creation
|
||||
proxy.ts # Generic API proxy utility
|
||||
routes.ts # Route definitions (session, bookings, availability)
|
||||
types.ts # Backend type definitions (JwtPayload, SessionData)
|
||||
|
||||
src/
|
||||
main.tsx # React entry point
|
||||
App.tsx # Root component (SessionProvider + routing guard)
|
||||
app.css # Global styles (design-tokens CSS variables, Tailwind)
|
||||
env.d.ts # TypeScript ambient declarations
|
||||
|
||||
api/
|
||||
client.ts # Fetch wrapper with error handling (ApiRequestError)
|
||||
bookings.ts # Booking list/detail API functions
|
||||
bookingRequests.ts # Create/update/cancel booking request API
|
||||
availability.ts # Check availability API
|
||||
|
||||
context/
|
||||
SessionContext.tsx # React context for session state and theme application
|
||||
|
||||
hooks/
|
||||
useSession.ts # Hook to consume SessionContext
|
||||
useBookings.ts # Hooks for booking list and detail (with polling)
|
||||
useAvailability.ts # Hook for availability checking
|
||||
|
||||
components/
|
||||
Layout/Layout.tsx # App shell with bottom tab navigation
|
||||
BookingCard/BookingCard.tsx # Booking summary card
|
||||
AvailabilitySlots/AvailabilitySlots.tsx # Slot selection grid
|
||||
DateTimePicker/DateTimePicker.tsx # datetime-local input wrapper
|
||||
|
||||
pages/
|
||||
Home/Home.tsx # Dashboard with upcoming bookings
|
||||
CreateBooking/CreateBooking.tsx # Two-step booking creation flow
|
||||
MyBookings/MyBookings.tsx # Upcoming/Past tabs
|
||||
BookingDetail/BookingDetail.tsx # Detail view with cancel action
|
||||
UpdateBooking/UpdateBooking.tsx # Edit booking time range
|
||||
UserIdFallback/UserIdFallback.tsx # Manual user ID entry (dev mode)
|
||||
|
||||
router/
|
||||
index.tsx # HashRouter route definitions
|
||||
|
||||
types/
|
||||
index.ts # Frontend type definitions (Booking, BookingRequest, etc.)
|
||||
|
||||
i18n/
|
||||
init.ts # i18next initialization
|
||||
locales/
|
||||
en.json # English translations
|
||||
```
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|-------------------------|------------------------------------------------------|
|
||||
| `@ampeco/design-tokens` | CSS custom properties for theming (LIGHT/DARK) |
|
||||
| `@ampeco/ewa-ui` | Shared UI component library and Tailwind plugin |
|
||||
| `react-router-dom` | Client-side routing (HashRouter) |
|
||||
| `i18next` / `react-i18next` | Internationalization |
|
||||
| `lucide-react` | Icon library |
|
||||
| `express` | Backend HTTP server |
|
||||
| `jsonwebtoken` | JWT signing and verification |
|
||||
|
||||
## Development Notes
|
||||
|
||||
- In `development` mode, if no JWT is provided, the backend falls back to a default session with `userId: 775`.
|
||||
- If the fallback session has no `userId` (set to `null`), the frontend displays a `UserIdFallback` page where you can manually enter a user ID.
|
||||
- Use `POST /dev/jwt` to generate a JWT token with custom session overrides. See [docs/USER-GUIDE.md](docs/USER-GUIDE.md) for details.
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { verifyJwt } from './jwt.js';
|
||||
import { router } from './routes.js';
|
||||
import type { SessionData } from './types.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || '3001', 10);
|
||||
|
||||
// CORS for dev
|
||||
app.use(cors({
|
||||
origin: 'http://localhost:5173',
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// JSON body parser
|
||||
app.use(express.json());
|
||||
|
||||
// JWT middleware
|
||||
app.use((req, _res, next) => {
|
||||
const token = req.headers['x-payload'] as string | undefined;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
req.session = verifyJwt(token);
|
||||
} catch (err) {
|
||||
console.warn('JWT verification failed:', (err as Error).message);
|
||||
// Fall through to dev fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Dev fallback: if no session and in development mode, use default session
|
||||
if (!req.session && process.env.NODE_ENV === 'development') {
|
||||
const devSession: SessionData = {
|
||||
userId: 775,
|
||||
operatorId: 1,
|
||||
appLanguage: 'en',
|
||||
appCountry: 'US',
|
||||
appTheme: 'LIGHT',
|
||||
};
|
||||
req.session = devSession;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Mount routes
|
||||
app.use(router);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Backend server running on http://localhost:${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV || 'production'}`);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { JwtPayload, SessionData } from './types.js';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-for-local-testing';
|
||||
|
||||
export function verifyJwt(token: string): SessionData {
|
||||
const decoded = jwt.verify(token, JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
}) as JwtPayload;
|
||||
|
||||
const params = decoded.payload.parameters;
|
||||
|
||||
return {
|
||||
userId: params.userId ?? null,
|
||||
operatorId: params.operatorId,
|
||||
appLanguage: params.appLanguage,
|
||||
appCountry: params.appCountry,
|
||||
appTheme: params.appTheme,
|
||||
designTokens: params.designTokens,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDevJwt(overrides: Partial<SessionData> = {}): string {
|
||||
const session: SessionData = {
|
||||
userId: 775,
|
||||
operatorId: 1,
|
||||
appLanguage: 'en',
|
||||
appCountry: 'US',
|
||||
appTheme: 'LIGHT',
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const payload: JwtPayload = {
|
||||
iss: 'dev',
|
||||
aud: 'booking-ewa',
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
nbf: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + 86400,
|
||||
payload: {
|
||||
type: 'ewa',
|
||||
timestamp: new Date().toISOString(),
|
||||
appVersionCode: '1.0.0',
|
||||
parameters: {
|
||||
userId: session.userId,
|
||||
operatorId: session.operatorId,
|
||||
appLanguage: session.appLanguage,
|
||||
appCountry: session.appCountry,
|
||||
appTheme: session.appTheme,
|
||||
designTokens: session.designTokens,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return jwt.sign(payload, JWT_SECRET, { algorithm: 'HS256' });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || '';
|
||||
const API_TOKEN = process.env.API_TOKEN || '';
|
||||
|
||||
interface ProxyOptions {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
path: string;
|
||||
mapParams?: (req: Request) => Record<string, string>;
|
||||
}
|
||||
|
||||
export async function proxyRequest(req: Request, res: Response, options: ProxyOptions): Promise<void> {
|
||||
const url = new URL(`${API_BASE_URL}${options.path}`);
|
||||
|
||||
// Forward query params
|
||||
if (options.method === 'GET') {
|
||||
for (const [key, value] of Object.entries(req.query)) {
|
||||
if (typeof value === 'string') {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map route params if provided
|
||||
if (options.mapParams) {
|
||||
const mapped = options.mapParams(req);
|
||||
for (const [key, value] of Object.entries(mapped)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `Bearer ${API_TOKEN}`,
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: options.method,
|
||||
headers,
|
||||
};
|
||||
|
||||
if (options.method !== 'GET' && req.body && Object.keys(req.body).length > 0) {
|
||||
fetchOptions.body = JSON.stringify(req.body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), fetchOptions);
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
res.status(response.status).json(data);
|
||||
} else {
|
||||
const text = await response.text();
|
||||
res.status(response.status).send(text);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Proxy error:', error);
|
||||
res.status(502).json({ error: 'Bad Gateway', message: 'Failed to reach upstream API' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Router } from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { proxyRequest } from './proxy.js';
|
||||
import { createDevJwt } from './jwt.js';
|
||||
import type { SessionData } from './types.js';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
session?: SessionData;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Session endpoint - returns session data from JWT (no proxy)
|
||||
router.get('/api/session', (req: Request, res: Response) => {
|
||||
if (!req.session) {
|
||||
res.status(401).json({ error: 'No session' });
|
||||
return;
|
||||
}
|
||||
res.json(req.session);
|
||||
});
|
||||
|
||||
// List bookings
|
||||
router.get('/api/bookings', (req: Request, res: Response) => {
|
||||
void proxyRequest(req, res, {
|
||||
method: 'GET',
|
||||
path: '/resources/bookings/v1.0',
|
||||
});
|
||||
});
|
||||
|
||||
// Get single booking
|
||||
router.get('/api/bookings/:id', (req: Request, res: Response) => {
|
||||
void proxyRequest(req, res, {
|
||||
method: 'GET',
|
||||
path: `/resources/bookings/v1.0/${req.params.id}`,
|
||||
});
|
||||
});
|
||||
|
||||
// Create booking request
|
||||
router.post('/api/booking-requests', (req: Request, res: Response) => {
|
||||
void proxyRequest(req, res, {
|
||||
method: 'POST',
|
||||
path: '/resources/booking-requests/v1.0',
|
||||
});
|
||||
});
|
||||
|
||||
// Check availability
|
||||
router.post('/api/locations/:id/check-availability', (req: Request, res: Response) => {
|
||||
void proxyRequest(req, res, {
|
||||
method: 'POST',
|
||||
path: `/actions/locations/v2.0/${req.params.id}/check-booking-availability`,
|
||||
});
|
||||
});
|
||||
|
||||
// Dev-only JWT creation
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
router.post('/dev/jwt', (req: Request, res: Response) => {
|
||||
const token = createDevJwt(req.body || {});
|
||||
res.json({ token });
|
||||
});
|
||||
}
|
||||
|
||||
export { router };
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface JwtPayload {
|
||||
iss: string;
|
||||
aud: string;
|
||||
iat: number;
|
||||
nbf: number;
|
||||
exp: number;
|
||||
payload: {
|
||||
type: string;
|
||||
timestamp: string;
|
||||
appVersionCode: string;
|
||||
parameters: {
|
||||
userId?: number | null;
|
||||
operatorId: number;
|
||||
appLanguage: string;
|
||||
appCountry: string;
|
||||
appTheme: 'LIGHT' | 'DARK';
|
||||
designTokens?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
userId: number | null;
|
||||
operatorId: number;
|
||||
appLanguage: string;
|
||||
appCountry: string;
|
||||
appTheme: 'LIGHT' | 'DARK';
|
||||
designTokens?: Record<string, string>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "../dist/backend",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
# Architecture
|
||||
|
||||
## System Overview
|
||||
|
||||
The Booking EWA is a two-tier application:
|
||||
|
||||
1. **Express Backend** (Node.js, TypeScript) -- Runs on port 3001. Handles JWT authentication, session management, and proxies all API calls to the AMPECO public API. The frontend never communicates with the public API directly.
|
||||
|
||||
2. **React SPA Frontend** (Vite, TypeScript, Tailwind CSS) -- Runs on port 5173 in development. A single-page application using `HashRouter` for client-side navigation. Styled with `@ampeco/design-tokens` CSS custom properties and `@ampeco/ewa-ui` Tailwind plugin.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
MobileApp["Mobile App"] -->|JWT in X-Payload header| Backend["Express Backend :3001"]
|
||||
Backend -->|Bearer token auth| PublicAPI["AMPECO Public API"]
|
||||
Browser["Browser / WebView"] -->|fetch /api/*| ViteDev["Vite Dev Server :5173"]
|
||||
ViteDev -->|proxy| Backend
|
||||
```
|
||||
|
||||
In production, the Vite dev server is replaced by static file serving. The built frontend assets are in `dist/`.
|
||||
|
||||
---
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
### Entry Point
|
||||
|
||||
**File:** `backend/src/index.ts`
|
||||
|
||||
The Express server configures CORS (for `localhost:5173` during dev), JSON body parsing, JWT middleware, and mounts the route handler.
|
||||
|
||||
### JWT Middleware Flow
|
||||
|
||||
Every incoming request passes through the JWT middleware:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Request arrives] --> B{X-Payload header present?}
|
||||
B -->|Yes| C[Verify JWT with HS256]
|
||||
C -->|Valid| D[Set req.session from JWT payload]
|
||||
C -->|Invalid| E{NODE_ENV === development?}
|
||||
B -->|No| E
|
||||
E -->|Yes| F[Set req.session to dev fallback]
|
||||
E -->|No| G[req.session remains undefined]
|
||||
D --> H[next]
|
||||
F --> H
|
||||
G --> H
|
||||
```
|
||||
|
||||
The JWT token is expected in the `X-Payload` HTTP header (not `Authorization`). This is the convention used by the mobile app when embedding the EWA in a WebView.
|
||||
|
||||
**JWT Payload Structure** (`backend/src/types.ts`):
|
||||
|
||||
```typescript
|
||||
interface JwtPayload {
|
||||
iss: string;
|
||||
aud: string;
|
||||
iat: number;
|
||||
nbf: number;
|
||||
exp: number;
|
||||
payload: {
|
||||
type: string; // "ewa"
|
||||
timestamp: string;
|
||||
appVersionCode: string;
|
||||
parameters: {
|
||||
userId?: number | null;
|
||||
operatorId: number;
|
||||
appLanguage: string; // e.g. "en"
|
||||
appCountry: string; // e.g. "US"
|
||||
appTheme: 'LIGHT' | 'DARK';
|
||||
designTokens?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The middleware extracts `parameters` into a `SessionData` object stored on `req.session`.
|
||||
|
||||
**Dev Fallback Session:**
|
||||
|
||||
When `NODE_ENV=development` and no valid JWT is present, the backend injects a default session:
|
||||
|
||||
```typescript
|
||||
{
|
||||
userId: 775,
|
||||
operatorId: 1,
|
||||
appLanguage: 'en',
|
||||
appCountry: 'US',
|
||||
appTheme: 'LIGHT',
|
||||
}
|
||||
```
|
||||
|
||||
### API Proxy Pattern
|
||||
|
||||
**File:** `backend/src/proxy.ts`
|
||||
|
||||
The `proxyRequest` function forwards requests from the frontend to the AMPECO public API. It:
|
||||
|
||||
1. Constructs the full URL from `API_BASE_URL` + the target path.
|
||||
2. Forwards query parameters (for GET requests) and maps route parameters.
|
||||
3. Attaches the `Authorization: Bearer <API_TOKEN>` header.
|
||||
4. Forwards the request body for non-GET methods.
|
||||
5. Returns the upstream response status and body to the client.
|
||||
6. Returns `502 Bad Gateway` on network errors.
|
||||
|
||||
The frontend never sees the `API_TOKEN` -- it is server-side only.
|
||||
|
||||
### Route Mapping
|
||||
|
||||
**File:** `backend/src/routes.ts`
|
||||
|
||||
| Frontend Path | Method | Upstream Public API Path |
|
||||
|---------------------------------------|--------|--------------------------------------------------------------|
|
||||
| `GET /api/session` | -- | No proxy. Returns `req.session` directly. |
|
||||
| `GET /api/bookings` | GET | `/resources/bookings/v1.0` |
|
||||
| `GET /api/bookings/:id` | GET | `/resources/bookings/v1.0/:id` |
|
||||
| `POST /api/booking-requests` | POST | `/resources/booking-requests/v1.0` |
|
||||
| `POST /api/locations/:id/check-availability` | POST | `/actions/locations/v2.0/:id/check-booking-availability` |
|
||||
| `POST /dev/jwt` (dev only) | -- | No proxy. Creates and returns a dev JWT token. |
|
||||
|
||||
### Session Data Flow
|
||||
|
||||
The `/api/session` endpoint does not proxy to the upstream API. It returns the session data extracted from the JWT by the middleware, which the frontend uses to:
|
||||
|
||||
- Identify the current user (`userId`).
|
||||
- Apply the correct theme (`appTheme` + `designTokens`).
|
||||
- Set the UI language (`appLanguage`).
|
||||
|
||||
---
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
### Application Bootstrap
|
||||
|
||||
**File:** `src/main.tsx`
|
||||
|
||||
1. i18next is initialized (`src/i18n/init.ts`).
|
||||
2. Global CSS is loaded (`src/app.css` -- imports `@ampeco/design-tokens/css/variables.css` and Tailwind).
|
||||
3. `<App />` is rendered into `#root`.
|
||||
|
||||
### Component Tree
|
||||
|
||||
```
|
||||
<App>
|
||||
<SessionProvider> -- Fetches /api/session, applies theme, provides context
|
||||
<AppContent> -- Guards on loading/error/userId
|
||||
<AppRouter> -- HashRouter with Layout wrapper
|
||||
<Layout> -- App shell with bottom nav (Home | Book | My Bookings)
|
||||
<Home /> -- Route: /
|
||||
<CreateBooking /> -- Route: /create
|
||||
<MyBookings /> -- Route: /bookings
|
||||
<BookingDetail /> -- Route: /bookings/:id
|
||||
<UpdateBooking /> -- Route: /bookings/:id/update
|
||||
</Layout>
|
||||
</AppRouter>
|
||||
</AppContent>
|
||||
-- OR (if no userId) --
|
||||
<UserIdFallback /> -- Manual user ID entry screen
|
||||
</SessionProvider>
|
||||
</App>
|
||||
```
|
||||
|
||||
### Page Routing
|
||||
|
||||
**File:** `src/router/index.tsx`
|
||||
|
||||
Uses `HashRouter` (URLs like `/#/bookings/123`) for compatibility with WebView environments that may not support HTML5 history API.
|
||||
|
||||
| Route | Component | Description |
|
||||
|-------------------------|------------------|---------------------------------|
|
||||
| `/#/` | `Home` | Dashboard with upcoming bookings |
|
||||
| `/#/create` | `CreateBooking` | Two-step booking creation |
|
||||
| `/#/bookings` | `MyBookings` | Upcoming/Past booking tabs |
|
||||
| `/#/bookings/:id` | `BookingDetail` | Single booking detail + actions |
|
||||
| `/#/bookings/:id/update`| `UpdateBooking` | Edit booking time range |
|
||||
|
||||
All routes are wrapped in the `<Layout>` component which provides a fixed bottom navigation bar with three tabs: Home, Book a Charger, and My Bookings.
|
||||
|
||||
### State Management
|
||||
|
||||
State is managed through React Context and local component state. There is no external state management library.
|
||||
|
||||
**SessionContext** (`src/context/SessionContext.tsx`):
|
||||
|
||||
- Fetches session data from `/api/session` on mount.
|
||||
- Applies the theme by dynamically importing `@ampeco/design-tokens` and calling `applyTheme(appTheme, designTokens)`.
|
||||
- Provides `session`, `loading`, `error`, and `setManualUserId` to all child components.
|
||||
- `setManualUserId` allows overriding the `userId` in the session (used by `UserIdFallback` in dev mode).
|
||||
|
||||
**useSession** (`src/hooks/useSession.ts`):
|
||||
|
||||
Convenience hook that calls `useContext(SessionContext)`.
|
||||
|
||||
### Data Fetching Hooks
|
||||
|
||||
**useBookings** (`src/hooks/useBookings.ts`):
|
||||
|
||||
- `useBookings(params)` -- Fetches a list of bookings with optional filter parameters (`userId`, `status`, date range, pagination). Loads once on mount.
|
||||
- `useBookingDetail(id)` -- Fetches a single booking by ID. Includes a **polling mechanism** that refreshes every 30 seconds. Polling:
|
||||
- Pauses when the browser tab is hidden (`document.hidden`).
|
||||
- Resumes immediately when the tab becomes visible.
|
||||
- Stops permanently when the booking reaches a terminal status (`completed`, `cancelled`, `no-show`, `failed`).
|
||||
|
||||
**useAvailability** (`src/hooks/useAvailability.ts`):
|
||||
|
||||
- Provides a `check(locationId, startAfter, endBefore)` function for on-demand availability queries.
|
||||
- Returns `slots`, `loading`, `error`, and a `reset` function.
|
||||
- No automatic polling.
|
||||
|
||||
### API Client Layer
|
||||
|
||||
**File:** `src/api/client.ts`
|
||||
|
||||
A thin `fetch` wrapper that:
|
||||
|
||||
1. Prepends `/api` to all paths (which Vite proxies to the backend).
|
||||
2. Sets `Accept: application/json` and `Content-Type: application/json` headers.
|
||||
3. Throws `ApiRequestError` on non-2xx responses, preserving the HTTP status and parsed response body.
|
||||
|
||||
```typescript
|
||||
export class ApiRequestError extends Error {
|
||||
status: number;
|
||||
body: Record<string, unknown> | null;
|
||||
|
||||
get isNotFound(): boolean;
|
||||
get isForbidden(): boolean;
|
||||
get isValidationError(): boolean; // status === 422
|
||||
get isUnauthorized(): boolean;
|
||||
get validationErrors(): Record<string, string[]>; // body.errors
|
||||
}
|
||||
```
|
||||
|
||||
**Endpoint modules:**
|
||||
|
||||
| Module | Functions |
|
||||
|---------------------------------|-----------------------------------------------------|
|
||||
| `src/api/bookings.ts` | `fetchBookings(params)`, `fetchBooking(id)` |
|
||||
| `src/api/bookingRequests.ts` | `createBookingRequest(payload)` -- handles create, update, cancel |
|
||||
| `src/api/availability.ts` | `checkAvailability(locationId, payload)` |
|
||||
|
||||
The `createBookingRequest` function accepts a discriminated union payload:
|
||||
|
||||
```typescript
|
||||
type BookingRequestPayload =
|
||||
| { type: 'create'; userId: number; locationId: number; startAt: string; endAt: string; evseCriteria?: {...} }
|
||||
| { type: 'update'; bookingId: number; startAt: string; endAt: string }
|
||||
| { type: 'cancel'; bookingId: number }
|
||||
```
|
||||
|
||||
### Design Token Integration
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[JWT payload] -->|designTokens + appTheme| B[SessionContext]
|
||||
B -->|applyTheme| C["@ampeco/design-tokens"]
|
||||
C -->|Sets CSS custom properties| D["document.documentElement.style"]
|
||||
D -->|var(--btn-primary-bg), etc.| E[All components]
|
||||
```
|
||||
|
||||
1. The JWT payload carries `appTheme` (`'LIGHT'` or `'DARK'`) and an optional `designTokens` map of CSS custom property overrides.
|
||||
2. `SessionContext` calls `applyTheme(appTheme, designTokens)` from `@ampeco/design-tokens` after fetching the session.
|
||||
3. `applyTheme` sets CSS custom properties on the document root element.
|
||||
4. All components reference these variables via inline styles (e.g., `var(--btn-primary-bg, #2563eb)`) with hardcoded fallback values.
|
||||
5. The base CSS variables are imported in `src/app.css` from `@ampeco/design-tokens/css/variables.css`.
|
||||
|
||||
Key CSS custom properties used throughout the UI:
|
||||
|
||||
| Variable | Usage |
|
||||
|-----------------------------|--------------------------------|
|
||||
| `--surface-elevation-0` | Page background |
|
||||
| `--surface-elevation-1` | Card/input backgrounds |
|
||||
| `--surface-elevation-2` | Elevated slot buttons |
|
||||
| `--text-primary` | Primary text color |
|
||||
| `--text-secondary` | Secondary/muted text |
|
||||
| `--btn-primary-bg` | Primary button background |
|
||||
| `--btn-primary-text` | Primary button text color |
|
||||
| `--border-default` | Border color for cards, inputs |
|
||||
|
||||
### Internationalization
|
||||
|
||||
**File:** `src/i18n/init.ts`
|
||||
|
||||
Uses `i18next` with `react-i18next`. Currently ships with English (`en`) translations only. Translation strings are organized by feature in `src/i18n/locales/en.json`.
|
||||
|
||||
Components access translations via the `useTranslation()` hook:
|
||||
|
||||
```typescript
|
||||
const { t } = useTranslation();
|
||||
// t('createBooking.title') --> "Book a Charger"
|
||||
```
|
||||
|
||||
### Tailwind CSS Configuration
|
||||
|
||||
**File:** `tailwind.config.ts`
|
||||
|
||||
- Content sources: `index.html`, all `src/**/*.{ts,tsx}`, and compiled `@ampeco/ewa-ui` dist files.
|
||||
- Plugins: `ewaUiPlugin` from `@ampeco/ewa-ui/plugin` (provides shared component styles).
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Diagrams
|
||||
|
||||
### JWT to Session to Theme
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as Mobile App
|
||||
participant WV as WebView
|
||||
participant BE as Express Backend
|
||||
participant API as AMPECO API
|
||||
|
||||
App->>WV: Load EWA URL with X-Payload JWT header
|
||||
WV->>BE: GET /api/session (X-Payload: <jwt>)
|
||||
BE->>BE: Verify JWT (HS256)
|
||||
BE->>WV: 200 { userId, operatorId, appTheme, designTokens, ... }
|
||||
WV->>WV: SessionContext.applyTheme(appTheme, designTokens)
|
||||
WV->>WV: Set CSS custom properties on :root
|
||||
WV->>WV: Render app with themed components
|
||||
```
|
||||
|
||||
### Booking CRUD Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as React Frontend
|
||||
participant BE as Express Backend
|
||||
participant API as AMPECO Public API
|
||||
|
||||
Note over UI: Create Booking (2-step)
|
||||
UI->>BE: POST /api/locations/:id/check-availability
|
||||
BE->>API: POST /actions/locations/v2.0/:id/check-booking-availability
|
||||
API-->>BE: { data: AvailabilitySlot[] }
|
||||
BE-->>UI: Available slots
|
||||
|
||||
UI->>BE: POST /api/booking-requests { type: "create", ... }
|
||||
BE->>API: POST /resources/booking-requests/v1.0
|
||||
API-->>BE: { data: BookingRequest { status: "approved", bookingId } }
|
||||
BE-->>UI: Booking confirmed
|
||||
|
||||
Note over UI: Read / Poll
|
||||
UI->>BE: GET /api/bookings/:id
|
||||
BE->>API: GET /resources/bookings/v1.0/:id
|
||||
API-->>BE: { data: Booking }
|
||||
BE-->>UI: Booking detail (polls every 30s)
|
||||
|
||||
Note over UI: Update Booking
|
||||
UI->>BE: POST /api/booking-requests { type: "update", bookingId, startAt, endAt }
|
||||
BE->>API: POST /resources/booking-requests/v1.0
|
||||
API-->>BE: { data: BookingRequest { status: "approved" } }
|
||||
BE-->>UI: Update confirmed
|
||||
|
||||
Note over UI: Cancel Booking
|
||||
UI->>BE: POST /api/booking-requests { type: "cancel", bookingId }
|
||||
BE->>API: POST /resources/booking-requests/v1.0
|
||||
API-->>BE: { data: BookingRequest }
|
||||
BE-->>UI: Cancellation confirmed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type System
|
||||
|
||||
### Backend Types (`backend/src/types.ts`)
|
||||
|
||||
- `JwtPayload` -- Full JWT structure with nested `payload.parameters`.
|
||||
- `SessionData` -- Flattened session data extracted from the JWT.
|
||||
|
||||
### Frontend Types (`src/types/index.ts`)
|
||||
|
||||
- `Booking` -- Core booking entity with `id`, `userId`, `locationId`, `startAt`, `endAt`, `status`, optional `bookedEvses`, `authorizedTokens`, `accessMethods`, `sessionId`.
|
||||
- `BookingStatus` -- Union type: `'accepted' | 'reserved' | 'completed' | 'cancelled' | 'no-show' | 'failed'`.
|
||||
- `BookingRequest` -- Result of a booking request operation (`type: 'create' | 'update' | 'cancel'`, `status: 'approved' | 'rejected'`).
|
||||
- `AvailabilitySlot` -- EVSE availability data with `evseId` and array of `{ startAt, endAt }` slots.
|
||||
- `SessionData` -- Mirror of the backend `SessionData` type.
|
||||
|
||||
---
|
||||
|
||||
## Build and Deployment
|
||||
|
||||
### Development
|
||||
|
||||
`npm run dev` runs two processes concurrently:
|
||||
|
||||
1. `vite` -- Frontend dev server on port 5173 with HMR. Proxies `/api/*` and `/dev/*` to `localhost:3001`.
|
||||
2. `tsx watch backend/src/index.ts` -- Backend with hot-reload via `tsx`.
|
||||
|
||||
### Production Build
|
||||
|
||||
`npm run build` executes:
|
||||
|
||||
1. `vite build` -- Bundles the React frontend into `dist/` (HTML, JS, CSS assets).
|
||||
2. `tsc -p backend/tsconfig.json` -- Compiles the backend TypeScript into `dist/backend/`.
|
||||
|
||||
The production deployment serves `dist/` as static files and runs the compiled backend.
|
||||
@@ -0,0 +1,234 @@
|
||||
# User Guide
|
||||
|
||||
## How the EWA is Embedded
|
||||
|
||||
The Booking EWA (Embedded Web App) is loaded inside a mobile app's WebView. The mobile app generates a signed JWT containing the user's session data and passes it via the `X-Payload` HTTP header when loading the EWA URL.
|
||||
|
||||
The flow:
|
||||
|
||||
1. The mobile app creates a JWT (signed with HS256 using a shared secret) containing user identity, operator config, theme preference, and optional design tokens.
|
||||
2. The WebView loads the EWA URL, attaching the JWT in the `X-Payload` request header.
|
||||
3. The Express backend verifies the JWT and extracts session data.
|
||||
4. The React frontend fetches `/api/session` to retrieve this session data.
|
||||
5. The frontend applies the theme (LIGHT/DARK + custom design tokens) and renders the UI.
|
||||
|
||||
---
|
||||
|
||||
## Screens
|
||||
|
||||
### 1. Home (`/#/`)
|
||||
|
||||
The landing screen of the application. Displays:
|
||||
|
||||
- **Title**: "Bookings"
|
||||
- **Upcoming Bookings**: Up to 3 active bookings (status `accepted` or `reserved`) with `startAt` in the future, displayed as `BookingCard` components. Each card shows the location ID, time range, and status badge. Tapping a card navigates to the Booking Detail screen.
|
||||
- **Empty State**: When no upcoming bookings exist, shows a calendar icon with "No Bookings Yet" and a prompt to book a charging slot.
|
||||
- **"Book a Charger" Button**: Primary action that navigates to the Create Booking screen.
|
||||
|
||||
### 2. Create Booking (`/#/create`)
|
||||
|
||||
A two-step flow for creating a new booking.
|
||||
|
||||
**Step 1 -- Select Time and Location:**
|
||||
|
||||
- **Location ID** (number input): The ID of the charging location. Enter the numeric location ID from the AMPECO platform.
|
||||
- **Start Time** (datetime picker): Minimum value is the current time.
|
||||
- **End Time** (datetime picker): Minimum value is the selected start time.
|
||||
- **"Check Availability" button**: Sends a POST request to check availability at the specified location for the given time range. Advances to Step 2 on success.
|
||||
|
||||
**Step 2 -- Confirm Booking:**
|
||||
|
||||
- **Available Slots**: Displays EVSE-grouped time slots returned by the availability check. Each EVSE shows its ID and a grid of selectable time windows. Tapping a slot populates the start/end time fields.
|
||||
- **EVSE Criteria** (collapsible section): Optional filters for the booking request:
|
||||
- Current Type: AC or DC (dropdown)
|
||||
- Min Power (kW)
|
||||
- Max Power (kW)
|
||||
- Connector Type (text)
|
||||
- **"Confirm Booking" button**: Submits a `create` booking request.
|
||||
|
||||
**Result Screen:**
|
||||
|
||||
After submission, displays one of:
|
||||
- **Approved**: Green check icon, "Booking Confirmed" message, and a "View Booking" button that navigates to the booking detail.
|
||||
- **Rejected**: Red X icon, "Booking Rejected" message with the rejection reason, and a "Try Again" button to reset the form.
|
||||
|
||||
### 3. My Bookings (`/#/bookings`)
|
||||
|
||||
Lists the user's bookings with a tab bar:
|
||||
|
||||
- **Upcoming Tab**: Bookings with status `accepted` or `reserved` where `startAt` is in the future.
|
||||
- **Past Tab**: Bookings where `endAt` is in the past (all statuses).
|
||||
|
||||
Each booking is rendered as a `BookingCard`. Tapping a card navigates to the Booking Detail screen.
|
||||
|
||||
Shows an empty state with a calendar icon when no bookings exist for the selected tab.
|
||||
|
||||
### 4. Booking Detail (`/#/bookings/:id`)
|
||||
|
||||
Displays full details of a single booking:
|
||||
|
||||
- **Location**: Location ID
|
||||
- **Time Range**: Formatted start and end times
|
||||
- **Status**: Color-coded badge (green for accepted, blue for reserved, gray for completed, red for cancelled/failed, amber for no-show)
|
||||
- **EVSEs**: List of booked EVSE IDs (if any)
|
||||
- **Access Methods**: List of access methods (if any)
|
||||
- **Session**: Linked charging session ID (if any)
|
||||
|
||||
**Actions** (shown only when status is `accepted` or `reserved`):
|
||||
|
||||
- **"Update Booking" button**: Navigates to the Update Booking screen.
|
||||
- **"Cancel Booking" button**: Opens a confirmation bottom sheet with "Yes, Cancel" and "Close" options.
|
||||
|
||||
The detail view **polls the API every 30 seconds** to keep the status current. Polling pauses when the browser tab is hidden and stops when the booking reaches a terminal status.
|
||||
|
||||
### 5. Update Booking (`/#/bookings/:id/update`)
|
||||
|
||||
Allows modifying the time range of an existing booking:
|
||||
|
||||
- Displays a read-only summary of the booking (location ID and booking ID).
|
||||
- **Start Time** and **End Time** datetime pickers, pre-populated with the current booking times.
|
||||
- **"Save Changes" button**: Submits an `update` booking request.
|
||||
- On success: Shows a green "Booking Confirmed" banner.
|
||||
- On rejection: Shows the rejection reason or validation errors.
|
||||
|
||||
---
|
||||
|
||||
## Development Mode
|
||||
|
||||
### Dev Fallback Session
|
||||
|
||||
When `NODE_ENV=development` (set in `.env`), the backend automatically provides a fallback session if no JWT is present:
|
||||
|
||||
```json
|
||||
{
|
||||
"userId": 775,
|
||||
"operatorId": 1,
|
||||
"appLanguage": "en",
|
||||
"appCountry": "US",
|
||||
"appTheme": "LIGHT"
|
||||
}
|
||||
```
|
||||
|
||||
This means you can open `http://localhost:5173` directly in a browser without needing a JWT. The app will use user ID 775 by default.
|
||||
|
||||
### UserIdFallback Screen
|
||||
|
||||
If the session has `userId: null` (which can happen with certain JWT configurations), the frontend displays a "User Identification" screen prompting you to enter a numeric user ID. This screen is shown instead of the main app.
|
||||
|
||||
### Generating a Dev JWT
|
||||
|
||||
In development mode, the backend exposes a `POST /dev/jwt` endpoint for generating JWT tokens with custom session data.
|
||||
|
||||
**Request:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/dev/jwt \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"userId": 123,
|
||||
"operatorId": 2,
|
||||
"appTheme": "DARK"
|
||||
}'
|
||||
```
|
||||
|
||||
Any field from `SessionData` can be overridden. Omitted fields use the default dev values.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIs..."
|
||||
}
|
||||
```
|
||||
|
||||
**Using the token:**
|
||||
|
||||
Pass it as the `X-Payload` header in subsequent requests:
|
||||
|
||||
```bash
|
||||
curl http://localhost:5173/api/session \
|
||||
-H "X-Payload: eyJhbGciOiJIUzI1NiIs..."
|
||||
```
|
||||
|
||||
Or use it in browser-based testing by setting the header in your HTTP client or browser extension.
|
||||
|
||||
The generated JWT has a 24-hour expiry, uses HS256, and is signed with the `JWT_SECRET` from `.env`.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------------|----------|--------------------------------|----------------------------------------------------------------------|
|
||||
| `API_BASE_URL` | Yes | `""` | Base URL of the AMPECO public API (e.g., `https://instance.charge.ampeco.tech/public-api`) |
|
||||
| `API_TOKEN` | Yes | `""` | Bearer token for authenticating backend requests to the public API |
|
||||
| `JWT_SECRET` | Yes | `dev-secret-for-local-testing` | Secret key for HS256 JWT verification. Must match the mobile app's signing key. |
|
||||
| `PORT` | No | `3001` | Port for the Express backend server |
|
||||
| `NODE_ENV` | No | `production` | Set to `development` for dev fallback session and `/dev/jwt` endpoint |
|
||||
|
||||
---
|
||||
|
||||
## Booking Lifecycle
|
||||
|
||||
### Statuses
|
||||
|
||||
| Status | Color | Description |
|
||||
|-------------|--------|----------------------------------------------------------|
|
||||
| `accepted` | Green | Booking request approved, slot is reserved for the user |
|
||||
| `reserved` | Blue | Booking is actively reserved at the charge point |
|
||||
| `completed` | Gray | Booking period ended normally |
|
||||
| `cancelled` | Red | Booking was cancelled by the user or system |
|
||||
| `no-show` | Amber | User did not show up during the booking window |
|
||||
| `failed` | Red | Booking request or reservation failed |
|
||||
|
||||
### Status Transitions
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> accepted : Booking request approved
|
||||
accepted --> reserved : Charge point reserved
|
||||
accepted --> cancelled : User cancels
|
||||
accepted --> failed : System failure
|
||||
reserved --> completed : Booking period ends
|
||||
reserved --> cancelled : User cancels
|
||||
reserved --> no_show : User does not show up
|
||||
completed --> [*]
|
||||
cancelled --> [*]
|
||||
no_show --> [*]
|
||||
failed --> [*]
|
||||
```
|
||||
|
||||
Terminal statuses: `completed`, `cancelled`, `no-show`, `failed`. Once a booking reaches a terminal status, the detail view stops polling and the update/cancel actions are hidden.
|
||||
|
||||
### Actions by Status
|
||||
|
||||
| Status | Can Update | Can Cancel |
|
||||
|-------------|------------|------------|
|
||||
| `accepted` | Yes | Yes |
|
||||
| `reserved` | Yes | Yes |
|
||||
| `completed` | No | No |
|
||||
| `cancelled` | No | No |
|
||||
| `no-show` | No | No |
|
||||
| `failed` | No | No |
|
||||
|
||||
### Booking Request Flow
|
||||
|
||||
All mutations (create, update, cancel) go through the booking request mechanism:
|
||||
|
||||
1. The frontend submits a `BookingRequest` with a `type` field (`create`, `update`, or `cancel`).
|
||||
2. The backend proxies this to `POST /resources/booking-requests/v1.0`.
|
||||
3. The API returns a `BookingRequest` response with `status: 'approved'` or `status: 'rejected'`.
|
||||
4. For `create` requests, an approved response includes a `bookingId` for the newly created booking.
|
||||
5. For `update` requests, the booking is modified in-place.
|
||||
6. For `cancel` requests, the booking transitions to `cancelled` status.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
The frontend handles errors at multiple levels:
|
||||
|
||||
- **API Client** (`src/api/client.ts`): Throws `ApiRequestError` with the HTTP status code and parsed response body. Provides convenience getters for common status codes (401, 403, 404, 422).
|
||||
- **Validation Errors** (422): The `validationErrors` getter extracts the `errors` object from the response body, which maps field names to arrays of error messages. The Create and Update booking forms display these inline.
|
||||
- **Network/Proxy Errors**: The backend returns `502 Bad Gateway` when the upstream API is unreachable.
|
||||
- **Session Errors**: If `/api/session` fails, the app displays an error screen with a "Retry" button that reloads the page.
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Booking EWA</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4976
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "booking-ewa",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"vite\" \"tsx watch backend/src/index.ts\"",
|
||||
"build": "vite build && tsc -p backend/tsconfig.json",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ampeco/design-tokens": "file:../design-tokens",
|
||||
"@ampeco/ewa-ui": "file:../ewa-ui",
|
||||
"i18next": "^23.16.8",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^6.28.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"concurrently": "^9.1.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.19.2",
|
||||
"dotenv": "^16.4.7",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { SessionProvider } from './context/SessionContext';
|
||||
import { useSession } from './hooks/useSession';
|
||||
import { AppRouter } from './router';
|
||||
import { UserIdFallback } from './pages/UserIdFallback/UserIdFallback';
|
||||
|
||||
function AppContent() {
|
||||
const { session, loading, error } = useSession();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div
|
||||
className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin"
|
||||
style={{ borderColor: 'var(--btn-primary-bg, #2563eb)', borderTopColor: 'transparent' }}
|
||||
/>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
Loading...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-6">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-sm font-medium" style={{ color: '#dc2626' }}>
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-sm font-medium"
|
||||
style={{ color: 'var(--btn-primary-bg, #2563eb)' }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session?.userId) {
|
||||
return <UserIdFallback />;
|
||||
}
|
||||
|
||||
return <AppRouter />;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<AppContent />
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { api } from './client';
|
||||
import type { AvailabilitySlot } from '../types';
|
||||
|
||||
interface AvailabilityResponse {
|
||||
data: AvailabilitySlot[];
|
||||
}
|
||||
|
||||
export interface CheckAvailabilityPayload {
|
||||
startAfter: string;
|
||||
endBefore: string;
|
||||
}
|
||||
|
||||
export async function checkAvailability(
|
||||
locationId: number,
|
||||
payload: CheckAvailabilityPayload,
|
||||
): Promise<AvailabilitySlot[]> {
|
||||
const response = await api.post<AvailabilityResponse>(
|
||||
`/locations/${locationId}/check-availability`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { api } from './client';
|
||||
import type { BookingRequest } from '../types';
|
||||
|
||||
interface BookingRequestResponse {
|
||||
data: BookingRequest;
|
||||
}
|
||||
|
||||
export interface CreateBookingRequestPayload {
|
||||
type: 'create';
|
||||
userId: number;
|
||||
locationId: number;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
evseCriteria?: {
|
||||
currentType?: 'AC' | 'DC';
|
||||
minPower?: number;
|
||||
maxPower?: number;
|
||||
connectorType?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateBookingRequestPayload {
|
||||
type: 'update';
|
||||
bookingId: number;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
}
|
||||
|
||||
export interface CancelBookingRequestPayload {
|
||||
type: 'cancel';
|
||||
bookingId: number;
|
||||
}
|
||||
|
||||
export type BookingRequestPayload =
|
||||
| CreateBookingRequestPayload
|
||||
| UpdateBookingRequestPayload
|
||||
| CancelBookingRequestPayload;
|
||||
|
||||
export async function createBookingRequest(payload: BookingRequestPayload): Promise<BookingRequest> {
|
||||
const response = await api.post<BookingRequestResponse>('/booking-requests', payload);
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { api } from './client';
|
||||
import type { Booking } from '../types';
|
||||
|
||||
interface BookingsResponse {
|
||||
data: Booking[];
|
||||
}
|
||||
|
||||
interface BookingResponse {
|
||||
data: Booking;
|
||||
}
|
||||
|
||||
export interface BookingListParams {
|
||||
userId?: number;
|
||||
status?: string;
|
||||
'startAt[after]'?: string;
|
||||
'startAt[before]'?: string;
|
||||
'endAt[after]'?: string;
|
||||
'endAt[before]'?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export async function fetchBookings(params: BookingListParams = {}): Promise<Booking[]> {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
query.set(key, String(value));
|
||||
}
|
||||
}
|
||||
const queryStr = query.toString();
|
||||
const path = queryStr ? `/bookings?${queryStr}` : '/bookings';
|
||||
const response = await api.get<BookingsResponse>(path);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function fetchBooking(id: number): Promise<Booking> {
|
||||
const response = await api.get<BookingResponse>(`/bookings/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly body: Record<string, unknown> | null,
|
||||
) {
|
||||
super(`API Error: ${status}`);
|
||||
this.name = 'ApiRequestError';
|
||||
}
|
||||
|
||||
get isNotFound(): boolean {
|
||||
return this.status === 404;
|
||||
}
|
||||
|
||||
get isForbidden(): boolean {
|
||||
return this.status === 403;
|
||||
}
|
||||
|
||||
get isValidationError(): boolean {
|
||||
return this.status === 422;
|
||||
}
|
||||
|
||||
get isUnauthorized(): boolean {
|
||||
return this.status === 401;
|
||||
}
|
||||
|
||||
get validationErrors(): Record<string, string[]> {
|
||||
return (this.body?.errors as Record<string, string[]>) || {};
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new ApiRequestError(res.status, body);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T = unknown>(path: string): Promise<T> => request<T>(path),
|
||||
post: <T = unknown>(path: string, body?: unknown): Promise<T> =>
|
||||
request<T>(path, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
};
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
@import '@ampeco/design-tokens/css/variables.css';
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--surface-elevation-0, #ffffff);
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Zap } from 'lucide-react';
|
||||
import type { AvailabilitySlot } from '../../types';
|
||||
|
||||
interface AvailabilitySlotsProps {
|
||||
slots: AvailabilitySlot[];
|
||||
onSelect: (evseId: number, startAt: string, endAt: string) => void;
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function AvailabilitySlots({ slots, onSelect }: AvailabilitySlotsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (slots.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('createBooking.noSlots')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold" style={{ color: 'var(--text-primary, #1a1a1a)' }}>
|
||||
{t('createBooking.availableSlots')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{slots.map((evseSlot) => (
|
||||
<div key={evseSlot.evseId} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap size={14} style={{ color: 'var(--btn-primary-bg, #2563eb)' }} />
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
EVSE #{evseSlot.evseId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{evseSlot.availableSlots.map((slot, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onSelect(evseSlot.evseId, slot.startAt, slot.endAt)}
|
||||
className="text-left rounded-lg px-3 py-2 text-sm transition-colors hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevation-2, #f0f0f0)',
|
||||
border: '1px solid var(--border-default, #e5e7eb)',
|
||||
color: 'var(--text-primary, #1a1a1a)',
|
||||
}}
|
||||
>
|
||||
{formatTime(slot.startAt)} - {formatTime(slot.endAt)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MapPin, Clock } from 'lucide-react';
|
||||
import type { Booking, BookingStatus } from '../../types';
|
||||
|
||||
interface BookingCardProps {
|
||||
booking: Booking;
|
||||
}
|
||||
|
||||
const statusColors: Record<BookingStatus, { bg: string; text: string }> = {
|
||||
accepted: { bg: '#dcfce7', text: '#166534' },
|
||||
reserved: { bg: '#dbeafe', text: '#1e40af' },
|
||||
completed: { bg: '#f3f4f6', text: '#374151' },
|
||||
cancelled: { bg: '#fee2e2', text: '#991b1b' },
|
||||
'no-show': { bg: '#fef3c7', text: '#92400e' },
|
||||
failed: { bg: '#fee2e2', text: '#991b1b' },
|
||||
};
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function BookingCard({ booking }: BookingCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const colors = statusColors[booking.status] || statusColors.accepted;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => navigate(`/bookings/${booking.id}`)}
|
||||
className="w-full text-left rounded-xl p-4 relative"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevation-1, #f9fafb)',
|
||||
border: '1px solid var(--border-default, #e5e7eb)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="absolute top-3 right-3 text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: colors.bg, color: colors.text }}
|
||||
>
|
||||
{t(`status.${booking.status}`)}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MapPin size={16} style={{ color: 'var(--text-secondary, #6b7280)' }} />
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary, #1a1a1a)' }}>
|
||||
Location #{booking.locationId}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={16} style={{ color: 'var(--text-secondary, #6b7280)' }} />
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{formatDateTime(booking.startAt)} - {formatDateTime(booking.endAt)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
interface DateTimePickerProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
min?: string;
|
||||
}
|
||||
|
||||
export function DateTimePicker({ label, value, onChange, min }: DateTimePickerProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
style={{ color: 'var(--text-primary, #1a1a1a)' }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
min={min}
|
||||
className="w-full rounded-lg px-3 py-2.5 text-sm outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevation-1, #f9fafb)',
|
||||
border: '1px solid var(--border-default, #e5e7eb)',
|
||||
color: 'var(--text-primary, #1a1a1a)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Home, CalendarPlus, List } from 'lucide-react';
|
||||
|
||||
interface NavTab {
|
||||
path: string;
|
||||
labelKey: string;
|
||||
icon: typeof Home;
|
||||
}
|
||||
|
||||
const tabs: NavTab[] = [
|
||||
{ path: '/', labelKey: 'home.title', icon: Home },
|
||||
{ path: '/create', labelKey: 'createBooking.title', icon: CalendarPlus },
|
||||
{ path: '/bookings', labelKey: 'myBookings.title', icon: List },
|
||||
];
|
||||
|
||||
export function Layout() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/';
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<main className="flex-1 pb-20 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<nav
|
||||
className="fixed bottom-0 left-0 right-0 flex items-center justify-around h-16 border-t"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevation-1, #f5f5f5)',
|
||||
borderColor: 'var(--border-default, #e0e0e0)',
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = isActive(tab.path);
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.path}
|
||||
onClick={() => navigate(tab.path)}
|
||||
className="flex flex-col items-center justify-center gap-1 flex-1 h-full"
|
||||
style={{
|
||||
color: active
|
||||
? 'var(--btn-primary-bg, #2563eb)'
|
||||
: 'var(--text-secondary, #6b7280)',
|
||||
}}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span className="text-xs font-medium">{t(tab.labelKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react';
|
||||
import type { SessionData } from '../types';
|
||||
|
||||
interface SessionContextValue {
|
||||
session: SessionData | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
setManualUserId: (userId: number) => void;
|
||||
}
|
||||
|
||||
export const SessionContext = createContext<SessionContextValue>({
|
||||
session: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
setManualUserId: () => {},
|
||||
});
|
||||
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<SessionData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchSession() {
|
||||
try {
|
||||
const res = await fetch('/api/session', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Session fetch failed: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as SessionData;
|
||||
setSession(data);
|
||||
|
||||
// Apply theme
|
||||
try {
|
||||
const { applyTheme } = await import('@ampeco/design-tokens');
|
||||
applyTheme(data.appTheme, data.designTokens);
|
||||
} catch {
|
||||
// design-tokens may not be available in dev
|
||||
console.warn('Could not apply design tokens');
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void fetchSession();
|
||||
}, []);
|
||||
|
||||
const setManualUserId = useCallback((userId: number) => {
|
||||
setSession((prev) => {
|
||||
if (!prev) {
|
||||
return {
|
||||
userId,
|
||||
operatorId: 1,
|
||||
appLanguage: 'en',
|
||||
appCountry: 'US',
|
||||
appTheme: 'LIGHT' as const,
|
||||
};
|
||||
}
|
||||
return { ...prev, userId };
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SessionContext.Provider value={{ session, loading, error, setManualUserId }}>
|
||||
{children}
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.css' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '@ampeco/design-tokens' {
|
||||
export function applyTheme(theme: 'LIGHT' | 'DARK', tokens?: Record<string, string>): void;
|
||||
}
|
||||
|
||||
declare module '@ampeco/design-tokens/css/variables.css' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '@ampeco/ewa-ui/plugin' {
|
||||
import type { PluginCreator } from 'tailwindcss/types/config';
|
||||
export const ewaUiPlugin: PluginCreator;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { checkAvailability } from '../api/availability';
|
||||
import type { AvailabilitySlot } from '../types';
|
||||
|
||||
interface UseAvailabilityResult {
|
||||
slots: AvailabilitySlot[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
check: (locationId: number, startAfter: string, endBefore: string) => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useAvailability(): UseAvailabilityResult {
|
||||
const [slots, setSlots] = useState<AvailabilitySlot[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const check = useCallback(async (locationId: number, startAfter: string, endBefore: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await checkAvailability(locationId, { startAfter, endBefore });
|
||||
setSlots(data);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
setSlots([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setSlots([]);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return { slots, loading, error, check, reset };
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { fetchBookings, fetchBooking, type BookingListParams } from '../api/bookings';
|
||||
import type { Booking, BookingStatus } from '../types';
|
||||
|
||||
const TERMINAL_STATUSES: BookingStatus[] = ['completed', 'cancelled', 'no-show', 'failed'];
|
||||
const POLL_INTERVAL = 30000;
|
||||
|
||||
interface UseBookingsResult {
|
||||
bookings: Booking[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useBookings(params: BookingListParams = {}): UseBookingsResult {
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const paramsRef = useRef(params);
|
||||
paramsRef.current = params;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await fetchBookings(paramsRef.current);
|
||||
setBookings(data);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return { bookings, loading, error, refetch: () => void load() };
|
||||
}
|
||||
|
||||
interface UseBookingDetailResult {
|
||||
booking: Booking | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useBookingDetail(id: number): UseBookingDetailResult {
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const data = await fetchBooking(id);
|
||||
setBooking(data);
|
||||
|
||||
// Stop polling if terminal status
|
||||
if (TERMINAL_STATUSES.includes(data.status) && intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
|
||||
// Start polling
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (!document.hidden) {
|
||||
void load();
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
|
||||
// Pause/resume on visibility change
|
||||
const handleVisibility = () => {
|
||||
if (!document.hidden && intervalRef.current) {
|
||||
void load();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
return { booking, loading, error, refetch: () => void load() };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useContext } from 'react';
|
||||
import { SessionContext } from '../context/SessionContext';
|
||||
|
||||
export function useSession() {
|
||||
return useContext(SessionContext);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import en from './locales/en.json';
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
},
|
||||
lng: 'en',
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"error": "Something went wrong",
|
||||
"retry": "Retry",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"any": "Any"
|
||||
},
|
||||
"home": {
|
||||
"title": "Bookings",
|
||||
"upcomingBookings": "Upcoming Bookings",
|
||||
"noBookings": "No upcoming bookings",
|
||||
"bookCharger": "Book a Charger",
|
||||
"emptyTitle": "No Bookings Yet",
|
||||
"emptyDescription": "Book a charging slot to get started."
|
||||
},
|
||||
"createBooking": {
|
||||
"title": "Book a Charger",
|
||||
"step1Title": "Select Time & Location",
|
||||
"step2Title": "Confirm Booking",
|
||||
"locationId": "Location ID",
|
||||
"locationHint": "Enter the ID of the charging location",
|
||||
"startTime": "Start Time",
|
||||
"endTime": "End Time",
|
||||
"checkAvailability": "Check Availability",
|
||||
"availableSlots": "Available Slots",
|
||||
"noSlots": "No available slots for the selected time range",
|
||||
"evseCriteria": "EVSE Criteria",
|
||||
"currentType": "Current Type",
|
||||
"minPower": "Min Power (kW)",
|
||||
"maxPower": "Max Power (kW)",
|
||||
"connectorType": "Connector Type",
|
||||
"confirm": "Confirm Booking",
|
||||
"creating": "Creating booking..."
|
||||
},
|
||||
"bookingResult": {
|
||||
"approved": "Booking Confirmed",
|
||||
"rejected": "Booking Rejected",
|
||||
"bookingId": "Booking #{{id}}",
|
||||
"viewBooking": "View Booking",
|
||||
"rejectionReason": "Reason: {{reason}}",
|
||||
"tryAgain": "Try Again"
|
||||
},
|
||||
"myBookings": {
|
||||
"title": "My Bookings",
|
||||
"upcoming": "Upcoming",
|
||||
"past": "Past",
|
||||
"noUpcoming": "No upcoming bookings",
|
||||
"noPast": "No past bookings"
|
||||
},
|
||||
"bookingDetail": {
|
||||
"title": "Booking Details",
|
||||
"location": "Location",
|
||||
"timeRange": "Time Range",
|
||||
"status": "Status",
|
||||
"evses": "EVSEs",
|
||||
"accessMethods": "Access Methods",
|
||||
"session": "Session",
|
||||
"update": "Update Booking",
|
||||
"cancel": "Cancel Booking",
|
||||
"cancelTitle": "Cancel Booking",
|
||||
"cancelMessage": "Are you sure you want to cancel this booking? This action cannot be undone.",
|
||||
"cancelConfirm": "Yes, Cancel",
|
||||
"cancelled": "Booking has been cancelled"
|
||||
},
|
||||
"updateBooking": {
|
||||
"title": "Update Booking",
|
||||
"saveChanges": "Save Changes",
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"userIdFallback": {
|
||||
"title": "User Identification",
|
||||
"description": "Please enter your User ID to continue. This is required when the session does not include user information.",
|
||||
"placeholder": "Enter your User ID",
|
||||
"submit": "Continue",
|
||||
"error": "Please enter a valid numeric User ID"
|
||||
},
|
||||
"status": {
|
||||
"accepted": "Accepted",
|
||||
"reserved": "Reserved",
|
||||
"completed": "Completed",
|
||||
"cancelled": "Cancelled",
|
||||
"no-show": "No Show",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"errors": {
|
||||
"forbidden": "You do not have permission to perform this action",
|
||||
"notFound": "The requested resource was not found",
|
||||
"sessionExpired": "Your session has expired. Please reload.",
|
||||
"network": "Network error. Please check your connection.",
|
||||
"server": "Server error. Please try again later.",
|
||||
"validation": "Please correct the errors below"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './i18n/init';
|
||||
import './app.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronLeft, MapPin, Clock, Activity, Zap, AlertTriangle } from 'lucide-react';
|
||||
import { useBookingDetail } from '../../hooks/useBookings';
|
||||
import { createBookingRequest } from '../../api/bookingRequests';
|
||||
import type { BookingStatus } from '../../types';
|
||||
|
||||
const statusColors: Record<BookingStatus, { bg: string; text: string }> = {
|
||||
accepted: { bg: '#dcfce7', text: '#166534' },
|
||||
reserved: { bg: '#dbeafe', text: '#1e40af' },
|
||||
completed: { bg: '#f3f4f6', text: '#374151' },
|
||||
cancelled: { bg: '#fee2e2', text: '#991b1b' },
|
||||
'no-show': { bg: '#fef3c7', text: '#92400e' },
|
||||
failed: { bg: '#fee2e2', text: '#991b1b' },
|
||||
};
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function BookingDetail() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { booking, loading, error } = useBookingDetail(parseInt(id || '0', 10));
|
||||
const [showCancelSheet, setShowCancelSheet] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [cancelError, setCancelError] = useState<string | null>(null);
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!booking) return;
|
||||
setCancelling(true);
|
||||
setCancelError(null);
|
||||
try {
|
||||
await createBookingRequest({ type: 'cancel', bookingId: booking.id });
|
||||
setCancelled(true);
|
||||
setShowCancelSheet(false);
|
||||
} catch (err) {
|
||||
setCancelError((err as Error).message);
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !booking) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-3 p-4">
|
||||
<AlertTriangle size={40} style={{ color: '#dc2626' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{error || t('errors.notFound')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-sm font-medium"
|
||||
style={{ color: 'var(--btn-primary-bg, #2563eb)' }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const colors = statusColors[booking.status] || statusColors.accepted;
|
||||
const canModify = booking.status === 'accepted' || booking.status === 'reserved';
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate(-1)}>
|
||||
<ChevronLeft size={24} style={{ color: 'var(--text-primary, #1a1a1a)' }} />
|
||||
</button>
|
||||
<h1
|
||||
className="text-xl font-bold"
|
||||
style={{ color: 'var(--text-primary, #1a1a1a)' }}
|
||||
>
|
||||
{t('bookingDetail.title')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{cancelled && (
|
||||
<div className="p-3 rounded-lg" style={{ backgroundColor: '#dcfce7' }}>
|
||||
<p className="text-sm font-medium" style={{ color: '#166534' }}>
|
||||
{t('bookingDetail.cancelled')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info rows */}
|
||||
<div
|
||||
className="rounded-xl divide-y overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevation-1, #f9fafb)',
|
||||
border: '1px solid var(--border-default, #e5e7eb)',
|
||||
}}
|
||||
>
|
||||
{/* Location */}
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<MapPin size={18} style={{ color: 'var(--text-secondary, #6b7280)' }} />
|
||||
<div>
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('bookingDetail.location')}
|
||||
</p>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary, #1a1a1a)' }}>
|
||||
Location #{booking.locationId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Range */}
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Clock size={18} style={{ color: 'var(--text-secondary, #6b7280)' }} />
|
||||
<div>
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('bookingDetail.timeRange')}
|
||||
</p>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary, #1a1a1a)' }}>
|
||||
{formatDateTime(booking.startAt)} - {formatDateTime(booking.endAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Activity size={18} style={{ color: 'var(--text-secondary, #6b7280)' }} />
|
||||
<div>
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('bookingDetail.status')}
|
||||
</p>
|
||||
<span
|
||||
className="inline-block text-xs font-medium px-2 py-0.5 rounded-full mt-0.5"
|
||||
style={{ backgroundColor: colors.bg, color: colors.text }}
|
||||
>
|
||||
{t(`status.${booking.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EVSEs */}
|
||||
{booking.bookedEvses && booking.bookedEvses.length > 0 && (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Zap size={18} style={{ color: 'var(--text-secondary, #6b7280)' }} />
|
||||
<div>
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('bookingDetail.evses')}
|
||||
</p>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary, #1a1a1a)' }}>
|
||||
{booking.bookedEvses.map((e) => `#${e.id}`).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Access Methods */}
|
||||
{booking.accessMethods && booking.accessMethods.length > 0 && (
|
||||
<div className="flex items-start gap-3 px-4 py-3">
|
||||
<Activity size={18} style={{ color: 'var(--text-secondary, #6b7280)', marginTop: 2 }} />
|
||||
<div>
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('bookingDetail.accessMethods')}
|
||||
</p>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary, #1a1a1a)' }}>
|
||||
{booking.accessMethods.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session */}
|
||||
{booking.sessionId && (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Activity size={18} style={{ color: 'var(--text-secondary, #6b7280)' }} />
|
||||
<div>
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('bookingDetail.session')}
|
||||
</p>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary, #1a1a1a)' }}>
|
||||
#{booking.sessionId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{canModify && !cancelled && (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => navigate(`/bookings/${booking.id}/update`)}
|
||||
className="w-full rounded-xl py-3 font-semibold text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--btn-primary-bg, #2563eb)',
|
||||
color: 'var(--btn-primary-text, #ffffff)',
|
||||
}}
|
||||
>
|
||||
{t('bookingDetail.update')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCancelSheet(true)}
|
||||
className="w-full rounded-xl py-3 font-semibold text-sm"
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
color: '#dc2626',
|
||||
border: '1px solid #dc2626',
|
||||
}}
|
||||
>
|
||||
{t('bookingDetail.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancel Bottom Sheet */}
|
||||
{showCancelSheet && (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={() => setShowCancelSheet(false)}
|
||||
/>
|
||||
<div
|
||||
className="relative w-full max-w-lg rounded-t-2xl p-6 space-y-4"
|
||||
style={{ backgroundColor: 'var(--surface-elevation-0, #ffffff)' }}
|
||||
>
|
||||
<h3
|
||||
className="text-lg font-bold"
|
||||
style={{ color: 'var(--text-primary, #1a1a1a)' }}
|
||||
>
|
||||
{t('bookingDetail.cancelTitle')}
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('bookingDetail.cancelMessage')}
|
||||
</p>
|
||||
|
||||
{cancelError && (
|
||||
<p className="text-sm" style={{ color: '#dc2626' }}>{cancelError}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowCancelSheet(false)}
|
||||
className="flex-1 rounded-xl py-3 font-semibold text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevation-1, #f3f4f6)',
|
||||
color: 'var(--text-primary, #1a1a1a)',
|
||||
}}
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleCancel()}
|
||||
disabled={cancelling}
|
||||
className="flex-1 rounded-xl py-3 font-semibold text-sm disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: '#dc2626',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
{cancelling ? t('common.loading') : t('bookingDetail.cancelConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronDown, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
|
||||
import { useSession } from '../../hooks/useSession';
|
||||
import { useAvailability } from '../../hooks/useAvailability';
|
||||
import { createBookingRequest } from '../../api/bookingRequests';
|
||||
import { DateTimePicker } from '../../components/DateTimePicker/DateTimePicker';
|
||||
import { AvailabilitySlots } from '../../components/AvailabilitySlots/AvailabilitySlots';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
import type { BookingRequest } from '../../types';
|
||||
|
||||
type Step = 'select' | 'confirm' | 'result';
|
||||
|
||||
export function CreateBooking() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useSession();
|
||||
const { slots, loading: slotsLoading, error: slotsError, check, reset } = useAvailability();
|
||||
|
||||
const [step, setStep] = useState<Step>('select');
|
||||
const [locationId, setLocationId] = useState('');
|
||||
const [startTime, setStartTime] = useState('');
|
||||
const [endTime, setEndTime] = useState('');
|
||||
const [showCriteria, setShowCriteria] = useState(false);
|
||||
const [currentType, setCurrentType] = useState('');
|
||||
const [minPower, setMinPower] = useState('');
|
||||
const [maxPower, setMaxPower] = useState('');
|
||||
const [connectorType, setConnectorType] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [result, setResult] = useState<BookingRequest | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCheckAvailability = async () => {
|
||||
if (!locationId || !startTime || !endTime) return;
|
||||
await check(
|
||||
parseInt(locationId, 10),
|
||||
new Date(startTime).toISOString(),
|
||||
new Date(endTime).toISOString(),
|
||||
);
|
||||
setStep('confirm');
|
||||
};
|
||||
|
||||
const handleSlotSelect = (_evseId: number, slotStart: string, slotEnd: string) => {
|
||||
setStartTime(new Date(slotStart).toISOString().slice(0, 16));
|
||||
setEndTime(new Date(slotEnd).toISOString().slice(0, 16));
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!session?.userId) return;
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const evseCriteria: Record<string, unknown> = {};
|
||||
if (currentType) evseCriteria.currentType = currentType;
|
||||
if (minPower) evseCriteria.minPower = parseFloat(minPower);
|
||||
if (maxPower) evseCriteria.maxPower = parseFloat(maxPower);
|
||||
if (connectorType) evseCriteria.connectorType = connectorType;
|
||||
|
||||
const response = await createBookingRequest({
|
||||
type: 'create',
|
||||
userId: session.userId,
|
||||
locationId: parseInt(locationId, 10),
|
||||
startAt: new Date(startTime).toISOString(),
|
||||
endAt: new Date(endTime).toISOString(),
|
||||
...(Object.keys(evseCriteria).length > 0 ? { evseCriteria: evseCriteria as { currentType?: 'AC' | 'DC'; minPower?: number; maxPower?: number; connectorType?: string } } : {}),
|
||||
});
|
||||
setResult(response);
|
||||
setStep('result');
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) {
|
||||
const validationErrors = err.validationErrors;
|
||||
const messages = Object.values(validationErrors).flat();
|
||||
setError(messages.length > 0 ? messages.join(', ') : err.message);
|
||||
} else {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStep('select');
|
||||
setResult(null);
|
||||
setError(null);
|
||||
reset();
|
||||
};
|
||||
|
||||
const now = new Date().toISOString().slice(0, 16);
|
||||
|
||||
// Result screen
|
||||
if (step === 'result' && result) {
|
||||
const approved = result.status === 'approved';
|
||||
return (
|
||||
<div className="p-4 flex flex-col items-center justify-center min-h-[60vh] space-y-4">
|
||||
{approved ? (
|
||||
<CheckCircle size={48} style={{ color: '#16a34a' }} />
|
||||
) : (
|
||||
<XCircle size={48} style={{ color: '#dc2626' }} />
|
||||
)}
|
||||
<h2
|
||||
className="text-lg font-bold"
|
||||
style={{ color: 'var(--text-primary, #1a1a1a)' }}
|
||||
>
|
||||
{approved ? t('bookingResult.approved') : t('bookingResult.rejected')}
|
||||
</h2>
|
||||
{approved && result.bookingId && (
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('bookingResult.bookingId', { id: result.bookingId })}
|
||||
</p>
|
||||
)}
|
||||
{!approved && result.rejectionReason && (
|
||||
<p className="text-sm text-center" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('bookingResult.rejectionReason', { reason: result.rejectionReason })}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3 w-full pt-4">
|
||||
{approved && result.bookingId ? (
|
||||
<button
|
||||
onClick={() => navigate(`/bookings/${result.bookingId}`)}
|
||||
className="flex-1 rounded-xl py-3 font-semibold text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--btn-primary-bg, #2563eb)',
|
||||
color: 'var(--btn-primary-text, #ffffff)',
|
||||
}}
|
||||
>
|
||||
{t('bookingResult.viewBooking')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="flex-1 rounded-xl py-3 font-semibold text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--btn-primary-bg, #2563eb)',
|
||||
color: 'var(--btn-primary-text, #ffffff)',
|
||||
}}
|
||||
>
|
||||
{t('bookingResult.tryAgain')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
<h1
|
||||
className="text-xl font-bold"
|
||||
style={{ color: 'var(--text-primary, #1a1a1a)' }}
|
||||
>
|
||||
{t('createBooking.title')}
|
||||
</h1>
|
||||
|
||||
{/* Step 1: Location & Time */}
|
||||
<div className="space-y-4">
|
||||
<h2
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: 'var(--text-secondary, #6b7280)' }}
|
||||
>
|
||||
{t('createBooking.step1Title')}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
style={{ color: 'var(--text-primary, #1a1a1a)' }}
|
||||
>
|
||||
{t('createBooking.locationId')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={locationId}
|
||||
onChange={(e) => setLocationId(e.target.value)}
|
||||
placeholder={t('createBooking.locationHint')}
|
||||
className="w-full rounded-lg px-3 py-2.5 text-sm outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevation-1, #f9fafb)',
|
||||
border: '1px solid var(--border-default, #e5e7eb)',
|
||||
color: 'var(--text-primary, #1a1a1a)',
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary, #9ca3af)' }}>
|
||||
{t('createBooking.locationHint')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DateTimePicker
|
||||
label={t('createBooking.startTime')}
|
||||
value={startTime}
|
||||
onChange={setStartTime}
|
||||
min={now}
|
||||
/>
|
||||
|
||||
<DateTimePicker
|
||||
label={t('createBooking.endTime')}
|
||||
value={endTime}
|
||||
onChange={setEndTime}
|
||||
min={startTime || now}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => void handleCheckAvailability()}
|
||||
disabled={!locationId || !startTime || !endTime || slotsLoading}
|
||||
className="w-full rounded-xl py-3 font-semibold text-sm transition-opacity disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--btn-primary-bg, #2563eb)',
|
||||
color: 'var(--btn-primary-text, #ffffff)',
|
||||
}}
|
||||
>
|
||||
{slotsLoading ? t('common.loading') : t('createBooking.checkAvailability')}
|
||||
</button>
|
||||
|
||||
{slotsError && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg" style={{ backgroundColor: '#fef2f2' }}>
|
||||
<AlertTriangle size={16} style={{ color: '#dc2626' }} />
|
||||
<p className="text-sm" style={{ color: '#dc2626' }}>{slotsError}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step 2: Availability & Confirm */}
|
||||
{step === 'confirm' && (
|
||||
<div className="space-y-4">
|
||||
<h2
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: 'var(--text-secondary, #6b7280)' }}
|
||||
>
|
||||
{t('createBooking.step2Title')}
|
||||
</h2>
|
||||
|
||||
<AvailabilitySlots slots={slots} onSelect={handleSlotSelect} />
|
||||
|
||||
{/* Collapsible EVSE Criteria */}
|
||||
<div
|
||||
className="rounded-xl overflow-hidden"
|
||||
style={{
|
||||
border: '1px solid var(--border-default, #e5e7eb)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowCriteria(!showCriteria)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevation-1, #f9fafb)',
|
||||
color: 'var(--text-primary, #1a1a1a)',
|
||||
}}
|
||||
>
|
||||
{t('createBooking.evseCriteria')}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`transition-transform ${showCriteria ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{showCriteria && (
|
||||
<div className="p-4 space-y-3" style={{ backgroundColor: 'var(--surface-elevation-0, #ffffff)' }}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('createBooking.currentType')}
|
||||
</label>
|
||||
<select
|
||||
value={currentType}
|
||||
onChange={(e) => setCurrentType(e.target.value)}
|
||||
className="w-full rounded-lg px-3 py-2 text-sm outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevation-1, #f9fafb)',
|
||||
border: '1px solid var(--border-default, #e5e7eb)',
|
||||
color: 'var(--text-primary, #1a1a1a)',
|
||||
}}
|
||||
>
|
||||
<option value="">{t('common.any')}</option>
|
||||
<option value="AC">AC</option>
|
||||
<option value="DC">DC</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('createBooking.minPower')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={minPower}
|
||||
onChange={(e) => setMinPower(e.target.value)}
|
||||
className="w-full rounded-lg px-3 py-2 text-sm outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevation-1, #f9fafb)',
|
||||
border: '1px solid var(--border-default, #e5e7eb)',
|
||||
color: 'var(--text-primary, #1a1a1a)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('createBooking.maxPower')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxPower}
|
||||
onChange={(e) => setMaxPower(e.target.value)}
|
||||
className="w-full rounded-lg px-3 py-2 text-sm outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevation-1, #f9fafb)',
|
||||
border: '1px solid var(--border-default, #e5e7eb)',
|
||||
color: 'var(--text-primary, #1a1a1a)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('createBooking.connectorType')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={connectorType}
|
||||
onChange={(e) => setConnectorType(e.target.value)}
|
||||
className="w-full rounded-lg px-3 py-2 text-sm outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevation-1, #f9fafb)',
|
||||
border: '1px solid var(--border-default, #e5e7eb)',
|
||||
color: 'var(--text-primary, #1a1a1a)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg" style={{ backgroundColor: '#fef2f2' }}>
|
||||
<AlertTriangle size={16} style={{ color: '#dc2626' }} />
|
||||
<p className="text-sm" style={{ color: '#dc2626' }}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={creating}
|
||||
className="w-full rounded-xl py-3 font-semibold text-sm transition-opacity disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--btn-primary-bg, #2563eb)',
|
||||
color: 'var(--btn-primary-text, #ffffff)',
|
||||
}}
|
||||
>
|
||||
{creating ? t('createBooking.creating') : t('createBooking.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CalendarPlus, Calendar } from 'lucide-react';
|
||||
import { useBookings } from '../../hooks/useBookings';
|
||||
import { useSession } from '../../hooks/useSession';
|
||||
import { BookingCard } from '../../components/BookingCard/BookingCard';
|
||||
|
||||
export function Home() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useSession();
|
||||
|
||||
const { bookings, loading } = useBookings(
|
||||
session?.userId
|
||||
? {
|
||||
userId: session.userId,
|
||||
status: 'accepted,reserved',
|
||||
'startAt[after]': new Date().toISOString(),
|
||||
perPage: 3,
|
||||
}
|
||||
: {},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
<h1
|
||||
className="text-xl font-bold"
|
||||
style={{ color: 'var(--text-primary, #1a1a1a)' }}
|
||||
>
|
||||
{t('home.title')}
|
||||
</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
</div>
|
||||
) : bookings.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<h2
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: 'var(--text-secondary, #6b7280)' }}
|
||||
>
|
||||
{t('home.upcomingBookings')}
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{bookings.map((booking) => (
|
||||
<BookingCard key={booking.id} booking={booking} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 space-y-3">
|
||||
<Calendar
|
||||
size={48}
|
||||
style={{ color: 'var(--text-secondary, #9ca3af)' }}
|
||||
/>
|
||||
<h2
|
||||
className="text-lg font-semibold"
|
||||
style={{ color: 'var(--text-primary, #1a1a1a)' }}
|
||||
>
|
||||
{t('home.emptyTitle')}
|
||||
</h2>
|
||||
<p
|
||||
className="text-sm text-center"
|
||||
style={{ color: 'var(--text-secondary, #6b7280)' }}
|
||||
>
|
||||
{t('home.emptyDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/create')}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl py-3 font-semibold text-sm transition-opacity hover:opacity-90"
|
||||
style={{
|
||||
backgroundColor: 'var(--btn-primary-bg, #2563eb)',
|
||||
color: 'var(--btn-primary-text, #ffffff)',
|
||||
}}
|
||||
>
|
||||
<CalendarPlus size={18} />
|
||||
{t('home.bookCharger')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { useBookings } from '../../hooks/useBookings';
|
||||
import { useSession } from '../../hooks/useSession';
|
||||
import { BookingCard } from '../../components/BookingCard/BookingCard';
|
||||
|
||||
type Tab = 'upcoming' | 'past';
|
||||
|
||||
export function MyBookings() {
|
||||
const { t } = useTranslation();
|
||||
const { session } = useSession();
|
||||
const [tab, setTab] = useState<Tab>('upcoming');
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const { bookings: upcomingBookings, loading: upcomingLoading } = useBookings(
|
||||
session?.userId
|
||||
? {
|
||||
userId: session.userId,
|
||||
status: 'accepted,reserved',
|
||||
'startAt[after]': now,
|
||||
}
|
||||
: {},
|
||||
);
|
||||
|
||||
const { bookings: pastBookings, loading: pastLoading } = useBookings(
|
||||
session?.userId
|
||||
? {
|
||||
userId: session.userId,
|
||||
'endAt[before]': now,
|
||||
}
|
||||
: {},
|
||||
);
|
||||
|
||||
const bookings = tab === 'upcoming' ? upcomingBookings : pastBookings;
|
||||
const loading = tab === 'upcoming' ? upcomingLoading : pastLoading;
|
||||
const emptyMessage = tab === 'upcoming' ? t('myBookings.noUpcoming') : t('myBookings.noPast');
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<h1
|
||||
className="text-xl font-bold"
|
||||
style={{ color: 'var(--text-primary, #1a1a1a)' }}
|
||||
>
|
||||
{t('myBookings.title')}
|
||||
</h1>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div
|
||||
className="flex rounded-lg overflow-hidden"
|
||||
style={{ border: '1px solid var(--border-default, #e5e7eb)' }}
|
||||
>
|
||||
{(['upcoming', 'past'] as Tab[]).map((tabKey) => (
|
||||
<button
|
||||
key={tabKey}
|
||||
onClick={() => setTab(tabKey)}
|
||||
className="flex-1 py-2.5 text-sm font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor:
|
||||
tab === tabKey
|
||||
? 'var(--btn-primary-bg, #2563eb)'
|
||||
: 'var(--surface-elevation-0, #ffffff)',
|
||||
color:
|
||||
tab === tabKey
|
||||
? 'var(--btn-primary-text, #ffffff)'
|
||||
: 'var(--text-secondary, #6b7280)',
|
||||
}}
|
||||
>
|
||||
{t(`myBookings.${tabKey}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
</div>
|
||||
) : bookings.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{bookings.map((booking) => (
|
||||
<BookingCard key={booking.id} booking={booking} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 space-y-3">
|
||||
<Calendar size={40} style={{ color: 'var(--text-secondary, #9ca3af)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{emptyMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronLeft, Clock, MapPin, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||
import { useBookingDetail } from '../../hooks/useBookings';
|
||||
import { createBookingRequest } from '../../api/bookingRequests';
|
||||
import { DateTimePicker } from '../../components/DateTimePicker/DateTimePicker';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
|
||||
export function UpdateBooking() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { booking, loading: bookingLoading } = useBookingDetail(parseInt(id || '0', 10));
|
||||
|
||||
const [startTime, setStartTime] = useState('');
|
||||
const [endTime, setEndTime] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (booking) {
|
||||
setStartTime(new Date(booking.startAt).toISOString().slice(0, 16));
|
||||
setEndTime(new Date(booking.endAt).toISOString().slice(0, 16));
|
||||
}
|
||||
}, [booking]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!booking) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await createBookingRequest({
|
||||
type: 'update',
|
||||
bookingId: booking.id,
|
||||
startAt: new Date(startTime).toISOString(),
|
||||
endAt: new Date(endTime).toISOString(),
|
||||
});
|
||||
|
||||
if (result.status === 'approved') {
|
||||
setSuccess(true);
|
||||
} else {
|
||||
setError(result.rejectionReason || t('bookingResult.rejected'));
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) {
|
||||
const validationErrors = err.validationErrors;
|
||||
const messages = Object.values(validationErrors).flat();
|
||||
setError(messages.length > 0 ? messages.join(', ') : err.message);
|
||||
} else {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (bookingLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-3 p-4">
|
||||
<AlertTriangle size={40} style={{ color: '#dc2626' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
{t('errors.notFound')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString().slice(0, 16);
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate(-1)}>
|
||||
<ChevronLeft size={24} style={{ color: 'var(--text-primary, #1a1a1a)' }} />
|
||||
</button>
|
||||
<h1
|
||||
className="text-xl font-bold"
|
||||
style={{ color: 'var(--text-primary, #1a1a1a)' }}
|
||||
>
|
||||
{t('updateBooking.title')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg" style={{ backgroundColor: '#dcfce7' }}>
|
||||
<CheckCircle size={16} style={{ color: '#166534' }} />
|
||||
<p className="text-sm font-medium" style={{ color: '#166534' }}>
|
||||
{t('bookingResult.approved')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current booking summary */}
|
||||
<div
|
||||
className="rounded-xl p-4 space-y-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevation-1, #f9fafb)',
|
||||
border: '1px solid var(--border-default, #e5e7eb)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin size={16} style={{ color: 'var(--text-secondary, #6b7280)' }} />
|
||||
<span className="text-sm" style={{ color: 'var(--text-primary, #1a1a1a)' }}>
|
||||
Location #{booking.locationId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={16} style={{ color: 'var(--text-secondary, #6b7280)' }} />
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary, #6b7280)' }}>
|
||||
Booking #{booking.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time pickers */}
|
||||
<div className="space-y-4">
|
||||
<DateTimePicker
|
||||
label={t('createBooking.startTime')}
|
||||
value={startTime}
|
||||
onChange={setStartTime}
|
||||
min={now}
|
||||
/>
|
||||
|
||||
<DateTimePicker
|
||||
label={t('createBooking.endTime')}
|
||||
value={endTime}
|
||||
onChange={setEndTime}
|
||||
min={startTime || now}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg" style={{ backgroundColor: '#fef2f2' }}>
|
||||
<AlertTriangle size={16} style={{ color: '#dc2626' }} />
|
||||
<p className="text-sm" style={{ color: '#dc2626' }}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || !startTime || !endTime || success}
|
||||
className="w-full rounded-xl py-3 font-semibold text-sm transition-opacity disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--btn-primary-bg, #2563eb)',
|
||||
color: 'var(--btn-primary-text, #ffffff)',
|
||||
}}
|
||||
>
|
||||
{saving ? t('updateBooking.saving') : t('updateBooking.saveChanges')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../../hooks/useSession';
|
||||
|
||||
export function UserIdFallback() {
|
||||
const { t } = useTranslation();
|
||||
const { setManualUserId } = useSession();
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const parsed = parseInt(value, 10);
|
||||
if (isNaN(parsed) || parsed <= 0) {
|
||||
setError(t('userIdFallback.error'));
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setManualUserId(parsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-6">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<h1
|
||||
className="text-xl font-bold text-center"
|
||||
style={{ color: 'var(--text-primary, #1a1a1a)' }}
|
||||
>
|
||||
{t('userIdFallback.title')}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
className="text-sm text-center"
|
||||
style={{ color: 'var(--text-secondary, #6b7280)' }}
|
||||
>
|
||||
{t('userIdFallback.description')}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={t('userIdFallback.placeholder')}
|
||||
className="w-full rounded-lg px-4 py-3 text-sm outline-none text-center"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevation-1, #f9fafb)',
|
||||
border: '1px solid var(--border-default, #e5e7eb)',
|
||||
color: 'var(--text-primary, #1a1a1a)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-center" style={{ color: '#dc2626' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-xl py-3 font-semibold text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--btn-primary-bg, #2563eb)',
|
||||
color: 'var(--btn-primary-text, #ffffff)',
|
||||
}}
|
||||
>
|
||||
{t('userIdFallback.submit')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { HashRouter, Routes, Route } from 'react-router-dom';
|
||||
import { Layout } from '../components/Layout/Layout';
|
||||
import { Home } from '../pages/Home/Home';
|
||||
import { CreateBooking } from '../pages/CreateBooking/CreateBooking';
|
||||
import { MyBookings } from '../pages/MyBookings/MyBookings';
|
||||
import { BookingDetail } from '../pages/BookingDetail/BookingDetail';
|
||||
import { UpdateBooking } from '../pages/UpdateBooking/UpdateBooking';
|
||||
|
||||
export function AppRouter() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/create" element={<CreateBooking />} />
|
||||
<Route path="/bookings" element={<MyBookings />} />
|
||||
<Route path="/bookings/:id" element={<BookingDetail />} />
|
||||
<Route path="/bookings/:id/update" element={<UpdateBooking />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface Booking {
|
||||
id: number;
|
||||
userId: number;
|
||||
locationId: number;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
status: BookingStatus;
|
||||
sessionId?: number | null;
|
||||
createdAt: string;
|
||||
lastUpdatedAt: string;
|
||||
bookedEvses?: { id: number }[];
|
||||
authorizedTokens?: { id: number; name: string }[];
|
||||
accessMethods?: string[];
|
||||
}
|
||||
|
||||
export type BookingStatus = 'accepted' | 'reserved' | 'completed' | 'cancelled' | 'no-show' | 'failed';
|
||||
|
||||
export interface BookingRequest {
|
||||
id: number;
|
||||
type: 'create' | 'update' | 'cancel';
|
||||
status: 'approved' | 'rejected';
|
||||
userId: number;
|
||||
locationId?: number;
|
||||
startAt?: string;
|
||||
endAt?: string;
|
||||
bookingId?: number;
|
||||
rejectionReason?: string;
|
||||
createdAt: string;
|
||||
lastUpdatedAt: string;
|
||||
}
|
||||
|
||||
export interface AvailabilitySlot {
|
||||
evseId: number;
|
||||
availableSlots: { startAt: string; endAt: string }[];
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
userId: number | null;
|
||||
operatorId: number;
|
||||
appLanguage: string;
|
||||
appCountry: string;
|
||||
appTheme: 'LIGHT' | 'DARK';
|
||||
designTokens?: Record<string, string>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
import { ewaUiPlugin } from '@ampeco/ewa-ui/plugin';
|
||||
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}', './node_modules/@ampeco/ewa-ui/dist/**/*.js'],
|
||||
plugins: [ewaUiPlugin],
|
||||
} satisfies Config;
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src", "src/env.d.ts"],
|
||||
"exclude": ["node_modules", "backend"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:3001', changeOrigin: true },
|
||||
'/dev': { target: 'http://localhost:3001', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user