mirror of
https://github.com/odoobiznes/BUS-Ticket-client.git
synced 2026-05-28 06:24:44 +00:00
Make project portable with configurable API URL
- Remove all workspace package dependencies (@bus-tickets/*) - Create local ApiClient, formatting utilities - Add environment.ts for configurable defaults - Update ConfigContext to use environment config - Add README with configuration instructions - API URL can be: 1. Changed at runtime via Settings screen 2. Set via EXPO_PUBLIC_API_URL environment variable 3. Changed in src/config/environment.ts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3aecbeb78f
commit
b2b19aac4e
108
README.md
Normal file
108
README.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# BUS-Tickets Mobile App
|
||||||
|
|
||||||
|
React Native / Expo mobile application for bus ticket booking.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### API URL Configuration
|
||||||
|
|
||||||
|
The app can connect to any compatible Odoo backend. There are several ways to configure the API URL:
|
||||||
|
|
||||||
|
#### 1. Runtime Configuration (Recommended for Development)
|
||||||
|
Users can change the backend URL directly in the app:
|
||||||
|
- Go to **Settings** > **Backend** > **Change Backend**
|
||||||
|
- Enter the new API URL
|
||||||
|
- Tap **Connect**
|
||||||
|
|
||||||
|
#### 2. Environment Variables (Build-time)
|
||||||
|
Set environment variables before building:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export EXPO_PUBLIC_API_URL="https://your-api-server.com"
|
||||||
|
export EXPO_PUBLIC_INSTANCE_NAME="Your Company Name"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then build with:
|
||||||
|
```bash
|
||||||
|
eas build --platform android --profile preview
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Edit Default Configuration
|
||||||
|
Edit `src/config/environment.ts` to change the default API URL:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const DEFAULT_API_URL = 'https://your-api-server.com';
|
||||||
|
export const DEFAULT_INSTANCE_NAME = 'Your Company Name';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deep Links Configuration
|
||||||
|
|
||||||
|
If you want to use deep links for your domain, update these files:
|
||||||
|
|
||||||
|
1. **app.json** - Update `ios.associatedDomains` and `android.intentFilters`
|
||||||
|
2. Configure your server to serve the `.well-known/apple-app-site-association` and `.well-known/assetlinks.json` files
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build for Android
|
||||||
|
npm run build:android:preview
|
||||||
|
|
||||||
|
# Build for iOS
|
||||||
|
npm run build:ios:preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
### Android APK (Preview)
|
||||||
|
```bash
|
||||||
|
export EXPO_TOKEN="your-expo-token"
|
||||||
|
eas build --platform android --profile preview
|
||||||
|
```
|
||||||
|
|
||||||
|
### iOS App (Preview)
|
||||||
|
```bash
|
||||||
|
export EXPO_TOKEN="your-expo-token"
|
||||||
|
eas build --platform ios --profile preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: iOS builds require Apple Developer account credentials.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── app/ # Expo Router pages
|
||||||
|
│ ├── (tabs)/ # Tab navigation screens
|
||||||
|
│ ├── auth/ # Authentication screens
|
||||||
|
│ └── ...
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # Reusable UI components
|
||||||
|
│ ├── contexts/ # React contexts (Auth, Theme, Config, etc.)
|
||||||
|
│ ├── config/ # Configuration files
|
||||||
|
│ ├── hooks/ # Custom React hooks
|
||||||
|
│ ├── services/ # API client and services
|
||||||
|
│ ├── types/ # TypeScript type definitions
|
||||||
|
│ └── utils/ # Utility functions
|
||||||
|
├── assets/ # Images, fonts, etc.
|
||||||
|
└── app.json # Expo configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Multi-language support (Ukrainian, Czech, English)
|
||||||
|
- Dark/Light/System theme
|
||||||
|
- Offline mode with sync
|
||||||
|
- QR code tickets
|
||||||
|
- Multiple payment providers
|
||||||
|
- Multiple bus operator support
|
||||||
|
- OAuth authentication (Google, Facebook, Apple)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Copyright (c) 2024-2026 IT Enterprise
|
||||||
4
app.json
4
app.json
@ -99,7 +99,9 @@
|
|||||||
},
|
},
|
||||||
"eas": {
|
"eas": {
|
||||||
"projectId": "6a58be99-18da-49c6-9fd9-385011d5cbdc"
|
"projectId": "6a58be99-18da-49c6-9fd9-385011d5cbdc"
|
||||||
}
|
},
|
||||||
|
"apiUrl": "${EXPO_PUBLIC_API_URL}",
|
||||||
|
"instanceName": "${EXPO_PUBLIC_INSTANCE_NAME}"
|
||||||
},
|
},
|
||||||
"owner": "it-enterpr"
|
"owner": "it-enterpr"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import type { Ticket } from '@/types';
|
import type { Ticket } from '@/types';
|
||||||
import { formatPrice, formatTime, formatShortDate, getTicketStatusColor } from '@bus-tickets/shared';
|
import { formatPrice, formatTime, formatShortDate, getTicketStatusColor } from '@/utils/formatting';
|
||||||
|
|
||||||
// Mock tickets data
|
// Mock tickets data
|
||||||
const MOCK_TICKETS: Ticket[] = [
|
const MOCK_TICKETS: Ticket[] = [
|
||||||
|
|||||||
@ -26,7 +26,7 @@ import {
|
|||||||
getTicketStatusLabel,
|
getTicketStatusLabel,
|
||||||
isTicketActive,
|
isTicketActive,
|
||||||
canCancelTicket,
|
canCancelTicket,
|
||||||
} from '@bus-tickets/shared';
|
} from '@/utils/formatting';
|
||||||
|
|
||||||
// Mock ticket data
|
// Mock ticket data
|
||||||
const MOCK_TICKET: Ticket = {
|
const MOCK_TICKET: Ticket = {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@bus-tickets/mobile",
|
"name": "bus-tickets",
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
|
|||||||
78
src/config/environment.ts
Normal file
78
src/config/environment.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* BUS-Tickets - Environment Configuration
|
||||||
|
* Copyright (c) 2024-2026 IT Enterprise
|
||||||
|
*
|
||||||
|
* This file contains default configuration values.
|
||||||
|
* These can be overridden at runtime via the Settings screen.
|
||||||
|
*
|
||||||
|
* To configure for your environment:
|
||||||
|
* 1. Edit the DEFAULT_API_URL to point to your backend
|
||||||
|
* 2. Or use the Settings screen in the app to change the API URL
|
||||||
|
* 3. Or set EXPO_PUBLIC_API_URL environment variable when building
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
|
||||||
|
// Environment variables from Expo
|
||||||
|
const expoPublicApiUrl = Constants.expoConfig?.extra?.apiUrl;
|
||||||
|
const expoPusblicInstanceName = Constants.expoConfig?.extra?.instanceName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default API URL
|
||||||
|
* Priority:
|
||||||
|
* 1. EXPO_PUBLIC_API_URL from environment (build-time)
|
||||||
|
* 2. Value set in app.json extra config
|
||||||
|
* 3. Hardcoded fallback
|
||||||
|
*
|
||||||
|
* This can be changed at runtime via Settings screen
|
||||||
|
*/
|
||||||
|
export const DEFAULT_API_URL: string =
|
||||||
|
expoPublicApiUrl ||
|
||||||
|
process.env.EXPO_PUBLIC_API_URL ||
|
||||||
|
'https://symcherabus.eu';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default instance name
|
||||||
|
*/
|
||||||
|
export const DEFAULT_INSTANCE_NAME: string =
|
||||||
|
expoPusblicInstanceName ||
|
||||||
|
process.env.EXPO_PUBLIC_INSTANCE_NAME ||
|
||||||
|
'BUS-Tickets';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API version
|
||||||
|
*/
|
||||||
|
export const API_VERSION = 'v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API timeout in milliseconds
|
||||||
|
*/
|
||||||
|
export const API_TIMEOUT = 30000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported languages
|
||||||
|
*/
|
||||||
|
export const SUPPORTED_LANGUAGES = ['uk', 'cs', 'en'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default language
|
||||||
|
*/
|
||||||
|
export const DEFAULT_LANGUAGE = 'uk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported currencies
|
||||||
|
*/
|
||||||
|
export const SUPPORTED_CURRENCIES = ['UAH', 'CZK', 'EUR', 'USD'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default currency
|
||||||
|
*/
|
||||||
|
export const DEFAULT_CURRENCY = 'UAH';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme colors
|
||||||
|
*/
|
||||||
|
export const THEME_COLORS = {
|
||||||
|
primary: '#e94560',
|
||||||
|
secondary: '#0f3460',
|
||||||
|
} as const;
|
||||||
@ -8,7 +8,7 @@
|
|||||||
import React, { createContext, useContext, useMemo, useEffect, ReactNode } from 'react';
|
import React, { createContext, useContext, useMemo, useEffect, ReactNode } from 'react';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { BusTicketsApiClient, createApiClient } from '@bus-tickets/api-client';
|
import { BusTicketsApiClient, createApiClient } from '@/services/ApiClient';
|
||||||
import type { AuthTokens } from '@/types';
|
import type { AuthTokens } from '@/types';
|
||||||
import { useConfig } from './ConfigContext';
|
import { useConfig } from './ConfigContext';
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,17 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
|
|||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import type { AppConfig, BackendConfig, OAuthProviderConfig, PaymentConfig } from '@/types';
|
import type { AppConfig, BackendConfig, OAuthProviderConfig, PaymentConfig } from '@/types';
|
||||||
|
import {
|
||||||
|
DEFAULT_API_URL,
|
||||||
|
DEFAULT_INSTANCE_NAME,
|
||||||
|
API_VERSION,
|
||||||
|
API_TIMEOUT,
|
||||||
|
SUPPORTED_LANGUAGES,
|
||||||
|
DEFAULT_LANGUAGE,
|
||||||
|
SUPPORTED_CURRENCIES,
|
||||||
|
DEFAULT_CURRENCY,
|
||||||
|
THEME_COLORS,
|
||||||
|
} from '@/config/environment';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure URL uses HTTPS (required for web to avoid mixed content issues)
|
* Ensure URL uses HTTPS (required for web to avoid mixed content issues)
|
||||||
@ -20,15 +31,15 @@ function ensureHttps(url: string | undefined): string {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default IT Enterprise configuration
|
// Default backend configuration - uses environment.ts values
|
||||||
const DEFAULT_BACKEND: BackendConfig = {
|
const DEFAULT_BACKEND: BackendConfig = {
|
||||||
id: 'default',
|
id: 'default',
|
||||||
name: 'BUS-Tickets Backend',
|
name: `${DEFAULT_INSTANCE_NAME} Backend`,
|
||||||
type: 'odoo',
|
type: 'odoo',
|
||||||
url: 'https://symcherabus.eu',
|
url: DEFAULT_API_URL,
|
||||||
apiUrl: 'https://symcherabus.eu',
|
apiUrl: DEFAULT_API_URL,
|
||||||
apiVersion: 'v1',
|
apiVersion: API_VERSION,
|
||||||
timeout: 30000,
|
timeout: API_TIMEOUT,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
features: {
|
features: {
|
||||||
booking: true,
|
booking: true,
|
||||||
@ -107,7 +118,7 @@ const DEFAULT_PAYMENT_PROVIDERS: PaymentConfig[] = [
|
|||||||
|
|
||||||
const DEFAULT_CONFIG: AppConfig = {
|
const DEFAULT_CONFIG: AppConfig = {
|
||||||
instanceId: 'default',
|
instanceId: 'default',
|
||||||
instanceName: 'BUS-Tickets',
|
instanceName: DEFAULT_INSTANCE_NAME,
|
||||||
backend: DEFAULT_BACKEND,
|
backend: DEFAULT_BACKEND,
|
||||||
authProviders: DEFAULT_OAUTH_PROVIDERS,
|
authProviders: DEFAULT_OAUTH_PROVIDERS,
|
||||||
emailConfig: {
|
emailConfig: {
|
||||||
@ -118,26 +129,26 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||||||
requireVerification: true,
|
requireVerification: true,
|
||||||
},
|
},
|
||||||
paymentProviders: DEFAULT_PAYMENT_PROVIDERS,
|
paymentProviders: DEFAULT_PAYMENT_PROVIDERS,
|
||||||
defaultLanguage: 'uk',
|
defaultLanguage: DEFAULT_LANGUAGE,
|
||||||
supportedLanguages: ['uk', 'cs', 'en'],
|
supportedLanguages: [...SUPPORTED_LANGUAGES],
|
||||||
defaultCurrency: 'UAH',
|
defaultCurrency: DEFAULT_CURRENCY,
|
||||||
supportedCurrencies: ['UAH', 'CZK', 'EUR', 'USD'],
|
supportedCurrencies: [...SUPPORTED_CURRENCIES],
|
||||||
localization: {
|
localization: {
|
||||||
defaultLanguage: 'uk',
|
defaultLanguage: DEFAULT_LANGUAGE,
|
||||||
supportedLanguages: ['uk', 'cs', 'en'],
|
supportedLanguages: [...SUPPORTED_LANGUAGES],
|
||||||
defaultCurrency: 'UAH',
|
defaultCurrency: DEFAULT_CURRENCY,
|
||||||
supportedCurrencies: ['UAH', 'CZK', 'EUR', 'USD'],
|
supportedCurrencies: [...SUPPORTED_CURRENCIES],
|
||||||
},
|
},
|
||||||
theme: {
|
theme: {
|
||||||
primaryColor: '#e94560',
|
primaryColor: THEME_COLORS.primary,
|
||||||
secondaryColor: '#0f3460',
|
secondaryColor: THEME_COLORS.secondary,
|
||||||
mode: 'system',
|
mode: 'system',
|
||||||
},
|
},
|
||||||
legal: {
|
legal: {
|
||||||
companyName: 'IT Enterprise',
|
companyName: 'IT Enterprise',
|
||||||
privacyPolicyUrl: 'https://symcherabus.eu/privacy',
|
privacyPolicyUrl: `${DEFAULT_API_URL}/privacy`,
|
||||||
termsOfServiceUrl: 'https://symcherabus.eu/terms',
|
termsOfServiceUrl: `${DEFAULT_API_URL}/terms`,
|
||||||
termsUrl: 'https://symcherabus.eu/terms',
|
termsUrl: `${DEFAULT_API_URL}/terms`,
|
||||||
gdprCompliant: true,
|
gdprCompliant: true,
|
||||||
cookieConsentRequired: true,
|
cookieConsentRequired: true,
|
||||||
},
|
},
|
||||||
|
|||||||
238
src/services/ApiClient.ts
Normal file
238
src/services/ApiClient.ts
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* BUS-Tickets - API Client
|
||||||
|
* Copyright (c) 2024-2026 IT Enterprise
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AuthTokens, Trip, Ticket, User } from '@/types';
|
||||||
|
|
||||||
|
interface ApiClientConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
timeout?: number;
|
||||||
|
onTokenRefresh?: (tokens: AuthTokens) => Promise<void>;
|
||||||
|
onAuthError?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BusTicketsApiClient {
|
||||||
|
private config: ApiClientConfig;
|
||||||
|
private tokens: AuthTokens | null = null;
|
||||||
|
|
||||||
|
constructor(config: ApiClientConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTokens(tokens: AuthTokens): void {
|
||||||
|
this.tokens = tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTokens(): void {
|
||||||
|
this.tokens = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAccessToken(): string | null {
|
||||||
|
return this.tokens?.accessToken ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.config.baseUrl}${endpoint}`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.tokens?.accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.tokens.accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
this.config.timeout ?? 30000
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
if (this.config.onAuthError) {
|
||||||
|
await this.config.onAuthError();
|
||||||
|
}
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API Error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth endpoints
|
||||||
|
async login(email: string, password: string): Promise<AuthTokens> {
|
||||||
|
const response = await this.request<{ data: AuthTokens }>(
|
||||||
|
'/api/v1/auth/login',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.tokens = response.data;
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(data: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
}): Promise<User> {
|
||||||
|
const response = await this.request<{ data: User }>(
|
||||||
|
'/api/v1/auth/register',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
await this.request('/api/v1/auth/logout', { method: 'POST' });
|
||||||
|
this.clearTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(): Promise<AuthTokens> {
|
||||||
|
if (!this.tokens?.refreshToken) {
|
||||||
|
throw new Error('No refresh token available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.request<{ data: AuthTokens }>(
|
||||||
|
'/api/v1/auth/refresh',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ refreshToken: this.tokens.refreshToken }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.tokens = response.data;
|
||||||
|
|
||||||
|
if (this.config.onTokenRefresh) {
|
||||||
|
await this.config.onTokenRefresh(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User endpoints
|
||||||
|
async getCurrentUser(): Promise<User> {
|
||||||
|
const response = await this.request<{ data: User }>('/api/v1/auth/me');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProfile(data: Partial<User>): Promise<User> {
|
||||||
|
const response = await this.request<{ data: User }>('/api/v1/auth/profile', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trip endpoints
|
||||||
|
async searchTrips(params: {
|
||||||
|
originId: number;
|
||||||
|
destinationId: number;
|
||||||
|
date: string;
|
||||||
|
passengers?: number;
|
||||||
|
}): Promise<Trip[]> {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
origin_id: params.originId.toString(),
|
||||||
|
destination_id: params.destinationId.toString(),
|
||||||
|
date: params.date,
|
||||||
|
passengers: (params.passengers ?? 1).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.request<{ data: Trip[] }>(
|
||||||
|
`/api/v1/trips?${searchParams}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTripById(tripId: number): Promise<Trip> {
|
||||||
|
const response = await this.request<{ data: Trip }>(
|
||||||
|
`/api/v1/trips/${tripId}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticket endpoints
|
||||||
|
async getMyTickets(): Promise<Ticket[]> {
|
||||||
|
const response = await this.request<{ data: Ticket[] }>(
|
||||||
|
'/api/v1/tickets/my'
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTicketById(ticketId: number): Promise<Ticket> {
|
||||||
|
const response = await this.request<{ data: Ticket }>(
|
||||||
|
`/api/v1/tickets/${ticketId}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async bookTicket(data: {
|
||||||
|
tripId: number;
|
||||||
|
passengers: Array<{
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
seat?: number;
|
||||||
|
}>;
|
||||||
|
}): Promise<Ticket[]> {
|
||||||
|
const response = await this.request<{ data: Ticket[] }>(
|
||||||
|
'/api/v1/tickets/book',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelTicket(ticketId: number): Promise<void> {
|
||||||
|
await this.request(`/api/v1/tickets/${ticketId}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Station endpoints
|
||||||
|
async searchStations(query: string): Promise<Array<{ id: number; name: string; city: string }>> {
|
||||||
|
const response = await this.request<{ data: Array<{ id: number; name: string; city: string }> }>(
|
||||||
|
`/api/v1/stations/search?q=${encodeURIComponent(query)}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPopularStations(): Promise<Array<{ id: number; name: string; city: string }>> {
|
||||||
|
const response = await this.request<{ data: Array<{ id: number; name: string; city: string }> }>(
|
||||||
|
'/api/v1/stations/popular'
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApiClient(config: ApiClientConfig): BusTicketsApiClient {
|
||||||
|
return new BusTicketsApiClient(config);
|
||||||
|
}
|
||||||
126
src/utils/formatting.ts
Normal file
126
src/utils/formatting.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* BUS-Tickets - Formatting Utilities
|
||||||
|
* Copyright (c) 2024-2026 IT Enterprise
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Ticket } from '@/types';
|
||||||
|
|
||||||
|
export interface Price {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currency formatters
|
||||||
|
const currencyFormatters: Record<string, Intl.NumberFormat> = {
|
||||||
|
UAH: new Intl.NumberFormat('uk-UA', { style: 'currency', currency: 'UAH' }),
|
||||||
|
EUR: new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }),
|
||||||
|
CZK: new Intl.NumberFormat('cs-CZ', { style: 'currency', currency: 'CZK' }),
|
||||||
|
USD: new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }),
|
||||||
|
PLN: new Intl.NumberFormat('pl-PL', { style: 'currency', currency: 'PLN' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format price with currency
|
||||||
|
*/
|
||||||
|
export function formatPrice(price: Price): string {
|
||||||
|
const formatter = currencyFormatters[price.currency];
|
||||||
|
if (formatter) {
|
||||||
|
return formatter.format(price.amount);
|
||||||
|
}
|
||||||
|
return `${price.amount} ${price.currency}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time from date string (HH:MM)
|
||||||
|
*/
|
||||||
|
export function formatTime(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format short date (e.g., "Feb 10, 2026")
|
||||||
|
*/
|
||||||
|
export function formatShortDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format duration in minutes to human readable (e.g., "12h 30m")
|
||||||
|
*/
|
||||||
|
export function formatDuration(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
|
||||||
|
if (hours === 0) {
|
||||||
|
return `${mins}m`;
|
||||||
|
}
|
||||||
|
if (mins === 0) {
|
||||||
|
return `${hours}h`;
|
||||||
|
}
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color for ticket status
|
||||||
|
*/
|
||||||
|
export function getTicketStatusColor(status: Ticket['status']): string {
|
||||||
|
const colors: Record<Ticket['status'], string> = {
|
||||||
|
reserved: '#f59e0b', // amber
|
||||||
|
paid: '#10b981', // green
|
||||||
|
checked_in: '#3b82f6', // blue
|
||||||
|
used: '#6b7280', // gray
|
||||||
|
cancelled: '#ef4444', // red
|
||||||
|
refunded: '#8b5cf6', // purple
|
||||||
|
};
|
||||||
|
return colors[status] || '#6b7280';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get label for ticket status
|
||||||
|
*/
|
||||||
|
export function getTicketStatusLabel(status: Ticket['status']): string {
|
||||||
|
const labels: Record<Ticket['status'], string> = {
|
||||||
|
reserved: 'Reserved',
|
||||||
|
paid: 'Paid',
|
||||||
|
checked_in: 'Checked In',
|
||||||
|
used: 'Used',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
refunded: 'Refunded',
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if ticket is active (can be used)
|
||||||
|
*/
|
||||||
|
export function isTicketActive(ticket: Ticket): boolean {
|
||||||
|
if (['cancelled', 'refunded', 'used'].includes(ticket.status)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const departureTime = new Date(ticket.trip.departureTime);
|
||||||
|
const now = new Date();
|
||||||
|
// Active if departure is in the future (with 2 hour buffer after departure)
|
||||||
|
return departureTime.getTime() > now.getTime() - 2 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if ticket can be cancelled
|
||||||
|
*/
|
||||||
|
export function canCancelTicket(ticket: Ticket): boolean {
|
||||||
|
if (['cancelled', 'refunded', 'used', 'checked_in'].includes(ticket.status)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const departureTime = new Date(ticket.trip.departureTime);
|
||||||
|
const now = new Date();
|
||||||
|
// Can cancel if more than 24 hours before departure
|
||||||
|
return departureTime.getTime() - now.getTime() > 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
@ -4,10 +4,7 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"],
|
"@/*": ["src/*"]
|
||||||
"@bus-tickets/types": ["../../packages/types/src"],
|
|
||||||
"@bus-tickets/shared": ["../../packages/shared/src"],
|
|
||||||
"@bus-tickets/api-client": ["../../packages/api-client/src"]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user