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:
user 2026-02-04 09:16:02 +00:00
parent 3aecbeb78f
commit b2b19aac4e
11 changed files with 589 additions and 29 deletions

108
README.md Normal file
View 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

View File

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

View File

@ -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[] = [

View File

@ -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 = {

View File

@ -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
View 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;

View File

@ -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';

View File

@ -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
View 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
View 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;
}

View File

@ -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": [