From b2b19aac4e68e6caf4b8d9a802488ea79293c431 Mon Sep 17 00:00:00 2001 From: user Date: Wed, 4 Feb 2026 09:16:02 +0000 Subject: [PATCH] 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 --- README.md | 108 +++++++++++++++ app.json | 4 +- app/(tabs)/tickets.tsx | 2 +- app/ticket/[ticketId].tsx | 2 +- package.json | 2 +- src/config/environment.ts | 78 +++++++++++ src/contexts/ApiContext.tsx | 2 +- src/contexts/ConfigContext.tsx | 51 ++++--- src/services/ApiClient.ts | 238 +++++++++++++++++++++++++++++++++ src/utils/formatting.ts | 126 +++++++++++++++++ tsconfig.json | 5 +- 11 files changed, 589 insertions(+), 29 deletions(-) create mode 100644 README.md create mode 100644 src/config/environment.ts create mode 100644 src/services/ApiClient.ts create mode 100644 src/utils/formatting.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..77ffc78 --- /dev/null +++ b/README.md @@ -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 diff --git a/app.json b/app.json index dac7114..4ac9428 100644 --- a/app.json +++ b/app.json @@ -99,7 +99,9 @@ }, "eas": { "projectId": "6a58be99-18da-49c6-9fd9-385011d5cbdc" - } + }, + "apiUrl": "${EXPO_PUBLIC_API_URL}", + "instanceName": "${EXPO_PUBLIC_INSTANCE_NAME}" }, "owner": "it-enterpr" } diff --git a/app/(tabs)/tickets.tsx b/app/(tabs)/tickets.tsx index a9dd3eb..13d9b01 100644 --- a/app/(tabs)/tickets.tsx +++ b/app/(tabs)/tickets.tsx @@ -17,7 +17,7 @@ import { Ionicons } from '@expo/vector-icons'; import { useTheme } from '@/contexts/ThemeContext'; import { useAuth } from '@/contexts/AuthContext'; 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 const MOCK_TICKETS: Ticket[] = [ diff --git a/app/ticket/[ticketId].tsx b/app/ticket/[ticketId].tsx index 045ac28..bc46321 100644 --- a/app/ticket/[ticketId].tsx +++ b/app/ticket/[ticketId].tsx @@ -26,7 +26,7 @@ import { getTicketStatusLabel, isTicketActive, canCancelTicket, -} from '@bus-tickets/shared'; +} from '@/utils/formatting'; // Mock ticket data const MOCK_TICKET: Ticket = { diff --git a/package.json b/package.json index e82fb6d..0d2555d 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@bus-tickets/mobile", + "name": "bus-tickets", "version": "1.1.0", "private": true, "main": "expo-router/entry", diff --git a/src/config/environment.ts b/src/config/environment.ts new file mode 100644 index 0000000..72b2541 --- /dev/null +++ b/src/config/environment.ts @@ -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; diff --git a/src/contexts/ApiContext.tsx b/src/contexts/ApiContext.tsx index 6c850c0..1f44270 100644 --- a/src/contexts/ApiContext.tsx +++ b/src/contexts/ApiContext.tsx @@ -8,7 +8,7 @@ import React, { createContext, useContext, useMemo, useEffect, ReactNode } from 'react'; import { Platform } from 'react-native'; 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 { useConfig } from './ConfigContext'; diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 48d2c27..2e97de8 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -7,6 +7,17 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from import { Platform } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; 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) @@ -20,15 +31,15 @@ function ensureHttps(url: string | undefined): string { return url; } -// Default IT Enterprise configuration +// Default backend configuration - uses environment.ts values const DEFAULT_BACKEND: BackendConfig = { id: 'default', - name: 'BUS-Tickets Backend', + name: `${DEFAULT_INSTANCE_NAME} Backend`, type: 'odoo', - url: 'https://symcherabus.eu', - apiUrl: 'https://symcherabus.eu', - apiVersion: 'v1', - timeout: 30000, + url: DEFAULT_API_URL, + apiUrl: DEFAULT_API_URL, + apiVersion: API_VERSION, + timeout: API_TIMEOUT, isActive: true, features: { booking: true, @@ -107,7 +118,7 @@ const DEFAULT_PAYMENT_PROVIDERS: PaymentConfig[] = [ const DEFAULT_CONFIG: AppConfig = { instanceId: 'default', - instanceName: 'BUS-Tickets', + instanceName: DEFAULT_INSTANCE_NAME, backend: DEFAULT_BACKEND, authProviders: DEFAULT_OAUTH_PROVIDERS, emailConfig: { @@ -118,26 +129,26 @@ const DEFAULT_CONFIG: AppConfig = { requireVerification: true, }, paymentProviders: DEFAULT_PAYMENT_PROVIDERS, - defaultLanguage: 'uk', - supportedLanguages: ['uk', 'cs', 'en'], - defaultCurrency: 'UAH', - supportedCurrencies: ['UAH', 'CZK', 'EUR', 'USD'], + defaultLanguage: DEFAULT_LANGUAGE, + supportedLanguages: [...SUPPORTED_LANGUAGES], + defaultCurrency: DEFAULT_CURRENCY, + supportedCurrencies: [...SUPPORTED_CURRENCIES], localization: { - defaultLanguage: 'uk', - supportedLanguages: ['uk', 'cs', 'en'], - defaultCurrency: 'UAH', - supportedCurrencies: ['UAH', 'CZK', 'EUR', 'USD'], + defaultLanguage: DEFAULT_LANGUAGE, + supportedLanguages: [...SUPPORTED_LANGUAGES], + defaultCurrency: DEFAULT_CURRENCY, + supportedCurrencies: [...SUPPORTED_CURRENCIES], }, theme: { - primaryColor: '#e94560', - secondaryColor: '#0f3460', + primaryColor: THEME_COLORS.primary, + secondaryColor: THEME_COLORS.secondary, mode: 'system', }, legal: { companyName: 'IT Enterprise', - privacyPolicyUrl: 'https://symcherabus.eu/privacy', - termsOfServiceUrl: 'https://symcherabus.eu/terms', - termsUrl: 'https://symcherabus.eu/terms', + privacyPolicyUrl: `${DEFAULT_API_URL}/privacy`, + termsOfServiceUrl: `${DEFAULT_API_URL}/terms`, + termsUrl: `${DEFAULT_API_URL}/terms`, gdprCompliant: true, cookieConsentRequired: true, }, diff --git a/src/services/ApiClient.ts b/src/services/ApiClient.ts new file mode 100644 index 0000000..4f057db --- /dev/null +++ b/src/services/ApiClient.ts @@ -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; + onAuthError?: () => Promise; +} + +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( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = `${this.config.baseUrl}${endpoint}`; + + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + }; + + 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 { + 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 { + const response = await this.request<{ data: User }>( + '/api/v1/auth/register', + { + method: 'POST', + body: JSON.stringify(data), + } + ); + return response.data; + } + + async logout(): Promise { + await this.request('/api/v1/auth/logout', { method: 'POST' }); + this.clearTokens(); + } + + async refreshToken(): Promise { + 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 { + const response = await this.request<{ data: User }>('/api/v1/auth/me'); + return response.data; + } + + async updateProfile(data: Partial): Promise { + 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 { + 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 { + const response = await this.request<{ data: Trip }>( + `/api/v1/trips/${tripId}` + ); + return response.data; + } + + // Ticket endpoints + async getMyTickets(): Promise { + const response = await this.request<{ data: Ticket[] }>( + '/api/v1/tickets/my' + ); + return response.data; + } + + async getTicketById(ticketId: number): Promise { + 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 { + 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 { + await this.request(`/api/v1/tickets/${ticketId}/cancel`, { + method: 'POST', + }); + } + + // Station endpoints + async searchStations(query: string): Promise> { + 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> { + 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); +} diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts new file mode 100644 index 0000000..264b7e5 --- /dev/null +++ b/src/utils/formatting.ts @@ -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 = { + 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 = { + 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 = { + 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; +} diff --git a/tsconfig.json b/tsconfig.json index b611928..e21aa49 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,10 +4,7 @@ "strict": true, "baseUrl": ".", "paths": { - "@/*": ["src/*"], - "@bus-tickets/types": ["../../packages/types/src"], - "@bus-tickets/shared": ["../../packages/shared/src"], - "@bus-tickets/api-client": ["../../packages/api-client/src"] + "@/*": ["src/*"] } }, "include": [