BUS-Tickets Mobile v1.1.0 - Standalone Build

Features:
- OAuth authentication (Google, Facebook, Apple)
- Two-factor authentication (TOTP, SMS, Email)
- Multilingual support (cs, en, uk)
- Multi-provider bus operators
- Complete booking flow
- Push notifications
- Offline mode
- Dark/light theme

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
user 2026-02-04 08:54:11 +00:00
commit 87d9bda46a
59 changed files with 34004 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.expo/
node_modules/

119
app.json Normal file
View File

@ -0,0 +1,119 @@
{
"expo": {
"name": "BUS-Tickets",
"slug": "bus-tickets",
"version": "1.1.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "bus-tickets",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#1a1a2e"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "solutions.itenterprise.bustickets",
"buildNumber": "2",
"infoPlist": {
"NSFaceIDUsageDescription": "Use Face ID for quick and secure login",
"NSCameraUsageDescription": "Scan QR codes for ticket validation",
"NSLocationWhenInUseUsageDescription": "Find nearby bus stops and stations",
"ITSAppUsesNonExemptEncryption": false
},
"associatedDomains": [
"applinks:symcherabus.eu",
"applinks:*.symcherabus.eu"
]
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#1a1a2e"
},
"package": "solutions.itenterprise.bustickets",
"versionCode": 2,
"permissions": [
"USE_BIOMETRIC",
"USE_FINGERPRINT",
"CAMERA",
"ACCESS_COARSE_LOCATION",
"ACCESS_FINE_LOCATION",
"RECEIVE_BOOT_COMPLETED",
"VIBRATE",
"android.permission.USE_BIOMETRIC",
"android.permission.USE_FINGERPRINT"
],
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "bus-tickets",
"host": "payment"
}
],
"category": [
"BROWSABLE",
"DEFAULT"
]
},
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "*.symcherabus.eu",
"pathPrefix": "/payment"
}
],
"category": [
"BROWSABLE",
"DEFAULT"
]
}
]
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"expo-secure-store",
"expo-localization",
[
"expo-local-authentication",
{
"faceIDPermission": "Allow BUS-Tickets to use Face ID for authentication"
}
],
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#e94560"
}
]
],
"experiments": {
"typedRoutes": true
},
"extra": {
"router": {
"origin": false
},
"eas": {
"projectId": "6a58be99-18da-49c6-9fd9-385011d5cbdc"
}
},
"owner": "it-enterpr"
}
}

78
app/(tabs)/_layout.tsx Normal file
View File

@ -0,0 +1,78 @@
/**
* BUS-Tickets - Tab Navigation Layout
* Copyright (c) 2024-2026 IT Enterprise
*/
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@/contexts/ThemeContext';
import { useLocale } from '@/contexts/LocaleContext';
type IconName = React.ComponentProps<typeof Ionicons>['name'];
export default function TabLayout() {
const { colors, isDark } = useTheme();
const { t } = useLocale();
const getTabBarIcon = (name: IconName, focused: boolean) => ({
tabBarIcon: ({ color, size }: { color: string; size: number }) => (
<Ionicons
name={focused ? name : (`${name}-outline` as IconName)}
size={size}
color={color}
/>
),
});
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textSecondary,
tabBarStyle: {
backgroundColor: colors.card,
borderTopColor: colors.border,
paddingBottom: 5,
paddingTop: 5,
height: 60,
},
headerStyle: {
backgroundColor: colors.background,
},
headerTintColor: colors.text,
headerTitleStyle: {
fontWeight: '600',
},
}}
>
<Tabs.Screen
name="index"
options={{
title: t.nav.home,
...getTabBarIcon('search', false),
}}
/>
<Tabs.Screen
name="tickets"
options={{
title: t.nav.tickets,
...getTabBarIcon('ticket', false),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: t.nav.profile,
...getTabBarIcon('person', false),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: t.nav.settings,
...getTabBarIcon('settings', false),
}}
/>
</Tabs>
);
}

623
app/(tabs)/index.tsx Normal file
View File

@ -0,0 +1,623 @@
/**
* BUS-Tickets - Search Screen (Home)
* Copyright (c) 2024-2026 IT Enterprise
*/
import { useState, useEffect } from 'react';
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
Platform,
Modal,
FlatList,
ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext';
import { useLocale } from '@/contexts/LocaleContext';
interface Location {
id: number;
name: string;
country?: string;
countryName?: string;
}
interface PopularRoute {
from: string;
to: string;
fromId: number;
toId: number;
price: string;
}
export default function SearchScreen() {
const router = useRouter();
const { colors } = useTheme();
const { config } = useConfig();
const { t, formatDate, formatCurrency, locale } = useLocale();
// Locations state
const [locations, setLocations] = useState<Location[]>([]);
const [isLoadingLocations, setIsLoadingLocations] = useState(true);
// Search form state
const [originId, setOriginId] = useState<number | null>(null);
const [destinationId, setDestinationId] = useState<number | null>(null);
const [origin, setOrigin] = useState('');
const [destination, setDestination] = useState('');
const [date, setDate] = useState(new Date());
const [passengers, setPassengers] = useState(1);
// Modal state
const [showOriginPicker, setShowOriginPicker] = useState(false);
const [showDestinationPicker, setShowDestinationPicker] = useState(false);
const [showDatePicker, setShowDatePicker] = useState(false);
// Popular routes
const [popularRoutes, setPopularRoutes] = useState<PopularRoute[]>([]);
// Load locations on mount
useEffect(() => {
loadLocations();
loadPopularRoutes();
}, [config.backend.url]);
const loadLocations = async () => {
setIsLoadingLocations(true);
try {
const response = await fetch(`${config.backend.url}/api/v1/locations`);
const data = await response.json();
if (data.success && data.data) {
setLocations(data.data);
}
} catch (error) {
console.error('Error loading locations:', error);
} finally {
setIsLoadingLocations(false);
}
};
const loadPopularRoutes = async () => {
try {
const response = await fetch(`${config.backend.url}/api/v1/trips/popular`);
const data = await response.json();
if (data.success && data.data?.trips) {
// Extract unique routes from popular trips
const routeMap = new Map<string, PopularRoute>();
data.data.trips.forEach((trip: any) => {
const key = `${trip.route?.origin?.id}-${trip.route?.destination?.id}`;
if (!routeMap.has(key) && trip.route?.origin && trip.route?.destination) {
routeMap.set(key, {
from: trip.route.origin.name,
to: trip.route.destination.name,
fromId: trip.route.origin.id,
toId: trip.route.destination.id,
price: `${trip.price?.amount || 0} ${trip.price?.currency || 'CZK'}`,
});
}
});
setPopularRoutes(Array.from(routeMap.values()).slice(0, 5));
}
} catch (error) {
console.error('Error loading popular routes:', error);
}
};
const handleSelectOrigin = (location: Location) => {
setOriginId(location.id);
setOrigin(location.name);
setShowOriginPicker(false);
// Reset destination if same as origin
if (destinationId === location.id) {
setDestinationId(null);
setDestination('');
}
};
const handleSelectDestination = (location: Location) => {
setDestinationId(location.id);
setDestination(location.name);
setShowDestinationPicker(false);
};
const handleSwapCities = () => {
const tempId = originId;
const tempName = origin;
setOriginId(destinationId);
setOrigin(destination);
setDestinationId(tempId);
setDestination(tempName);
};
const handleSearch = () => {
if (!origin || !destination) {
return;
}
router.push({
pathname: '/search/results',
params: {
origin,
destination,
date: date.toISOString().split('T')[0], // YYYY-MM-DD format
passengers: passengers.toString(),
},
});
};
const handleSelectPopularRoute = (route: PopularRoute) => {
setOriginId(route.fromId);
setOrigin(route.from);
setDestinationId(route.toId);
setDestination(route.to);
};
// Date formatting with locale
const formatDateLocal = (d: Date) => {
return formatDate(d, 'long');
};
// Generate date options for next 60 days
const dateOptions = Array.from({ length: 60 }, (_, i) => {
const d = new Date();
d.setDate(d.getDate() + i);
return d;
});
// Available destinations (exclude origin)
const availableDestinations = locations.filter((loc) => loc.id !== originId);
const styles = createStyles(colors);
// Location Picker Modal
const LocationPickerModal = ({
visible,
onClose,
onSelect,
title,
data,
selectedId,
}: {
visible: boolean;
onClose: () => void;
onSelect: (loc: Location) => void;
title: string;
data: Location[];
selectedId: number | null;
}) => (
<Modal visible={visible} animationType="slide" transparent>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>{title}</Text>
<TouchableOpacity onPress={onClose}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
</View>
{isLoadingLocations ? (
<ActivityIndicator size="large" color={colors.primary} style={{ marginVertical: 40 }} />
) : (
<FlatList
data={data}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<TouchableOpacity
style={[
styles.locationItem,
selectedId === item.id && styles.locationItemSelected,
]}
onPress={() => onSelect(item)}
>
<Ionicons
name="location"
size={20}
color={selectedId === item.id ? colors.primary : colors.textSecondary}
/>
<Text
style={[
styles.locationText,
selectedId === item.id && styles.locationTextSelected,
]}
>
{item.name}
</Text>
{selectedId === item.id && (
<Ionicons name="checkmark" size={20} color={colors.primary} />
)}
</TouchableOpacity>
)}
/>
)}
</View>
</View>
</Modal>
);
// Date Picker Modal
const DatePickerModal = () => (
<Modal visible={showDatePicker} animationType="slide" transparent>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>{t.search.selectDate}</Text>
<TouchableOpacity onPress={() => setShowDatePicker(false)}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
</View>
<FlatList
data={dateOptions}
keyExtractor={(item) => item.toISOString()}
renderItem={({ item }) => {
const isSelected = item.toDateString() === date.toDateString();
const isToday = item.toDateString() === new Date().toDateString();
return (
<TouchableOpacity
style={[styles.dateItem, isSelected && styles.dateItemSelected]}
onPress={() => {
setDate(item);
setShowDatePicker(false);
}}
>
<View>
<Text style={[styles.dateText, isSelected && styles.dateTextSelected]}>
{formatDateLocal(item)}
</Text>
{isToday && <Text style={styles.todayBadge}>{t.common.today}</Text>}
</View>
{isSelected && <Ionicons name="checkmark" size={20} color={colors.primary} />}
</TouchableOpacity>
);
}}
/>
</View>
</View>
</Modal>
);
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>{t.search.title}</Text>
<Text style={styles.subtitle}>{t.search.subtitle}</Text>
</View>
{/* Search Card */}
<View style={styles.searchCard}>
{/* Origin */}
<TouchableOpacity
style={styles.inputContainer}
onPress={() => setShowOriginPicker(true)}
>
<Ionicons name="location" size={20} color={colors.primary} />
<Text style={[styles.inputText, !origin && styles.placeholderText]}>
{origin || t.search.originPlaceholder}
</Text>
<Ionicons name="chevron-down" size={20} color={colors.textSecondary} />
</TouchableOpacity>
{/* Swap Button */}
<TouchableOpacity
style={styles.swapButton}
onPress={handleSwapCities}
disabled={!origin && !destination}
>
<Ionicons name="swap-vertical" size={24} color={colors.primary} />
</TouchableOpacity>
{/* Destination */}
<TouchableOpacity
style={[styles.inputContainer, !originId && styles.inputDisabled]}
onPress={() => originId && setShowDestinationPicker(true)}
disabled={!originId}
>
<Ionicons name="location-outline" size={20} color={colors.primary} />
<Text style={[styles.inputText, !destination && styles.placeholderText]}>
{destination || (originId ? t.search.destinationPlaceholder : t.search.selectOriginFirst)}
</Text>
<Ionicons name="chevron-down" size={20} color={colors.textSecondary} />
</TouchableOpacity>
{/* Divider */}
<View style={styles.divider} />
{/* Date */}
<TouchableOpacity style={styles.inputContainer} onPress={() => setShowDatePicker(true)}>
<Ionicons name="calendar" size={20} color={colors.primary} />
<Text style={styles.inputText}>{formatDateLocal(date)}</Text>
<Ionicons name="chevron-down" size={20} color={colors.textSecondary} />
</TouchableOpacity>
{/* Passengers */}
<View style={styles.inputContainer}>
<Ionicons name="people" size={20} color={colors.primary} />
<Text style={styles.inputText}>{t.search.passengers}</Text>
<View style={styles.passengerControls}>
<TouchableOpacity
style={styles.passengerButton}
onPress={() => setPassengers(Math.max(1, passengers - 1))}
>
<Ionicons name="remove" size={20} color={colors.text} />
</TouchableOpacity>
<Text style={styles.passengerCount}>{passengers}</Text>
<TouchableOpacity
style={styles.passengerButton}
onPress={() => setPassengers(Math.min(9, passengers + 1))}
>
<Ionicons name="add" size={20} color={colors.text} />
</TouchableOpacity>
</View>
</View>
{/* Search Button */}
<TouchableOpacity
style={[styles.searchButton, (!origin || !destination) && styles.searchButtonDisabled]}
onPress={handleSearch}
disabled={!origin || !destination}
>
<Ionicons name="search" size={20} color="#fff" />
<Text style={styles.searchButtonText}>{t.search.searchButton}</Text>
</TouchableOpacity>
</View>
{/* Popular Routes */}
{popularRoutes.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t.search.popularRoutes}</Text>
{popularRoutes.map((route, index) => (
<TouchableOpacity
key={index}
style={styles.routeCard}
onPress={() => handleSelectPopularRoute(route)}
>
<View style={styles.routeInfo}>
<Text style={styles.routeText}>
{route.from} {route.to}
</Text>
</View>
<Text style={styles.routePrice}>{t.search.priceFrom} {route.price}</Text>
</TouchableOpacity>
))}
</View>
)}
{/* Modals */}
<LocationPickerModal
visible={showOriginPicker}
onClose={() => setShowOriginPicker(false)}
onSelect={handleSelectOrigin}
title={t.search.whereFrom}
data={locations}
selectedId={originId}
/>
<LocationPickerModal
visible={showDestinationPicker}
onClose={() => setShowDestinationPicker(false)}
onSelect={handleSelectDestination}
title={t.search.whereTo}
data={availableDestinations}
selectedId={destinationId}
/>
<DatePickerModal />
</ScrollView>
);
}
const createStyles = (colors: any) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
content: {
padding: 16,
paddingBottom: 32,
},
header: {
marginBottom: 24,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: colors.text,
},
subtitle: {
fontSize: 16,
color: colors.textSecondary,
marginTop: 4,
},
searchCard: {
backgroundColor: colors.card,
borderRadius: 16,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
inputDisabled: {
opacity: 0.5,
},
inputText: {
flex: 1,
marginLeft: 12,
fontSize: 16,
color: colors.text,
},
placeholderText: {
color: colors.textSecondary,
},
swapButton: {
position: 'absolute',
right: 16,
top: 52,
backgroundColor: colors.background,
borderRadius: 20,
padding: 8,
zIndex: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
divider: {
height: 16,
},
passengerControls: {
flexDirection: 'row',
alignItems: 'center',
},
passengerButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
},
passengerCount: {
fontSize: 18,
fontWeight: '600',
color: colors.text,
marginHorizontal: 16,
minWidth: 24,
textAlign: 'center',
},
searchButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primary,
borderRadius: 12,
padding: 16,
marginTop: 16,
},
searchButtonDisabled: {
backgroundColor: colors.textSecondary,
opacity: 0.6,
},
searchButtonText: {
color: '#fff',
fontSize: 18,
fontWeight: '600',
marginLeft: 8,
},
section: {
marginTop: 32,
},
sectionTitle: {
fontSize: 20,
fontWeight: '600',
color: colors.text,
marginBottom: 16,
},
routeCard: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: colors.card,
borderRadius: 12,
padding: 16,
marginBottom: 12,
},
routeInfo: {
flex: 1,
},
routeText: {
fontSize: 16,
color: colors.text,
},
routePrice: {
fontSize: 14,
color: colors.primary,
fontWeight: '600',
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: colors.card,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
maxHeight: '80%',
paddingBottom: 20,
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
modalTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.text,
},
locationItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: colors.border,
gap: 12,
},
locationItemSelected: {
backgroundColor: colors.primary + '10',
},
locationText: {
flex: 1,
fontSize: 16,
color: colors.text,
},
locationTextSelected: {
fontWeight: '600',
color: colors.primary,
},
dateItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
dateItemSelected: {
backgroundColor: colors.primary + '10',
},
dateText: {
fontSize: 16,
color: colors.text,
},
dateTextSelected: {
fontWeight: '600',
color: colors.primary,
},
todayBadge: {
fontSize: 12,
color: colors.primary,
fontWeight: '600',
marginTop: 2,
},
});

234
app/(tabs)/profile.tsx Normal file
View File

@ -0,0 +1,234 @@
/**
* BUS-Tickets - Profile Screen
* Copyright (c) 2024-2026 IT Enterprise
*/
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
Alert,
} from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@/contexts/ThemeContext';
import { useAuth } from '@/contexts/AuthContext';
export default function ProfileScreen() {
const router = useRouter();
const { colors } = useTheme();
const { user, isAuthenticated, signOut } = useAuth();
const handleSignOut = () => {
Alert.alert(
'Sign Out',
'Are you sure you want to sign out?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Sign Out',
style: 'destructive',
onPress: async () => {
await signOut();
},
},
]
);
};
const styles = createStyles(colors);
if (!isAuthenticated) {
return (
<View style={styles.emptyContainer}>
<Ionicons name="person-circle-outline" size={80} color={colors.textSecondary} />
<Text style={styles.emptyTitle}>Sign in to your account</Text>
<Text style={styles.emptyText}>
Manage your profile, view booking history, and more
</Text>
<TouchableOpacity
style={styles.signInButton}
onPress={() => router.push('/auth/signin')}
>
<Text style={styles.signInButtonText}>Sign In</Text>
</TouchableOpacity>
</View>
);
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Profile Header */}
<View style={styles.header}>
<View style={styles.avatarContainer}>
<Text style={styles.avatarText}>
{user?.name?.charAt(0).toUpperCase() || 'U'}
</Text>
</View>
<Text style={styles.userName}>{user?.name || 'User'}</Text>
<Text style={styles.userEmail}>{user?.email}</Text>
</View>
{/* Menu Items */}
<View style={styles.menuSection}>
<Text style={styles.menuSectionTitle}>Account</Text>
<TouchableOpacity style={styles.menuItem}>
<Ionicons name="person-outline" size={24} color={colors.text} />
<Text style={styles.menuItemText}>Personal Information</Text>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity style={styles.menuItem}>
<Ionicons name="card-outline" size={24} color={colors.text} />
<Text style={styles.menuItemText}>Payment Methods</Text>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity style={styles.menuItem}>
<Ionicons name="notifications-outline" size={24} color={colors.text} />
<Text style={styles.menuItemText}>Notifications</Text>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity style={styles.menuItem}>
<Ionicons name="shield-checkmark-outline" size={24} color={colors.text} />
<Text style={styles.menuItemText}>Privacy & Security</Text>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
<View style={styles.menuSection}>
<Text style={styles.menuSectionTitle}>Support</Text>
<TouchableOpacity style={styles.menuItem}>
<Ionicons name="help-circle-outline" size={24} color={colors.text} />
<Text style={styles.menuItemText}>Help Center</Text>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity style={styles.menuItem}>
<Ionicons name="chatbubble-outline" size={24} color={colors.text} />
<Text style={styles.menuItemText}>Contact Support</Text>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
{/* Sign Out Button */}
<TouchableOpacity style={styles.signOutButton} onPress={handleSignOut}>
<Ionicons name="log-out-outline" size={24} color={colors.error} />
<Text style={styles.signOutText}>Sign Out</Text>
</TouchableOpacity>
</ScrollView>
);
}
const createStyles = (colors: any) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
content: {
padding: 16,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
emptyTitle: {
fontSize: 20,
fontWeight: '600',
color: colors.text,
marginTop: 16,
},
emptyText: {
fontSize: 16,
color: colors.textSecondary,
textAlign: 'center',
marginTop: 8,
},
signInButton: {
backgroundColor: colors.primary,
paddingHorizontal: 32,
paddingVertical: 12,
borderRadius: 8,
marginTop: 24,
},
signInButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
header: {
alignItems: 'center',
paddingVertical: 24,
},
avatarContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: colors.primary,
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 32,
fontWeight: '600',
color: '#fff',
},
userName: {
fontSize: 24,
fontWeight: '600',
color: colors.text,
marginTop: 12,
},
userEmail: {
fontSize: 16,
color: colors.textSecondary,
marginTop: 4,
},
menuSection: {
marginTop: 24,
},
menuSectionTitle: {
fontSize: 14,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
marginBottom: 8,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.card,
padding: 16,
borderRadius: 12,
marginBottom: 8,
},
menuItemText: {
flex: 1,
fontSize: 16,
color: colors.text,
marginLeft: 12,
},
signOutButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.card,
padding: 16,
borderRadius: 12,
marginTop: 32,
},
signOutText: {
fontSize: 16,
color: colors.error,
fontWeight: '600',
marginLeft: 8,
},
});

494
app/(tabs)/settings.tsx Normal file
View File

@ -0,0 +1,494 @@
/**
* BUS-Tickets - Settings Screen
* Copyright (c) 2024-2026 IT Enterprise
*/
import { useState } from 'react';
import {
View,
Text,
ScrollView,
TouchableOpacity,
Switch,
TextInput,
StyleSheet,
Alert,
Linking,
} from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext';
import { useNetwork } from '@/contexts/NetworkContext';
import { useLocale } from '@/contexts/LocaleContext';
import { SyncIndicator } from '@/components/SyncIndicator';
export default function SettingsScreen() {
const router = useRouter();
const { colors, isDark, toggleTheme, setTheme, themeMode } = useTheme();
const { config, loadConfigFromUrl, resetToDefault } = useConfig();
const { isOnline, syncState, forceSync } = useNetwork();
const { locale, setLocale, t, availableLanguages, getLanguageName, getLanguageFlag } = useLocale();
const [showBackendInput, setShowBackendInput] = useState(false);
const [backendUrl, setBackendUrl] = useState(config.backend.apiUrl);
const [isConnecting, setIsConnecting] = useState(false);
const handleConnectBackend = async () => {
if (!backendUrl) {
Alert.alert('Error', 'Please enter a backend URL');
return;
}
setIsConnecting(true);
try {
await loadConfigFromUrl(backendUrl);
Alert.alert('Success', 'Connected to backend successfully');
setShowBackendInput(false);
} catch (error) {
Alert.alert('Error', 'Could not connect to backend. Please check the URL.');
} finally {
setIsConnecting(false);
}
};
const handleResetConfig = () => {
Alert.alert(
'Reset Configuration',
'This will reset all settings to default. Continue?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Reset',
style: 'destructive',
onPress: async () => {
await resetToDefault();
setBackendUrl(config.backend.apiUrl);
},
},
]
);
};
const styles = createStyles(colors);
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Theme Settings */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t.settings.appearance}</Text>
<View style={styles.settingCard}>
<View style={styles.settingRow}>
<Ionicons name="moon-outline" size={24} color={colors.text} />
<Text style={styles.settingText}>{t.settings.darkMode}</Text>
<Switch
value={isDark}
onValueChange={toggleTheme}
trackColor={{ false: colors.border, true: colors.primary }}
thumbColor="#fff"
/>
</View>
<View style={styles.divider} />
<View style={styles.themeOptions}>
{(['light', 'dark', 'system'] as const).map((mode) => (
<TouchableOpacity
key={mode}
style={[
styles.themeOption,
themeMode === mode && styles.themeOptionActive,
]}
onPress={() => setTheme(mode)}
>
<Text
style={[
styles.themeOptionText,
themeMode === mode && styles.themeOptionTextActive,
]}
>
{t.settings[mode]}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</View>
{/* Notifications & Sync */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t.settings.notifications}</Text>
<View style={styles.settingCard}>
<TouchableOpacity
style={styles.settingRow}
onPress={() => router.push('/settings/notifications')}
>
<Ionicons name="notifications-outline" size={24} color={colors.text} />
<View style={styles.settingInfo}>
<Text style={styles.settingText}>{t.settings.notifications}</Text>
<Text style={styles.settingValue}>{t.settings.notificationsDesc}</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
<View style={styles.divider} />
<View style={styles.settingRow}>
<Ionicons
name={isOnline ? 'cloud-done-outline' : 'cloud-offline-outline'}
size={24}
color={isOnline ? colors.success : colors.error}
/>
<View style={styles.settingInfo}>
<Text style={styles.settingText}>{t.settings.syncStatus}</Text>
<Text style={styles.settingValue}>
{!isOnline
? t.settings.offline
: syncState.pendingActions > 0
? `${syncState.pendingActions} ${t.settings.pending}`
: t.settings.synced}
</Text>
</View>
<SyncIndicator />
</View>
{isOnline && (
<>
<View style={styles.divider} />
<TouchableOpacity style={styles.actionButton} onPress={forceSync}>
<Ionicons name="refresh-outline" size={20} color={colors.primary} />
<Text style={styles.actionButtonText}>{t.settings.forceSync}</Text>
</TouchableOpacity>
</>
)}
</View>
</View>
{/* Backend Settings */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t.settings.backend}</Text>
<View style={styles.settingCard}>
<View style={styles.settingRow}>
<Ionicons name="server-outline" size={24} color={colors.text} />
<View style={styles.settingInfo}>
<Text style={styles.settingText}>{t.settings.currentBackend}</Text>
<Text style={styles.settingValue} numberOfLines={1}>
{config.backend.apiUrl}
</Text>
</View>
</View>
<View style={styles.divider} />
<TouchableOpacity
style={styles.actionButton}
onPress={() => setShowBackendInput(!showBackendInput)}
>
<Ionicons name="link-outline" size={20} color={colors.primary} />
<Text style={styles.actionButtonText}>{t.settings.changeBackend}</Text>
</TouchableOpacity>
{showBackendInput && (
<View style={styles.inputContainer}>
<TextInput
style={styles.input}
placeholder="https://your-odoo-server.com"
placeholderTextColor={colors.textSecondary}
value={backendUrl}
onChangeText={setBackendUrl}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<TouchableOpacity
style={[styles.connectButton, isConnecting && styles.connectButtonDisabled]}
onPress={handleConnectBackend}
disabled={isConnecting}
>
<Text style={styles.connectButtonText}>
{isConnecting ? t.settings.connecting : t.settings.connect}
</Text>
</TouchableOpacity>
</View>
)}
<View style={styles.divider} />
<TouchableOpacity
style={styles.settingRow}
onPress={() => router.push('/settings/providers' as any)}
>
<Ionicons name="bus-outline" size={24} color={colors.text} />
<View style={styles.settingInfo}>
<Text style={styles.settingText}>Bus Operators</Text>
<Text style={styles.settingValue}>Manage multiple bus company connections</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
</View>
{/* Language Settings */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t.settings.language}</Text>
<View style={styles.settingCard}>
{availableLanguages.map((lang, index) => (
<View key={lang}>
{index > 0 && <View style={styles.divider} />}
<TouchableOpacity
style={styles.languageOption}
onPress={() => setLocale(lang)}
>
<Text style={styles.languageFlag}>{getLanguageFlag(lang)}</Text>
<Text style={[
styles.languageText,
locale === lang && styles.languageTextActive
]}>
{getLanguageName(lang)}
</Text>
{locale === lang && (
<Ionicons name="checkmark-circle" size={22} color={colors.primary} />
)}
</TouchableOpacity>
</View>
))}
</View>
</View>
{/* Legal */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t.settings.legal}</Text>
<View style={styles.settingCard}>
<TouchableOpacity
style={styles.legalItem}
onPress={() => Linking.openURL(config.legal.privacyPolicyUrl)}
>
<Ionicons name="document-text-outline" size={20} color={colors.text} />
<Text style={styles.legalText}>{t.settings.privacyPolicy}</Text>
<Ionicons name="open-outline" size={16} color={colors.textSecondary} />
</TouchableOpacity>
<View style={styles.divider} />
<TouchableOpacity
style={styles.legalItem}
onPress={() => Linking.openURL(config.legal.termsUrl || config.legal.termsOfServiceUrl)}
>
<Ionicons name="document-text-outline" size={20} color={colors.text} />
<Text style={styles.legalText}>{t.settings.termsOfService}</Text>
<Ionicons name="open-outline" size={16} color={colors.textSecondary} />
</TouchableOpacity>
</View>
</View>
{/* About */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t.settings.about}</Text>
<View style={styles.settingCard}>
<View style={styles.aboutItem}>
<Text style={styles.aboutLabel}>{t.settings.appName}</Text>
<Text style={styles.aboutValue}>{config.instanceName}</Text>
</View>
<View style={styles.divider} />
<View style={styles.aboutItem}>
<Text style={styles.aboutLabel}>{t.settings.version}</Text>
<Text style={styles.aboutValue}>1.0.0</Text>
</View>
<View style={styles.divider} />
<View style={styles.aboutItem}>
<Text style={styles.aboutLabel}>{t.settings.developer}</Text>
<Text style={styles.aboutValue}>IT Enterprise</Text>
</View>
</View>
</View>
{/* Reset */}
<TouchableOpacity style={styles.resetButton} onPress={handleResetConfig}>
<Ionicons name="refresh-outline" size={20} color={colors.error} />
<Text style={styles.resetButtonText}>{t.settings.resetSettings}</Text>
</TouchableOpacity>
<View style={styles.footer}>
<Text style={styles.footerText}>
© 2024-2026 IT Enterprise{'\n'}
support@it-enterprise.cz
</Text>
</View>
</ScrollView>
);
}
const createStyles = (colors: any) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
content: {
padding: 16,
},
section: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 14,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
marginBottom: 8,
},
settingCard: {
backgroundColor: colors.card,
borderRadius: 12,
padding: 16,
},
settingRow: {
flexDirection: 'row',
alignItems: 'center',
},
settingInfo: {
flex: 1,
marginLeft: 12,
},
settingText: {
flex: 1,
fontSize: 16,
color: colors.text,
marginLeft: 12,
},
settingValue: {
fontSize: 12,
color: colors.textSecondary,
marginTop: 2,
},
divider: {
height: 1,
backgroundColor: colors.border,
marginVertical: 12,
},
themeOptions: {
flexDirection: 'row',
gap: 8,
},
themeOption: {
flex: 1,
paddingVertical: 8,
alignItems: 'center',
borderRadius: 8,
backgroundColor: colors.background,
},
themeOptionActive: {
backgroundColor: colors.primary,
},
themeOptionText: {
fontSize: 14,
color: colors.textSecondary,
},
themeOptionTextActive: {
color: '#fff',
fontWeight: '600',
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
actionButtonText: {
fontSize: 16,
color: colors.primary,
},
inputContainer: {
marginTop: 12,
},
input: {
backgroundColor: colors.background,
borderRadius: 8,
padding: 12,
fontSize: 16,
color: colors.text,
marginBottom: 8,
},
connectButton: {
backgroundColor: colors.primary,
borderRadius: 8,
padding: 12,
alignItems: 'center',
},
connectButtonDisabled: {
opacity: 0.6,
},
connectButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
languageOption: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
gap: 12,
},
languageFlag: {
fontSize: 24,
},
languageText: {
flex: 1,
fontSize: 16,
color: colors.text,
},
languageTextActive: {
fontWeight: '600',
color: colors.primary,
},
legalItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
legalText: {
flex: 1,
fontSize: 16,
color: colors.text,
},
aboutItem: {
flexDirection: 'row',
justifyContent: 'space-between',
},
aboutLabel: {
fontSize: 16,
color: colors.textSecondary,
},
aboutValue: {
fontSize: 16,
color: colors.text,
},
resetButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: colors.card,
borderRadius: 12,
padding: 16,
marginBottom: 24,
},
resetButtonText: {
fontSize: 16,
color: colors.error,
},
footer: {
alignItems: 'center',
paddingVertical: 24,
},
footerText: {
fontSize: 12,
color: colors.textSecondary,
textAlign: 'center',
},
});

421
app/(tabs)/tickets.tsx Normal file
View File

@ -0,0 +1,421 @@
/**
* BUS-Tickets - My Tickets Screen
* Copyright (c) 2024-2026 IT Enterprise
*/
import { useState, useEffect } from 'react';
import {
View,
Text,
ScrollView,
TouchableOpacity,
RefreshControl,
StyleSheet,
} from 'react-native';
import { useRouter } from 'expo-router';
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';
// Mock tickets data
const MOCK_TICKETS: Ticket[] = [
{
id: 1,
ticketNumber: 'BT-2026-00001',
trip: {
id: 1,
route: {
id: 1,
name: 'Uzhorod - Praha',
origin: { id: 1, name: 'Uzhorod', city: 'Uzhorod', country: 'UA' },
destination: { id: 2, name: 'Praha', city: 'Praha', country: 'CZ' },
},
departureTime: '2026-02-10T06:00:00Z',
arrivalTime: '2026-02-10T18:00:00Z',
bus: {
id: 1,
name: 'Mercedes Tourismo',
plateNumber: 'AA1234BB',
capacity: 50,
amenities: ['wifi', 'ac', 'usb', 'toilet'],
},
availableSeats: 20,
totalSeats: 50,
price: { amount: 1200, currency: 'UAH' },
status: 'scheduled',
},
passenger: {
name: 'John Doe',
email: 'john@example.com',
phone: '+380501234567',
},
seat: 15,
price: { amount: 1200, currency: 'UAH' },
status: 'paid',
qrCode: 'BT-2026-00001',
purchasedAt: '2026-02-03T10:30:00Z',
},
];
type TicketFilter = 'all' | 'upcoming' | 'past';
export default function TicketsScreen() {
const router = useRouter();
const { colors } = useTheme();
const { isAuthenticated } = useAuth();
const [tickets, setTickets] = useState<Ticket[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [filter, setFilter] = useState<TicketFilter>('all');
useEffect(() => {
loadTickets();
}, []);
const loadTickets = async () => {
setIsLoading(true);
try {
// TODO: Replace with API call
await new Promise((resolve) => setTimeout(resolve, 500));
setTickets(MOCK_TICKETS);
} catch (error) {
console.error('Error loading tickets:', error);
} finally {
setIsLoading(false);
}
};
const onRefresh = async () => {
setRefreshing(true);
await loadTickets();
setRefreshing(false);
};
const filterTickets = (tickets: Ticket[]): Ticket[] => {
const now = new Date();
switch (filter) {
case 'upcoming':
return tickets.filter(
(t) =>
new Date(t.trip.departureTime) > now &&
['reserved', 'paid', 'checked_in'].includes(t.status)
);
case 'past':
return tickets.filter(
(t) =>
new Date(t.trip.departureTime) <= now ||
['used', 'cancelled', 'refunded'].includes(t.status)
);
default:
return tickets;
}
};
const filteredTickets = filterTickets(tickets);
const styles = createStyles(colors);
if (!isAuthenticated) {
return (
<View style={styles.emptyContainer}>
<Ionicons name="ticket-outline" size={64} color={colors.textSecondary} />
<Text style={styles.emptyTitle}>Sign in to view your tickets</Text>
<Text style={styles.emptyText}>
Your purchased tickets will appear here
</Text>
<TouchableOpacity
style={styles.signInButton}
onPress={() => router.push('/auth/signin')}
>
<Text style={styles.signInButtonText}>Sign In</Text>
</TouchableOpacity>
</View>
);
}
if (isLoading) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>Loading tickets...</Text>
</View>
);
}
return (
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
{/* Filter tabs */}
<View style={styles.filterContainer}>
{(['all', 'upcoming', 'past'] as TicketFilter[]).map((filterType) => (
<TouchableOpacity
key={filterType}
style={[
styles.filterTab,
filter === filterType && styles.filterTabActive,
]}
onPress={() => setFilter(filterType)}
>
<Text
style={[
styles.filterTabText,
filter === filterType && styles.filterTabTextActive,
]}
>
{filterType.charAt(0).toUpperCase() + filterType.slice(1)}
</Text>
</TouchableOpacity>
))}
</View>
{/* Tickets list */}
{filteredTickets.length === 0 ? (
<View style={styles.emptyContainer}>
<Ionicons name="ticket-outline" size={64} color={colors.textSecondary} />
<Text style={styles.emptyTitle}>No tickets found</Text>
<Text style={styles.emptyText}>
{filter === 'upcoming'
? "You don't have any upcoming trips."
: filter === 'past'
? "You haven't taken any trips yet."
: "You haven't purchased any tickets yet."}
</Text>
<TouchableOpacity
style={styles.bookButton}
onPress={() => router.push('/')}
>
<Text style={styles.bookButtonText}>Book a Trip</Text>
</TouchableOpacity>
</View>
) : (
<View style={styles.ticketsList}>
{filteredTickets.map((ticket) => (
<TouchableOpacity
key={ticket.id}
style={styles.ticketCard}
onPress={() =>
router.push({
pathname: '/ticket/[ticketId]',
params: { ticketId: ticket.id.toString() },
})
}
>
{/* Status indicator */}
<View
style={[
styles.statusIndicator,
{ backgroundColor: getTicketStatusColor(ticket.status) },
]}
/>
<View style={styles.ticketContent}>
{/* Header */}
<View style={styles.ticketHeader}>
<Text style={styles.ticketNumber}>{ticket.ticketNumber}</Text>
<View
style={[
styles.statusBadge,
{ backgroundColor: `${getTicketStatusColor(ticket.status)}20` },
]}
>
<Text
style={[
styles.statusText,
{ color: getTicketStatusColor(ticket.status) },
]}
>
{ticket.status.toUpperCase()}
</Text>
</View>
</View>
{/* Route */}
<View style={styles.routeContainer}>
<Text style={styles.cityText}>
{ticket.trip.route.origin.city}
</Text>
<Ionicons name="arrow-forward" size={16} color={colors.primary} />
<Text style={styles.cityText}>
{ticket.trip.route.destination.city}
</Text>
</View>
{/* Details */}
<View style={styles.detailsContainer}>
<View style={styles.detailItem}>
<Ionicons name="calendar-outline" size={14} color={colors.textSecondary} />
<Text style={styles.detailText}>
{formatShortDate(ticket.trip.departureTime)}
</Text>
</View>
<View style={styles.detailItem}>
<Ionicons name="time-outline" size={14} color={colors.textSecondary} />
<Text style={styles.detailText}>
{formatTime(ticket.trip.departureTime)}
</Text>
</View>
<View style={styles.detailItem}>
<Text style={styles.priceText}>
{formatPrice(ticket.price)}
</Text>
</View>
</View>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
))}
</View>
)}
</ScrollView>
);
}
const createStyles = (colors: any) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
content: {
padding: 16,
},
filterContainer: {
flexDirection: 'row',
gap: 8,
marginBottom: 16,
},
filterTab: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: colors.card,
},
filterTabActive: {
backgroundColor: colors.primary,
},
filterTabText: {
fontSize: 14,
fontWeight: '500',
color: colors.textSecondary,
},
filterTabTextActive: {
color: '#fff',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
emptyTitle: {
fontSize: 20,
fontWeight: '600',
color: colors.text,
marginTop: 16,
},
emptyText: {
fontSize: 16,
color: colors.textSecondary,
textAlign: 'center',
marginTop: 8,
},
signInButton: {
backgroundColor: colors.primary,
paddingHorizontal: 32,
paddingVertical: 12,
borderRadius: 8,
marginTop: 24,
},
signInButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
bookButton: {
backgroundColor: colors.primary,
paddingHorizontal: 32,
paddingVertical: 12,
borderRadius: 8,
marginTop: 24,
},
bookButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
ticketsList: {
gap: 12,
},
ticketCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.card,
borderRadius: 12,
overflow: 'hidden',
},
statusIndicator: {
width: 4,
height: '100%',
},
ticketContent: {
flex: 1,
padding: 16,
},
ticketHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
ticketNumber: {
fontSize: 12,
color: colors.textSecondary,
fontFamily: 'monospace',
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
},
statusText: {
fontSize: 10,
fontWeight: '600',
},
routeContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 12,
},
cityText: {
fontSize: 16,
fontWeight: '600',
color: colors.text,
},
detailsContainer: {
flexDirection: 'row',
gap: 16,
},
detailItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
detailText: {
fontSize: 12,
color: colors.textSecondary,
},
priceText: {
fontSize: 14,
fontWeight: '600',
color: colors.primary,
},
});

183
app/_layout.tsx Normal file
View File

@ -0,0 +1,183 @@
/**
* BUS-Tickets - Root Layout
* Copyright (c) 2024-2026 IT Enterprise
*/
import { useEffect } from 'react';
import { View } from 'react-native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useColorScheme } from 'react-native';
import * as SplashScreen from 'expo-splash-screen';
import { useFonts } from 'expo-font';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { ThemeProvider, useTheme } from '@/contexts/ThemeContext';
import { AuthProvider, useAuth } from '@/contexts/AuthContext';
import { ConfigProvider, useConfig } from '@/contexts/ConfigContext';
import { ApiProvider } from '@/contexts/ApiContext';
import { NetworkProvider } from '@/contexts/NetworkContext';
import { LocaleProvider, useLocale } from '@/contexts/LocaleContext';
import { ProvidersProvider } from '@/contexts/ProvidersContext';
import { OfflineBanner } from '@/components/OfflineBanner';
import { useNotifications } from '@/hooks/useNotifications';
import { notificationService } from '@/services/NotificationService';
// Prevent splash screen from auto-hiding
SplashScreen.preventAutoHideAsync();
function NotificationInitializer() {
const { user } = useAuth();
const { config } = useConfig();
const { expoPushToken } = useNotifications();
useEffect(() => {
// Register push token with backend when user logs in
const apiUrl = config.backend.apiUrl || config.backend.url;
if (user && expoPushToken && apiUrl) {
notificationService.registerTokenWithBackend(
apiUrl,
user.id
);
}
}, [user, expoPushToken, config.backend.apiUrl, config.backend.url]);
return null;
}
function RootLayoutNav() {
const { colors, isDark } = useTheme();
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<StatusBar style={isDark ? 'light' : 'dark'} />
<OfflineBanner />
<Stack
screenOptions={{
headerStyle: {
backgroundColor: colors.background,
},
headerTintColor: colors.text,
headerTitleStyle: {
fontWeight: '600',
},
contentStyle: {
backgroundColor: colors.background,
},
}}
>
<Stack.Screen
name="(tabs)"
options={{ headerShown: false }}
/>
<Stack.Screen
name="auth/signin"
options={{
title: 'Sign In',
presentation: 'modal',
}}
/>
<Stack.Screen
name="auth/two-factor"
options={{
title: '2FA Verification',
presentation: 'modal',
headerShown: false,
}}
/>
<Stack.Screen
name="search/results"
options={{
title: 'Search Results',
}}
/>
<Stack.Screen
name="booking/[tripId]"
options={{
title: 'Book Trip',
presentation: 'card',
}}
/>
<Stack.Screen
name="ticket/[ticketId]"
options={{
title: 'Ticket Details',
}}
/>
<Stack.Screen
name="settings/notifications"
options={{
title: 'Notifications',
}}
/>
<Stack.Screen
name="settings/providers"
options={{
title: 'Bus Operators',
}}
/>
<Stack.Screen
name="payment/return"
options={{
title: 'Platba',
headerShown: false,
presentation: 'modal',
}}
/>
</Stack>
</View>
);
}
function AppContent() {
const { config } = useConfig();
const apiUrl = config.backend.apiUrl || config.backend.url;
return (
<NetworkProvider apiUrl={apiUrl}>
<NotificationInitializer />
<RootLayoutNav />
</NetworkProvider>
);
}
export default function RootLayout() {
const colorScheme = useColorScheme();
const [fontsLoaded] = useFonts({
// Add custom fonts here if needed
});
useEffect(() => {
// Initialize notifications
notificationService.initialize();
}, []);
useEffect(() => {
if (fontsLoaded) {
SplashScreen.hideAsync();
}
}, [fontsLoaded]);
if (!fontsLoaded) {
return null;
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<ConfigProvider>
<LocaleProvider>
<ProvidersProvider>
<ApiProvider>
<AuthProvider>
<ThemeProvider>
<AppContent />
</ThemeProvider>
</AuthProvider>
</ApiProvider>
</ProvidersProvider>
</LocaleProvider>
</ConfigProvider>
</GestureHandlerRootView>
);
}

629
app/auth/signin.tsx Normal file
View File

@ -0,0 +1,629 @@
/**
* BUS-Tickets - Sign In Screen
* Supports Email, Phone OTP, and OAuth (Google, Facebook, Apple)
* Copyright (c) 2024-2026 IT Enterprise
*/
import { useState, useEffect } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
ScrollView,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert,
ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@/contexts/ThemeContext';
import { useAuth } from '@/contexts/AuthContext';
import { useConfig } from '@/contexts/ConfigContext';
import { useLocale } from '@/contexts/LocaleContext';
import { oAuthService, OAuthProvider } from '@/services/OAuthService';
type AuthMethod = 'email' | 'phone' | 'magic';
export default function SignInScreen() {
const router = useRouter();
const { colors } = useTheme();
const { signIn, signInWithOAuth, signInWithOTP, requestOTP, isLoading } = useAuth();
const { config } = useConfig();
const { t } = useLocale();
const [authMethod, setAuthMethod] = useState<AuthMethod>('email');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [phone, setPhone] = useState('');
const [otpCode, setOtpCode] = useState('');
const [showOtpInput, setShowOtpInput] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [magicLinkSent, setMagicLinkSent] = useState(false);
const [oauthLoading, setOauthLoading] = useState<OAuthProvider | null>(null);
// Configure OAuth providers from app config
useEffect(() => {
if (config.authProviders) {
oAuthService.configureFromAppConfig(config.authProviders);
}
}, [config.authProviders]);
const handleEmailSignIn = async () => {
if (!email || !password) {
Alert.alert(t.common.error, t.auth.invalidCredentials);
return;
}
try {
await signIn(email, password);
router.back();
} catch (error) {
Alert.alert(t.common.error, t.auth.invalidCredentials);
}
};
const handleRequestOTP = async () => {
if (!phone) {
Alert.alert(t.common.error, t.auth.invalidCredentials);
return;
}
try {
await requestOTP(undefined, phone);
setShowOtpInput(true);
} catch (error) {
Alert.alert(t.common.error, t.auth.networkError);
}
};
const handleVerifyOTP = async () => {
if (!otpCode) {
Alert.alert(t.common.error, t.auth.enterCode);
return;
}
try {
await signInWithOTP(phone, otpCode);
router.back();
} catch (error) {
Alert.alert(t.common.error, t.auth.invalidCredentials);
}
};
const handleMagicLink = async () => {
if (!email) {
Alert.alert(t.common.error, t.auth.invalidCredentials);
return;
}
try {
await requestOTP(email);
setMagicLinkSent(true);
} catch (error) {
Alert.alert(t.common.error, t.auth.networkError);
}
};
const handleOAuthSignIn = async (provider: OAuthProvider) => {
setOauthLoading(provider);
try {
const result = await oAuthService.signIn(provider);
if (result.success && (result.idToken || result.accessToken)) {
// Send to backend for verification
await signInWithOAuth(provider, result.idToken || result.accessToken!);
router.back();
} else {
if (result.error !== 'User cancelled authentication') {
Alert.alert(t.common.error, result.error || t.auth.networkError);
}
}
} catch (error) {
console.error('OAuth error:', error);
Alert.alert(t.common.error, t.auth.networkError);
} finally {
setOauthLoading(null);
}
};
const styles = createStyles(colors);
const enabledOAuthProviders = (config.authProviders || []).filter((p) => p.enabled);
const getProviderIcon = (providerId: string) => {
switch (providerId) {
case 'google':
return 'logo-google';
case 'facebook':
return 'logo-facebook';
case 'apple':
return 'logo-apple';
default:
return 'log-in-outline';
}
};
const getProviderColor = (providerId: string) => {
switch (providerId) {
case 'google':
return '#4285F4';
case 'facebook':
return '#1877F2';
case 'apple':
return colors.text;
default:
return colors.primary;
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>{t.auth.signInTitle}</Text>
<Text style={styles.subtitle}>{config.instanceName || 'BUS-Tickets'}</Text>
</View>
{/* OAuth Buttons */}
{enabledOAuthProviders.length > 0 && (
<View style={styles.oauthSection}>
{enabledOAuthProviders.map((provider) => (
<TouchableOpacity
key={provider.id}
style={[
styles.oauthButton,
oauthLoading === provider.id && styles.oauthButtonLoading,
]}
onPress={() => handleOAuthSignIn(provider.id as OAuthProvider)}
disabled={!!oauthLoading}
>
{oauthLoading === provider.id ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons
name={getProviderIcon(provider.id) as any}
size={24}
color={getProviderColor(provider.id)}
/>
<Text style={styles.oauthButtonText}>
{t.auth.orContinueWith} {provider.name}
</Text>
</>
)}
</TouchableOpacity>
))}
</View>
)}
{/* Divider */}
{enabledOAuthProviders.length > 0 && (
<View style={styles.dividerContainer}>
<View style={styles.divider} />
<Text style={styles.dividerText}>{t.auth.orContinueWith.split(' ')[0]}</Text>
<View style={styles.divider} />
</View>
)}
{/* Auth Method Tabs */}
<View style={styles.authMethodTabs}>
<TouchableOpacity
style={[styles.authMethodTab, authMethod === 'email' && styles.authMethodTabActive]}
onPress={() => setAuthMethod('email')}
>
<Ionicons
name="mail-outline"
size={20}
color={authMethod === 'email' ? colors.primary : colors.textSecondary}
/>
<Text
style={[
styles.authMethodTabText,
authMethod === 'email' && styles.authMethodTabTextActive,
]}
>
{t.auth.email}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.authMethodTab, authMethod === 'phone' && styles.authMethodTabActive]}
onPress={() => setAuthMethod('phone')}
>
<Ionicons
name="phone-portrait-outline"
size={20}
color={authMethod === 'phone' ? colors.primary : colors.textSecondary}
/>
<Text
style={[
styles.authMethodTabText,
authMethod === 'phone' && styles.authMethodTabTextActive,
]}
>
OTP
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.authMethodTab, authMethod === 'magic' && styles.authMethodTabActive]}
onPress={() => setAuthMethod('magic')}
>
<Ionicons
name="link-outline"
size={20}
color={authMethod === 'magic' ? colors.primary : colors.textSecondary}
/>
<Text
style={[
styles.authMethodTabText,
authMethod === 'magic' && styles.authMethodTabTextActive,
]}
>
Link
</Text>
</TouchableOpacity>
</View>
{/* Email Form */}
{authMethod === 'email' && (
<View style={styles.form}>
<View style={styles.inputContainer}>
<Ionicons name="mail-outline" size={20} color={colors.textSecondary} />
<TextInput
style={styles.input}
placeholder={t.auth.email}
placeholderTextColor={colors.textSecondary}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
/>
</View>
<View style={styles.inputContainer}>
<Ionicons name="lock-closed-outline" size={20} color={colors.textSecondary} />
<TextInput
style={styles.input}
placeholder={t.auth.password}
placeholderTextColor={colors.textSecondary}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
/>
<TouchableOpacity onPress={() => setShowPassword(!showPassword)}>
<Ionicons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.forgotPassword}>
<Text style={styles.forgotPasswordText}>{t.auth.forgotPassword}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.signInButton, isLoading && styles.signInButtonDisabled]}
onPress={handleEmailSignIn}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.signInButtonText}>{t.auth.signInButton}</Text>
)}
</TouchableOpacity>
</View>
)}
{/* Phone OTP Form */}
{authMethod === 'phone' && (
<View style={styles.form}>
<View style={styles.inputContainer}>
<Ionicons name="phone-portrait-outline" size={20} color={colors.textSecondary} />
<TextInput
style={styles.input}
placeholder="+380... / +420..."
placeholderTextColor={colors.textSecondary}
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
/>
</View>
{showOtpInput && (
<View style={styles.inputContainer}>
<Ionicons name="keypad-outline" size={20} color={colors.textSecondary} />
<TextInput
style={styles.input}
placeholder={t.auth.enterCode}
placeholderTextColor={colors.textSecondary}
value={otpCode}
onChangeText={setOtpCode}
keyboardType="number-pad"
maxLength={6}
/>
</View>
)}
<TouchableOpacity
style={[styles.signInButton, isLoading && styles.signInButtonDisabled]}
onPress={showOtpInput ? handleVerifyOTP : handleRequestOTP}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.signInButtonText}>
{showOtpInput ? t.auth.verifyButton : t.auth.sendMagicLink.replace('link', 'OTP')}
</Text>
)}
</TouchableOpacity>
{showOtpInput && (
<TouchableOpacity style={styles.resendButton} onPress={handleRequestOTP}>
<Text style={styles.resendButtonText}>{t.auth.resendCode}</Text>
</TouchableOpacity>
)}
</View>
)}
{/* Magic Link Form */}
{authMethod === 'magic' && (
<View style={styles.form}>
{!magicLinkSent ? (
<>
<View style={styles.infoBox}>
<Ionicons name="information-circle-outline" size={24} color={colors.primary} />
<Text style={styles.infoText}>{t.auth.checkEmail}</Text>
</View>
<View style={styles.inputContainer}>
<Ionicons name="mail-outline" size={20} color={colors.textSecondary} />
<TextInput
style={styles.input}
placeholder={t.auth.email}
placeholderTextColor={colors.textSecondary}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
/>
</View>
<TouchableOpacity
style={[styles.signInButton, isLoading && styles.signInButtonDisabled]}
onPress={handleMagicLink}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.signInButtonText}>{t.auth.sendMagicLink}</Text>
)}
</TouchableOpacity>
</>
) : (
<View style={styles.successBox}>
<Ionicons name="checkmark-circle" size={64} color={colors.success} />
<Text style={styles.successTitle}>{t.auth.magicLinkSent}</Text>
<Text style={styles.successText}>{t.auth.checkEmail}</Text>
<TouchableOpacity
style={styles.resendButton}
onPress={() => setMagicLinkSent(false)}
>
<Text style={styles.resendButtonText}>{t.auth.resendCode}</Text>
</TouchableOpacity>
</View>
)}
</View>
)}
{/* Footer */}
<View style={styles.footer}>
<Text style={styles.footerText}>
{t.auth.noAccount}{' '}
<Text style={styles.footerLink}>{t.auth.signUpButton}</Text>
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const createStyles = (colors: any) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
scrollView: {
flex: 1,
},
content: {
padding: 24,
},
header: {
marginBottom: 32,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: colors.text,
},
subtitle: {
fontSize: 16,
color: colors.textSecondary,
marginTop: 8,
},
oauthSection: {
gap: 12,
},
oauthButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
backgroundColor: colors.card,
borderRadius: 12,
padding: 16,
borderWidth: 1,
borderColor: colors.border,
},
oauthButtonLoading: {
opacity: 0.7,
},
oauthButtonText: {
fontSize: 16,
color: colors.text,
fontWeight: '500',
},
dividerContainer: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 24,
},
divider: {
flex: 1,
height: 1,
backgroundColor: colors.border,
},
dividerText: {
fontSize: 14,
color: colors.textSecondary,
marginHorizontal: 16,
},
authMethodTabs: {
flexDirection: 'row',
backgroundColor: colors.card,
borderRadius: 12,
padding: 4,
marginBottom: 24,
},
authMethodTab: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 12,
borderRadius: 8,
},
authMethodTabActive: {
backgroundColor: colors.background,
},
authMethodTabText: {
fontSize: 13,
color: colors.textSecondary,
},
authMethodTabTextActive: {
color: colors.primary,
fontWeight: '600',
},
form: {
gap: 16,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.card,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
borderWidth: 1,
borderColor: colors.border,
},
input: {
flex: 1,
fontSize: 16,
color: colors.text,
marginLeft: 12,
},
forgotPassword: {
alignSelf: 'flex-end',
},
forgotPasswordText: {
fontSize: 14,
color: colors.primary,
},
signInButton: {
backgroundColor: colors.primary,
borderRadius: 12,
padding: 16,
alignItems: 'center',
marginTop: 8,
},
signInButtonDisabled: {
opacity: 0.6,
},
signInButtonText: {
fontSize: 18,
fontWeight: '600',
color: '#fff',
},
resendButton: {
alignItems: 'center',
padding: 12,
},
resendButtonText: {
fontSize: 14,
color: colors.primary,
},
infoBox: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
backgroundColor: colors.primary + '15',
borderRadius: 12,
padding: 16,
},
infoText: {
flex: 1,
fontSize: 14,
color: colors.text,
lineHeight: 20,
},
successBox: {
alignItems: 'center',
padding: 32,
},
successTitle: {
fontSize: 20,
fontWeight: '600',
color: colors.text,
marginTop: 16,
},
successText: {
fontSize: 14,
color: colors.textSecondary,
textAlign: 'center',
marginTop: 8,
},
footer: {
marginTop: 32,
alignItems: 'center',
},
footerText: {
fontSize: 14,
color: colors.textSecondary,
},
footerLink: {
color: colors.primary,
fontWeight: '600',
},
});

461
app/auth/two-factor.tsx Normal file
View File

@ -0,0 +1,461 @@
/**
* BUS-Tickets - Two-Factor Authentication Screen
* Copyright (c) 2024-2026 IT Enterprise
*/
import { useState, useRef, useEffect } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert,
ActivityIndicator,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@/contexts/ThemeContext';
import { useLocale } from '@/contexts/LocaleContext';
import { twoFactorService, TwoFactorMethod } from '@/services/TwoFactorService';
const CODE_LENGTH = 6;
export default function TwoFactorScreen() {
const router = useRouter();
const params = useLocalSearchParams<{
tempToken: string;
method?: TwoFactorMethod;
email?: string;
phone?: string;
}>();
const { colors } = useTheme();
const { t } = useLocale();
const [code, setCode] = useState<string[]>(Array(CODE_LENGTH).fill(''));
const [isLoading, setIsLoading] = useState(false);
const [showBackupInput, setShowBackupInput] = useState(false);
const [backupCode, setBackupCode] = useState('');
const [countdown, setCountdown] = useState(0);
const inputRefs = useRef<(TextInput | null)[]>([]);
const method = (params.method as TwoFactorMethod) || 'totp';
// Countdown timer for resend code
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
}
}, [countdown]);
// Auto-focus first input
useEffect(() => {
setTimeout(() => {
inputRefs.current[0]?.focus();
}, 100);
}, []);
const handleCodeChange = (index: number, value: string) => {
if (value.length > 1) {
// Handle paste
const pastedCode = value.slice(0, CODE_LENGTH).split('');
const newCode = [...code];
pastedCode.forEach((char, i) => {
if (index + i < CODE_LENGTH) {
newCode[index + i] = char;
}
});
setCode(newCode);
// Focus last filled input or next empty
const nextIndex = Math.min(index + pastedCode.length, CODE_LENGTH - 1);
inputRefs.current[nextIndex]?.focus();
// Auto-submit if complete
if (newCode.every((c) => c !== '')) {
handleVerify(newCode.join(''));
}
} else {
const newCode = [...code];
newCode[index] = value;
setCode(newCode);
// Move to next input
if (value && index < CODE_LENGTH - 1) {
inputRefs.current[index + 1]?.focus();
}
// Auto-submit if complete
if (newCode.every((c) => c !== '')) {
handleVerify(newCode.join(''));
}
}
};
const handleKeyPress = (index: number, key: string) => {
if (key === 'Backspace' && !code[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
};
const handleVerify = async (codeString?: string) => {
const verifyCode = codeString || code.join('');
if (verifyCode.length !== CODE_LENGTH) {
Alert.alert(t.common.error, t.auth.enterCode);
return;
}
setIsLoading(true);
try {
const result = await twoFactorService.verifyCode(
params.tempToken!,
verifyCode,
method
);
if (result.success && result.accessToken) {
// Store tokens and navigate to home
// This would typically be handled by AuthContext
router.replace('/');
} else {
Alert.alert(t.common.error, result.error || t.auth.invalidCredentials);
setCode(Array(CODE_LENGTH).fill(''));
inputRefs.current[0]?.focus();
}
} catch (error) {
Alert.alert(t.common.error, t.auth.networkError);
} finally {
setIsLoading(false);
}
};
const handleResendCode = async () => {
if (countdown > 0) return;
setIsLoading(true);
try {
const result = await twoFactorService.requestCode(params.tempToken!);
if (result.success) {
setCountdown(60);
Alert.alert(t.common.success, t.auth.magicLinkSent);
} else {
Alert.alert(t.common.error, result.error || t.auth.networkError);
}
} catch (error) {
Alert.alert(t.common.error, t.auth.networkError);
} finally {
setIsLoading(false);
}
};
const handleBackupCode = async () => {
if (!backupCode.trim()) {
Alert.alert(t.common.error, t.auth.enterCode);
return;
}
setIsLoading(true);
try {
const result = await twoFactorService.useBackupCode(
params.tempToken!,
backupCode.trim()
);
if (result.success && result.accessToken) {
router.replace('/');
} else {
Alert.alert(t.common.error, result.error || t.auth.invalidCredentials);
}
} catch (error) {
Alert.alert(t.common.error, t.auth.networkError);
} finally {
setIsLoading(false);
}
};
const styles = createStyles(colors);
const getMethodIcon = () => {
switch (method) {
case 'totp':
return 'keypad';
case 'sms':
return 'phone-portrait';
case 'email':
return 'mail';
default:
return 'shield-checkmark';
}
};
const getMethodDescription = () => {
switch (method) {
case 'totp':
return t.auth.enterCode;
case 'sms':
return `${t.auth.enterCode} (${params.phone || 'SMS'})`;
case 'email':
return `${t.auth.enterCode} (${params.email || 'Email'})`;
default:
return t.auth.enterCode;
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.content}>
{/* Header */}
<View style={styles.header}>
<View style={styles.iconContainer}>
<Ionicons name={getMethodIcon() as any} size={48} color={colors.primary} />
</View>
<Text style={styles.title}>{t.auth.twoFactor}</Text>
<Text style={styles.subtitle}>{getMethodDescription()}</Text>
</View>
{!showBackupInput ? (
<>
{/* Code Input */}
<View style={styles.codeContainer}>
{code.map((digit, index) => (
<TextInput
key={index}
ref={(ref) => (inputRefs.current[index] = ref)}
style={[
styles.codeInput,
digit ? styles.codeInputFilled : undefined,
]}
value={digit}
onChangeText={(value) => handleCodeChange(index, value)}
onKeyPress={({ nativeEvent }) =>
handleKeyPress(index, nativeEvent.key)
}
keyboardType="number-pad"
maxLength={CODE_LENGTH}
selectTextOnFocus
editable={!isLoading}
/>
))}
</View>
{/* Verify Button */}
<TouchableOpacity
style={[styles.verifyButton, isLoading && styles.buttonDisabled]}
onPress={() => handleVerify()}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.verifyButtonText}>{t.auth.verifyButton}</Text>
)}
</TouchableOpacity>
{/* Resend Code (for SMS/Email) */}
{method !== 'totp' && (
<TouchableOpacity
style={styles.resendButton}
onPress={handleResendCode}
disabled={countdown > 0 || isLoading}
>
<Text
style={[
styles.resendButtonText,
countdown > 0 && styles.resendButtonTextDisabled,
]}
>
{countdown > 0
? `${t.auth.resendCode} (${countdown}s)`
: t.auth.resendCode}
</Text>
</TouchableOpacity>
)}
{/* Use Backup Code */}
<TouchableOpacity
style={styles.backupButton}
onPress={() => setShowBackupInput(true)}
>
<Ionicons name="key-outline" size={16} color={colors.textSecondary} />
<Text style={styles.backupButtonText}>Use backup code</Text>
</TouchableOpacity>
</>
) : (
<>
{/* Backup Code Input */}
<View style={styles.backupInputContainer}>
<Text style={styles.backupLabel}>Enter your backup code:</Text>
<TextInput
style={styles.backupInput}
value={backupCode}
onChangeText={setBackupCode}
placeholder="XXXX-XXXX-XXXX"
placeholderTextColor={colors.textSecondary}
autoCapitalize="characters"
editable={!isLoading}
/>
</View>
{/* Verify Backup Button */}
<TouchableOpacity
style={[styles.verifyButton, isLoading && styles.buttonDisabled]}
onPress={handleBackupCode}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.verifyButtonText}>{t.auth.verifyButton}</Text>
)}
</TouchableOpacity>
{/* Back to Code Input */}
<TouchableOpacity
style={styles.backupButton}
onPress={() => {
setShowBackupInput(false);
setBackupCode('');
}}
>
<Ionicons name="arrow-back" size={16} color={colors.textSecondary} />
<Text style={styles.backupButtonText}>Back to code input</Text>
</TouchableOpacity>
</>
)}
{/* Cancel */}
<TouchableOpacity style={styles.cancelButton} onPress={() => router.back()}>
<Text style={styles.cancelButtonText}>{t.common.cancel}</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const createStyles = (colors: any) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
content: {
flex: 1,
padding: 24,
justifyContent: 'center',
},
header: {
alignItems: 'center',
marginBottom: 40,
},
iconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: colors.primary + '20',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: colors.text,
},
subtitle: {
fontSize: 14,
color: colors.textSecondary,
marginTop: 8,
textAlign: 'center',
},
codeContainer: {
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
marginBottom: 32,
},
codeInput: {
width: 48,
height: 56,
borderRadius: 12,
backgroundColor: colors.card,
borderWidth: 2,
borderColor: colors.border,
textAlign: 'center',
fontSize: 24,
fontWeight: '600',
color: colors.text,
},
codeInputFilled: {
borderColor: colors.primary,
},
verifyButton: {
backgroundColor: colors.primary,
borderRadius: 12,
padding: 16,
alignItems: 'center',
},
buttonDisabled: {
opacity: 0.6,
},
verifyButtonText: {
fontSize: 18,
fontWeight: '600',
color: '#fff',
},
resendButton: {
alignItems: 'center',
padding: 16,
},
resendButtonText: {
fontSize: 14,
color: colors.primary,
},
resendButtonTextDisabled: {
color: colors.textSecondary,
},
backupButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: 16,
},
backupButtonText: {
fontSize: 14,
color: colors.textSecondary,
},
backupInputContainer: {
marginBottom: 24,
},
backupLabel: {
fontSize: 14,
color: colors.text,
marginBottom: 8,
},
backupInput: {
backgroundColor: colors.card,
borderRadius: 12,
padding: 16,
fontSize: 18,
color: colors.text,
textAlign: 'center',
letterSpacing: 2,
},
cancelButton: {
alignItems: 'center',
padding: 16,
marginTop: 16,
},
cancelButtonText: {
fontSize: 14,
color: colors.error,
},
});

1035
app/booking/[tripId].tsx Normal file

File diff suppressed because it is too large Load Diff

187
app/payment/return.tsx Normal file
View File

@ -0,0 +1,187 @@
/**
* BUS-Tickets - Payment Return Screen
* Copyright (c) 2024-2026 IT Enterprise
*
* This screen handles deep link returns from payment providers:
* - bus-tickets://payment/return?ref=xxx
* - bus-tickets://payment/success?ref=xxx
* - bus-tickets://payment/cancelled
* - bus-tickets://payment/error
*/
import { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
ActivityIndicator,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext';
type PaymentResultStatus = 'loading' | 'success' | 'error' | 'cancelled';
export default function PaymentReturnScreen() {
const router = useRouter();
const params = useLocalSearchParams<{
ref?: string;
reference?: string;
session_id?: string;
token?: string;
PayerID?: string;
cancelled?: string;
error?: string;
}>();
const { colors } = useTheme();
const { config } = useConfig();
const [status, setStatus] = useState<PaymentResultStatus>('loading');
const [message, setMessage] = useState('Ověřuji platbu...');
useEffect(() => {
checkPaymentResult();
}, []);
const checkPaymentResult = async () => {
// Check for cancellation
if (params.cancelled === 'true' || params.error) {
setStatus(params.error ? 'error' : 'cancelled');
setMessage(params.error ? 'Platba selhala' : 'Platba byla zrušena');
redirectAfterDelay();
return;
}
// Get reference from various possible params
const reference = params.ref || params.reference || params.session_id || params.token;
if (!reference) {
setStatus('error');
setMessage('Chybí reference platby');
redirectAfterDelay();
return;
}
try {
// Check payment status via API
const response = await fetch(
`${config.backend.url}/api/v1/payments/status?reference=${reference}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
const data = await response.json();
if (data.success && data.data.status === 'done') {
setStatus('success');
setMessage('Platba úspěšná!');
} else if (data.data?.status === 'pending' || data.data?.status === 'processing') {
setMessage('Čekám na potvrzení platby...');
// Poll again after delay
setTimeout(checkPaymentResult, 2000);
return;
} else if (data.data?.status === 'cancel') {
setStatus('cancelled');
setMessage('Platba byla zrušena');
} else {
setStatus('error');
setMessage(data.data?.errorMessage || 'Platba selhala');
}
} catch (error) {
console.error('Payment status check error:', error);
setStatus('error');
setMessage('Nepodařilo se ověřit platbu');
}
redirectAfterDelay();
};
const redirectAfterDelay = () => {
setTimeout(() => {
if (status === 'success') {
router.replace('/tickets');
} else {
router.replace('/');
}
}, 2500);
};
const getStatusIcon = (): { name: string; color: string } => {
switch (status) {
case 'success':
return { name: 'checkmark-circle', color: '#28a745' };
case 'error':
return { name: 'close-circle', color: '#dc3545' };
case 'cancelled':
return { name: 'alert-circle', color: '#ffc107' };
default:
return { name: 'time', color: colors.primary };
}
};
const statusIcon = getStatusIcon();
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.content}>
{status === 'loading' ? (
<ActivityIndicator size="large" color={colors.primary} />
) : (
<Ionicons
name={statusIcon.name as any}
size={80}
color={statusIcon.color}
/>
)}
<Text style={[styles.title, { color: colors.text }]}>
{status === 'loading' && 'Ověřuji platbu'}
{status === 'success' && 'Platba úspěšná'}
{status === 'error' && 'Chyba platby'}
{status === 'cancelled' && 'Platba zrušena'}
</Text>
<Text style={[styles.message, { color: colors.textSecondary }]}>
{message}
</Text>
{status !== 'loading' && (
<Text style={[styles.redirect, { color: colors.textSecondary }]}>
Přesměrovávám...
</Text>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
content: {
alignItems: 'center',
padding: 32,
},
title: {
fontSize: 24,
fontWeight: '700',
marginTop: 24,
marginBottom: 8,
},
message: {
fontSize: 16,
textAlign: 'center',
},
redirect: {
fontSize: 14,
marginTop: 24,
},
});

541
app/search/results.tsx Normal file
View File

@ -0,0 +1,541 @@
/**
* BUS-Tickets - Search Results Screen
* Shows trips from all connected providers with operator info
* Copyright (c) 2024-2026 IT Enterprise
*/
import { useState, useEffect } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
RefreshControl,
Image,
Platform,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext';
import { useLocale } from '@/contexts/LocaleContext';
import { useProviders, TripWithProvider } from '@/contexts/ProvidersContext';
import type { Trip } from '@/types';
// Ensure HTTPS for web
function ensureHttps(url: string): string {
if (!url) return url;
if (Platform.OS === 'web' && url.startsWith('http://')) {
return url.replace('http://', 'https://');
}
return url;
}
export default function SearchResultsScreen() {
const router = useRouter();
const params = useLocalSearchParams<{
origin: string;
destination: string;
originId?: string;
destinationId?: string;
date: string;
passengers: string;
}>();
const { colors } = useTheme();
const { config } = useConfig();
const { t, formatTime: formatTimeLocale, formatCurrency, locale } = useLocale();
const { providers, activeProviders } = useProviders();
const [trips, setTrips] = useState<TripWithProvider[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
loadTrips();
}, [params.origin, params.destination, params.date]);
const loadTrips = async () => {
setIsLoading(true);
const allTrips: TripWithProvider[] = [];
try {
// Search all active providers in parallel
const searchPromises = activeProviders.map(async (provider) => {
try {
const apiUrl = ensureHttps(provider.apiUrl);
const searchParams = new URLSearchParams();
if (params.origin) searchParams.append('origin', params.origin);
if (params.destination) searchParams.append('destination', params.destination);
if (params.date) searchParams.append('date', params.date);
if (params.passengers) searchParams.append('passengers', params.passengers);
const fullUrl = `${apiUrl}/api/v1/trips/search?${searchParams.toString()}`;
console.log(`Searching ${provider.displayName}:`, fullUrl);
const response = await fetch(fullUrl, {
headers: {
'Content-Type': 'application/json',
...(provider.apiKey ? { 'X-API-Key': provider.apiKey } : {}),
},
});
const data = await response.json();
if (data.success && data.data?.trips) {
return data.data.trips.map((t: any) => ({
id: t.id,
providerId: provider.id,
providerName: provider.displayName,
providerLogo: provider.logoUrl,
providerColor: provider.primaryColor || '#e94560',
route: {
id: t.route?.id || 0,
name: t.route?.name || '',
origin: {
id: t.route?.origin?.id || 0,
name: t.route?.origin?.name || '',
city: t.route?.origin?.city || t.route?.origin?.name || '',
},
destination: {
id: t.route?.destination?.id || 0,
name: t.route?.destination?.name || '',
city: t.route?.destination?.city || t.route?.destination?.name || '',
},
},
departure: t.departureTime || t.tripDate,
arrival: t.arrivalTime || t.tripDate,
duration: getDurationMinutes(
t.departureTime || t.tripDate,
t.arrivalTime || t.tripDate
),
bus: {
id: t.bus?.id || 0,
name: t.bus?.name || 'Bus',
plateNumber: t.bus?.plateNumber || '',
capacity: t.bus?.capacity || t.totalSeats || 50,
amenities: t.bus?.amenities || ['wifi', 'ac'],
},
availableSeats: t.availableSeats || 0,
price: {
amount: t.price?.amount || 0,
currency: t.price?.currency || 'CZK',
},
}));
}
return [];
} catch (error) {
console.error(`Error searching ${provider.displayName}:`, error);
return [];
}
});
const results = await Promise.all(searchPromises);
results.forEach((providerTrips) => {
allTrips.push(...providerTrips);
});
// Sort by departure time
allTrips.sort(
(a, b) => new Date(a.departure).getTime() - new Date(b.departure).getTime()
);
setTrips(allTrips);
} catch (error) {
console.error('Error loading trips:', error);
setTrips([]);
} finally {
setIsLoading(false);
}
};
const onRefresh = async () => {
setRefreshing(true);
await loadTrips();
setRefreshing(false);
};
const getDurationMinutes = (departure: string, arrival: string): number => {
return Math.round(
(new Date(arrival).getTime() - new Date(departure).getTime()) / 1000 / 60
);
};
const formatDuration = (minutes: number) => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}${t.datetime.hours} ${mins}${t.datetime.minutes}`;
};
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleTimeString(locale === 'cs' ? 'cs-CZ' : locale === 'uk' ? 'uk-UA' : 'en-GB', {
hour: '2-digit',
minute: '2-digit',
});
};
const getAmenityIcon = (amenity: string): string => {
const icons: Record<string, string> = {
wifi: 'wifi',
ac: 'snow',
usb: 'flash',
toilet: 'water',
sleeper: 'bed',
};
return icons[amenity] || 'ellipse';
};
const styles = createStyles(colors);
const renderTrip = ({ item: trip }: { item: TripWithProvider }) => {
const seatsLow = trip.availableSeats <= 5;
return (
<TouchableOpacity
style={styles.tripCard}
onPress={() =>
router.push({
pathname: '/booking/[tripId]',
params: {
tripId: trip.id.toString(),
passengers: params.passengers || '1',
providerId: trip.providerId,
},
})
}
>
{/* Provider badge */}
<View style={[styles.providerBadge, { backgroundColor: trip.providerColor + '20' }]}>
{trip.providerLogo ? (
<Image
source={{ uri: trip.providerLogo }}
style={styles.providerLogoSmall}
resizeMode="contain"
/>
) : (
<View
style={[styles.providerLogoPlaceholder, { backgroundColor: trip.providerColor }]}
>
<Text style={styles.providerLogoText}>
{trip.providerName.charAt(0).toUpperCase()}
</Text>
</View>
)}
<Text style={[styles.providerNameText, { color: trip.providerColor }]}>
{trip.providerName}
</Text>
</View>
{/* Time and route */}
<View style={styles.tripMain}>
<View style={styles.timeColumn}>
<Text style={styles.timeText}>{formatTime(trip.departure)}</Text>
<Text style={styles.cityText}>{trip.route.origin.city || trip.route.origin.name}</Text>
</View>
<View style={styles.durationColumn}>
<View style={styles.durationLine}>
<View style={[styles.dot, { backgroundColor: trip.providerColor }]} />
<View style={styles.line} />
<View style={[styles.dot, { backgroundColor: trip.providerColor }]} />
</View>
<Text style={styles.durationText}>{formatDuration(trip.duration)}</Text>
</View>
<View style={[styles.timeColumn, styles.timeColumnRight]}>
<Text style={styles.timeText}>{formatTime(trip.arrival)}</Text>
<Text style={styles.cityText}>
{trip.route.destination.city || trip.route.destination.name}
</Text>
</View>
</View>
{/* Divider */}
<View style={styles.divider} />
{/* Details */}
<View style={styles.tripDetails}>
<View style={styles.detailRow}>
<Ionicons name="bus-outline" size={16} color={colors.textSecondary} />
<Text style={styles.detailText}>{trip.bus?.name || 'Bus'}</Text>
</View>
{/* Amenities */}
{trip.bus?.amenities && (
<View style={styles.amenitiesRow}>
{trip.bus.amenities.slice(0, 4).map((amenity, index) => (
<View key={`${amenity}-${index}`} style={styles.amenityBadge}>
<Ionicons
name={getAmenityIcon(amenity) as any}
size={12}
color={colors.textSecondary}
/>
</View>
))}
</View>
)}
</View>
{/* Footer */}
<View style={styles.tripFooter}>
<View>
<Text style={[styles.seatsText, seatsLow && styles.seatsTextLow]}>
{trip.availableSeats} {t.results.seatsAvailable}
</Text>
</View>
<View style={styles.priceContainer}>
<Text style={[styles.priceText, { color: trip.providerColor }]}>
{formatCurrency(trip.price.amount, trip.price.currency)}
</Text>
<Ionicons name="chevron-forward" size={20} color={trip.providerColor} />
</View>
</View>
</TouchableOpacity>
);
};
return (
<View style={styles.container}>
{/* Header info */}
<View style={styles.headerInfo}>
<Text style={styles.routeText}>
{params.origin || t.common.from} {params.destination || t.common.to}
</Text>
<Text style={styles.dateText}>
{params.date
? new Date(params.date).toLocaleDateString(
locale === 'cs' ? 'cs-CZ' : locale === 'uk' ? 'uk-UA' : 'en-GB',
{ weekday: 'short', month: 'short', day: 'numeric' }
)
: t.search.selectDate}{' '}
{params.passengers || 1} {t.search.passengers.toLowerCase()}
</Text>
{activeProviders.length > 1 && (
<Text style={styles.providersInfo}>
{t.common.search} across {activeProviders.length} operators
</Text>
)}
</View>
{/* Results */}
{isLoading ? (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>{t.common.loading}</Text>
</View>
) : trips.length === 0 ? (
<View style={styles.emptyContainer}>
<Ionicons name="bus-outline" size={64} color={colors.textSecondary} />
<Text style={styles.emptyTitle}>{t.results.noTrips}</Text>
<Text style={styles.emptyText}>{t.results.tryDifferentCriteria}</Text>
</View>
) : (
<FlatList
data={trips}
renderItem={renderTrip}
keyExtractor={(item) => `${item.providerId}-${item.id}`}
contentContainerStyle={styles.listContent}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
/>
)}
</View>
);
}
const createStyles = (colors: any) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
headerInfo: {
padding: 16,
backgroundColor: colors.card,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
routeText: {
fontSize: 18,
fontWeight: '600',
color: colors.text,
},
dateText: {
fontSize: 14,
color: colors.textSecondary,
marginTop: 4,
},
providersInfo: {
fontSize: 12,
color: colors.primary,
marginTop: 4,
fontWeight: '500',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: 16,
color: colors.textSecondary,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
emptyTitle: {
fontSize: 20,
fontWeight: '600',
color: colors.text,
marginTop: 16,
},
emptyText: {
fontSize: 16,
color: colors.textSecondary,
textAlign: 'center',
marginTop: 8,
},
listContent: {
padding: 16,
},
tripCard: {
backgroundColor: colors.card,
borderRadius: 12,
padding: 16,
},
providerBadge: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 16,
marginBottom: 12,
gap: 6,
},
providerLogoSmall: {
width: 20,
height: 20,
borderRadius: 4,
},
providerLogoPlaceholder: {
width: 20,
height: 20,
borderRadius: 4,
justifyContent: 'center',
alignItems: 'center',
},
providerLogoText: {
color: '#fff',
fontSize: 10,
fontWeight: 'bold',
},
providerNameText: {
fontSize: 12,
fontWeight: '600',
},
tripMain: {
flexDirection: 'row',
alignItems: 'center',
},
timeColumn: {
flex: 1,
},
timeColumnRight: {
alignItems: 'flex-end',
},
timeText: {
fontSize: 20,
fontWeight: '600',
color: colors.text,
},
cityText: {
fontSize: 14,
color: colors.textSecondary,
marginTop: 2,
},
durationColumn: {
flex: 1,
alignItems: 'center',
},
durationLine: {
flexDirection: 'row',
alignItems: 'center',
width: '100%',
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.primary,
},
line: {
flex: 1,
height: 2,
backgroundColor: colors.border,
},
durationText: {
fontSize: 12,
color: colors.textSecondary,
marginTop: 4,
},
divider: {
height: 1,
backgroundColor: colors.border,
marginVertical: 12,
},
tripDetails: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
detailRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
detailText: {
fontSize: 14,
color: colors.textSecondary,
},
amenitiesRow: {
flexDirection: 'row',
gap: 6,
},
amenityBadge: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
},
tripFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 12,
},
seatsText: {
fontSize: 14,
color: colors.textSecondary,
},
seatsTextLow: {
color: colors.error,
fontWeight: '600',
},
priceContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
priceText: {
fontSize: 20,
fontWeight: '700',
},
});

View File

@ -0,0 +1,409 @@
/**
* BUS-Tickets - Notification Settings Screen
* Copyright (c) 2024-2026 IT Enterprise
*/
import { useState, useEffect } from 'react';
import {
View,
Text,
ScrollView,
Switch,
TouchableOpacity,
StyleSheet,
Alert,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@/contexts/ThemeContext';
import { useNotifications } from '@/hooks/useNotifications';
import { NotificationSettings } from '@/services/NotificationService';
const REMINDER_OPTIONS = [
{ value: 15, label: '15 minutes' },
{ value: 30, label: '30 minutes' },
{ value: 60, label: '1 hour' },
{ value: 120, label: '2 hours' },
{ value: 1440, label: '1 day' },
];
export default function NotificationSettingsScreen() {
const { colors } = useTheme();
const { settings, updateSettings, expoPushToken } = useNotifications();
const [localSettings, setLocalSettings] = useState<NotificationSettings>(settings);
const [showReminderPicker, setShowReminderPicker] = useState(false);
useEffect(() => {
setLocalSettings(settings);
}, [settings]);
const handleToggle = async (
key: keyof NotificationSettings,
value: boolean
) => {
const newSettings = { ...localSettings, [key]: value };
setLocalSettings(newSettings);
await updateSettings({ [key]: value });
// If disabling all notifications
if (key === 'enabled' && !value) {
Alert.alert(
'Notifications Disabled',
'You will no longer receive push notifications. You can enable them again anytime.'
);
}
};
const handleReminderChange = async (minutes: number) => {
setLocalSettings({ ...localSettings, tripReminderMinutes: minutes });
await updateSettings({ tripReminderMinutes: minutes });
setShowReminderPicker(false);
};
const styles = createStyles(colors);
const getReminderLabel = (minutes: number): string => {
const option = REMINDER_OPTIONS.find((o) => o.value === minutes);
return option?.label || `${minutes} minutes`;
};
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Push Token Info (for debugging) */}
{__DEV__ && expoPushToken && (
<View style={styles.debugSection}>
<Text style={styles.debugLabel}>Push Token:</Text>
<Text style={styles.debugValue} numberOfLines={2}>
{expoPushToken}
</Text>
</View>
)}
{/* Master Toggle */}
<View style={styles.section}>
<View style={styles.settingRow}>
<View style={styles.settingIcon}>
<Ionicons name="notifications" size={24} color={colors.primary} />
</View>
<View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Push Notifications</Text>
<Text style={styles.settingDescription}>
Receive notifications about your trips
</Text>
</View>
<Switch
value={localSettings.enabled}
onValueChange={(value) => handleToggle('enabled', value)}
trackColor={{ false: colors.border, true: colors.primary }}
thumbColor="#fff"
/>
</View>
</View>
{/* Notification Types */}
{localSettings.enabled && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Notification Types</Text>
<View style={styles.card}>
{/* Trip Reminders */}
<View style={styles.settingRow}>
<Ionicons name="alarm-outline" size={20} color={colors.text} />
<View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Trip Reminders</Text>
<Text style={styles.settingDescription}>
Get reminded before your trip
</Text>
</View>
<Switch
value={localSettings.tripReminders}
onValueChange={(value) => handleToggle('tripReminders', value)}
trackColor={{ false: colors.border, true: colors.primary }}
thumbColor="#fff"
/>
</View>
{/* Reminder Time */}
{localSettings.tripReminders && (
<TouchableOpacity
style={[styles.settingRow, styles.subSetting]}
onPress={() => setShowReminderPicker(!showReminderPicker)}
>
<Ionicons name="time-outline" size={20} color={colors.textSecondary} />
<View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Reminder Time</Text>
<Text style={styles.settingDescription}>
{getReminderLabel(localSettings.tripReminderMinutes)} before departure
</Text>
</View>
<Ionicons
name="chevron-forward"
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
)}
{showReminderPicker && (
<View style={styles.pickerContainer}>
{REMINDER_OPTIONS.map((option) => (
<TouchableOpacity
key={option.value}
style={[
styles.pickerOption,
localSettings.tripReminderMinutes === option.value &&
styles.pickerOptionSelected,
]}
onPress={() => handleReminderChange(option.value)}
>
<Text
style={[
styles.pickerOptionText,
localSettings.tripReminderMinutes === option.value &&
styles.pickerOptionTextSelected,
]}
>
{option.label}
</Text>
{localSettings.tripReminderMinutes === option.value && (
<Ionicons name="checkmark" size={20} color={colors.primary} />
)}
</TouchableOpacity>
))}
</View>
)}
<View style={styles.divider} />
{/* Booking Confirmations */}
<View style={styles.settingRow}>
<Ionicons name="checkmark-circle-outline" size={20} color={colors.text} />
<View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Booking Confirmations</Text>
<Text style={styles.settingDescription}>
Notifications when you book a ticket
</Text>
</View>
<Switch
value={localSettings.bookingConfirmations}
onValueChange={(value) =>
handleToggle('bookingConfirmations', value)
}
trackColor={{ false: colors.border, true: colors.primary }}
thumbColor="#fff"
/>
</View>
<View style={styles.divider} />
{/* Trip Updates */}
<View style={styles.settingRow}>
<Ionicons name="bus-outline" size={20} color={colors.text} />
<View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Trip Updates</Text>
<Text style={styles.settingDescription}>
Delays, cancellations, and changes
</Text>
</View>
<Switch
value={localSettings.tripUpdates}
onValueChange={(value) => handleToggle('tripUpdates', value)}
trackColor={{ false: colors.border, true: colors.primary }}
thumbColor="#fff"
/>
</View>
<View style={styles.divider} />
{/* Promotions */}
<View style={styles.settingRow}>
<Ionicons name="pricetag-outline" size={20} color={colors.text} />
<View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Promotions & Offers</Text>
<Text style={styles.settingDescription}>
Special deals and discounts
</Text>
</View>
<Switch
value={localSettings.promotions}
onValueChange={(value) => handleToggle('promotions', value)}
trackColor={{ false: colors.border, true: colors.primary }}
thumbColor="#fff"
/>
</View>
</View>
</View>
)}
{/* Sound & Vibration */}
{localSettings.enabled && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Sound & Vibration</Text>
<View style={styles.card}>
<View style={styles.settingRow}>
<Ionicons name="volume-high-outline" size={20} color={colors.text} />
<View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Sound</Text>
<Text style={styles.settingDescription}>
Play sound for notifications
</Text>
</View>
<Switch
value={localSettings.sound}
onValueChange={(value) => handleToggle('sound', value)}
trackColor={{ false: colors.border, true: colors.primary }}
thumbColor="#fff"
/>
</View>
<View style={styles.divider} />
<View style={styles.settingRow}>
<Ionicons name="phone-portrait-outline" size={20} color={colors.text} />
<View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Vibration</Text>
<Text style={styles.settingDescription}>
Vibrate for notifications
</Text>
</View>
<Switch
value={localSettings.vibration}
onValueChange={(value) => handleToggle('vibration', value)}
trackColor={{ false: colors.border, true: colors.primary }}
thumbColor="#fff"
/>
</View>
</View>
</View>
)}
{/* Info */}
<View style={styles.infoSection}>
<Ionicons name="information-circle-outline" size={16} color={colors.textSecondary} />
<Text style={styles.infoText}>
You can also manage notifications in your device settings.
</Text>
</View>
</ScrollView>
);
}
const createStyles = (colors: any) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
content: {
padding: 16,
},
debugSection: {
backgroundColor: colors.card,
padding: 12,
borderRadius: 8,
marginBottom: 16,
},
debugLabel: {
fontSize: 12,
color: colors.textSecondary,
marginBottom: 4,
},
debugValue: {
fontSize: 10,
fontFamily: 'monospace',
color: colors.text,
},
section: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 14,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
marginBottom: 8,
marginLeft: 4,
},
card: {
backgroundColor: colors.card,
borderRadius: 12,
padding: 4,
},
settingRow: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
},
subSetting: {
paddingLeft: 44,
backgroundColor: colors.background,
marginHorizontal: 8,
borderRadius: 8,
marginBottom: 8,
},
settingIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: `${colors.primary}20`,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
settingInfo: {
flex: 1,
marginLeft: 8,
},
settingTitle: {
fontSize: 16,
color: colors.text,
},
settingDescription: {
fontSize: 12,
color: colors.textSecondary,
marginTop: 2,
},
divider: {
height: 1,
backgroundColor: colors.border,
marginHorizontal: 12,
},
pickerContainer: {
backgroundColor: colors.background,
marginHorizontal: 8,
borderRadius: 8,
marginBottom: 8,
overflow: 'hidden',
},
pickerOption: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
paddingHorizontal: 16,
},
pickerOptionSelected: {
backgroundColor: `${colors.primary}10`,
},
pickerOptionText: {
fontSize: 14,
color: colors.text,
},
pickerOptionTextSelected: {
color: colors.primary,
fontWeight: '600',
},
infoSection: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 8,
padding: 16,
},
infoText: {
flex: 1,
fontSize: 12,
color: colors.textSecondary,
lineHeight: 18,
},
});

721
app/settings/providers.tsx Normal file
View File

@ -0,0 +1,721 @@
/**
* BUS-Tickets - Providers Management Screen
* Copyright (c) 2024-2026 IT Enterprise
*/
import { useState } from 'react';
import {
View,
Text,
ScrollView,
TouchableOpacity,
TextInput,
Switch,
StyleSheet,
Alert,
Modal,
ActivityIndicator,
Image,
} from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@/contexts/ThemeContext';
import { useLocale } from '@/contexts/LocaleContext';
import { useProviders, BusProvider } from '@/contexts/ProvidersContext';
export default function ProvidersScreen() {
const router = useRouter();
const { colors } = useTheme();
const { t } = useLocale();
const {
providers,
addProvider,
updateProvider,
removeProvider,
toggleProvider,
setDefaultProvider,
testConnection,
syncAllProviders,
} = useProviders();
const [showAddModal, setShowAddModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<BusProvider | null>(null);
const [isSyncing, setIsSyncing] = useState(false);
const [testingId, setTestingId] = useState<string | null>(null);
// New provider form state
const [formData, setFormData] = useState({
name: '',
displayName: '',
apiUrl: '',
apiKey: '',
logoUrl: '',
primaryColor: '#e94560',
});
const resetForm = () => {
setFormData({
name: '',
displayName: '',
apiUrl: '',
apiKey: '',
logoUrl: '',
primaryColor: '#e94560',
});
setEditingProvider(null);
};
const handleAddProvider = async () => {
if (!formData.name || !formData.apiUrl) {
Alert.alert(t.common.error, 'Name and API URL are required');
return;
}
try {
if (editingProvider) {
await updateProvider(editingProvider.id, {
name: formData.name,
displayName: formData.displayName || formData.name,
apiUrl: formData.apiUrl,
apiKey: formData.apiKey || undefined,
logoUrl: formData.logoUrl || undefined,
primaryColor: formData.primaryColor,
});
} else {
await addProvider({
name: formData.name,
displayName: formData.displayName || formData.name,
apiUrl: formData.apiUrl,
apiKey: formData.apiKey || undefined,
logoUrl: formData.logoUrl || undefined,
primaryColor: formData.primaryColor,
enabled: true,
isDefault: providers.length === 0,
supportsOnlinePayment: true,
supportsSeatSelection: true,
supportsRefunds: false,
});
}
setShowAddModal(false);
resetForm();
} catch (error) {
Alert.alert(t.common.error, 'Failed to save provider');
}
};
const handleEditProvider = (provider: BusProvider) => {
setEditingProvider(provider);
setFormData({
name: provider.name,
displayName: provider.displayName,
apiUrl: provider.apiUrl,
apiKey: provider.apiKey || '',
logoUrl: provider.logoUrl || '',
primaryColor: provider.primaryColor || '#e94560',
});
setShowAddModal(true);
};
const handleDeleteProvider = (provider: BusProvider) => {
if (provider.isDefault) {
Alert.alert(t.common.error, 'Cannot delete default provider');
return;
}
Alert.alert(
t.common.delete,
`Are you sure you want to remove ${provider.displayName}?`,
[
{ text: t.common.cancel, style: 'cancel' },
{
text: t.common.delete,
style: 'destructive',
onPress: () => removeProvider(provider.id),
},
]
);
};
const handleTestConnection = async (provider: BusProvider) => {
setTestingId(provider.id);
try {
const success = await testConnection(provider);
if (success) {
Alert.alert(t.common.success, 'Connection successful!');
} else {
Alert.alert(t.common.error, `Connection failed: ${provider.errorMessage || 'Unknown error'}`);
}
} catch (error) {
Alert.alert(t.common.error, 'Connection test failed');
} finally {
setTestingId(null);
}
};
const handleSyncAll = async () => {
setIsSyncing(true);
try {
await syncAllProviders();
Alert.alert(t.common.success, 'All providers synced');
} catch (error) {
Alert.alert(t.common.error, 'Sync failed');
} finally {
setIsSyncing(false);
}
};
const styles = createStyles(colors);
const ProviderCard = ({ provider }: { provider: BusProvider }) => (
<View style={styles.providerCard}>
<View style={styles.providerHeader}>
{provider.logoUrl ? (
<Image
source={{ uri: provider.logoUrl }}
style={styles.providerLogo}
resizeMode="contain"
/>
) : (
<View style={[styles.providerLogoPlaceholder, { backgroundColor: provider.primaryColor }]}>
<Text style={styles.providerLogoText}>
{provider.displayName.charAt(0).toUpperCase()}
</Text>
</View>
)}
<View style={styles.providerInfo}>
<View style={styles.providerNameRow}>
<Text style={styles.providerName}>{provider.displayName}</Text>
{provider.isDefault && (
<View style={styles.defaultBadge}>
<Text style={styles.defaultBadgeText}>Default</Text>
</View>
)}
</View>
<Text style={styles.providerUrl} numberOfLines={1}>
{provider.apiUrl}
</Text>
<View style={styles.statusRow}>
<View
style={[
styles.statusDot,
{ backgroundColor: provider.isConnected ? colors.success : colors.error },
]}
/>
<Text style={styles.statusText}>
{provider.isConnected ? 'Connected' : provider.errorMessage || 'Not connected'}
</Text>
</View>
</View>
<Switch
value={provider.enabled}
onValueChange={(value) => toggleProvider(provider.id, value)}
trackColor={{ false: colors.border, true: colors.primary }}
thumbColor="#fff"
/>
</View>
<View style={styles.providerActions}>
<TouchableOpacity
style={styles.actionBtn}
onPress={() => handleTestConnection(provider)}
disabled={testingId === provider.id}
>
{testingId === provider.id ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<>
<Ionicons name="refresh-outline" size={18} color={colors.primary} />
<Text style={styles.actionBtnText}>Test</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.actionBtn}
onPress={() => handleEditProvider(provider)}
>
<Ionicons name="pencil-outline" size={18} color={colors.primary} />
<Text style={styles.actionBtnText}>{t.common.edit}</Text>
</TouchableOpacity>
{!provider.isDefault && (
<>
<TouchableOpacity
style={styles.actionBtn}
onPress={() => setDefaultProvider(provider.id)}
>
<Ionicons name="star-outline" size={18} color={colors.primary} />
<Text style={styles.actionBtnText}>Set Default</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.actionBtnDanger]}
onPress={() => handleDeleteProvider(provider)}
>
<Ionicons name="trash-outline" size={18} color={colors.error} />
<Text style={[styles.actionBtnText, { color: colors.error }]}>
{t.common.delete}
</Text>
</TouchableOpacity>
</>
)}
</View>
</View>
);
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Header Actions */}
<View style={styles.headerActions}>
<TouchableOpacity
style={styles.syncButton}
onPress={handleSyncAll}
disabled={isSyncing}
>
{isSyncing ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<>
<Ionicons name="sync-outline" size={20} color="#fff" />
<Text style={styles.syncButtonText}>Sync All</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.addButton}
onPress={() => {
resetForm();
setShowAddModal(true);
}}
>
<Ionicons name="add" size={20} color="#fff" />
<Text style={styles.addButtonText}>Add Provider</Text>
</TouchableOpacity>
</View>
{/* Info Card */}
<View style={styles.infoCard}>
<Ionicons name="information-circle-outline" size={20} color={colors.primary} />
<Text style={styles.infoText}>
Connect multiple bus operators to search and book across different companies.
Each provider needs an API URL to their Odoo backend.
</Text>
</View>
{/* Providers List */}
<Text style={styles.sectionTitle}>
Connected Providers ({providers.length})
</Text>
{providers.map((provider) => (
<ProviderCard key={provider.id} provider={provider} />
))}
{providers.length === 0 && (
<View style={styles.emptyState}>
<Ionicons name="bus-outline" size={48} color={colors.textSecondary} />
<Text style={styles.emptyStateText}>No providers configured</Text>
<Text style={styles.emptyStateSubtext}>
Add your first bus operator to start searching for trips
</Text>
</View>
)}
{/* Add/Edit Provider Modal */}
<Modal visible={showAddModal} animationType="slide" transparent>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{editingProvider ? 'Edit Provider' : 'Add New Provider'}
</Text>
<TouchableOpacity onPress={() => {
setShowAddModal(false);
resetForm();
}}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
</View>
<ScrollView style={styles.modalBody}>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>Provider ID *</Text>
<TextInput
style={styles.input}
placeholder="e.g., popov_bus"
placeholderTextColor={colors.textSecondary}
value={formData.name}
onChangeText={(text) => setFormData({ ...formData, name: text })}
autoCapitalize="none"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>Display Name *</Text>
<TextInput
style={styles.input}
placeholder="e.g., Popov Bus"
placeholderTextColor={colors.textSecondary}
value={formData.displayName}
onChangeText={(text) => setFormData({ ...formData, displayName: text })}
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>API URL *</Text>
<TextInput
style={styles.input}
placeholder="https://api.popovbus.com"
placeholderTextColor={colors.textSecondary}
value={formData.apiUrl}
onChangeText={(text) => setFormData({ ...formData, apiUrl: text })}
autoCapitalize="none"
keyboardType="url"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>API Key (optional)</Text>
<TextInput
style={styles.input}
placeholder="sk_live_xxx..."
placeholderTextColor={colors.textSecondary}
value={formData.apiKey}
onChangeText={(text) => setFormData({ ...formData, apiKey: text })}
autoCapitalize="none"
secureTextEntry
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>Logo URL (optional)</Text>
<TextInput
style={styles.input}
placeholder="https://example.com/logo.png"
placeholderTextColor={colors.textSecondary}
value={formData.logoUrl}
onChangeText={(text) => setFormData({ ...formData, logoUrl: text })}
autoCapitalize="none"
keyboardType="url"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>Brand Color</Text>
<View style={styles.colorPicker}>
{['#e94560', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#34495e'].map(
(color) => (
<TouchableOpacity
key={color}
style={[
styles.colorOption,
{ backgroundColor: color },
formData.primaryColor === color && styles.colorOptionSelected,
]}
onPress={() => setFormData({ ...formData, primaryColor: color })}
>
{formData.primaryColor === color && (
<Ionicons name="checkmark" size={16} color="#fff" />
)}
</TouchableOpacity>
)
)}
</View>
</View>
</ScrollView>
<View style={styles.modalFooter}>
<TouchableOpacity
style={styles.cancelButton}
onPress={() => {
setShowAddModal(false);
resetForm();
}}
>
<Text style={styles.cancelButtonText}>{t.common.cancel}</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.saveButton} onPress={handleAddProvider}>
<Text style={styles.saveButtonText}>{t.common.save}</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</ScrollView>
);
}
const createStyles = (colors: any) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
content: {
padding: 16,
},
headerActions: {
flexDirection: 'row',
gap: 12,
marginBottom: 16,
},
syncButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: colors.secondary,
borderRadius: 12,
padding: 14,
},
syncButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
addButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: colors.primary,
borderRadius: 12,
padding: 14,
},
addButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
infoCard: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
backgroundColor: colors.primary + '15',
borderRadius: 12,
padding: 16,
marginBottom: 24,
},
infoText: {
flex: 1,
fontSize: 14,
color: colors.text,
lineHeight: 20,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: colors.text,
marginBottom: 12,
},
providerCard: {
backgroundColor: colors.card,
borderRadius: 12,
padding: 16,
marginBottom: 12,
},
providerHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
providerLogo: {
width: 48,
height: 48,
borderRadius: 8,
},
providerLogoPlaceholder: {
width: 48,
height: 48,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
},
providerLogoText: {
color: '#fff',
fontSize: 20,
fontWeight: 'bold',
},
providerInfo: {
flex: 1,
},
providerNameRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
providerName: {
fontSize: 16,
fontWeight: '600',
color: colors.text,
},
defaultBadge: {
backgroundColor: colors.primary,
borderRadius: 4,
paddingHorizontal: 6,
paddingVertical: 2,
},
defaultBadgeText: {
color: '#fff',
fontSize: 10,
fontWeight: '600',
},
providerUrl: {
fontSize: 12,
color: colors.textSecondary,
marginTop: 2,
},
statusRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginTop: 4,
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
statusText: {
fontSize: 12,
color: colors.textSecondary,
},
providerActions: {
flexDirection: 'row',
gap: 8,
marginTop: 12,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: colors.border,
},
actionBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 6,
backgroundColor: colors.background,
},
actionBtnDanger: {
backgroundColor: colors.error + '15',
},
actionBtnText: {
fontSize: 12,
color: colors.primary,
fontWeight: '500',
},
emptyState: {
alignItems: 'center',
padding: 48,
},
emptyStateText: {
fontSize: 18,
fontWeight: '600',
color: colors.text,
marginTop: 16,
},
emptyStateSubtext: {
fontSize: 14,
color: colors.textSecondary,
textAlign: 'center',
marginTop: 8,
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: colors.card,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
maxHeight: '90%',
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
modalTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.text,
},
modalBody: {
padding: 16,
maxHeight: 400,
},
modalFooter: {
flexDirection: 'row',
gap: 12,
padding: 16,
borderTopWidth: 1,
borderTopColor: colors.border,
},
formGroup: {
marginBottom: 16,
},
formLabel: {
fontSize: 14,
fontWeight: '500',
color: colors.text,
marginBottom: 8,
},
input: {
backgroundColor: colors.background,
borderRadius: 8,
padding: 12,
fontSize: 16,
color: colors.text,
},
colorPicker: {
flexDirection: 'row',
gap: 12,
},
colorOption: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
},
colorOptionSelected: {
borderWidth: 3,
borderColor: '#fff',
},
cancelButton: {
flex: 1,
padding: 14,
borderRadius: 12,
backgroundColor: colors.background,
alignItems: 'center',
},
cancelButtonText: {
fontSize: 16,
fontWeight: '600',
color: colors.text,
},
saveButton: {
flex: 1,
padding: 14,
borderRadius: 12,
backgroundColor: colors.primary,
alignItems: 'center',
},
saveButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
},
});

563
app/ticket/[ticketId].tsx Normal file
View File

@ -0,0 +1,563 @@
/**
* BUS-Tickets - Ticket Details Screen
* Copyright (c) 2024-2026 IT Enterprise
*/
import { useState, useEffect } from 'react';
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
Alert,
Share,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@/contexts/ThemeContext';
import type { Ticket } from '@/types';
import {
formatPrice,
formatTime,
formatShortDate,
formatDuration,
getTicketStatusColor,
getTicketStatusLabel,
isTicketActive,
canCancelTicket,
} from '@bus-tickets/shared';
// Mock ticket data
const MOCK_TICKET: Ticket = {
id: 1,
ticketNumber: 'BT-2026-00001',
trip: {
id: 1,
route: {
id: 1,
name: 'Uzhorod - Praha',
origin: { id: 1, name: 'Uzhorod', city: 'Uzhorod', country: 'UA' },
destination: { id: 2, name: 'Praha', city: 'Praha', country: 'CZ' },
},
departureTime: '2026-02-10T06:00:00Z',
arrivalTime: '2026-02-10T18:00:00Z',
bus: {
id: 1,
name: 'Mercedes Tourismo',
plateNumber: 'AA1234BB',
capacity: 50,
amenities: ['wifi', 'ac', 'usb', 'toilet'],
},
availableSeats: 20,
totalSeats: 50,
price: { amount: 1200, currency: 'UAH' },
status: 'scheduled',
},
passenger: {
name: 'John Doe',
email: 'john@example.com',
phone: '+380501234567',
},
seat: 15,
price: { amount: 1200, currency: 'UAH' },
status: 'paid',
qrCode: 'BT-2026-00001',
purchasedAt: '2026-02-03T10:30:00Z',
};
export default function TicketDetailsScreen() {
const router = useRouter();
const params = useLocalSearchParams<{ ticketId: string }>();
const { colors } = useTheme();
const [ticket, setTicket] = useState<Ticket | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showQR, setShowQR] = useState(true);
useEffect(() => {
loadTicket();
}, [params.ticketId]);
const loadTicket = async () => {
setIsLoading(true);
try {
// TODO: Replace with API call
await new Promise((resolve) => setTimeout(resolve, 300));
setTicket(MOCK_TICKET);
} catch (error) {
console.error('Error loading ticket:', error);
Alert.alert('Error', 'Could not load ticket details');
router.back();
} finally {
setIsLoading(false);
}
};
const handleCancelTicket = () => {
Alert.alert(
'Cancel Ticket',
'Are you sure you want to cancel this ticket? This action cannot be undone.',
[
{ text: 'No', style: 'cancel' },
{
text: 'Yes, Cancel',
style: 'destructive',
onPress: async () => {
// TODO: Implement cancellation
Alert.alert('Success', 'Ticket has been cancelled');
router.back();
},
},
]
);
};
const handleShare = async () => {
if (!ticket) return;
try {
await Share.share({
message: `My bus ticket ${ticket.ticketNumber}\n${ticket.trip.route.origin.city}${ticket.trip.route.destination.city}\n${formatShortDate(ticket.trip.departureTime)} at ${formatTime(ticket.trip.departureTime)}`,
});
} catch (error) {
console.error('Error sharing:', error);
}
};
const styles = createStyles(colors);
if (isLoading || !ticket) {
return (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Loading ticket...</Text>
</View>
);
}
const isActive = isTicketActive(ticket);
const canCancel = canCancelTicket(ticket);
const statusColor = getTicketStatusColor(ticket.status);
const statusLabel = getTicketStatusLabel(ticket.status);
const duration =
(new Date(ticket.trip.arrivalTime).getTime() -
new Date(ticket.trip.departureTime).getTime()) /
1000 /
60;
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Ticket Card */}
<View style={styles.ticketCard}>
{/* Status Badge */}
<View style={styles.statusContainer}>
<View
style={[styles.statusBadge, { backgroundColor: `${statusColor}20` }]}
>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
<Text style={[styles.statusText, { color: statusColor }]}>
{statusLabel}
</Text>
</View>
</View>
{/* Ticket Number */}
<Text style={styles.ticketNumber}>{ticket.ticketNumber}</Text>
{/* Route */}
<View style={styles.routeContainer}>
<View style={styles.routePoint}>
<View style={styles.routeDot} />
<View>
<Text style={styles.timeText}>
{formatTime(ticket.trip.departureTime)}
</Text>
<Text style={styles.cityText}>{ticket.trip.route.origin.city}</Text>
</View>
</View>
<View style={styles.routeLine}>
<View style={styles.dottedLine} />
<Text style={styles.durationText}>{formatDuration(duration)}</Text>
</View>
<View style={styles.routePoint}>
<View style={[styles.routeDot, styles.routeDotDestination]} />
<View>
<Text style={styles.timeText}>
{formatTime(ticket.trip.arrivalTime)}
</Text>
<Text style={styles.cityText}>
{ticket.trip.route.destination.city}
</Text>
</View>
</View>
</View>
{/* Divider with date */}
<View style={styles.dateDivider}>
<View style={styles.dateDividerLine} />
<View style={styles.dateBadge}>
<Ionicons name="calendar" size={14} color={colors.primary} />
<Text style={styles.dateText}>
{formatShortDate(ticket.trip.departureTime)}
</Text>
</View>
<View style={styles.dateDividerLine} />
</View>
{/* QR Code */}
{isActive && showQR && (
<TouchableOpacity
style={styles.qrContainer}
onPress={() => setShowQR(!showQR)}
>
<View style={styles.qrPlaceholder}>
<Ionicons name="qr-code" size={100} color={colors.text} />
<Text style={styles.qrText}>{ticket.ticketNumber}</Text>
</View>
<Text style={styles.qrHint}>
Show this QR code to the driver when boarding
</Text>
</TouchableOpacity>
)}
{/* Details */}
<View style={styles.detailsSection}>
<View style={styles.detailRow}>
<Ionicons name="person-outline" size={20} color={colors.textSecondary} />
<View style={styles.detailContent}>
<Text style={styles.detailLabel}>Passenger</Text>
<Text style={styles.detailValue}>{ticket.passenger.name}</Text>
</View>
</View>
<View style={styles.detailRow}>
<Ionicons name="grid-outline" size={20} color={colors.textSecondary} />
<View style={styles.detailContent}>
<Text style={styles.detailLabel}>Seat</Text>
<Text style={styles.detailValue}>{ticket.seat || 'Auto'}</Text>
</View>
</View>
<View style={styles.detailRow}>
<Ionicons name="bus-outline" size={20} color={colors.textSecondary} />
<View style={styles.detailContent}>
<Text style={styles.detailLabel}>Bus</Text>
<Text style={styles.detailValue}>
{ticket.trip.bus.name} ({ticket.trip.bus.plateNumber})
</Text>
</View>
</View>
<View style={styles.detailRow}>
<Ionicons name="pricetag-outline" size={20} color={colors.textSecondary} />
<View style={styles.detailContent}>
<Text style={styles.detailLabel}>Price</Text>
<Text style={[styles.detailValue, styles.priceValue]}>
{formatPrice(ticket.price)}
</Text>
</View>
</View>
</View>
{/* Amenities */}
<View style={styles.amenitiesSection}>
<Text style={styles.amenitiesTitle}>Amenities</Text>
<View style={styles.amenitiesList}>
{ticket.trip.bus.amenities.map((amenity) => (
<View key={amenity} style={styles.amenityBadge}>
<Ionicons
name={
amenity === 'wifi'
? 'wifi'
: amenity === 'ac'
? 'snow'
: amenity === 'usb'
? 'flash'
: amenity === 'toilet'
? 'water'
: 'ellipse'
}
size={14}
color={colors.textSecondary}
/>
<Text style={styles.amenityText}>
{amenity.charAt(0).toUpperCase() + amenity.slice(1)}
</Text>
</View>
))}
</View>
</View>
</View>
{/* Actions */}
<View style={styles.actionsContainer}>
<TouchableOpacity style={styles.actionButton} onPress={handleShare}>
<Ionicons name="share-outline" size={24} color={colors.primary} />
<Text style={styles.actionButtonText}>Share</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton}>
<Ionicons name="download-outline" size={24} color={colors.primary} />
<Text style={styles.actionButtonText}>Download PDF</Text>
</TouchableOpacity>
{canCancel && (
<TouchableOpacity
style={[styles.actionButton, styles.cancelButton]}
onPress={handleCancelTicket}
>
<Ionicons name="close-circle-outline" size={24} color={colors.error} />
<Text style={[styles.actionButtonText, styles.cancelButtonText]}>
Cancel
</Text>
</TouchableOpacity>
)}
</View>
{/* Contact Support */}
<TouchableOpacity style={styles.supportButton}>
<Ionicons name="help-circle-outline" size={20} color={colors.textSecondary} />
<Text style={styles.supportButtonText}>Need help? Contact Support</Text>
</TouchableOpacity>
</ScrollView>
);
}
const createStyles = (colors: any) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: 16,
color: colors.textSecondary,
},
content: {
padding: 16,
},
ticketCard: {
backgroundColor: colors.card,
borderRadius: 16,
padding: 20,
overflow: 'hidden',
},
statusContainer: {
alignItems: 'flex-start',
marginBottom: 12,
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
gap: 6,
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
statusText: {
fontSize: 12,
fontWeight: '600',
textTransform: 'uppercase',
},
ticketNumber: {
fontSize: 14,
fontFamily: 'monospace',
color: colors.textSecondary,
marginBottom: 20,
},
routeContainer: {
marginBottom: 20,
},
routePoint: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
routeDot: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: colors.primary,
},
routeDotDestination: {
backgroundColor: colors.success,
},
timeText: {
fontSize: 20,
fontWeight: '600',
color: colors.text,
},
cityText: {
fontSize: 14,
color: colors.textSecondary,
},
routeLine: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: 5,
paddingVertical: 8,
},
dottedLine: {
width: 2,
height: 40,
backgroundColor: colors.border,
marginRight: 12,
},
durationText: {
fontSize: 12,
color: colors.textSecondary,
},
dateDivider: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 20,
},
dateDividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.border,
borderStyle: 'dashed',
},
dateBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: colors.background,
borderRadius: 20,
marginHorizontal: 8,
},
dateText: {
fontSize: 14,
fontWeight: '500',
color: colors.text,
},
qrContainer: {
alignItems: 'center',
padding: 20,
backgroundColor: '#fff',
borderRadius: 12,
marginBottom: 20,
},
qrPlaceholder: {
alignItems: 'center',
},
qrText: {
fontSize: 12,
fontFamily: 'monospace',
color: '#000',
marginTop: 8,
},
qrHint: {
fontSize: 12,
color: '#666',
textAlign: 'center',
marginTop: 12,
},
detailsSection: {
gap: 16,
},
detailRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
detailContent: {
flex: 1,
},
detailLabel: {
fontSize: 12,
color: colors.textSecondary,
},
detailValue: {
fontSize: 16,
color: colors.text,
fontWeight: '500',
},
priceValue: {
color: colors.primary,
fontWeight: '700',
},
amenitiesSection: {
marginTop: 20,
paddingTop: 20,
borderTopWidth: 1,
borderTopColor: colors.border,
},
amenitiesTitle: {
fontSize: 14,
fontWeight: '600',
color: colors.text,
marginBottom: 12,
},
amenitiesList: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
amenityBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 6,
backgroundColor: colors.background,
borderRadius: 8,
},
amenityText: {
fontSize: 12,
color: colors.textSecondary,
},
actionsContainer: {
flexDirection: 'row',
gap: 12,
marginTop: 16,
},
actionButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: 12,
backgroundColor: colors.card,
borderRadius: 12,
},
actionButtonText: {
fontSize: 14,
fontWeight: '500',
color: colors.primary,
},
cancelButton: {
borderWidth: 1,
borderColor: colors.error,
},
cancelButtonText: {
color: colors.error,
},
supportButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: 16,
marginTop: 16,
},
supportButtonText: {
fontSize: 14,
color: colors.textSecondary,
},
});

7
assets/.gitkeep Normal file
View File

@ -0,0 +1,7 @@
# Placeholder for asset files
# Required assets:
# - icon.png (1024x1024)
# - splash.png (1284x2778)
# - adaptive-icon.png (1024x1024)
# - favicon.png (48x48)
# - notification-icon.png (96x96)

BIN
assets/adaptive-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 B

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

BIN
assets/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

9
babel.config.js Normal file
View File

@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
'react-native-reanimated/plugin',
],
};
};

61
eas.json Normal file
View File

@ -0,0 +1,61 @@
{
"cli": {
"version": ">= 7.0.0",
"appVersionSource": "local"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"simulator": true
},
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleDebug"
},
"env": {
"APP_ENV": "development"
}
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk",
"credentialsSource": "remote"
},
"ios": {
"resourceClass": "m-medium",
"credentialsSource": "remote"
},
"env": {
"APP_ENV": "preview"
}
},
"production": {
"android": {
"buildType": "app-bundle"
},
"ios": {
"resourceClass": "m-medium"
},
"env": {
"APP_ENV": "production"
},
"channel": "production",
"autoIncrement": true
}
},
"submit": {
"production": {
"android": {
"serviceAccountKeyPath": "./google-services.json",
"track": "internal"
},
"ios": {
"ascAppId": "your-app-store-connect-app-id",
"appleTeamId": "your-apple-team-id"
}
}
}
}

3
expo-env.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
/// <reference types="expo/types" />
// NOTE: This file should not be edited and should be in your git ignore

10
metro.config.js Normal file
View File

@ -0,0 +1,10 @@
/**
* BUS-Tickets - Metro Configuration
* Copyright (c) 2024-2026 IT Enterprise
*/
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
module.exports = config;

20011
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

75
package.json Normal file
View File

@ -0,0 +1,75 @@
{
"name": "@bus-tickets/mobile",
"version": "1.1.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"dev": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"build:android": "eas build --platform android --profile production",
"build:android:preview": "eas build --platform android --profile preview",
"build:android:dev": "eas build --platform android --profile development",
"build:ios": "eas build --platform ios --profile production",
"build:ios:preview": "eas build --platform ios --profile preview",
"build:ios:dev": "eas build --platform ios --profile development",
"build:all": "eas build --platform all --profile production",
"build:all:preview": "eas build --platform all --profile preview",
"build:web": "expo export --platform web",
"submit:android": "eas submit --platform android --profile production",
"submit:ios": "eas submit --platform ios --profile production",
"lint": "eslint . --ext .ts,.tsx",
"typecheck": "tsc --noEmit",
"test": "jest"
},
"dependencies": {
"@babel/runtime": "^7.28.6",
"@expo/metro-runtime": "^3.2.3",
"@expo/vector-icons": "^14.0.0",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/datetimepicker": "8.0.1",
"@react-navigation/core": "^7.14.0",
"@react-navigation/native": "^6.1.9",
"axios": "^1.6.0",
"debug": "^4.4.3",
"expo": "~51.0.0",
"expo-auth-session": "~5.5.2",
"expo-constants": "~16.0.0",
"expo-crypto": "~13.0.0",
"expo-device": "~6.0.0",
"expo-font": "~12.0.0",
"expo-image": "~1.13.0",
"expo-linking": "~6.3.0",
"expo-local-authentication": "~14.0.0",
"expo-localization": "~15.0.0",
"expo-network": "~6.0.0",
"expo-notifications": "~0.28.0",
"expo-router": "~3.5.0",
"expo-secure-store": "~13.0.0",
"expo-splash-screen": "~0.27.0",
"expo-sqlite": "~14.0.0",
"expo-status-bar": "~1.12.0",
"expo-system-ui": "~3.0.7",
"expo-web-browser": "~13.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.74.5",
"react-native-gesture-handler": "~2.16.0",
"react-native-reanimated": "~3.10.0",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1",
"react-native-svg": "15.2.0",
"react-native-web": "~0.19.13",
"zustand": "^4.5.0"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@types/react": "~18.2.0",
"eslint": "^8.57.0",
"eslint-config-expo": "^7.0.0",
"jest": "^29.7.0",
"jest-expo": "~51.0.0",
"typescript": "~5.3.0"
}
}

View File

@ -0,0 +1,142 @@
/**
* BUS-Tickets - Offline Banner Component
* Copyright (c) 2024-2026 IT Enterprise
*/
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Animated } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useNetwork } from '../contexts/NetworkContext';
import { useTheme } from '../contexts/ThemeContext';
interface OfflineBannerProps {
onSync?: () => void;
}
export function OfflineBanner({ onSync }: OfflineBannerProps) {
const { isOnline, syncState, sync } = useNetwork();
const { colors } = useTheme();
// Don't show if online and not syncing
if (isOnline && syncState.status !== 'syncing' && syncState.pendingActions === 0) {
return null;
}
const handleSync = async () => {
await sync();
onSync?.();
};
const styles = createStyles(colors, isOnline);
return (
<View style={styles.container}>
<View style={styles.content}>
<Ionicons
name={isOnline ? 'cloud-upload-outline' : 'cloud-offline-outline'}
size={20}
color={isOnline ? colors.warning : '#fff'}
/>
<View style={styles.textContainer}>
{!isOnline ? (
<>
<Text style={styles.title}>You're offline</Text>
<Text style={styles.subtitle}>
Changes will sync when connected
</Text>
</>
) : syncState.status === 'syncing' ? (
<>
<Text style={[styles.title, styles.syncingTitle]}>Syncing...</Text>
<Text style={[styles.subtitle, styles.syncingSubtitle]}>
Please wait
</Text>
</>
) : syncState.pendingActions > 0 ? (
<>
<Text style={[styles.title, styles.pendingTitle]}>
{syncState.pendingActions} pending action(s)
</Text>
<Text style={[styles.subtitle, styles.pendingSubtitle]}>
Tap to sync now
</Text>
</>
) : null}
</View>
{isOnline && syncState.status !== 'syncing' && (
<TouchableOpacity style={styles.syncButton} onPress={handleSync}>
<Ionicons name="refresh" size={20} color={colors.primary} />
</TouchableOpacity>
)}
</View>
{syncState.error && (
<View style={styles.errorContainer}>
<Ionicons name="warning-outline" size={14} color={colors.error} />
<Text style={styles.errorText}>{syncState.error}</Text>
</View>
)}
</View>
);
}
const createStyles = (colors: any, isOnline: boolean) =>
StyleSheet.create({
container: {
backgroundColor: isOnline ? colors.card : '#dc3545',
borderBottomWidth: 1,
borderBottomColor: isOnline ? colors.border : 'transparent',
},
content: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 10,
},
textContainer: {
flex: 1,
marginLeft: 12,
},
title: {
fontSize: 14,
fontWeight: '600',
color: '#fff',
},
subtitle: {
fontSize: 12,
color: 'rgba(255, 255, 255, 0.8)',
},
syncingTitle: {
color: colors.text,
},
syncingSubtitle: {
color: colors.textSecondary,
},
pendingTitle: {
color: colors.warning,
},
pendingSubtitle: {
color: colors.textSecondary,
},
syncButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
},
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingBottom: 8,
gap: 6,
},
errorText: {
fontSize: 12,
color: colors.error,
},
});

View File

@ -0,0 +1,335 @@
/**
* BUS-Tickets - Payment Method Picker Component
* Copyright (c) 2024-2026 IT Enterprise
*/
import React from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
ScrollView,
Platform,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../contexts/ThemeContext';
import type { PaymentConfig } from '@/types';
interface PaymentMethodPickerProps {
providers: PaymentConfig[];
selectedId?: string | number;
onSelect: (provider: PaymentConfig) => void;
disabled?: boolean;
showWalletBadges?: boolean;
}
// Payment provider icons/logos
const PROVIDER_ICONS: Record<string, string> = {
monobank: 'card',
liqpay: 'card-outline',
stripe: 'card',
stripe_apple_pay: 'logo-apple',
stripe_google_pay: 'logo-google',
paypal: 'logo-paypal',
gopay: 'wallet',
fondy: 'card',
cash: 'cash-outline',
bank_transfer: 'business-outline',
};
const PROVIDER_COLORS: Record<string, string> = {
monobank: '#000000',
liqpay: '#7AB72B',
stripe: '#635BFF',
stripe_apple_pay: '#000000',
stripe_google_pay: '#4285F4',
paypal: '#003087',
gopay: '#2E7D32',
fondy: '#FF6B00',
cash: '#28a745',
bank_transfer: '#0D47A1',
};
export function PaymentMethodPicker({
providers,
selectedId,
onSelect,
disabled = false,
showWalletBadges = true,
}: PaymentMethodPickerProps) {
const { colors, isDark } = useTheme();
// Filter providers based on platform for wallet support
const filteredProviders = providers.filter((p) => {
// Always show non-wallet providers
if (!p.supportsApplePay && !p.supportsGooglePay) return true;
// Show Apple Pay only on iOS
if (p.provider === 'stripe_apple_pay' && Platform.OS !== 'ios') return false;
// Show Google Pay only on Android
if (p.provider === 'stripe_google_pay' && Platform.OS !== 'android') return false;
return true;
});
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.container}
>
{filteredProviders.map((provider) => {
const isSelected = String(selectedId) === String(provider.id);
const providerCode = provider.provider || String(provider.id);
const providerColor = PROVIDER_COLORS[providerCode] || colors.primary;
return (
<TouchableOpacity
key={provider.id}
style={[
styles.card,
{
backgroundColor: isDark ? '#2a2a2a' : '#ffffff',
borderColor: isSelected ? providerColor : isDark ? '#444' : '#e0e0e0',
borderWidth: isSelected ? 2 : 1,
opacity: disabled ? 0.5 : 1,
},
]}
onPress={() => onSelect(provider)}
disabled={disabled}
activeOpacity={0.7}
>
<View
style={[
styles.iconContainer,
{ backgroundColor: providerColor + '20' },
]}
>
<Ionicons
name={PROVIDER_ICONS[providerCode] as any || 'card'}
size={28}
color={providerColor}
/>
</View>
<Text
style={[
styles.providerName,
{ color: colors.text },
]}
numberOfLines={1}
>
{provider.name}
</Text>
{/* Wallet support badges */}
{showWalletBadges && (provider.supportsApplePay || provider.supportsGooglePay) && (
<View style={styles.walletBadges}>
{provider.supportsApplePay && Platform.OS === 'ios' && (
<View style={[styles.walletBadge, { backgroundColor: '#000' }]}>
<Ionicons name="logo-apple" size={10} color="#fff" />
</View>
)}
{provider.supportsGooglePay && Platform.OS === 'android' && (
<View style={[styles.walletBadge, { backgroundColor: '#4285F4' }]}>
<Ionicons name="logo-google" size={10} color="#fff" />
</View>
)}
</View>
)}
{provider.testMode && (
<View style={styles.testBadge}>
<Text style={styles.testBadgeText}>TEST</Text>
</View>
)}
{isSelected && (
<View style={[styles.checkmark, { backgroundColor: providerColor }]}>
<Ionicons name="checkmark" size={14} color="#ffffff" />
</View>
)}
</TouchableOpacity>
);
})}
</ScrollView>
);
}
interface PaymentSummaryProps {
amount: number;
currency: string;
ticketCount: number;
onPay: () => void;
loading?: boolean;
disabled?: boolean;
}
export function PaymentSummary({
amount,
currency,
ticketCount,
onPay,
loading = false,
disabled = false,
}: PaymentSummaryProps) {
const { colors } = useTheme();
const formatAmount = (value: number, curr: string) => {
const formatter = new Intl.NumberFormat('uk-UA', {
style: 'currency',
currency: curr,
minimumFractionDigits: 0,
});
return formatter.format(value);
};
return (
<View style={[styles.summaryContainer, { backgroundColor: colors.card }]}>
<View style={styles.summaryRow}>
<Text style={[styles.summaryLabel, { color: colors.textSecondary }]}>
{ticketCount} {ticketCount === 1 ? 'ticket' : 'tickets'}
</Text>
<Text style={[styles.summaryAmount, { color: colors.text }]}>
{formatAmount(amount, currency)}
</Text>
</View>
<TouchableOpacity
style={[
styles.payButton,
{ backgroundColor: colors.primary },
disabled && styles.payButtonDisabled,
]}
onPress={onPay}
disabled={disabled || loading}
activeOpacity={0.8}
>
{loading ? (
<Text style={styles.payButtonText}>Processing...</Text>
) : (
<>
<Ionicons name="lock-closed" size={18} color="#ffffff" />
<Text style={styles.payButtonText}>Pay {formatAmount(amount, currency)}</Text>
</>
)}
</TouchableOpacity>
<View style={styles.secureRow}>
<Ionicons name="shield-checkmark" size={14} color={colors.textSecondary} />
<Text style={[styles.secureText, { color: colors.textSecondary }]}>
Secure payment
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: 16,
paddingVertical: 8,
gap: 12,
},
card: {
width: 100,
height: 100,
borderRadius: 12,
padding: 12,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
position: 'relative',
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 8,
},
providerName: {
fontSize: 12,
fontWeight: '600',
textAlign: 'center',
},
testBadge: {
position: 'absolute',
top: 4,
right: 4,
backgroundColor: '#ff9800',
paddingHorizontal: 4,
paddingVertical: 2,
borderRadius: 4,
},
testBadgeText: {
color: '#ffffff',
fontSize: 8,
fontWeight: 'bold',
},
walletBadges: {
flexDirection: 'row',
gap: 4,
marginTop: 4,
},
walletBadge: {
width: 18,
height: 18,
borderRadius: 9,
alignItems: 'center',
justifyContent: 'center',
},
checkmark: {
position: 'absolute',
top: -6,
right: -6,
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
summaryContainer: {
padding: 16,
borderRadius: 12,
margin: 16,
},
summaryRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
summaryLabel: {
fontSize: 14,
},
summaryAmount: {
fontSize: 24,
fontWeight: 'bold',
},
payButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 16,
borderRadius: 12,
gap: 8,
},
payButtonDisabled: {
opacity: 0.5,
},
payButtonText: {
color: '#ffffff',
fontSize: 18,
fontWeight: 'bold',
},
secureRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 12,
gap: 4,
},
secureText: {
fontSize: 12,
},
});

View File

@ -0,0 +1,75 @@
/**
* BUS-Tickets - Sync Indicator Component
* Copyright (c) 2024-2026 IT Enterprise
*/
import React from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useNetwork } from '../contexts/NetworkContext';
import { useTheme } from '../contexts/ThemeContext';
export function SyncIndicator() {
const { isOnline, syncState } = useNetwork();
const { colors } = useTheme();
const getStatusIcon = () => {
if (!isOnline) {
return <Ionicons name="cloud-offline" size={16} color={colors.error} />;
}
switch (syncState.status) {
case 'syncing':
return <ActivityIndicator size="small" color={colors.primary} />;
case 'success':
return <Ionicons name="checkmark-circle" size={16} color={colors.success} />;
case 'error':
return <Ionicons name="alert-circle" size={16} color={colors.error} />;
default:
return <Ionicons name="cloud-done" size={16} color={colors.textSecondary} />;
}
};
const getStatusText = () => {
if (!isOnline) return 'Offline';
switch (syncState.status) {
case 'syncing':
return 'Syncing...';
case 'success':
return 'Synced';
case 'error':
return 'Sync failed';
default:
return syncState.pendingActions > 0
? `${syncState.pendingActions} pending`
: 'Up to date';
}
};
const styles = createStyles(colors);
return (
<View style={styles.container}>
{getStatusIcon()}
<Text style={styles.text}>{getStatusText()}</Text>
</View>
);
}
const createStyles = (colors: any) =>
StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 8,
paddingVertical: 4,
backgroundColor: colors.card,
borderRadius: 12,
},
text: {
fontSize: 12,
color: colors.textSecondary,
},
});

8
src/components/index.ts Normal file
View File

@ -0,0 +1,8 @@
/**
* BUS-Tickets - Components Exports
* Copyright (c) 2024-2026 IT Enterprise
*/
export { OfflineBanner } from './OfflineBanner';
export { SyncIndicator } from './SyncIndicator';
export { PaymentMethodPicker, PaymentSummary } from './PaymentMethodPicker';

155
src/contexts/ApiContext.tsx Normal file
View File

@ -0,0 +1,155 @@
/**
* BUS-Tickets - API Context
* Copyright (c) 2024-2026 IT Enterprise
*
* Provides a shared API client instance across the app
*/
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 type { AuthTokens } from '@/types';
import { useConfig } from './ConfigContext';
interface ApiContextType {
api: BusTicketsApiClient;
}
const ApiContext = createContext<ApiContextType | undefined>(undefined);
const TOKEN_KEY = 'bus_tickets_auth_tokens';
// Platform-safe storage functions
const storage = {
async getItem(key: string): Promise<string | null> {
// Check if we're in a browser environment
if (typeof window === 'undefined') {
return null; // Server-side rendering
}
if (Platform.OS === 'web') {
// Use localStorage on web
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
// Use AsyncStorage on native (SecureStore would require native modules)
try {
return await AsyncStorage.getItem(key);
} catch {
return null;
}
},
async setItem(key: string, value: string): Promise<void> {
if (typeof window === 'undefined') {
return; // Server-side rendering
}
if (Platform.OS === 'web') {
try {
localStorage.setItem(key, value);
} catch {
// Ignore storage errors
}
return;
}
try {
await AsyncStorage.setItem(key, value);
} catch {
// Ignore storage errors
}
},
async removeItem(key: string): Promise<void> {
if (typeof window === 'undefined') {
return; // Server-side rendering
}
if (Platform.OS === 'web') {
try {
localStorage.removeItem(key);
} catch {
// Ignore storage errors
}
return;
}
try {
await AsyncStorage.removeItem(key);
} catch {
// Ignore storage errors
}
},
};
export function ApiProvider({ children }: { children: ReactNode }) {
const { config } = useConfig();
const api = useMemo(() => {
const client = createApiClient({
baseUrl: config.backend.apiUrl || config.backend.url,
timeout: config.backend.timeout ?? 30000,
onTokenRefresh: async (tokens: AuthTokens) => {
// Save refreshed tokens
await storage.setItem(
TOKEN_KEY,
JSON.stringify({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: Date.now() + tokens.expiresIn * 1000,
})
);
},
onAuthError: async () => {
// Clear tokens on auth error
await storage.removeItem(TOKEN_KEY);
},
});
return client;
}, [config.backend.apiUrl, config.backend.url, config.backend.timeout]);
// Load stored tokens on mount (client-side only)
useEffect(() => {
const loadTokens = async () => {
try {
const storedTokens = await storage.getItem(TOKEN_KEY);
if (storedTokens) {
const parsed = JSON.parse(storedTokens);
if (parsed.expiresAt > Date.now()) {
api.setTokens({
accessToken: parsed.accessToken,
refreshToken: parsed.refreshToken,
expiresIn: Math.floor((parsed.expiresAt - Date.now()) / 1000),
tokenType: 'Bearer',
});
}
}
} catch (error) {
console.warn('Failed to load stored tokens:', error);
}
};
loadTokens();
}, [api]);
return (
<ApiContext.Provider value={{ api }}>
{children}
</ApiContext.Provider>
);
}
export function useApi() {
const context = useContext(ApiContext);
if (context === undefined) {
throw new Error('useApi must be used within an ApiProvider');
}
return context.api;
}

View File

@ -0,0 +1,324 @@
/**
* BUS-Tickets - Auth Context
* Copyright (c) 2024-2026 IT Enterprise
*/
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import { Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useApi } from './ApiContext';
import type { User, AuthTokens } from '@/types';
// Dynamic import for native-only modules
let LocalAuthentication: typeof import('expo-local-authentication') | null = null;
if (Platform.OS !== 'web') {
LocalAuthentication = require('expo-local-authentication');
}
interface StoredAuth {
accessToken: string;
refreshToken: string;
expiresAt: number;
}
interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
signIn: (email: string, password: string) => Promise<void>;
signUp: (data: { email: string; password: string; name: string; phone?: string }) => Promise<void>;
signInWithOAuth: (provider: 'google' | 'facebook' | 'apple', idToken: string) => Promise<void>;
signInWithOTP: (phone: string, code: string) => Promise<void>;
signOut: () => Promise<void>;
requestOTP: (email?: string, phone?: string) => Promise<void>;
checkBiometric: () => Promise<boolean>;
authenticateWithBiometric: () => Promise<boolean>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const TOKEN_KEY = 'bus_tickets_auth_tokens';
const USER_KEY = 'bus_tickets_user';
// Platform-safe storage functions
const storage = {
async getItem(key: string): Promise<string | null> {
if (typeof window === 'undefined') {
return null; // Server-side rendering
}
if (Platform.OS === 'web') {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
try {
return await AsyncStorage.getItem(key);
} catch {
return null;
}
},
async setItem(key: string, value: string): Promise<void> {
if (typeof window === 'undefined') {
return;
}
if (Platform.OS === 'web') {
try {
localStorage.setItem(key, value);
} catch {
// Ignore storage errors
}
return;
}
try {
await AsyncStorage.setItem(key, value);
} catch {
// Ignore storage errors
}
},
async removeItem(key: string): Promise<void> {
if (typeof window === 'undefined') {
return;
}
if (Platform.OS === 'web') {
try {
localStorage.removeItem(key);
} catch {
// Ignore storage errors
}
return;
}
try {
await AsyncStorage.removeItem(key);
} catch {
// Ignore storage errors
}
},
};
export function AuthProvider({ children }: { children: ReactNode }) {
const api = useApi();
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadStoredAuth();
}, []);
const loadStoredAuth = async () => {
try {
const storedTokens = await storage.getItem(TOKEN_KEY);
const storedUser = await storage.getItem(USER_KEY);
if (storedTokens && storedUser) {
const parsedTokens: StoredAuth = JSON.parse(storedTokens);
const parsedUser: User = JSON.parse(storedUser);
// Check if token is expired
if (parsedTokens.expiresAt > Date.now()) {
// Set tokens in API client
api.setTokens({
accessToken: parsedTokens.accessToken,
refreshToken: parsedTokens.refreshToken,
expiresIn: Math.floor((parsedTokens.expiresAt - Date.now()) / 1000),
tokenType: 'Bearer',
});
setUser(parsedUser);
// Refresh user data in background
refreshUserFromApi().catch(console.error);
} else {
// Token expired, try to refresh
try {
api.setTokens({
accessToken: parsedTokens.accessToken,
refreshToken: parsedTokens.refreshToken,
expiresIn: 0,
tokenType: 'Bearer',
});
await refreshUserFromApi();
} catch (error) {
await clearAuth();
}
}
}
} catch (error) {
console.error('Error loading auth:', error);
} finally {
setIsLoading(false);
}
};
const refreshUserFromApi = async () => {
const userData = await api.getCurrentUser();
setUser(userData);
await storage.setItem(USER_KEY, JSON.stringify(userData));
};
const saveAuth = async (newUser: User, tokens: AuthTokens) => {
const storedAuth: StoredAuth = {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: Date.now() + tokens.expiresIn * 1000,
};
await storage.setItem(TOKEN_KEY, JSON.stringify(storedAuth));
await storage.setItem(USER_KEY, JSON.stringify(newUser));
setUser(newUser);
};
const clearAuth = async () => {
await storage.removeItem(TOKEN_KEY);
await storage.removeItem(USER_KEY);
api.clearTokens();
setUser(null);
};
const signIn = useCallback(async (email: string, password: string) => {
setIsLoading(true);
try {
const response = await api.login({
provider: 'email',
email,
password,
});
await saveAuth(response.user, response.tokens);
} catch (error) {
console.error('Sign in error:', error);
throw error;
} finally {
setIsLoading(false);
}
}, [api]);
const signUp = useCallback(async (data: { email: string; password: string; name: string; phone?: string }) => {
setIsLoading(true);
try {
const response = await api.register(data);
await saveAuth(response.user, response.tokens);
} catch (error) {
console.error('Sign up error:', error);
throw error;
} finally {
setIsLoading(false);
}
}, [api]);
const signInWithOAuth = useCallback(async (provider: 'google' | 'facebook' | 'apple', idToken: string) => {
setIsLoading(true);
try {
const response = await api.login({
provider,
idToken,
});
await saveAuth(response.user, response.tokens);
} catch (error) {
console.error('OAuth error:', error);
throw error;
} finally {
setIsLoading(false);
}
}, [api]);
const signInWithOTP = useCallback(async (phone: string, code: string) => {
setIsLoading(true);
try {
const response = await api.login({
provider: 'phone',
phone,
otp: code,
});
await saveAuth(response.user, response.tokens);
} catch (error) {
console.error('OTP error:', error);
throw error;
} finally {
setIsLoading(false);
}
}, [api]);
const requestOTP = useCallback(async (email?: string, phone?: string) => {
try {
await api.requestOtp(email, phone);
} catch (error) {
console.error('OTP request error:', error);
throw error;
}
}, [api]);
const signOut = useCallback(async () => {
setIsLoading(true);
try {
await api.logout();
} catch (error) {
console.error('Logout error:', error);
} finally {
await clearAuth();
setIsLoading(false);
}
}, [api]);
const refreshUser = useCallback(async () => {
if (user) {
await refreshUserFromApi();
}
}, [user, api]);
const checkBiometric = async (): Promise<boolean> => {
// Biometric not available on web
if (Platform.OS === 'web' || !LocalAuthentication) {
return false;
}
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
return hasHardware && isEnrolled;
};
const authenticateWithBiometric = async (): Promise<boolean> => {
// Biometric not available on web
if (Platform.OS === 'web' || !LocalAuthentication) {
return false;
}
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Authenticate to access BUS-Tickets',
cancelLabel: 'Cancel',
disableDeviceFallback: false,
});
return result.success;
};
return (
<AuthContext.Provider
value={{
user,
isLoading,
isAuthenticated: !!user,
signIn,
signUp,
signInWithOAuth,
signInWithOTP,
signOut,
requestOTP,
checkBiometric,
authenticateWithBiometric,
refreshUser,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@ -0,0 +1,321 @@
/**
* BUS-Tickets - Config Context
* Copyright (c) 2024-2026 IT Enterprise
*/
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { AppConfig, BackendConfig, OAuthProviderConfig, PaymentConfig } from '@/types';
/**
* Ensure URL uses HTTPS (required for web to avoid mixed content issues)
*/
function ensureHttps(url: string | undefined): string {
if (!url) return '';
// On web, always use HTTPS to avoid mixed content issues
if (Platform.OS === 'web' && url.startsWith('http://')) {
return url.replace('http://', 'https://');
}
return url;
}
// Default IT Enterprise configuration
const DEFAULT_BACKEND: BackendConfig = {
id: 'default',
name: 'BUS-Tickets Backend',
type: 'odoo',
url: 'https://symcherabus.eu',
apiUrl: 'https://symcherabus.eu',
apiVersion: 'v1',
timeout: 30000,
isActive: true,
features: {
booking: true,
payments: true,
userAccounts: true,
multiLanguage: true,
pushNotifications: true,
offlineMode: true,
seatSelection: true,
qrTickets: true,
},
};
const DEFAULT_OAUTH_PROVIDERS: OAuthProviderConfig[] = [
{
id: 'google',
name: 'Google',
provider: 'google',
enabled: true,
clientId: '', // Set in Odoo
},
{
id: 'facebook',
name: 'Facebook',
provider: 'facebook',
enabled: true,
clientId: '', // Set in Odoo
},
{
id: 'apple',
name: 'Apple',
provider: 'apple',
enabled: true,
clientId: '', // Set in Odoo
},
];
const DEFAULT_PAYMENT_PROVIDERS: PaymentConfig[] = [
{
id: 'monobank',
name: 'Monobank',
provider: 'monobank',
enabled: true,
type: 'card',
currencies: ['UAH'],
testMode: false,
},
{
id: 'liqpay',
name: 'LiqPay',
provider: 'liqpay',
enabled: true,
type: 'card',
currencies: ['UAH', 'USD', 'EUR'],
testMode: false,
},
{
id: 'paypal',
name: 'PayPal',
provider: 'paypal',
enabled: true,
type: 'wallet',
currencies: ['USD', 'EUR', 'CZK'],
testMode: false,
},
{
id: 'cash',
name: 'Cash on Board',
provider: 'cash',
enabled: true,
type: 'cash',
currencies: ['UAH', 'CZK', 'EUR'],
testMode: false,
},
];
const DEFAULT_CONFIG: AppConfig = {
instanceId: 'default',
instanceName: 'BUS-Tickets',
backend: DEFAULT_BACKEND,
authProviders: DEFAULT_OAUTH_PROVIDERS,
emailConfig: {
enabled: true,
provider: 'smtp',
magicLink: true,
otp: true,
requireVerification: true,
},
paymentProviders: DEFAULT_PAYMENT_PROVIDERS,
defaultLanguage: 'uk',
supportedLanguages: ['uk', 'cs', 'en'],
defaultCurrency: 'UAH',
supportedCurrencies: ['UAH', 'CZK', 'EUR', 'USD'],
localization: {
defaultLanguage: 'uk',
supportedLanguages: ['uk', 'cs', 'en'],
defaultCurrency: 'UAH',
supportedCurrencies: ['UAH', 'CZK', 'EUR', 'USD'],
},
theme: {
primaryColor: '#e94560',
secondaryColor: '#0f3460',
mode: 'system',
},
legal: {
companyName: 'IT Enterprise',
privacyPolicyUrl: 'https://symcherabus.eu/privacy',
termsOfServiceUrl: 'https://symcherabus.eu/terms',
termsUrl: 'https://symcherabus.eu/terms',
gdprCompliant: true,
cookieConsentRequired: true,
},
analytics: {},
features: {
enableRegistration: true,
enableGuestCheckout: true,
enableSeatSelection: true,
enablePushNotifications: true,
enableOfflineMode: true,
enableBiometricAuth: true,
enableDarkMode: true,
maintenanceMode: false,
offlineMode: true,
},
};
interface ConfigContextType {
config: AppConfig;
isLoading: boolean;
error: string | null;
loadConfig: () => Promise<void>;
loadConfigFromUrl: (url: string) => Promise<void>;
updateBackend: (backend: BackendConfig) => Promise<void>;
resetToDefault: () => Promise<void>;
}
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
const CONFIG_STORAGE_KEY = '@bus_tickets_config';
export function ConfigProvider({ children }: { children: ReactNode }) {
const [config, setConfig] = useState<AppConfig>(DEFAULT_CONFIG);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
setIsLoading(true);
setError(null);
try {
// First try to load from storage
const storedConfig = await AsyncStorage.getItem(CONFIG_STORAGE_KEY);
if (storedConfig) {
const parsed = JSON.parse(storedConfig) as AppConfig;
// Ensure HTTPS URLs to avoid mixed content issues
if (parsed.backend) {
parsed.backend.url = ensureHttps(parsed.backend.url);
parsed.backend.apiUrl = ensureHttps(parsed.backend.apiUrl);
}
setConfig(parsed);
// Try to refresh from backend
const apiUrl = ensureHttps(parsed.backend.apiUrl || parsed.backend.url);
await fetchConfigFromBackend(apiUrl);
} else {
// No stored config, try to fetch from default backend
const apiUrl = DEFAULT_BACKEND.apiUrl || DEFAULT_BACKEND.url;
await fetchConfigFromBackend(apiUrl);
}
} catch (err) {
console.error('Error loading config:', err);
setError('Failed to load configuration');
// Use default config
setConfig(DEFAULT_CONFIG);
} finally {
setIsLoading(false);
}
};
const fetchConfigFromBackend = async (apiUrl: string) => {
try {
const secureUrl = ensureHttps(apiUrl);
const response = await fetch(`${secureUrl}/api/v1/config`);
if (response.ok) {
const data = await response.json();
// API returns data in data.data, not data.config
const configData = data.data || data.config;
if (data.success && configData) {
const newConfig = { ...DEFAULT_CONFIG, ...configData };
// Ensure backend URLs are HTTPS
if (newConfig.backend) {
newConfig.backend.url = ensureHttps(newConfig.backend.url);
newConfig.backend.apiUrl = ensureHttps(newConfig.backend.apiUrl);
}
setConfig(newConfig);
await AsyncStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(newConfig));
}
}
} catch (err) {
console.log('Could not fetch config from backend:', err);
// Continue with stored/default config
}
};
const loadConfigFromUrl = async (url: string) => {
setIsLoading(true);
setError(null);
try {
const secureUrl = ensureHttps(url);
const response = await fetch(`${secureUrl}/api/v1/config`);
if (!response.ok) {
throw new Error('Failed to load config from URL');
}
const data = await response.json();
if (data.success && data.config) {
const newConfig: AppConfig = {
...DEFAULT_CONFIG,
...data.config,
backend: {
...DEFAULT_BACKEND,
url: secureUrl,
apiUrl: secureUrl,
},
};
setConfig(newConfig);
await AsyncStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(newConfig));
} else {
throw new Error('Invalid config response');
}
} catch (err) {
console.error('Error loading config from URL:', err);
setError('Failed to connect to backend');
throw err;
} finally {
setIsLoading(false);
}
};
const updateBackend = async (backend: BackendConfig) => {
// Ensure HTTPS URLs
const secureBackend = {
...backend,
url: ensureHttps(backend.url),
apiUrl: ensureHttps(backend.apiUrl),
};
const newConfig = { ...config, backend: secureBackend };
setConfig(newConfig);
await AsyncStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(newConfig));
};
const resetToDefault = async () => {
setConfig(DEFAULT_CONFIG);
await AsyncStorage.removeItem(CONFIG_STORAGE_KEY);
};
return (
<ConfigContext.Provider
value={{
config,
isLoading,
error,
loadConfig,
loadConfigFromUrl,
updateBackend,
resetToDefault,
}}
>
{children}
</ConfigContext.Provider>
);
}
export function useConfig() {
const context = useContext(ConfigContext);
if (context === undefined) {
throw new Error('useConfig must be used within a ConfigProvider');
}
return context;
}

View File

@ -0,0 +1,180 @@
/**
* BUS-Tickets - Locale Context
* Copyright (c) 2024-2026 IT Enterprise
*/
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Localization from 'expo-localization';
import {
translations,
Translations,
SupportedLanguage,
languageNames,
languageFlags,
} from '@/i18n/translations';
interface LocaleContextType {
locale: SupportedLanguage;
t: Translations;
setLocale: (locale: SupportedLanguage) => void;
availableLanguages: SupportedLanguage[];
getLanguageName: (locale: SupportedLanguage) => string;
getLanguageFlag: (locale: SupportedLanguage) => string;
formatDate: (date: Date, format?: 'short' | 'long' | 'full') => string;
formatTime: (date: Date) => string;
formatCurrency: (amount: number, currency?: string) => string;
formatNumber: (num: number) => string;
}
const LocaleContext = createContext<LocaleContextType | undefined>(undefined);
const LOCALE_STORAGE_KEY = '@bus_tickets_locale';
const AVAILABLE_LANGUAGES: SupportedLanguage[] = ['cs', 'en', 'uk'];
// Get device locale and map to supported language
function getDeviceLocale(): SupportedLanguage {
const deviceLocale = Localization.locale;
const languageCode = deviceLocale.split('-')[0].toLowerCase();
if (languageCode === 'cs' || languageCode === 'sk') return 'cs';
if (languageCode === 'uk' || languageCode === 'ru') return 'uk';
return 'en';
}
// Date format options per locale
const dateLocaleMap: Record<SupportedLanguage, string> = {
cs: 'cs-CZ',
en: 'en-GB',
uk: 'uk-UA',
};
// Currency symbols
const currencySymbols: Record<string, string> = {
CZK: 'Kč',
UAH: '₴',
EUR: '€',
USD: '$',
GBP: '£',
};
export function LocaleProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<SupportedLanguage>('cs');
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadLocale();
}, []);
const loadLocale = async () => {
try {
const savedLocale = await AsyncStorage.getItem(LOCALE_STORAGE_KEY);
if (savedLocale && AVAILABLE_LANGUAGES.includes(savedLocale as SupportedLanguage)) {
setLocaleState(savedLocale as SupportedLanguage);
} else {
// Use device locale as default
const deviceLocale = getDeviceLocale();
setLocaleState(deviceLocale);
}
} catch (error) {
console.error('Error loading locale:', error);
setLocaleState(getDeviceLocale());
} finally {
setIsLoading(false);
}
};
const setLocale = useCallback(async (newLocale: SupportedLanguage) => {
try {
await AsyncStorage.setItem(LOCALE_STORAGE_KEY, newLocale);
setLocaleState(newLocale);
} catch (error) {
console.error('Error saving locale:', error);
}
}, []);
const getLanguageName = useCallback((lang: SupportedLanguage): string => {
return languageNames[lang] || lang;
}, []);
const getLanguageFlag = useCallback((lang: SupportedLanguage): string => {
return languageFlags[lang] || '';
}, []);
const formatDate = useCallback((date: Date, format: 'short' | 'long' | 'full' = 'short'): string => {
const localeString = dateLocaleMap[locale];
const optionsMap: Record<'short' | 'long' | 'full', Intl.DateTimeFormatOptions> = {
short: { day: 'numeric', month: 'numeric', year: 'numeric' },
long: { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' },
full: { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' },
};
return date.toLocaleDateString(localeString, optionsMap[format]);
}, [locale]);
const formatTime = useCallback((date: Date): string => {
const localeString = dateLocaleMap[locale];
return date.toLocaleTimeString(localeString, {
hour: '2-digit',
minute: '2-digit',
});
}, [locale]);
const formatCurrency = useCallback((amount: number, currency: string = 'CZK'): string => {
const symbol = currencySymbols[currency] || currency;
const formatted = amount.toLocaleString(dateLocaleMap[locale], {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
// CZK and UAH typically have symbol after the number
if (currency === 'CZK' || currency === 'UAH') {
return `${formatted} ${symbol}`;
}
return `${symbol}${formatted}`;
}, [locale]);
const formatNumber = useCallback((num: number): string => {
return num.toLocaleString(dateLocaleMap[locale]);
}, [locale]);
if (isLoading) {
return null;
}
const t = translations[locale];
return (
<LocaleContext.Provider
value={{
locale,
t,
setLocale,
availableLanguages: AVAILABLE_LANGUAGES,
getLanguageName,
getLanguageFlag,
formatDate,
formatTime,
formatCurrency,
formatNumber,
}}
>
{children}
</LocaleContext.Provider>
);
}
export function useLocale() {
const context = useContext(LocaleContext);
if (context === undefined) {
throw new Error('useLocale must be used within a LocaleProvider');
}
return context;
}
// Hook for translations only (shorter syntax)
export function useTranslations() {
const { t } = useLocale();
return t;
}

View File

@ -0,0 +1,171 @@
/**
* BUS-Tickets - Network Context
* Copyright (c) 2024-2026 IT Enterprise
*/
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { Platform } from 'react-native';
// Only import native modules on non-web platforms
const isWeb = Platform.OS === 'web';
let Network: typeof import('expo-network') | null = null;
let syncService: typeof import('../services/SyncService').syncService | null = null;
let database: typeof import('../db/database').database | null = null;
if (!isWeb) {
Network = require('expo-network');
syncService = require('../services/SyncService').syncService;
database = require('../db/database').database;
}
// Default sync state for web
type SyncState = {
status: 'idle' | 'syncing' | 'success' | 'error' | 'offline';
lastSyncTime: number | null;
pendingActions: number;
error: string | null;
};
interface NetworkContextType {
isOnline: boolean;
isInitialized: boolean;
syncState: SyncState;
sync: () => Promise<boolean>;
forceSync: () => Promise<boolean>;
}
const NetworkContext = createContext<NetworkContextType | undefined>(undefined);
interface NetworkProviderProps {
children: ReactNode;
apiUrl: string;
}
const defaultSyncState: SyncState = {
status: 'idle',
lastSyncTime: null,
pendingActions: 0,
error: null,
};
export function NetworkProvider({ children, apiUrl }: NetworkProviderProps) {
const [isOnline, setIsOnline] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const [syncState, setSyncState] = useState<SyncState>(
isWeb ? defaultSyncState : (syncService?.getState() || defaultSyncState)
);
useEffect(() => {
initialize();
// Skip native-only setup on web
if (isWeb) {
// Use navigator.onLine for web
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
if (typeof window !== 'undefined') {
setIsOnline(navigator.onLine);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}
return;
}
// Native: Subscribe to sync state changes
const unsubscribe = syncService?.subscribe((state: SyncState) => {
setSyncState(state);
setIsOnline(syncService?.isNetworkOnline() ?? true);
});
// Check network periodically
const interval = setInterval(checkNetwork, 10000);
return () => {
unsubscribe?.();
clearInterval(interval);
syncService?.stopNetworkMonitoring();
};
}, [apiUrl]);
const initialize = async () => {
// On web, skip database/sync initialization
if (isWeb) {
setIsInitialized(true);
return;
}
try {
// Initialize database (native only)
await database?.initialize();
// Initialize sync service (native only)
await syncService?.initialize(apiUrl);
// Check initial network state
await checkNetwork();
setIsInitialized(true);
// Initial sync if online
if (syncService?.isNetworkOnline()) {
syncService.sync();
}
} catch (error) {
console.error('Error initializing network provider:', error);
setIsInitialized(true); // Continue anyway
}
};
const checkNetwork = async () => {
if (isWeb) {
setIsOnline(typeof navigator !== 'undefined' ? navigator.onLine : true);
return;
}
try {
const state = await Network?.getNetworkStateAsync();
const online = state?.isConnected === true && state?.isInternetReachable === true;
setIsOnline(online);
} catch (error) {
console.error('Error checking network:', error);
}
};
const sync = async (): Promise<boolean> => {
if (isWeb) return true; // No-op on web
return syncService?.sync() ?? true;
};
const forceSync = async (): Promise<boolean> => {
if (isWeb) return true; // No-op on web
return syncService?.sync({ forceSync: true }) ?? true;
};
return (
<NetworkContext.Provider
value={{
isOnline,
isInitialized,
syncState,
sync,
forceSync,
}}
>
{children}
</NetworkContext.Provider>
);
}
export function useNetwork() {
const context = useContext(NetworkContext);
if (context === undefined) {
throw new Error('useNetwork must be used within a NetworkProvider');
}
return context;
}

View File

@ -0,0 +1,355 @@
/**
* BUS-Tickets - Bus Operators/Providers Context
* Manages multiple data sources for different bus companies
* Copyright (c) 2024-2026 IT Enterprise
*/
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
// Bus operator/provider interface
export interface BusProvider {
id: string;
name: string;
displayName: string;
apiUrl: string;
logoUrl?: string;
primaryColor?: string;
enabled: boolean;
isDefault: boolean;
// API credentials (optional for some providers)
apiKey?: string;
// Provider metadata
country?: string;
supportEmail?: string;
supportPhone?: string;
website?: string;
// Feature flags
supportsOnlinePayment: boolean;
supportsSeatSelection: boolean;
supportsRefunds: boolean;
// Connection status
isConnected: boolean;
lastSyncAt?: string;
errorMessage?: string;
}
// Trip with provider info
export interface TripWithProvider {
id: number | string;
providerId: string;
providerName: string;
providerLogo?: string;
providerColor?: string;
// Trip details
route: {
id?: number;
name?: string;
origin: { id: number; name: string; city?: string };
destination: { id: number; name: string; city?: string };
};
departure: string;
arrival: string;
duration: number;
price: {
amount: number;
currency: string;
};
availableSeats: number;
bus?: {
id?: number;
name?: string;
plateNumber?: string;
capacity?: number;
amenities?: string[];
};
busInfo?: {
type: string;
amenities: string[];
};
}
interface ProvidersContextType {
providers: BusProvider[];
activeProviders: BusProvider[];
isLoading: boolean;
// Provider management
addProvider: (provider: Omit<BusProvider, 'id' | 'isConnected'>) => Promise<void>;
updateProvider: (id: string, updates: Partial<BusProvider>) => Promise<void>;
removeProvider: (id: string) => Promise<void>;
toggleProvider: (id: string, enabled: boolean) => Promise<void>;
setDefaultProvider: (id: string) => Promise<void>;
// Connection
testConnection: (provider: BusProvider) => Promise<boolean>;
syncProvider: (id: string) => Promise<void>;
syncAllProviders: () => Promise<void>;
// Search across providers
searchTrips: (params: SearchParams) => Promise<TripWithProvider[]>;
// Get provider by ID
getProvider: (id: string) => BusProvider | undefined;
}
interface SearchParams {
originId: number;
destinationId: number;
date: string;
passengers: number;
}
const ProvidersContext = createContext<ProvidersContextType | undefined>(undefined);
const PROVIDERS_STORAGE_KEY = '@bus_tickets_providers';
// Ensure HTTPS for web platform
function ensureHttps(url: string): string {
if (!url) return url;
if (Platform.OS === 'web' && url.startsWith('http://')) {
return url.replace('http://', 'https://');
}
return url;
}
// Default providers (can be customized per installation)
const defaultProviders: BusProvider[] = [
{
id: 'symchera',
name: 'symchera',
displayName: 'SymcheraBus',
apiUrl: 'https://symcherabus.eu/odoo',
logoUrl: 'https://symcherabus.eu/odoo/web/image/website/1/logo',
primaryColor: '#e94560',
enabled: true,
isDefault: true,
country: 'CZ/UA',
supportsOnlinePayment: true,
supportsSeatSelection: true,
supportsRefunds: true,
isConnected: false,
},
];
export function ProvidersProvider({ children }: { children: ReactNode }) {
const [providers, setProviders] = useState<BusProvider[]>(defaultProviders);
const [isLoading, setIsLoading] = useState(true);
// Load providers on mount
useEffect(() => {
loadProviders();
}, []);
const loadProviders = async () => {
try {
const stored = await AsyncStorage.getItem(PROVIDERS_STORAGE_KEY);
if (stored) {
const parsedProviders = JSON.parse(stored) as BusProvider[];
// Merge with defaults to ensure all fields exist
const mergedProviders = parsedProviders.map(p => ({
...defaultProviders.find(dp => dp.id === p.id) || {},
...p,
}));
setProviders(mergedProviders);
}
} catch (error) {
console.error('Error loading providers:', error);
} finally {
setIsLoading(false);
}
};
const saveProviders = async (newProviders: BusProvider[]) => {
try {
await AsyncStorage.setItem(PROVIDERS_STORAGE_KEY, JSON.stringify(newProviders));
setProviders(newProviders);
} catch (error) {
console.error('Error saving providers:', error);
throw error;
}
};
const addProvider = useCallback(async (providerData: Omit<BusProvider, 'id' | 'isConnected'>) => {
const id = `provider_${Date.now()}`;
const newProvider: BusProvider = {
...providerData,
id,
isConnected: false,
};
const updated = [...providers, newProvider];
await saveProviders(updated);
}, [providers]);
const updateProvider = useCallback(async (id: string, updates: Partial<BusProvider>) => {
const updated = providers.map(p =>
p.id === id ? { ...p, ...updates } : p
);
await saveProviders(updated);
}, [providers]);
const removeProvider = useCallback(async (id: string) => {
const updated = providers.filter(p => p.id !== id);
await saveProviders(updated);
}, [providers]);
const toggleProvider = useCallback(async (id: string, enabled: boolean) => {
await updateProvider(id, { enabled });
}, [updateProvider]);
const setDefaultProvider = useCallback(async (id: string) => {
const updated = providers.map(p => ({
...p,
isDefault: p.id === id,
}));
await saveProviders(updated);
}, [providers]);
const testConnection = useCallback(async (provider: BusProvider): Promise<boolean> => {
try {
const url = ensureHttps(`${provider.apiUrl}/api/v1/config`);
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...(provider.apiKey ? { 'X-API-Key': provider.apiKey } : {}),
},
});
if (response.ok) {
await updateProvider(provider.id, {
isConnected: true,
lastSyncAt: new Date().toISOString(),
errorMessage: undefined,
});
return true;
} else {
await updateProvider(provider.id, {
isConnected: false,
errorMessage: `HTTP ${response.status}: ${response.statusText}`,
});
return false;
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Connection failed';
await updateProvider(provider.id, {
isConnected: false,
errorMessage: message,
});
return false;
}
}, [updateProvider]);
const syncProvider = useCallback(async (id: string) => {
const provider = providers.find(p => p.id === id);
if (provider) {
await testConnection(provider);
}
}, [providers, testConnection]);
const syncAllProviders = useCallback(async () => {
const activeProviders = providers.filter(p => p.enabled);
await Promise.all(activeProviders.map(p => testConnection(p)));
}, [providers, testConnection]);
const searchTrips = useCallback(async (params: SearchParams): Promise<TripWithProvider[]> => {
const activeProviders = providers.filter(p => p.enabled);
const allTrips: TripWithProvider[] = [];
// Search all active providers in parallel
const results = await Promise.allSettled(
activeProviders.map(async (provider) => {
try {
const url = ensureHttps(
`${provider.apiUrl}/api/v1/trips/search?` +
`origin_id=${params.originId}&` +
`destination_id=${params.destinationId}&` +
`date=${params.date}&` +
`passengers=${params.passengers}`
);
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...(provider.apiKey ? { 'X-API-Key': provider.apiKey } : {}),
},
});
if (!response.ok) {
console.warn(`Provider ${provider.name} returned ${response.status}`);
return [];
}
const data = await response.json();
if (data.success && data.data?.trips) {
return data.data.trips.map((trip: any) => ({
...trip,
providerId: provider.id,
providerName: provider.displayName,
providerLogo: provider.logoUrl,
providerColor: provider.primaryColor,
}));
}
return [];
} catch (error) {
console.error(`Error searching provider ${provider.name}:`, error);
return [];
}
})
);
// Collect all successful results
results.forEach((result) => {
if (result.status === 'fulfilled' && Array.isArray(result.value)) {
allTrips.push(...result.value);
}
});
// Sort by departure time
allTrips.sort((a, b) =>
new Date(a.departure).getTime() - new Date(b.departure).getTime()
);
return allTrips;
}, [providers]);
const getProvider = useCallback((id: string): BusProvider | undefined => {
return providers.find(p => p.id === id);
}, [providers]);
const activeProviders = providers.filter(p => p.enabled);
return (
<ProvidersContext.Provider
value={{
providers,
activeProviders,
isLoading,
addProvider,
updateProvider,
removeProvider,
toggleProvider,
setDefaultProvider,
testConnection,
syncProvider,
syncAllProviders,
searchTrips,
getProvider,
}}
>
{children}
</ProvidersContext.Provider>
);
}
export function useProviders() {
const context = useContext(ProvidersContext);
if (context === undefined) {
throw new Error('useProviders must be used within a ProvidersProvider');
}
return context;
}

View File

@ -0,0 +1,128 @@
/**
* BUS-Tickets - Theme Context
* Copyright (c) 2024-2026 IT Enterprise
*/
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useColorScheme } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
export interface ThemeColors {
primary: string;
primaryDark: string;
secondary: string;
background: string;
card: string;
text: string;
textSecondary: string;
border: string;
error: string;
success: string;
warning: string;
}
const lightColors: ThemeColors = {
primary: '#e94560',
primaryDark: '#d63850',
secondary: '#0f3460',
background: '#f8f9fa',
card: '#ffffff',
text: '#1a1a2e',
textSecondary: '#6c757d',
border: '#e9ecef',
error: '#dc3545',
success: '#28a745',
warning: '#ffc107',
};
const darkColors: ThemeColors = {
primary: '#e94560',
primaryDark: '#d63850',
secondary: '#16213e',
background: '#1a1a2e',
card: '#16213e',
text: '#ffffff',
textSecondary: '#adb5bd',
border: '#2d3748',
error: '#f56565',
success: '#48bb78',
warning: '#ed8936',
};
interface ThemeContextType {
isDark: boolean;
colors: ThemeColors;
toggleTheme: () => void;
setTheme: (mode: 'light' | 'dark' | 'system') => void;
themeMode: 'light' | 'dark' | 'system';
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const THEME_STORAGE_KEY = '@bus_tickets_theme';
export function ThemeProvider({ children }: { children: ReactNode }) {
const systemColorScheme = useColorScheme();
const [themeMode, setThemeMode] = useState<'light' | 'dark' | 'system'>('system');
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadTheme();
}, []);
const loadTheme = async () => {
try {
const savedTheme = await AsyncStorage.getItem(THEME_STORAGE_KEY);
if (savedTheme && ['light', 'dark', 'system'].includes(savedTheme)) {
setThemeMode(savedTheme as 'light' | 'dark' | 'system');
}
} catch (error) {
console.error('Error loading theme:', error);
} finally {
setIsLoading(false);
}
};
const saveTheme = async (mode: 'light' | 'dark' | 'system') => {
try {
await AsyncStorage.setItem(THEME_STORAGE_KEY, mode);
} catch (error) {
console.error('Error saving theme:', error);
}
};
const isDark = themeMode === 'system'
? systemColorScheme === 'dark'
: themeMode === 'dark';
const colors = isDark ? darkColors : lightColors;
const toggleTheme = () => {
const newMode = isDark ? 'light' : 'dark';
setThemeMode(newMode);
saveTheme(newMode);
};
const setTheme = (mode: 'light' | 'dark' | 'system') => {
setThemeMode(mode);
saveTheme(mode);
};
if (isLoading) {
return null;
}
return (
<ThemeContext.Provider value={{ isDark, colors, toggleTheme, setTheme, themeMode }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

13
src/contexts/index.ts Normal file
View File

@ -0,0 +1,13 @@
/**
* BUS-Tickets - Context Exports
* Copyright (c) 2024-2026 IT Enterprise
*/
export { ThemeProvider, useTheme } from './ThemeContext';
export { AuthProvider, useAuth } from './AuthContext';
export { ConfigProvider, useConfig } from './ConfigContext';
export { NetworkProvider, useNetwork } from './NetworkContext';
export { ApiProvider, useApi } from './ApiContext';
export { LocaleProvider, useLocale, useTranslations } from './LocaleContext';
export { ProvidersProvider, useProviders } from './ProvidersContext';
export type { BusProvider, TripWithProvider } from './ProvidersContext';

162
src/db/OfflineQueue.ts Normal file
View File

@ -0,0 +1,162 @@
/**
* BUS-Tickets - Offline Action Queue
* Copyright (c) 2024-2026 IT Enterprise
*/
import { database } from './database';
export type ActionType =
| 'CREATE_BOOKING'
| 'CANCEL_TICKET'
| 'UPDATE_PROFILE'
| 'CHECK_IN';
export type EntityType = 'ticket' | 'booking' | 'user';
export interface QueuedAction {
id: number;
action_type: ActionType;
entity_type: EntityType;
entity_id: number | null;
payload: string;
created_at: number;
retry_count: number;
last_error: string | null;
}
export interface ActionPayload {
[key: string]: unknown;
}
const MAX_RETRIES = 3;
class OfflineQueue {
/**
* Add action to queue
*/
async enqueue(
actionType: ActionType,
entityType: EntityType,
entityId: number | null,
payload: ActionPayload
): Promise<number> {
const db = await database.getDb();
const result = await db.runAsync(
`INSERT INTO offline_queue (
action_type, entity_type, entity_id, payload, created_at, retry_count
) VALUES (?, ?, ?, ?, ?, 0)`,
[actionType, entityType, entityId, JSON.stringify(payload), Date.now()]
);
console.log(`Action queued: ${actionType} for ${entityType}:${entityId}`);
return result.lastInsertRowId;
}
/**
* Get all pending actions
*/
async getPendingActions(): Promise<QueuedAction[]> {
const db = await database.getDb();
return db.getAllAsync<QueuedAction>(
`SELECT * FROM offline_queue
WHERE retry_count < ?
ORDER BY created_at ASC`,
[MAX_RETRIES]
);
}
/**
* Get pending action count
*/
async getPendingCount(): Promise<number> {
const db = await database.getDb();
const result = await db.getFirstAsync<{ count: number }>(
'SELECT COUNT(*) as count FROM offline_queue WHERE retry_count < ?',
[MAX_RETRIES]
);
return result?.count || 0;
}
/**
* Mark action as completed (remove from queue)
*/
async complete(actionId: number): Promise<void> {
const db = await database.getDb();
await db.runAsync('DELETE FROM offline_queue WHERE id = ?', [actionId]);
console.log(`Action completed: ${actionId}`);
}
/**
* Mark action as failed (increment retry count)
*/
async fail(actionId: number, error: string): Promise<void> {
const db = await database.getDb();
await db.runAsync(
`UPDATE offline_queue
SET retry_count = retry_count + 1, last_error = ?
WHERE id = ?`,
[error, actionId]
);
console.log(`Action failed: ${actionId} - ${error}`);
}
/**
* Get failed actions (exceeded max retries)
*/
async getFailedActions(): Promise<QueuedAction[]> {
const db = await database.getDb();
return db.getAllAsync<QueuedAction>(
`SELECT * FROM offline_queue
WHERE retry_count >= ?
ORDER BY created_at ASC`,
[MAX_RETRIES]
);
}
/**
* Retry failed action
*/
async retry(actionId: number): Promise<void> {
const db = await database.getDb();
await db.runAsync(
`UPDATE offline_queue
SET retry_count = 0, last_error = NULL
WHERE id = ?`,
[actionId]
);
}
/**
* Clear all completed actions
*/
async clearCompleted(): Promise<void> {
const db = await database.getDb();
await db.runAsync('DELETE FROM offline_queue WHERE retry_count >= ?', [
MAX_RETRIES,
]);
}
/**
* Clear all actions
*/
async clearAll(): Promise<void> {
const db = await database.getDb();
await db.runAsync('DELETE FROM offline_queue');
}
/**
* Get action by ID
*/
async getAction(actionId: number): Promise<QueuedAction | null> {
const db = await database.getDb();
return db.getFirstAsync<QueuedAction>(
'SELECT * FROM offline_queue WHERE id = ?',
[actionId]
);
}
}
export const offlineQueue = new OfflineQueue();

374
src/db/TicketRepository.ts Normal file
View File

@ -0,0 +1,374 @@
/**
* BUS-Tickets - Ticket Repository
* Copyright (c) 2024-2026 IT Enterprise
*/
import { database } from './database';
import type { Ticket, Trip, Currency } from '@/types';
export interface CachedTicket {
id: number;
ticket_number: string;
trip_id: number;
passenger_name: string;
passenger_email: string;
passenger_phone: string;
seat: number | null;
price_amount: number;
price_currency: string;
status: string;
qr_code: string | null;
purchased_at: string;
checked_in_at: string | null;
synced_at: number;
}
export interface CachedTrip {
id: number;
route_id: number;
route_name: string;
origin_city: string;
origin_country: string;
destination_city: string;
destination_country: string;
departure_time: string;
arrival_time: string;
bus_name: string;
bus_plate: string;
bus_capacity: number;
bus_amenities: string;
available_seats: number;
total_seats: number;
price_amount: number;
price_currency: string;
status: string;
synced_at: number;
}
class TicketRepository {
/**
* Save trip to local database
*/
async saveTrip(trip: Trip): Promise<void> {
const db = await database.getDb();
await db.runAsync(
`INSERT OR REPLACE INTO trips (
id, route_id, route_name, origin_city, origin_country,
destination_city, destination_country, departure_time, arrival_time,
bus_name, bus_plate, bus_capacity, bus_amenities,
available_seats, total_seats, price_amount, price_currency,
status, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
trip.id,
trip.route.id,
trip.route.name,
trip.route.origin.city,
trip.route.origin.country,
trip.route.destination.city,
trip.route.destination.country,
trip.departureTime,
trip.arrivalTime,
trip.bus.name,
trip.bus.plateNumber,
trip.bus.capacity,
JSON.stringify(trip.bus.amenities),
trip.availableSeats,
trip.totalSeats,
trip.price.amount,
trip.price.currency,
trip.status,
Date.now(),
]
);
}
/**
* Save multiple trips
*/
async saveTrips(trips: Trip[]): Promise<void> {
for (const trip of trips) {
await this.saveTrip(trip);
}
}
/**
* Get trip by ID
*/
async getTrip(id: number): Promise<Trip | null> {
const db = await database.getDb();
const row = await db.getFirstAsync<CachedTrip>(
'SELECT * FROM trips WHERE id = ?',
[id]
);
if (!row) return null;
return this.mapCachedTripToTrip(row);
}
/**
* Get trips by route
*/
async getTripsByRoute(
originCity: string,
destinationCity: string,
date?: string
): Promise<Trip[]> {
const db = await database.getDb();
let query = `
SELECT * FROM trips
WHERE origin_city LIKE ? AND destination_city LIKE ?
`;
const params: (string | number)[] = [`%${originCity}%`, `%${destinationCity}%`];
if (date) {
query += ' AND DATE(departure_time) = DATE(?)';
params.push(date);
}
query += ' ORDER BY departure_time ASC';
const rows = await db.getAllAsync<CachedTrip>(query, params);
return rows.map(this.mapCachedTripToTrip);
}
/**
* Save ticket to local database
*/
async saveTicket(ticket: Ticket): Promise<void> {
const db = await database.getDb();
// First save the trip
await this.saveTrip(ticket.trip);
// Then save the ticket
await db.runAsync(
`INSERT OR REPLACE INTO tickets (
id, ticket_number, trip_id, passenger_name, passenger_email,
passenger_phone, seat, price_amount, price_currency, status,
qr_code, purchased_at, checked_in_at, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
ticket.id,
ticket.ticketNumber,
ticket.trip.id,
ticket.passenger.name,
ticket.passenger.email,
ticket.passenger.phone || null,
ticket.seat || null,
ticket.price.amount,
ticket.price.currency,
ticket.status,
ticket.qrCode || null,
ticket.purchasedAt,
ticket.checkedInAt || null,
Date.now(),
]
);
}
/**
* Save multiple tickets
*/
async saveTickets(tickets: Ticket[]): Promise<void> {
for (const ticket of tickets) {
await this.saveTicket(ticket);
}
}
/**
* Get ticket by ID
*/
async getTicket(id: number): Promise<Ticket | null> {
const db = await database.getDb();
const row = await db.getFirstAsync<CachedTicket>(
'SELECT * FROM tickets WHERE id = ?',
[id]
);
if (!row) return null;
const trip = await this.getTrip(row.trip_id);
if (!trip) return null;
return this.mapCachedTicketToTicket(row, trip);
}
/**
* Get ticket by ticket number
*/
async getTicketByNumber(ticketNumber: string): Promise<Ticket | null> {
const db = await database.getDb();
const row = await db.getFirstAsync<CachedTicket>(
'SELECT * FROM tickets WHERE ticket_number = ?',
[ticketNumber]
);
if (!row) return null;
const trip = await this.getTrip(row.trip_id);
if (!trip) return null;
return this.mapCachedTicketToTicket(row, trip);
}
/**
* Get all user tickets
*/
async getAllTickets(): Promise<Ticket[]> {
const db = await database.getDb();
const rows = await db.getAllAsync<CachedTicket>(
'SELECT * FROM tickets ORDER BY purchased_at DESC'
);
const tickets: Ticket[] = [];
for (const row of rows) {
const trip = await this.getTrip(row.trip_id);
if (trip) {
tickets.push(this.mapCachedTicketToTicket(row, trip));
}
}
return tickets;
}
/**
* Get upcoming tickets
*/
async getUpcomingTickets(): Promise<Ticket[]> {
const db = await database.getDb();
const now = new Date().toISOString();
const rows = await db.getAllAsync<CachedTicket & { departure_time: string }>(
`SELECT t.*, tr.departure_time
FROM tickets t
JOIN trips tr ON t.trip_id = tr.id
WHERE tr.departure_time > ?
AND t.status IN ('reserved', 'paid', 'checked_in')
ORDER BY tr.departure_time ASC`,
[now]
);
const tickets: Ticket[] = [];
for (const row of rows) {
const trip = await this.getTrip(row.trip_id);
if (trip) {
tickets.push(this.mapCachedTicketToTicket(row, trip));
}
}
return tickets;
}
/**
* Update ticket status locally
*/
async updateTicketStatus(id: number, status: string): Promise<void> {
const db = await database.getDb();
await db.runAsync(
'UPDATE tickets SET status = ?, synced_at = ? WHERE id = ?',
[status, Date.now(), id]
);
}
/**
* Delete old tickets (older than 90 days)
*/
async cleanupOldTickets(): Promise<number> {
const db = await database.getDb();
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - 90);
const result = await db.runAsync(
`DELETE FROM tickets WHERE id IN (
SELECT t.id FROM tickets t
JOIN trips tr ON t.trip_id = tr.id
WHERE tr.departure_time < ?
)`,
[cutoffDate.toISOString()]
);
return result.changes;
}
/**
* Get last sync timestamp
*/
async getLastSyncTime(): Promise<number | null> {
const db = await database.getDb();
const result = await db.getFirstAsync<{ max_synced: number }>(
'SELECT MAX(synced_at) as max_synced FROM tickets'
);
return result?.max_synced || null;
}
/**
* Convert cached trip row to Trip object
*/
private mapCachedTripToTrip(row: CachedTrip): Trip {
return {
id: row.id,
route: {
id: row.route_id,
name: row.route_name,
origin: {
id: 0,
name: row.origin_city,
city: row.origin_city,
country: row.origin_country,
},
destination: {
id: 0,
name: row.destination_city,
city: row.destination_city,
country: row.destination_country,
},
},
departureTime: row.departure_time,
arrivalTime: row.arrival_time,
bus: {
id: 0,
name: row.bus_name,
plateNumber: row.bus_plate,
capacity: row.bus_capacity,
amenities: JSON.parse(row.bus_amenities || '[]'),
},
availableSeats: row.available_seats,
totalSeats: row.total_seats,
price: {
amount: row.price_amount,
currency: row.price_currency as Currency,
},
status: row.status as Trip['status'],
};
}
/**
* Convert cached ticket row to Ticket object
*/
private mapCachedTicketToTicket(row: CachedTicket, trip: Trip): Ticket {
return {
id: row.id,
ticketNumber: row.ticket_number,
trip,
passenger: {
name: row.passenger_name,
email: row.passenger_email,
phone: row.passenger_phone,
},
seat: row.seat || undefined,
price: {
amount: row.price_amount,
currency: row.price_currency as Currency,
},
status: row.status as Ticket['status'],
qrCode: row.qr_code ?? '',
purchasedAt: row.purchased_at,
checkedInAt: row.checked_in_at || undefined,
};
}
}
export const ticketRepository = new TicketRepository();

183
src/db/database.ts Normal file
View File

@ -0,0 +1,183 @@
/**
* BUS-Tickets - SQLite Database
* Copyright (c) 2024-2026 IT Enterprise
*/
import * as SQLite from 'expo-sqlite';
const DB_NAME = 'bus_tickets.db';
const DB_VERSION = 1;
class Database {
private static instance: Database;
private db: SQLite.SQLiteDatabase | null = null;
private initialized: boolean = false;
private constructor() {}
static getInstance(): Database {
if (!Database.instance) {
Database.instance = new Database();
}
return Database.instance;
}
/**
* Get database instance
*/
async getDb(): Promise<SQLite.SQLiteDatabase> {
if (!this.db) {
this.db = await SQLite.openDatabaseAsync(DB_NAME);
}
return this.db;
}
/**
* Initialize database schema
*/
async initialize(): Promise<void> {
if (this.initialized) return;
const db = await this.getDb();
// Create tables
await db.execAsync(`
-- User table for offline access
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
email TEXT,
name TEXT,
phone TEXT,
avatar TEXT,
synced_at INTEGER
);
-- Cached trips
CREATE TABLE IF NOT EXISTS trips (
id INTEGER PRIMARY KEY,
route_id INTEGER,
route_name TEXT,
origin_city TEXT,
origin_country TEXT,
destination_city TEXT,
destination_country TEXT,
departure_time TEXT,
arrival_time TEXT,
bus_name TEXT,
bus_plate TEXT,
bus_capacity INTEGER,
bus_amenities TEXT,
available_seats INTEGER,
total_seats INTEGER,
price_amount REAL,
price_currency TEXT,
status TEXT,
synced_at INTEGER
);
-- User tickets (most important for offline)
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY,
ticket_number TEXT UNIQUE,
trip_id INTEGER,
passenger_name TEXT,
passenger_email TEXT,
passenger_phone TEXT,
seat INTEGER,
price_amount REAL,
price_currency TEXT,
status TEXT,
qr_code TEXT,
purchased_at TEXT,
checked_in_at TEXT,
synced_at INTEGER,
FOREIGN KEY (trip_id) REFERENCES trips(id)
);
-- Search history
CREATE TABLE IF NOT EXISTS search_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
origin TEXT,
destination TEXT,
search_date TEXT,
passengers INTEGER,
created_at INTEGER
);
-- Offline actions queue
CREATE TABLE IF NOT EXISTS offline_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action_type TEXT,
entity_type TEXT,
entity_id INTEGER,
payload TEXT,
created_at INTEGER,
retry_count INTEGER DEFAULT 0,
last_error TEXT
);
-- Cached routes for autocomplete
CREATE TABLE IF NOT EXISTS routes (
id INTEGER PRIMARY KEY,
name TEXT,
origin_city TEXT,
destination_city TEXT,
synced_at INTEGER
);
-- App metadata
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value TEXT
);
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE INDEX IF NOT EXISTS idx_tickets_trip ON tickets(trip_id);
CREATE INDEX IF NOT EXISTS idx_trips_departure ON trips(departure_time);
CREATE INDEX IF NOT EXISTS idx_offline_queue_created ON offline_queue(created_at);
`);
// Store database version
await this.setMetadata('db_version', String(DB_VERSION));
this.initialized = true;
console.log('Database initialized');
}
/**
* Get metadata value
*/
async getMetadata(key: string): Promise<string | null> {
const db = await this.getDb();
const result = await db.getFirstAsync<{ value: string }>(
'SELECT value FROM metadata WHERE key = ?',
[key]
);
return result?.value || null;
}
/**
* Set metadata value
*/
async setMetadata(key: string, value: string): Promise<void> {
const db = await this.getDb();
await db.runAsync(
'INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)',
[key, value]
);
}
/**
* Close database connection
*/
async close(): Promise<void> {
if (this.db) {
await this.db.closeAsync();
this.db = null;
this.initialized = false;
}
}
}
export const database = Database.getInstance();

10
src/db/index.ts Normal file
View File

@ -0,0 +1,10 @@
/**
* BUS-Tickets - Database Exports
* Copyright (c) 2024-2026 IT Enterprise
*/
export { database } from './database';
export { ticketRepository } from './TicketRepository';
export { offlineQueue } from './OfflineQueue';
export type { CachedTicket, CachedTrip } from './TicketRepository';
export type { QueuedAction, ActionType, EntityType, ActionPayload } from './OfflineQueue';

11
src/hooks/index.ts Normal file
View File

@ -0,0 +1,11 @@
/**
* BUS-Tickets - Hooks Exports
* Copyright (c) 2024-2026 IT Enterprise
*/
export { useNotifications } from './useNotifications';
export { useOffline } from './useOffline';
export { useTrips } from './useTrips';
export { useTickets } from './useTickets';
export { usePayment, usePaymentProvider } from './usePayment';
export type { PaymentStatus } from './usePayment';

View File

@ -0,0 +1,173 @@
/**
* BUS-Tickets - Notification Hook
* Copyright (c) 2024-2026 IT Enterprise
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import * as Notifications from 'expo-notifications';
import { useRouter } from 'expo-router';
import { notificationService, NotificationSettings } from '../services/NotificationService';
interface UseNotificationsReturn {
expoPushToken: string | null;
notification: Notifications.Notification | null;
settings: NotificationSettings;
updateSettings: (settings: Partial<NotificationSettings>) => Promise<void>;
scheduleReminder: (
ticketId: number,
tripId: number,
origin: string,
destination: string,
departureTime: Date
) => Promise<string | null>;
cancelReminder: (notificationId: string) => Promise<void>;
clearBadge: () => Promise<void>;
}
export function useNotifications(): UseNotificationsReturn {
const router = useRouter();
const [expoPushToken, setExpoPushToken] = useState<string | null>(null);
const [notification, setNotification] = useState<Notifications.Notification | null>(null);
const [settings, setSettings] = useState<NotificationSettings>(
notificationService.getSettings()
);
const notificationListener = useRef<Notifications.Subscription>();
const responseListener = useRef<Notifications.Subscription>();
useEffect(() => {
// Initialize notification service
const init = async () => {
await notificationService.initialize();
const token = notificationService.getPushToken();
setExpoPushToken(token);
setSettings(notificationService.getSettings());
};
init();
// Listen for incoming notifications while app is foregrounded
notificationListener.current = Notifications.addNotificationReceivedListener(
(notification) => {
setNotification(notification);
console.log('Notification received:', notification);
}
);
// Listen for notification responses (user tapped on notification)
responseListener.current = Notifications.addNotificationResponseReceivedListener(
(response) => {
handleNotificationResponse(response);
}
);
return () => {
if (notificationListener.current) {
Notifications.removeNotificationSubscription(notificationListener.current);
}
if (responseListener.current) {
Notifications.removeNotificationSubscription(responseListener.current);
}
};
}, []);
/**
* Handle notification tap
*/
const handleNotificationResponse = (
response: Notifications.NotificationResponse
) => {
const data = response.notification.request.content.data;
console.log('Notification tapped:', data);
// Navigate based on notification type
switch (data.type) {
case 'trip_reminder':
case 'booking_confirmation':
if (data.ticketId) {
router.push({
pathname: '/ticket/[ticketId]',
params: { ticketId: String(data.ticketId) },
});
}
break;
case 'trip_update':
if (data.tripId) {
router.push({
pathname: '/booking/[tripId]',
params: { tripId: String(data.tripId) },
});
}
break;
case 'promotion':
router.push('/');
break;
default:
// Navigate to tickets by default
router.push('/(tabs)/tickets');
}
// Clear badge
notificationService.clearBadge();
};
/**
* Update notification settings
*/
const updateSettings = useCallback(
async (newSettings: Partial<NotificationSettings>) => {
await notificationService.updateSettings(newSettings);
setSettings(notificationService.getSettings());
},
[]
);
/**
* Schedule trip reminder
*/
const scheduleReminder = useCallback(
async (
ticketId: number,
tripId: number,
origin: string,
destination: string,
departureTime: Date
): Promise<string | null> => {
return notificationService.scheduleTripReminder(
ticketId,
tripId,
origin,
destination,
departureTime
);
},
[]
);
/**
* Cancel scheduled reminder
*/
const cancelReminder = useCallback(async (notificationId: string) => {
await notificationService.cancelNotification(notificationId);
}, []);
/**
* Clear badge count
*/
const clearBadge = useCallback(async () => {
await notificationService.clearBadge();
}, []);
return {
expoPushToken,
notification,
settings,
updateSettings,
scheduleReminder,
cancelReminder,
clearBadge,
};
}

199
src/hooks/useOffline.ts Normal file
View File

@ -0,0 +1,199 @@
/**
* BUS-Tickets - Offline Mode Hook
* Copyright (c) 2024-2026 IT Enterprise
*/
import { useState, useEffect, useCallback } from 'react';
import { syncService, SyncState, SyncOptions } from '../services/SyncService';
import { ticketRepository } from '../db/TicketRepository';
import { offlineQueue, ActionType, EntityType, ActionPayload } from '../db/OfflineQueue';
import type { Ticket, Trip } from '@/types';
interface UseOfflineReturn {
// Sync state
syncState: SyncState;
isOnline: boolean;
isSyncing: boolean;
pendingActions: number;
lastSyncTime: Date | null;
// Sync operations
sync: (options?: SyncOptions) => Promise<boolean>;
forceSync: () => Promise<boolean>;
// Data operations (with offline support)
getTickets: () => Promise<Ticket[]>;
getTicket: (id: number) => Promise<Ticket | null>;
getUpcomingTickets: () => Promise<Ticket[]>;
searchTrips: (origin: string, destination: string, date?: string) => Promise<Trip[]>;
// Offline actions
queueBooking: (tripId: number, passengers: number, seats: number[], passenger: any) => Promise<number>;
queueCancelTicket: (ticketId: number, reason?: string) => Promise<number>;
queueCheckIn: (ticketId: number) => Promise<number>;
// Queue management
getPendingActions: () => Promise<any[]>;
retryFailedAction: (actionId: number) => Promise<void>;
clearFailedActions: () => Promise<void>;
}
export function useOffline(): UseOfflineReturn {
const [syncState, setSyncState] = useState<SyncState>(syncService.getState());
useEffect(() => {
// Subscribe to sync state changes
const unsubscribe = syncService.subscribe((state) => {
setSyncState(state);
});
return unsubscribe;
}, []);
/**
* Perform sync
*/
const sync = useCallback(async (options?: SyncOptions): Promise<boolean> => {
return syncService.sync(options);
}, []);
/**
* Force full sync
*/
const forceSync = useCallback(async (): Promise<boolean> => {
return syncService.sync({
forceSync: true,
syncTickets: true,
syncTrips: true,
processQueue: true,
});
}, []);
/**
* Get all tickets (with offline support)
*/
const getTickets = useCallback(async (): Promise<Ticket[]> => {
return syncService.getTickets();
}, []);
/**
* Get single ticket
*/
const getTicket = useCallback(async (id: number): Promise<Ticket | null> => {
return syncService.getTicket(id);
}, []);
/**
* Get upcoming tickets
*/
const getUpcomingTickets = useCallback(async (): Promise<Ticket[]> => {
return ticketRepository.getUpcomingTickets();
}, []);
/**
* Search trips (with offline cache)
*/
const searchTrips = useCallback(
async (origin: string, destination: string, date?: string): Promise<Trip[]> => {
return syncService.searchTrips(origin, destination, date);
},
[]
);
/**
* Queue booking for offline execution
*/
const queueBooking = useCallback(
async (
tripId: number,
passengers: number,
seats: number[],
passenger: any
): Promise<number> => {
return syncService.queueAction('CREATE_BOOKING', 'booking', null, {
trip_id: tripId,
passengers,
seats,
passenger,
});
},
[]
);
/**
* Queue ticket cancellation
*/
const queueCancelTicket = useCallback(
async (ticketId: number, reason?: string): Promise<number> => {
// Update local status immediately
await ticketRepository.updateTicketStatus(ticketId, 'cancelled');
return syncService.queueAction('CANCEL_TICKET', 'ticket', ticketId, {
reason,
});
},
[]
);
/**
* Queue check-in
*/
const queueCheckIn = useCallback(async (ticketId: number): Promise<number> => {
// Update local status immediately
await ticketRepository.updateTicketStatus(ticketId, 'checked_in');
return syncService.queueAction('CHECK_IN', 'ticket', ticketId, {});
}, []);
/**
* Get pending actions from queue
*/
const getPendingActions = useCallback(async () => {
return offlineQueue.getPendingActions();
}, []);
/**
* Retry a failed action
*/
const retryFailedAction = useCallback(async (actionId: number): Promise<void> => {
await offlineQueue.retry(actionId);
// Trigger sync to process
syncService.sync({ processQueue: true, syncTickets: false, syncTrips: false });
}, []);
/**
* Clear all failed actions
*/
const clearFailedActions = useCallback(async (): Promise<void> => {
await offlineQueue.clearCompleted();
}, []);
return {
// State
syncState,
isOnline: syncService.isNetworkOnline(),
isSyncing: syncState.status === 'syncing',
pendingActions: syncState.pendingActions,
lastSyncTime: syncState.lastSyncTime ? new Date(syncState.lastSyncTime) : null,
// Sync operations
sync,
forceSync,
// Data operations
getTickets,
getTicket,
getUpcomingTickets,
searchTrips,
// Offline actions
queueBooking,
queueCancelTicket,
queueCheckIn,
// Queue management
getPendingActions,
retryFailedAction,
clearFailedActions,
};
}

441
src/hooks/usePayment.ts Normal file
View File

@ -0,0 +1,441 @@
/**
* BUS-Tickets - Payment Hook
* Copyright (c) 2024-2026 IT Enterprise
*
* Enhanced payment hook with:
* - Deep linking support
* - Payment status polling
* - Provider-specific handling (PayPal, Monobank, Stripe, etc.)
* - Offline queue support
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import { Linking, Platform, AppState, AppStateStatus } from 'react-native';
import * as WebBrowser from 'expo-web-browser';
import { useApi } from '../contexts/ApiContext';
import { useConfig } from '../contexts/ConfigContext';
import type { PaymentConfig } from '@/types';
export type PaymentStatus =
| 'idle'
| 'initiating'
| 'processing'
| 'awaiting_confirmation'
| 'success'
| 'error'
| 'cancelled'
| 'refunded';
interface PaymentResult {
transactionId: number;
reference: string;
status: string;
paymentUrl?: string;
amount: number;
currency: string;
provider?: string;
confirmedAt?: string;
errorCode?: string;
errorMessage?: string;
// PayPal specific
formData?: Record<string, string>;
method?: 'GET' | 'POST';
// Bank transfer specific
bankDetails?: {
accountNumber: string;
bankCode: string;
iban: string;
swift: string;
variableSymbol: string;
amount: number;
currency: string;
};
}
interface UsePaymentOptions {
pollInterval?: number;
maxPollAttempts?: number;
onSuccess?: (transaction: PaymentResult) => void;
onError?: (error: string) => void;
onCancelled?: () => void;
}
interface UsePaymentReturn {
status: PaymentStatus;
error: string | null;
transaction: PaymentResult | null;
availableProviders: PaymentConfig[];
isPolling: boolean;
initiatePayment: (
reservationIds: number[],
providerId: number,
returnUrl?: string
) => Promise<PaymentResult>;
checkPaymentStatus: (transactionId: number) => Promise<PaymentResult>;
openPaymentPage: (paymentUrl: string) => Promise<WebBrowser.WebBrowserResult>;
startPolling: (transactionId: number) => void;
stopPolling: () => void;
reset: () => void;
}
// Deep link scheme for the app
const APP_SCHEME = 'bus-tickets';
export function usePayment(options: UsePaymentOptions = {}): UsePaymentReturn {
const {
pollInterval = 3000,
maxPollAttempts = 60, // 3 minutes max
onSuccess,
onError,
onCancelled,
} = options;
const api = useApi();
const { config } = useConfig();
const [status, setStatus] = useState<PaymentStatus>('idle');
const [error, setError] = useState<string | null>(null);
const [transaction, setTransaction] = useState<PaymentResult | null>(null);
const [isPolling, setIsPolling] = useState(false);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollAttemptsRef = useRef(0);
const appStateRef = useRef(AppState.currentState);
// Get available payment providers from config
const availableProviders = config.paymentProviders.filter((p) => p.enabled);
// Handle deep links for payment return
useEffect(() => {
const handleDeepLink = async (event: { url: string }) => {
const url = event.url;
if (url.includes('payment/success') || url.includes('payment/return')) {
// Extract transaction reference from URL
const params = new URLSearchParams(url.split('?')[1]);
const ref = params.get('ref') || params.get('reference');
const sessionId = params.get('session_id');
const token = params.get('token'); // PayPal
if (transaction?.transactionId) {
// Check final status
const result = await checkPaymentStatus(transaction.transactionId);
if (result.status === 'done') {
setStatus('success');
onSuccess?.(result);
}
}
} else if (url.includes('payment/cancelled') || url.includes('cancelled=true')) {
setStatus('cancelled');
onCancelled?.();
} else if (url.includes('payment/error')) {
setStatus('error');
setError('Payment failed');
onError?.('Payment failed');
}
};
// Listen for deep links
const subscription = Linking.addEventListener('url', handleDeepLink);
// Check if app was opened via deep link
Linking.getInitialURL().then((url) => {
if (url) {
handleDeepLink({ url });
}
});
return () => {
subscription.remove();
};
}, [transaction?.transactionId]);
// Handle app state changes (for polling when returning from browser)
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (
appStateRef.current.match(/inactive|background/) &&
nextAppState === 'active' &&
status === 'processing' &&
transaction?.transactionId
) {
// App came to foreground, check payment status
checkPaymentStatus(transaction.transactionId);
}
appStateRef.current = nextAppState;
};
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => subscription.remove();
}, [status, transaction?.transactionId]);
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
const initiatePayment = useCallback(
async (
reservationIds: number[],
providerId: number,
returnUrl?: string
): Promise<PaymentResult> => {
setStatus('initiating');
setError(null);
pollAttemptsRef.current = 0;
try {
// Build return URL with deep link support
const baseReturnUrl = returnUrl || `${config.backend.url}/payment/return`;
const mobileReturnUrl = `${APP_SCHEME}://payment/return`;
const response = await fetch(`${config.backend.url}/api/v1/payments/initiate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
reservationIds,
providerId,
returnUrl: Platform.OS === 'web' ? baseReturnUrl : mobileReturnUrl,
}),
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error?.message || 'Payment initiation failed');
}
const result: PaymentResult = {
transactionId: data.data.transactionId,
reference: data.data.reference,
status: data.data.status,
paymentUrl: data.data.paymentUrl,
amount: data.data.amount,
currency: data.data.currency,
provider: data.data.provider,
formData: data.data.formData,
method: data.data.method,
bankDetails: data.data.bankDetails,
};
setTransaction(result);
setStatus('processing');
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Payment failed';
setError(message);
setStatus('error');
onError?.(message);
throw err;
}
},
[config.backend.url, onError]
);
const checkPaymentStatus = useCallback(
async (transactionId: number): Promise<PaymentResult> => {
try {
const response = await fetch(
`${config.backend.url}/api/v1/payments/${transactionId}/status`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
const data = await response.json();
if (!data.success) {
throw new Error(data.error?.message || 'Failed to check payment status');
}
const result: PaymentResult = {
transactionId: data.data.transactionId,
reference: data.data.reference,
status: data.data.status,
amount: data.data.amount,
currency: data.data.currency,
confirmedAt: data.data.confirmedAt,
errorCode: data.data.errorCode,
errorMessage: data.data.errorMessage,
};
setTransaction(result);
// Update status based on payment state
switch (result.status) {
case 'done':
setStatus('success');
stopPolling();
onSuccess?.(result);
break;
case 'error':
setStatus('error');
setError(result.errorMessage || 'Payment failed');
stopPolling();
onError?.(result.errorMessage || 'Payment failed');
break;
case 'cancel':
setStatus('cancelled');
stopPolling();
onCancelled?.();
break;
case 'refunded':
setStatus('refunded');
stopPolling();
break;
case 'authorized':
setStatus('awaiting_confirmation');
break;
case 'pending':
case 'draft':
// Keep processing status
break;
}
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Status check failed';
// Don't set error state for polling failures, just log
console.warn('Payment status check failed:', message);
throw err;
}
},
[config.backend.url, onSuccess, onError, onCancelled]
);
const openPaymentPage = useCallback(
async (paymentUrl: string): Promise<WebBrowser.WebBrowserResult> => {
try {
// Use expo-web-browser for in-app browser (better UX)
const result = await WebBrowser.openBrowserAsync(paymentUrl, {
dismissButtonStyle: 'close',
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FULL_SCREEN,
controlsColor: config.theme?.primaryColor || '#007AFF',
toolbarColor: config.theme?.backgroundColor || '#FFFFFF',
// Enable deep link handling
showTitle: true,
enableBarCollapsing: true,
});
// Check if user closed the browser
if (result.type === 'cancel') {
setStatus('cancelled');
onCancelled?.();
}
// When browser closes, check payment status
if (transaction?.transactionId) {
setTimeout(() => {
checkPaymentStatus(transaction.transactionId);
}, 1000);
}
return result;
} catch (err) {
// Fallback to system browser
const canOpen = await Linking.canOpenURL(paymentUrl);
if (canOpen) {
await Linking.openURL(paymentUrl);
return { type: 'opened' } as WebBrowser.WebBrowserResult;
} else {
throw new Error('Cannot open payment page');
}
}
},
[config.theme, transaction?.transactionId, checkPaymentStatus, onCancelled]
);
const startPolling = useCallback(
(transactionId: number) => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
setIsPolling(true);
pollAttemptsRef.current = 0;
pollIntervalRef.current = setInterval(async () => {
pollAttemptsRef.current += 1;
if (pollAttemptsRef.current >= maxPollAttempts) {
stopPolling();
setStatus('error');
setError('Payment timeout - please check your payment status manually');
onError?.('Payment timeout');
return;
}
try {
const result = await checkPaymentStatus(transactionId);
// Stop polling if we have a final status
if (['done', 'error', 'cancel', 'refunded'].includes(result.status)) {
stopPolling();
}
} catch (err) {
// Continue polling on error, unless max attempts reached
console.warn('Polling error:', err);
}
}, pollInterval);
},
[pollInterval, maxPollAttempts, checkPaymentStatus, onError]
);
const stopPolling = useCallback(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setIsPolling(false);
}, []);
const reset = useCallback(() => {
stopPolling();
setStatus('idle');
setError(null);
setTransaction(null);
pollAttemptsRef.current = 0;
}, [stopPolling]);
return {
status,
error,
transaction,
availableProviders,
isPolling,
initiatePayment,
checkPaymentStatus,
openPaymentPage,
startPolling,
stopPolling,
reset,
};
}
/**
* Helper hook for specific payment provider
*/
export function usePaymentProvider(providerCode: string) {
const { availableProviders } = usePayment();
const provider = availableProviders.find(
(p) => p.provider === providerCode || p.id === providerCode
);
return {
provider,
isAvailable: !!provider?.enabled,
supportsApplePay: provider?.supportsApplePay || false,
supportsGooglePay: provider?.supportsGooglePay || false,
};
}

155
src/hooks/useTickets.ts Normal file
View File

@ -0,0 +1,155 @@
/**
* BUS-Tickets - Tickets Hook
* Copyright (c) 2024-2026 IT Enterprise
*/
import { useState, useCallback, useEffect } from 'react';
import { useApi } from '../contexts/ApiContext';
import { useAuth } from '../contexts/AuthContext';
import type { Ticket } from '@/types';
interface UseTicketsReturn {
tickets: Ticket[];
isLoading: boolean;
error: string | null;
loadTickets: () => Promise<void>;
getTicketDetails: (ticketId: number) => Promise<Ticket>;
cancelTicket: (ticketId: number) => Promise<Ticket>;
checkInTicket: (ticketId: number, location?: { latitude: number; longitude: number }) => Promise<Ticket>;
createBooking: (data: {
tripId: number;
passengers: Array<{
name: string;
email: string;
phone: string;
seat_number?: number;
}>;
payment_method: string;
}) => Promise<{
booking_id: number;
tickets: Ticket[];
payment_url?: string;
}>;
}
export function useTickets(): UseTicketsReturn {
const api = useApi();
const { isAuthenticated } = useAuth();
const [tickets, setTickets] = useState<Ticket[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadTickets = useCallback(async () => {
if (!isAuthenticated) {
setTickets([]);
return;
}
setIsLoading(true);
setError(null);
try {
const userTickets = await api.getUserTickets();
setTickets(userTickets);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load tickets';
setError(message);
} finally {
setIsLoading(false);
}
}, [api, isAuthenticated]);
// Auto-load tickets when authenticated
useEffect(() => {
if (isAuthenticated) {
loadTickets();
}
}, [isAuthenticated]);
const getTicketDetails = useCallback(async (ticketId: number): Promise<Ticket> => {
setIsLoading(true);
setError(null);
try {
return await api.getTicketDetails(ticketId);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to get ticket details';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, [api]);
const cancelTicket = useCallback(async (ticketId: number): Promise<Ticket> => {
setIsLoading(true);
setError(null);
try {
const cancelledTicket = await api.cancelTicket(ticketId);
// Update local state
setTickets(prev => prev.map(t => t.id === ticketId ? cancelledTicket : t));
return cancelledTicket;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to cancel ticket';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, [api]);
const checkInTicket = useCallback(async (
ticketId: number,
location?: { latitude: number; longitude: number }
): Promise<Ticket> => {
setIsLoading(true);
setError(null);
try {
const checkedInTicket = await api.checkInTicket(ticketId, location);
// Update local state
setTickets(prev => prev.map(t => t.id === ticketId ? checkedInTicket : t));
return checkedInTicket;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to check in';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, [api]);
const createBooking = useCallback(async (data: {
tripId: number;
passengers: Array<{
name: string;
email: string;
phone: string;
seat_number?: number;
}>;
payment_method: string;
}) => {
setIsLoading(true);
setError(null);
try {
const result = await api.createBooking(data);
// Add new tickets to state
setTickets(prev => [...result.tickets, ...prev]);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create booking';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, [api]);
return {
tickets,
isLoading,
error,
loadTickets,
getTicketDetails,
cancelTicket,
checkInTicket,
createBooking,
};
}

86
src/hooks/useTrips.ts Normal file
View File

@ -0,0 +1,86 @@
/**
* BUS-Tickets - Trips Hook
* Copyright (c) 2024-2026 IT Enterprise
*/
import { useState, useCallback } from 'react';
import { useApi } from '../contexts/ApiContext';
import type { Trip, TripSearchParams, TripSearchResult } from '@/types';
interface UseTripsReturn {
trips: Trip[];
isLoading: boolean;
error: string | null;
searchTrips: (params: TripSearchParams) => Promise<TripSearchResult>;
getTripDetails: (tripId: number) => Promise<Trip>;
getPopularTrips: () => Promise<Trip[]>;
clearTrips: () => void;
}
export function useTrips(): UseTripsReturn {
const api = useApi();
const [trips, setTrips] = useState<Trip[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const searchTrips = useCallback(async (params: TripSearchParams): Promise<TripSearchResult> => {
setIsLoading(true);
setError(null);
try {
const result = await api.searchTrips(params);
setTrips(result.outbound);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to search trips';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, [api]);
const getTripDetails = useCallback(async (tripId: number): Promise<Trip> => {
setIsLoading(true);
setError(null);
try {
return await api.getTripDetails(tripId);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to get trip details';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, [api]);
const getPopularTrips = useCallback(async (): Promise<Trip[]> => {
setIsLoading(true);
setError(null);
try {
const popular = await api.getPopularTrips();
setTrips(popular);
return popular;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to get popular trips';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, [api]);
const clearTrips = useCallback(() => {
setTrips([]);
setError(null);
}, []);
return {
trips,
isLoading,
error,
searchTrips,
getTripDetails,
getPopularTrips,
clearTrips,
};
}

12
src/i18n/index.ts Normal file
View File

@ -0,0 +1,12 @@
/**
* BUS-Tickets - i18n Exports
* Copyright (c) 2024-2026 IT Enterprise
*/
export {
translations,
languageNames,
languageFlags,
type SupportedLanguage,
type Translations,
} from './translations';

986
src/i18n/translations.ts Normal file
View File

@ -0,0 +1,986 @@
/**
* BUS-Tickets - Translations
* Copyright (c) 2024-2026 IT Enterprise
*/
export type SupportedLanguage = 'cs' | 'en' | 'uk';
export interface Translations {
// Common
common: {
loading: string;
error: string;
success: string;
cancel: string;
confirm: string;
save: string;
delete: string;
edit: string;
close: string;
back: string;
next: string;
done: string;
search: string;
retry: string;
refresh: string;
noResults: string;
today: string;
tomorrow: string;
from: string;
to: string;
price: string;
total: string;
free: string;
};
// Navigation
nav: {
home: string;
tickets: string;
profile: string;
settings: string;
};
// Search screen
search: {
title: string;
subtitle: string;
originPlaceholder: string;
destinationPlaceholder: string;
selectOriginFirst: string;
selectDate: string;
passengers: string;
searchButton: string;
popularRoutes: string;
priceFrom: string;
whereFrom: string;
whereTo: string;
};
// Search results
results: {
title: string;
noTrips: string;
tryDifferentCriteria: string;
departure: string;
arrival: string;
duration: string;
seatsAvailable: string;
soldOut: string;
bookNow: string;
filters: string;
sortBy: string;
cheapest: string;
fastest: string;
earliest: string;
latest: string;
};
// Booking
booking: {
title: string;
tripDetails: string;
passengerDetails: string;
firstName: string;
lastName: string;
email: string;
phone: string;
selectSeat: string;
selectedSeats: string;
paymentMethod: string;
termsAccept: string;
termsLink: string;
bookButton: string;
processing: string;
successTitle: string;
successMessage: string;
errorTitle: string;
errorMessage: string;
pricePerPerson: string;
totalPrice: string;
};
// Payment
payment: {
title: string;
selectMethod: string;
card: string;
cash: string;
monobank: string;
liqpay: string;
applePay: string;
googlePay: string;
processing: string;
success: string;
failed: string;
cancelled: string;
payNow: string;
payOnBoard: string;
redirecting: string;
confirmCash: string;
cashNote: string;
};
// Tickets
tickets: {
title: string;
active: string;
past: string;
noTickets: string;
bookFirst: string;
ticketNumber: string;
passenger: string;
seat: string;
status: string;
confirmed: string;
pending: string;
cancelled: string;
used: string;
downloadPdf: string;
showQr: string;
addToWallet: string;
};
// Profile
profile: {
title: string;
guest: string;
signIn: string;
signOut: string;
signOutConfirm: string;
myTickets: string;
myBookings: string;
favorites: string;
paymentHistory: string;
personalInfo: string;
editProfile: string;
deleteAccount: string;
deleteAccountConfirm: string;
};
// Auth
auth: {
signInTitle: string;
signUpTitle: string;
email: string;
password: string;
confirmPassword: string;
forgotPassword: string;
signInButton: string;
signUpButton: string;
orContinueWith: string;
google: string;
facebook: string;
apple: string;
noAccount: string;
hasAccount: string;
magicLink: string;
sendMagicLink: string;
magicLinkSent: string;
checkEmail: string;
twoFactor: string;
enterCode: string;
resendCode: string;
verifyButton: string;
invalidCredentials: string;
networkError: string;
};
// Settings
settings: {
title: string;
appearance: string;
darkMode: string;
light: string;
dark: string;
system: string;
language: string;
notifications: string;
notificationsDesc: string;
syncStatus: string;
offline: string;
synced: string;
pending: string;
forceSync: string;
backend: string;
currentBackend: string;
changeBackend: string;
connect: string;
connecting: string;
legal: string;
privacyPolicy: string;
termsOfService: string;
about: string;
appName: string;
version: string;
developer: string;
resetSettings: string;
resetConfirm: string;
reset: string;
};
// Notifications
notifications: {
title: string;
pushEnabled: string;
pushDisabled: string;
enablePush: string;
tripReminders: string;
tripRemindersDesc: string;
promoOffers: string;
promoOffersDesc: string;
priceAlerts: string;
priceAlertsDesc: string;
scheduleChanges: string;
scheduleChangesDesc: string;
soundEnabled: string;
vibrationEnabled: string;
};
// Errors
errors: {
generic: string;
network: string;
serverError: string;
notFound: string;
unauthorized: string;
sessionExpired: string;
invalidInput: string;
paymentFailed: string;
bookingFailed: string;
tryAgain: string;
};
// Date/Time
datetime: {
today: string;
tomorrow: string;
yesterday: string;
minutes: string;
hours: string;
days: string;
ago: string;
in: string;
};
}
export const translations: Record<SupportedLanguage, Translations> = {
// Czech translations
cs: {
common: {
loading: 'Načítání...',
error: 'Chyba',
success: 'Úspěch',
cancel: 'Zrušit',
confirm: 'Potvrdit',
save: 'Uložit',
delete: 'Smazat',
edit: 'Upravit',
close: 'Zavřít',
back: 'Zpět',
next: 'Další',
done: 'Hotovo',
search: 'Hledat',
retry: 'Zkusit znovu',
refresh: 'Obnovit',
noResults: 'Žádné výsledky',
today: 'Dnes',
tomorrow: 'Zítra',
from: 'Z',
to: 'Do',
price: 'Cena',
total: 'Celkem',
free: 'Zdarma',
},
nav: {
home: 'Hledat',
tickets: 'Jízdenky',
profile: 'Profil',
settings: 'Nastavení',
},
search: {
title: 'Autobusové jízdenky',
subtitle: 'Vyhledejte a rezervujte jízdenky',
originPlaceholder: 'Odkud (vyberte zastávku)',
destinationPlaceholder: 'Kam (vyberte cíl)',
selectOriginFirst: 'Nejprve vyberte odkud',
selectDate: 'Vyberte datum',
passengers: 'Počet cestujících',
searchButton: 'Hledat spoje',
popularRoutes: 'Oblíbené trasy',
priceFrom: 'od',
whereFrom: 'Odkud jedete?',
whereTo: 'Kam jedete?',
},
results: {
title: 'Výsledky hledání',
noTrips: 'Nebyly nalezeny žádné spoje',
tryDifferentCriteria: 'Zkuste změnit kritéria vyhledávání',
departure: 'Odjezd',
arrival: 'Příjezd',
duration: 'Doba jízdy',
seatsAvailable: 'volných míst',
soldOut: 'Vyprodáno',
bookNow: 'Rezervovat',
filters: 'Filtry',
sortBy: 'Seřadit podle',
cheapest: 'Nejlevnější',
fastest: 'Nejrychlejší',
earliest: 'Nejdříve',
latest: 'Nejpozději',
},
booking: {
title: 'Rezervace',
tripDetails: 'Detaily cesty',
passengerDetails: 'Údaje cestujícího',
firstName: 'Jméno',
lastName: 'Příjmení',
email: 'E-mail',
phone: 'Telefon',
selectSeat: 'Vyberte místo',
selectedSeats: 'Vybraná místa',
paymentMethod: 'Způsob platby',
termsAccept: 'Souhlasím s',
termsLink: 'obchodními podmínkami',
bookButton: 'Dokončit rezervaci',
processing: 'Zpracování...',
successTitle: 'Rezervace dokončena!',
successMessage: 'Vaše jízdenky byly odeslány na váš e-mail.',
errorTitle: 'Chyba rezervace',
errorMessage: 'Při rezervaci došlo k chybě. Zkuste to prosím znovu.',
pricePerPerson: 'Cena za osobu',
totalPrice: 'Celková cena',
},
payment: {
title: 'Platba',
selectMethod: 'Vyberte způsob platby',
card: 'Platební karta',
cash: 'Hotově u řidiče',
monobank: 'Monobank',
liqpay: 'LiqPay',
applePay: 'Apple Pay',
googlePay: 'Google Pay',
processing: 'Zpracování platby...',
success: 'Platba byla úspěšná!',
failed: 'Platba se nezdařila',
cancelled: 'Platba byla zrušena',
payNow: 'Zaplatit nyní',
payOnBoard: 'Platba na místě',
redirecting: 'Přesměrování na platební bránu...',
confirmCash: 'Potvrdit platbu v hotovosti',
cashNote: 'Zaplatíte přímo řidiči při nástupu do autobusu.',
},
tickets: {
title: 'Moje jízdenky',
active: 'Aktivní',
past: 'Historie',
noTickets: 'Žádné jízdenky',
bookFirst: 'Zatím nemáte žádné jízdenky. Rezervujte si první cestu!',
ticketNumber: 'Číslo jízdenky',
passenger: 'Cestující',
seat: 'Místo',
status: 'Stav',
confirmed: 'Potvrzeno',
pending: 'Čeká na platbu',
cancelled: 'Zrušeno',
used: 'Použito',
downloadPdf: 'Stáhnout PDF',
showQr: 'Zobrazit QR kód',
addToWallet: 'Přidat do Wallet',
},
profile: {
title: 'Můj profil',
guest: 'Host',
signIn: 'Přihlásit se',
signOut: 'Odhlásit se',
signOutConfirm: 'Opravdu se chcete odhlásit?',
myTickets: 'Moje jízdenky',
myBookings: 'Moje rezervace',
favorites: 'Oblíbené',
paymentHistory: 'Historie plateb',
personalInfo: 'Osobní údaje',
editProfile: 'Upravit profil',
deleteAccount: 'Smazat účet',
deleteAccountConfirm: 'Opravdu chcete smazat svůj účet? Tato akce je nevratná.',
},
auth: {
signInTitle: 'Přihlášení',
signUpTitle: 'Registrace',
email: 'E-mail',
password: 'Heslo',
confirmPassword: 'Potvrzení hesla',
forgotPassword: 'Zapomenuté heslo?',
signInButton: 'Přihlásit se',
signUpButton: 'Zaregistrovat se',
orContinueWith: 'nebo pokračujte přes',
google: 'Google',
facebook: 'Facebook',
apple: 'Apple',
noAccount: 'Nemáte účet?',
hasAccount: 'Máte již účet?',
magicLink: 'Přihlášení odkazem',
sendMagicLink: 'Odeslat přihlašovací odkaz',
magicLinkSent: 'Odkaz odeslán!',
checkEmail: 'Zkontrolujte svůj e-mail a klikněte na přihlašovací odkaz.',
twoFactor: 'Dvoufaktorové ověření',
enterCode: 'Zadejte kód z aplikace',
resendCode: 'Odeslat znovu',
verifyButton: 'Ověřit',
invalidCredentials: 'Neplatné přihlašovací údaje',
networkError: 'Chyba sítě. Zkontrolujte připojení.',
},
settings: {
title: 'Nastavení',
appearance: 'Vzhled',
darkMode: 'Tmavý režim',
light: 'Světlý',
dark: 'Tmavý',
system: 'Systém',
language: 'Jazyk',
notifications: 'Oznámení',
notificationsDesc: 'Spravovat nastavení oznámení',
syncStatus: 'Stav synchronizace',
offline: 'Offline - Změny budou synchronizovány po připojení',
synced: 'Vše synchronizováno',
pending: 'čekajících akcí',
forceSync: 'Vynutit synchronizaci',
backend: 'Konfigurace backendu',
currentBackend: 'Aktuální backend',
changeBackend: 'Změnit backend',
connect: 'Připojit',
connecting: 'Připojování...',
legal: 'Právní',
privacyPolicy: 'Zásady ochrany osobních údajů',
termsOfService: 'Obchodní podmínky',
about: 'O aplikaci',
appName: 'Název aplikace',
version: 'Verze',
developer: 'Vývojář',
resetSettings: 'Obnovit výchozí nastavení',
resetConfirm: 'Tímto se všechna nastavení vrátí do výchozího stavu. Pokračovat?',
reset: 'Obnovit',
},
notifications: {
title: 'Oznámení',
pushEnabled: 'Push oznámení jsou povolena',
pushDisabled: 'Push oznámení jsou zakázána',
enablePush: 'Povolit push oznámení',
tripReminders: 'Připomenutí cesty',
tripRemindersDesc: 'Připomenout cestu den předem a hodinu před odjezdem',
promoOffers: 'Slevové nabídky',
promoOffersDesc: 'Informovat o speciálních akcích a slevách',
priceAlerts: 'Upozornění na ceny',
priceAlertsDesc: 'Upozornit při změně ceny na oblíbených trasách',
scheduleChanges: 'Změny jízdního řádu',
scheduleChangesDesc: 'Upozornit na zpoždění nebo změny v jízdním řádu',
soundEnabled: 'Zvuk',
vibrationEnabled: 'Vibrace',
},
errors: {
generic: 'Něco se pokazilo',
network: 'Chyba sítě. Zkontrolujte připojení k internetu.',
serverError: 'Chyba serveru. Zkuste to prosím později.',
notFound: 'Požadovaná položka nebyla nalezena.',
unauthorized: 'Nemáte oprávnění k této akci.',
sessionExpired: 'Vaše relace vypršela. Přihlaste se znovu.',
invalidInput: 'Neplatný vstup. Zkontrolujte zadaná data.',
paymentFailed: 'Platba se nezdařila. Zkuste to prosím znovu.',
bookingFailed: 'Rezervace se nezdařila. Zkuste to prosím znovu.',
tryAgain: 'Zkusit znovu',
},
datetime: {
today: 'Dnes',
tomorrow: 'Zítra',
yesterday: 'Včera',
minutes: 'min',
hours: 'hod',
days: 'dní',
ago: 'před',
in: 'za',
},
},
// English translations
en: {
common: {
loading: 'Loading...',
error: 'Error',
success: 'Success',
cancel: 'Cancel',
confirm: 'Confirm',
save: 'Save',
delete: 'Delete',
edit: 'Edit',
close: 'Close',
back: 'Back',
next: 'Next',
done: 'Done',
search: 'Search',
retry: 'Retry',
refresh: 'Refresh',
noResults: 'No results',
today: 'Today',
tomorrow: 'Tomorrow',
from: 'From',
to: 'To',
price: 'Price',
total: 'Total',
free: 'Free',
},
nav: {
home: 'Search',
tickets: 'Tickets',
profile: 'Profile',
settings: 'Settings',
},
search: {
title: 'Bus Tickets',
subtitle: 'Search and book your tickets',
originPlaceholder: 'From (select stop)',
destinationPlaceholder: 'To (select destination)',
selectOriginFirst: 'First select origin',
selectDate: 'Select date',
passengers: 'Passengers',
searchButton: 'Search trips',
popularRoutes: 'Popular routes',
priceFrom: 'from',
whereFrom: 'Where from?',
whereTo: 'Where to?',
},
results: {
title: 'Search Results',
noTrips: 'No trips found',
tryDifferentCriteria: 'Try changing your search criteria',
departure: 'Departure',
arrival: 'Arrival',
duration: 'Duration',
seatsAvailable: 'seats available',
soldOut: 'Sold out',
bookNow: 'Book now',
filters: 'Filters',
sortBy: 'Sort by',
cheapest: 'Cheapest',
fastest: 'Fastest',
earliest: 'Earliest',
latest: 'Latest',
},
booking: {
title: 'Booking',
tripDetails: 'Trip details',
passengerDetails: 'Passenger details',
firstName: 'First name',
lastName: 'Last name',
email: 'Email',
phone: 'Phone',
selectSeat: 'Select seat',
selectedSeats: 'Selected seats',
paymentMethod: 'Payment method',
termsAccept: 'I agree to the',
termsLink: 'terms and conditions',
bookButton: 'Complete booking',
processing: 'Processing...',
successTitle: 'Booking complete!',
successMessage: 'Your tickets have been sent to your email.',
errorTitle: 'Booking error',
errorMessage: 'An error occurred during booking. Please try again.',
pricePerPerson: 'Price per person',
totalPrice: 'Total price',
},
payment: {
title: 'Payment',
selectMethod: 'Select payment method',
card: 'Credit card',
cash: 'Cash to driver',
monobank: 'Monobank',
liqpay: 'LiqPay',
applePay: 'Apple Pay',
googlePay: 'Google Pay',
processing: 'Processing payment...',
success: 'Payment successful!',
failed: 'Payment failed',
cancelled: 'Payment cancelled',
payNow: 'Pay now',
payOnBoard: 'Pay on board',
redirecting: 'Redirecting to payment gateway...',
confirmCash: 'Confirm cash payment',
cashNote: 'You will pay directly to the driver when boarding the bus.',
},
tickets: {
title: 'My Tickets',
active: 'Active',
past: 'Past',
noTickets: 'No tickets',
bookFirst: 'You have no tickets yet. Book your first trip!',
ticketNumber: 'Ticket number',
passenger: 'Passenger',
seat: 'Seat',
status: 'Status',
confirmed: 'Confirmed',
pending: 'Pending payment',
cancelled: 'Cancelled',
used: 'Used',
downloadPdf: 'Download PDF',
showQr: 'Show QR code',
addToWallet: 'Add to Wallet',
},
profile: {
title: 'My Profile',
guest: 'Guest',
signIn: 'Sign in',
signOut: 'Sign out',
signOutConfirm: 'Are you sure you want to sign out?',
myTickets: 'My tickets',
myBookings: 'My bookings',
favorites: 'Favorites',
paymentHistory: 'Payment history',
personalInfo: 'Personal info',
editProfile: 'Edit profile',
deleteAccount: 'Delete account',
deleteAccountConfirm: 'Are you sure you want to delete your account? This action cannot be undone.',
},
auth: {
signInTitle: 'Sign In',
signUpTitle: 'Sign Up',
email: 'Email',
password: 'Password',
confirmPassword: 'Confirm password',
forgotPassword: 'Forgot password?',
signInButton: 'Sign in',
signUpButton: 'Sign up',
orContinueWith: 'or continue with',
google: 'Google',
facebook: 'Facebook',
apple: 'Apple',
noAccount: "Don't have an account?",
hasAccount: 'Already have an account?',
magicLink: 'Sign in with link',
sendMagicLink: 'Send login link',
magicLinkSent: 'Link sent!',
checkEmail: 'Check your email and click the login link.',
twoFactor: 'Two-factor authentication',
enterCode: 'Enter the code from your app',
resendCode: 'Resend code',
verifyButton: 'Verify',
invalidCredentials: 'Invalid credentials',
networkError: 'Network error. Check your connection.',
},
settings: {
title: 'Settings',
appearance: 'Appearance',
darkMode: 'Dark mode',
light: 'Light',
dark: 'Dark',
system: 'System',
language: 'Language',
notifications: 'Notifications',
notificationsDesc: 'Manage notification settings',
syncStatus: 'Sync status',
offline: 'Offline - Changes will sync when connected',
synced: 'All synced',
pending: 'pending actions',
forceSync: 'Force sync',
backend: 'Backend configuration',
currentBackend: 'Current backend',
changeBackend: 'Change backend',
connect: 'Connect',
connecting: 'Connecting...',
legal: 'Legal',
privacyPolicy: 'Privacy Policy',
termsOfService: 'Terms of Service',
about: 'About',
appName: 'App name',
version: 'Version',
developer: 'Developer',
resetSettings: 'Reset to default',
resetConfirm: 'This will reset all settings to default. Continue?',
reset: 'Reset',
},
notifications: {
title: 'Notifications',
pushEnabled: 'Push notifications are enabled',
pushDisabled: 'Push notifications are disabled',
enablePush: 'Enable push notifications',
tripReminders: 'Trip reminders',
tripRemindersDesc: 'Remind about trip a day before and an hour before departure',
promoOffers: 'Promotional offers',
promoOffersDesc: 'Receive special offers and discounts',
priceAlerts: 'Price alerts',
priceAlertsDesc: 'Alert when price changes on favorite routes',
scheduleChanges: 'Schedule changes',
scheduleChangesDesc: 'Alert about delays or schedule changes',
soundEnabled: 'Sound',
vibrationEnabled: 'Vibration',
},
errors: {
generic: 'Something went wrong',
network: 'Network error. Check your internet connection.',
serverError: 'Server error. Please try again later.',
notFound: 'The requested item was not found.',
unauthorized: 'You are not authorized to perform this action.',
sessionExpired: 'Your session has expired. Please sign in again.',
invalidInput: 'Invalid input. Please check your data.',
paymentFailed: 'Payment failed. Please try again.',
bookingFailed: 'Booking failed. Please try again.',
tryAgain: 'Try again',
},
datetime: {
today: 'Today',
tomorrow: 'Tomorrow',
yesterday: 'Yesterday',
minutes: 'min',
hours: 'h',
days: 'days',
ago: 'ago',
in: 'in',
},
},
// Ukrainian translations
uk: {
common: {
loading: 'Завантаження...',
error: 'Помилка',
success: 'Успішно',
cancel: 'Скасувати',
confirm: 'Підтвердити',
save: 'Зберегти',
delete: 'Видалити',
edit: 'Редагувати',
close: 'Закрити',
back: 'Назад',
next: 'Далі',
done: 'Готово',
search: 'Пошук',
retry: 'Повторити',
refresh: 'Оновити',
noResults: 'Нічого не знайдено',
today: 'Сьогодні',
tomorrow: 'Завтра',
from: 'Звідки',
to: 'Куди',
price: 'Ціна',
total: 'Всього',
free: 'Безкоштовно',
},
nav: {
home: 'Пошук',
tickets: 'Квитки',
profile: 'Профіль',
settings: 'Налаштування',
},
search: {
title: 'Автобусні квитки',
subtitle: 'Шукайте та бронюйте квитки',
originPlaceholder: 'Звідки (оберіть зупинку)',
destinationPlaceholder: 'Куди (оберіть пункт призначення)',
selectOriginFirst: 'Спочатку оберіть звідки',
selectDate: 'Оберіть дату',
passengers: 'Кількість пасажирів',
searchButton: 'Знайти рейси',
popularRoutes: 'Популярні маршрути',
priceFrom: 'від',
whereFrom: 'Звідки їдете?',
whereTo: 'Куди їдете?',
},
results: {
title: 'Результати пошуку',
noTrips: 'Рейсів не знайдено',
tryDifferentCriteria: 'Спробуйте змінити критерії пошуку',
departure: 'Відправлення',
arrival: 'Прибуття',
duration: 'Тривалість',
seatsAvailable: 'вільних місць',
soldOut: 'Розпродано',
bookNow: 'Забронювати',
filters: 'Фільтри',
sortBy: 'Сортувати за',
cheapest: 'Найдешевші',
fastest: 'Найшвидші',
earliest: 'Найраніше',
latest: 'Найпізніше',
},
booking: {
title: 'Бронювання',
tripDetails: 'Деталі поїздки',
passengerDetails: 'Дані пасажира',
firstName: "Ім'я",
lastName: 'Прізвище',
email: 'Електронна пошта',
phone: 'Телефон',
selectSeat: 'Оберіть місце',
selectedSeats: 'Обрані місця',
paymentMethod: 'Спосіб оплати',
termsAccept: 'Я погоджуюсь з',
termsLink: 'умовами використання',
bookButton: 'Завершити бронювання',
processing: 'Обробка...',
successTitle: 'Бронювання завершено!',
successMessage: 'Ваші квитки надіслано на електронну пошту.',
errorTitle: 'Помилка бронювання',
errorMessage: 'Під час бронювання сталася помилка. Будь ласка, спробуйте ще раз.',
pricePerPerson: 'Ціна за особу',
totalPrice: 'Загальна вартість',
},
payment: {
title: 'Оплата',
selectMethod: 'Оберіть спосіб оплати',
card: 'Банківська картка',
cash: 'Готівкою водієві',
monobank: 'Monobank',
liqpay: 'LiqPay',
applePay: 'Apple Pay',
googlePay: 'Google Pay',
processing: 'Обробка платежу...',
success: 'Оплата успішна!',
failed: 'Оплата не вдалася',
cancelled: 'Оплату скасовано',
payNow: 'Оплатити зараз',
payOnBoard: 'Оплата на місці',
redirecting: 'Перенаправлення на платіжний шлюз...',
confirmCash: 'Підтвердити оплату готівкою',
cashNote: 'Ви оплатите безпосередньо водієві при посадці в автобус.',
},
tickets: {
title: 'Мої квитки',
active: 'Активні',
past: 'Історія',
noTickets: 'Немає квитків',
bookFirst: 'У вас ще немає квитків. Забронюйте свою першу поїздку!',
ticketNumber: 'Номер квитка',
passenger: 'Пасажир',
seat: 'Місце',
status: 'Статус',
confirmed: 'Підтверджено',
pending: 'Очікує оплати',
cancelled: 'Скасовано',
used: 'Використано',
downloadPdf: 'Завантажити PDF',
showQr: 'Показати QR-код',
addToWallet: 'Додати в Wallet',
},
profile: {
title: 'Мій профіль',
guest: 'Гість',
signIn: 'Увійти',
signOut: 'Вийти',
signOutConfirm: 'Ви впевнені, що хочете вийти?',
myTickets: 'Мої квитки',
myBookings: 'Мої бронювання',
favorites: 'Обрані',
paymentHistory: 'Історія платежів',
personalInfo: 'Особисті дані',
editProfile: 'Редагувати профіль',
deleteAccount: 'Видалити акаунт',
deleteAccountConfirm: 'Ви впевнені, що хочете видалити свій акаунт? Цю дію неможливо скасувати.',
},
auth: {
signInTitle: 'Вхід',
signUpTitle: 'Реєстрація',
email: 'Електронна пошта',
password: 'Пароль',
confirmPassword: 'Підтвердження пароля',
forgotPassword: 'Забули пароль?',
signInButton: 'Увійти',
signUpButton: 'Зареєструватися',
orContinueWith: 'або продовжити через',
google: 'Google',
facebook: 'Facebook',
apple: 'Apple',
noAccount: 'Немає акаунту?',
hasAccount: 'Вже є акаунт?',
magicLink: 'Вхід за посиланням',
sendMagicLink: 'Надіслати посилання для входу',
magicLinkSent: 'Посилання надіслано!',
checkEmail: 'Перевірте електронну пошту та натисніть на посилання для входу.',
twoFactor: 'Двофакторна автентифікація',
enterCode: 'Введіть код з додатка',
resendCode: 'Надіслати знову',
verifyButton: 'Підтвердити',
invalidCredentials: 'Невірні облікові дані',
networkError: "Помилка мережі. Перевірте з'єднання.",
},
settings: {
title: 'Налаштування',
appearance: 'Зовнішній вигляд',
darkMode: 'Темний режим',
light: 'Світлий',
dark: 'Темний',
system: 'Системний',
language: 'Мова',
notifications: 'Сповіщення',
notificationsDesc: 'Керувати налаштуваннями сповіщень',
syncStatus: 'Статус синхронізації',
offline: "Офлайн - Зміни синхронізуються після з'єднання",
synced: 'Все синхронізовано',
pending: 'очікуючих дій',
forceSync: 'Примусова синхронізація',
backend: 'Налаштування бекенду',
currentBackend: 'Поточний бекенд',
changeBackend: 'Змінити бекенд',
connect: "З'єднати",
connecting: "З'єднання...",
legal: 'Правова інформація',
privacyPolicy: 'Політика конфіденційності',
termsOfService: 'Умови використання',
about: 'Про додаток',
appName: 'Назва додатка',
version: 'Версія',
developer: 'Розробник',
resetSettings: 'Скинути до стандартних',
resetConfirm: 'Це скине всі налаштування до стандартних. Продовжити?',
reset: 'Скинути',
},
notifications: {
title: 'Сповіщення',
pushEnabled: 'Push-сповіщення увімкнені',
pushDisabled: 'Push-сповіщення вимкнені',
enablePush: 'Увімкнути push-сповіщення',
tripReminders: 'Нагадування про поїздку',
tripRemindersDesc: 'Нагадати про поїздку за день та за годину до відправлення',
promoOffers: 'Акційні пропозиції',
promoOffersDesc: 'Отримувати спеціальні пропозиції та знижки',
priceAlerts: 'Сповіщення про ціни',
priceAlertsDesc: 'Повідомляти про зміну цін на обраних маршрутах',
scheduleChanges: 'Зміни в розкладі',
scheduleChangesDesc: 'Повідомляти про затримки або зміни в розкладі',
soundEnabled: 'Звук',
vibrationEnabled: 'Вібрація',
},
errors: {
generic: 'Щось пішло не так',
network: "Помилка мережі. Перевірте з'єднання з інтернетом.",
serverError: 'Помилка сервера. Будь ласка, спробуйте пізніше.',
notFound: 'Запитаний елемент не знайдено.',
unauthorized: 'У вас немає дозволу на цю дію.',
sessionExpired: 'Ваша сесія закінчилась. Будь ласка, увійдіть знову.',
invalidInput: 'Невірні дані. Перевірте введену інформацію.',
paymentFailed: 'Оплата не вдалася. Будь ласка, спробуйте ще раз.',
bookingFailed: 'Бронювання не вдалося. Будь ласка, спробуйте ще раз.',
tryAgain: 'Спробувати знову',
},
datetime: {
today: 'Сьогодні',
tomorrow: 'Завтра',
yesterday: 'Вчора',
minutes: 'хв',
hours: 'год',
days: 'днів',
ago: 'тому',
in: 'через',
},
},
};
// Language display names
export const languageNames: Record<SupportedLanguage, string> = {
cs: 'Čeština',
en: 'English',
uk: 'Українська',
};
// Language flags
export const languageFlags: Record<SupportedLanguage, string> = {
cs: '🇨🇿',
en: '🇬🇧',
uk: '🇺🇦',
};

View File

@ -0,0 +1,438 @@
/**
* BUS-Tickets - Push Notification Service
* Copyright (c) 2024-2026 IT Enterprise
*/
import { Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
// Check if we're on web
const isWeb = Platform.OS === 'web';
// Conditionally import native modules
let Notifications: typeof import('expo-notifications') | null = null;
let Device: typeof import('expo-device') | null = null;
let Constants: typeof import('expo-constants').default | null = null;
if (!isWeb) {
Notifications = require('expo-notifications');
Device = require('expo-device');
Constants = require('expo-constants').default;
}
const PUSH_TOKEN_KEY = '@bus_tickets_push_token';
const NOTIFICATION_SETTINGS_KEY = '@bus_tickets_notification_settings';
export interface NotificationSettings {
enabled: boolean;
tripReminders: boolean;
tripReminderMinutes: number; // Minutes before departure
bookingConfirmations: boolean;
promotions: boolean;
tripUpdates: boolean;
sound: boolean;
vibration: boolean;
}
const DEFAULT_SETTINGS: NotificationSettings = {
enabled: true,
tripReminders: true,
tripReminderMinutes: 60, // 1 hour before
bookingConfirmations: true,
promotions: false,
tripUpdates: true,
sound: true,
vibration: true,
};
export interface NotificationData {
type: 'trip_reminder' | 'booking_confirmation' | 'trip_update' | 'promotion' | 'general';
ticketId?: number;
tripId?: number;
title: string;
body: string;
data?: Record<string, unknown>;
}
class NotificationService {
private static instance: NotificationService;
private pushToken: string | null = null;
private settings: NotificationSettings = DEFAULT_SETTINGS;
private initialized: boolean = false;
private constructor() {}
static getInstance(): NotificationService {
if (!NotificationService.instance) {
NotificationService.instance = new NotificationService();
}
return NotificationService.instance;
}
/**
* Initialize notification service
*/
async initialize(): Promise<void> {
if (this.initialized) return;
// Skip push notifications on web (requires VAPID key setup)
if (isWeb) {
await this.loadSettings();
this.initialized = true;
return;
}
// Configure notification handler (native only)
if (Notifications) {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: this.settings.sound,
shouldSetBadge: true,
}),
});
}
// Load settings
await this.loadSettings();
// Request permissions and get push token (native only)
if (this.settings.enabled && !isWeb) {
await this.registerForPushNotifications();
}
this.initialized = true;
}
/**
* Request permission and register for push notifications
*/
async registerForPushNotifications(): Promise<string | null> {
// Skip on web - would require VAPID key
if (isWeb || !Notifications || !Device || !Constants) {
console.log('Push notifications not available on web');
return null;
}
if (!Device.isDevice) {
console.log('Push notifications require a physical device');
return null;
}
try {
// Check existing permission
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
// Request permission if not granted
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
console.log('Push notification permission denied');
return null;
}
// Get push token
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const tokenData = await Notifications.getExpoPushTokenAsync({
projectId,
});
this.pushToken = tokenData.data;
// Save token locally
await AsyncStorage.setItem(PUSH_TOKEN_KEY, this.pushToken);
// Configure Android channel
if (Platform.OS === 'android') {
await this.setupAndroidChannels();
}
console.log('Push token:', this.pushToken);
return this.pushToken;
} catch (error) {
console.error('Error registering for push notifications:', error);
return null;
}
}
/**
* Setup Android notification channels
*/
private async setupAndroidChannels(): Promise<void> {
if (!Notifications) return;
// Trip reminders channel
await Notifications.setNotificationChannelAsync('trip-reminders', {
name: 'Trip Reminders',
description: 'Reminders about upcoming trips',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#e94560',
sound: 'default',
});
// Booking confirmations channel
await Notifications.setNotificationChannelAsync('booking-confirmations', {
name: 'Booking Confirmations',
description: 'Notifications about booking status',
importance: Notifications.AndroidImportance.HIGH,
sound: 'default',
});
// Trip updates channel
await Notifications.setNotificationChannelAsync('trip-updates', {
name: 'Trip Updates',
description: 'Updates about trip changes or delays',
importance: Notifications.AndroidImportance.HIGH,
sound: 'default',
});
// Promotions channel
await Notifications.setNotificationChannelAsync('promotions', {
name: 'Promotions & Offers',
description: 'Special offers and discounts',
importance: Notifications.AndroidImportance.DEFAULT,
sound: 'default',
});
// General channel
await Notifications.setNotificationChannelAsync('general', {
name: 'General',
description: 'General notifications',
importance: Notifications.AndroidImportance.DEFAULT,
});
}
/**
* Get push token
*/
getPushToken(): string | null {
return this.pushToken;
}
/**
* Register token with backend
*/
async registerTokenWithBackend(apiUrl: string, userId: number): Promise<void> {
if (!this.pushToken) {
console.log('No push token to register');
return;
}
try {
const response = await fetch(`${apiUrl}/api/v1/push-tokens/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: this.pushToken,
user_id: userId,
platform: Platform.OS,
device_name: Device?.deviceName || 'Unknown',
}),
});
if (!response.ok) {
throw new Error('Failed to register push token');
}
console.log('Push token registered with backend');
} catch (error) {
console.error('Error registering token with backend:', error);
}
}
/**
* Schedule local notification
*/
async scheduleLocalNotification(
notification: NotificationData,
trigger: unknown
): Promise<string> {
if (isWeb || !Notifications) {
console.log('Local notifications not available on web');
return '';
}
const channelId = this.getChannelForType(notification.type);
const notificationId = await Notifications.scheduleNotificationAsync({
content: {
title: notification.title,
body: notification.body,
data: {
type: notification.type,
ticketId: notification.ticketId,
tripId: notification.tripId,
...notification.data,
},
sound: this.settings.sound ? 'default' : undefined,
...(Platform.OS === 'android' && { channelId }),
},
trigger: trigger as Parameters<typeof Notifications.scheduleNotificationAsync>[0]['trigger'],
});
return notificationId;
}
/**
* Schedule trip reminder
*/
async scheduleTripReminder(
ticketId: number,
tripId: number,
origin: string,
destination: string,
departureTime: Date
): Promise<string | null> {
if (!this.settings.tripReminders) {
return null;
}
const reminderTime = new Date(departureTime);
reminderTime.setMinutes(
reminderTime.getMinutes() - this.settings.tripReminderMinutes
);
// Don't schedule if reminder time is in the past
if (reminderTime <= new Date()) {
return null;
}
return this.scheduleLocalNotification(
{
type: 'trip_reminder',
ticketId,
tripId,
title: 'Trip Reminder',
body: `Your trip from ${origin} to ${destination} departs in ${this.settings.tripReminderMinutes} minutes`,
},
{
date: reminderTime,
}
);
}
/**
* Send instant local notification
*/
async sendLocalNotification(notification: NotificationData): Promise<void> {
await this.scheduleLocalNotification(notification, null);
}
/**
* Cancel scheduled notification
*/
async cancelNotification(notificationId: string): Promise<void> {
if (isWeb || !Notifications) return;
await Notifications.cancelScheduledNotificationAsync(notificationId);
}
/**
* Cancel all scheduled notifications
*/
async cancelAllNotifications(): Promise<void> {
if (isWeb || !Notifications) return;
await Notifications.cancelAllScheduledNotificationsAsync();
}
/**
* Get all scheduled notifications
*/
async getScheduledNotifications(): Promise<unknown[]> {
if (isWeb || !Notifications) return [];
return Notifications.getAllScheduledNotificationsAsync();
}
/**
* Get badge count
*/
async getBadgeCount(): Promise<number> {
if (isWeb || !Notifications) return 0;
return Notifications.getBadgeCountAsync();
}
/**
* Set badge count
*/
async setBadgeCount(count: number): Promise<void> {
if (isWeb || !Notifications) return;
await Notifications.setBadgeCountAsync(count);
}
/**
* Clear badge
*/
async clearBadge(): Promise<void> {
if (isWeb || !Notifications) return;
await Notifications.setBadgeCountAsync(0);
}
/**
* Get notification settings
*/
getSettings(): NotificationSettings {
return { ...this.settings };
}
/**
* Update notification settings
*/
async updateSettings(newSettings: Partial<NotificationSettings>): Promise<void> {
this.settings = { ...this.settings, ...newSettings };
await AsyncStorage.setItem(
NOTIFICATION_SETTINGS_KEY,
JSON.stringify(this.settings)
);
// Re-register if enabling notifications
if (newSettings.enabled && !this.pushToken) {
await this.registerForPushNotifications();
}
}
/**
* Load settings from storage
*/
private async loadSettings(): Promise<void> {
try {
const stored = await AsyncStorage.getItem(NOTIFICATION_SETTINGS_KEY);
if (stored) {
this.settings = { ...DEFAULT_SETTINGS, ...JSON.parse(stored) };
}
// Load stored token
const storedToken = await AsyncStorage.getItem(PUSH_TOKEN_KEY);
if (storedToken) {
this.pushToken = storedToken;
}
} catch (error) {
console.error('Error loading notification settings:', error);
}
}
/**
* Get Android channel for notification type
*/
private getChannelForType(type: NotificationData['type']): string {
switch (type) {
case 'trip_reminder':
return 'trip-reminders';
case 'booking_confirmation':
return 'booking-confirmations';
case 'trip_update':
return 'trip-updates';
case 'promotion':
return 'promotions';
default:
return 'general';
}
}
}
export const notificationService = NotificationService.getInstance();

View File

@ -0,0 +1,436 @@
/**
* BUS-Tickets - OAuth Service
* Handles OAuth authentication with Google, Facebook, and Apple
* Copyright (c) 2024-2026 IT Enterprise
*/
import * as AuthSession from 'expo-auth-session';
import * as WebBrowser from 'expo-web-browser';
import * as Crypto from 'expo-crypto';
import { Platform } from 'react-native';
// Complete auth session for web
WebBrowser.maybeCompleteAuthSession();
export type OAuthProvider = 'google' | 'facebook' | 'apple';
interface OAuthConfig {
clientId: string;
clientSecret?: string;
scopes: string[];
}
interface OAuthResult {
success: boolean;
provider: OAuthProvider;
idToken?: string;
accessToken?: string;
user?: {
id: string;
email?: string;
name?: string;
picture?: string;
};
error?: string;
}
// OAuth endpoints
const OAUTH_ENDPOINTS = {
google: {
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenEndpoint: 'https://oauth2.googleapis.com/token',
revocationEndpoint: 'https://oauth2.googleapis.com/revoke',
userInfoEndpoint: 'https://www.googleapis.com/oauth2/v3/userinfo',
},
facebook: {
authorizationEndpoint: 'https://www.facebook.com/v18.0/dialog/oauth',
tokenEndpoint: 'https://graph.facebook.com/v18.0/oauth/access_token',
userInfoEndpoint: 'https://graph.facebook.com/me',
},
apple: {
authorizationEndpoint: 'https://appleid.apple.com/auth/authorize',
tokenEndpoint: 'https://appleid.apple.com/auth/token',
},
};
// Default scopes per provider
const DEFAULT_SCOPES: Record<OAuthProvider, string[]> = {
google: ['openid', 'profile', 'email'],
facebook: ['email', 'public_profile'],
apple: ['name', 'email'],
};
class OAuthService {
private configs: Partial<Record<OAuthProvider, OAuthConfig>> = {};
/**
* Configure OAuth provider
*/
configure(provider: OAuthProvider, config: OAuthConfig) {
this.configs[provider] = {
...config,
scopes: config.scopes || DEFAULT_SCOPES[provider],
};
}
/**
* Configure all providers from app config
*/
configureFromAppConfig(authProviders: Array<{ id: string; clientId?: string; enabled: boolean }>) {
authProviders.forEach((p) => {
if (p.enabled && p.clientId && ['google', 'facebook', 'apple'].includes(p.id)) {
this.configure(p.id as OAuthProvider, {
clientId: p.clientId,
scopes: DEFAULT_SCOPES[p.id as OAuthProvider],
});
}
});
}
/**
* Get redirect URI for OAuth
*/
getRedirectUri(): string {
return AuthSession.makeRedirectUri({
scheme: 'bustickets',
path: 'auth',
});
}
/**
* Generate random state for CSRF protection
*/
private async generateState(): Promise<string> {
const randomBytes = await Crypto.getRandomBytesAsync(32);
return Array.from(randomBytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Generate code verifier for PKCE
*/
private async generateCodeVerifier(): Promise<string> {
const randomBytes = await Crypto.getRandomBytesAsync(32);
return AuthSession.makeRedirectUri({ native: 'bustickets://auth' })
.split('')
.map((c, i) => randomBytes[i % randomBytes.length].toString(16).padStart(2, '0'))
.join('')
.slice(0, 43);
}
/**
* Sign in with Google
*/
async signInWithGoogle(): Promise<OAuthResult> {
const config = this.configs.google;
if (!config?.clientId) {
return {
success: false,
provider: 'google',
error: 'Google OAuth not configured. Please set client ID.',
};
}
try {
const redirectUri = this.getRedirectUri();
const state = await this.generateState();
const discovery = {
authorizationEndpoint: OAUTH_ENDPOINTS.google.authorizationEndpoint,
tokenEndpoint: OAUTH_ENDPOINTS.google.tokenEndpoint,
revocationEndpoint: OAUTH_ENDPOINTS.google.revocationEndpoint,
};
const request = new AuthSession.AuthRequest({
clientId: config.clientId,
scopes: config.scopes,
redirectUri,
state,
responseType: AuthSession.ResponseType.Code,
usePKCE: true,
extraParams: {
access_type: 'offline',
prompt: 'consent',
},
});
const result = await request.promptAsync(discovery);
if (result.type === 'success' && result.params.code) {
// Exchange code for tokens
const tokenResult = await AuthSession.exchangeCodeAsync(
{
clientId: config.clientId,
code: result.params.code,
redirectUri,
extraParams: {
code_verifier: request.codeVerifier!,
},
},
discovery
);
// Fetch user info
const userInfo = await this.fetchGoogleUserInfo(tokenResult.accessToken);
return {
success: true,
provider: 'google',
idToken: tokenResult.idToken,
accessToken: tokenResult.accessToken,
user: userInfo,
};
} else if (result.type === 'cancel') {
return {
success: false,
provider: 'google',
error: 'User cancelled authentication',
};
} else {
return {
success: false,
provider: 'google',
error: result.type === 'error' ? result.error?.message : 'Authentication failed',
};
}
} catch (error) {
console.error('Google OAuth error:', error);
return {
success: false,
provider: 'google',
error: error instanceof Error ? error.message : 'Google authentication failed',
};
}
}
/**
* Sign in with Facebook
*/
async signInWithFacebook(): Promise<OAuthResult> {
const config = this.configs.facebook;
if (!config?.clientId) {
return {
success: false,
provider: 'facebook',
error: 'Facebook OAuth not configured. Please set client ID.',
};
}
try {
const redirectUri = this.getRedirectUri();
const state = await this.generateState();
const discovery = {
authorizationEndpoint: OAUTH_ENDPOINTS.facebook.authorizationEndpoint,
tokenEndpoint: OAUTH_ENDPOINTS.facebook.tokenEndpoint,
};
const request = new AuthSession.AuthRequest({
clientId: config.clientId,
scopes: config.scopes,
redirectUri,
state,
responseType: AuthSession.ResponseType.Token, // Facebook uses implicit flow
});
const result = await request.promptAsync(discovery);
if (result.type === 'success' && result.params.access_token) {
// Fetch user info
const userInfo = await this.fetchFacebookUserInfo(result.params.access_token);
return {
success: true,
provider: 'facebook',
accessToken: result.params.access_token,
user: userInfo,
};
} else if (result.type === 'cancel') {
return {
success: false,
provider: 'facebook',
error: 'User cancelled authentication',
};
} else {
return {
success: false,
provider: 'facebook',
error: 'Authentication failed',
};
}
} catch (error) {
console.error('Facebook OAuth error:', error);
return {
success: false,
provider: 'facebook',
error: error instanceof Error ? error.message : 'Facebook authentication failed',
};
}
}
/**
* Sign in with Apple
*/
async signInWithApple(): Promise<OAuthResult> {
const config = this.configs.apple;
// Apple Sign In is only available on iOS and web
if (Platform.OS === 'android') {
return {
success: false,
provider: 'apple',
error: 'Apple Sign In is not available on Android',
};
}
if (!config?.clientId) {
return {
success: false,
provider: 'apple',
error: 'Apple OAuth not configured. Please set client ID.',
};
}
try {
const redirectUri = this.getRedirectUri();
const state = await this.generateState();
const discovery = {
authorizationEndpoint: OAUTH_ENDPOINTS.apple.authorizationEndpoint,
tokenEndpoint: OAUTH_ENDPOINTS.apple.tokenEndpoint,
};
const request = new AuthSession.AuthRequest({
clientId: config.clientId,
scopes: config.scopes,
redirectUri,
state,
responseType: AuthSession.ResponseType.Code,
usePKCE: true,
extraParams: {
response_mode: 'form_post',
},
});
const result = await request.promptAsync(discovery);
if (result.type === 'success' && result.params.code) {
// For Apple, the id_token is returned directly
// The backend should verify this token
return {
success: true,
provider: 'apple',
idToken: result.params.id_token,
accessToken: result.params.code,
user: result.params.user
? JSON.parse(result.params.user)
: undefined,
};
} else if (result.type === 'cancel') {
return {
success: false,
provider: 'apple',
error: 'User cancelled authentication',
};
} else {
return {
success: false,
provider: 'apple',
error: 'Authentication failed',
};
}
} catch (error) {
console.error('Apple OAuth error:', error);
return {
success: false,
provider: 'apple',
error: error instanceof Error ? error.message : 'Apple authentication failed',
};
}
}
/**
* Sign in with any provider
*/
async signIn(provider: OAuthProvider): Promise<OAuthResult> {
switch (provider) {
case 'google':
return this.signInWithGoogle();
case 'facebook':
return this.signInWithFacebook();
case 'apple':
return this.signInWithApple();
default:
return {
success: false,
provider,
error: `Unknown provider: ${provider}`,
};
}
}
/**
* Fetch Google user info
*/
private async fetchGoogleUserInfo(
accessToken: string
): Promise<{ id: string; email?: string; name?: string; picture?: string }> {
const response = await fetch(OAUTH_ENDPOINTS.google.userInfoEndpoint, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
throw new Error('Failed to fetch Google user info');
}
const data = await response.json();
return {
id: data.sub,
email: data.email,
name: data.name,
picture: data.picture,
};
}
/**
* Fetch Facebook user info
*/
private async fetchFacebookUserInfo(
accessToken: string
): Promise<{ id: string; email?: string; name?: string; picture?: string }> {
const response = await fetch(
`${OAUTH_ENDPOINTS.facebook.userInfoEndpoint}?fields=id,name,email,picture&access_token=${accessToken}`
);
if (!response.ok) {
throw new Error('Failed to fetch Facebook user info');
}
const data = await response.json();
return {
id: data.id,
email: data.email,
name: data.name,
picture: data.picture?.data?.url,
};
}
/**
* Check if provider is configured
*/
isConfigured(provider: OAuthProvider): boolean {
return !!this.configs[provider]?.clientId;
}
/**
* Get configured providers
*/
getConfiguredProviders(): OAuthProvider[] {
return (Object.keys(this.configs) as OAuthProvider[]).filter(
(p) => this.configs[p]?.clientId
);
}
}
export const oAuthService = new OAuthService();

542
src/services/SyncService.ts Normal file
View File

@ -0,0 +1,542 @@
/**
* BUS-Tickets - Sync Service
* Copyright (c) 2024-2026 IT Enterprise
*/
import * as Network from 'expo-network';
import { database } from '../db/database';
import { ticketRepository } from '../db/TicketRepository';
import { offlineQueue, QueuedAction, ActionPayload } from '../db/OfflineQueue';
import type { Ticket, Trip } from '@/types';
export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error' | 'offline';
export interface SyncState {
status: SyncStatus;
lastSyncTime: number | null;
pendingActions: number;
error: string | null;
}
export interface SyncOptions {
forceSync?: boolean;
syncTickets?: boolean;
syncTrips?: boolean;
processQueue?: boolean;
}
type SyncListener = (state: SyncState) => void;
class SyncService {
private static instance: SyncService;
private apiUrl: string = '';
private authToken: string | null = null;
private state: SyncState = {
status: 'idle',
lastSyncTime: null,
pendingActions: 0,
error: null,
};
private listeners: Set<SyncListener> = new Set();
private syncInterval: ReturnType<typeof setInterval> | null = null;
private isOnline: boolean = true;
private constructor() {}
static getInstance(): SyncService {
if (!SyncService.instance) {
SyncService.instance = new SyncService();
}
return SyncService.instance;
}
/**
* Initialize sync service
*/
async initialize(apiUrl: string): Promise<void> {
this.apiUrl = apiUrl;
// Initialize database
await database.initialize();
// Check initial network state
await this.checkNetworkStatus();
// Start network monitoring
this.startNetworkMonitoring();
// Get pending action count
this.state.pendingActions = await offlineQueue.getPendingCount();
// Get last sync time
this.state.lastSyncTime = await ticketRepository.getLastSyncTime();
this.notifyListeners();
}
/**
* Set authentication token
*/
setAuthToken(token: string | null): void {
this.authToken = token;
}
/**
* Subscribe to sync state changes
*/
subscribe(listener: SyncListener): () => void {
this.listeners.add(listener);
listener(this.state);
return () => {
this.listeners.delete(listener);
};
}
/**
* Get current sync state
*/
getState(): SyncState {
return { ...this.state };
}
/**
* Check if online
*/
isNetworkOnline(): boolean {
return this.isOnline;
}
/**
* Check network status
*/
private async checkNetworkStatus(): Promise<boolean> {
try {
const networkState = await Network.getNetworkStateAsync();
this.isOnline = networkState.isConnected === true && networkState.isInternetReachable === true;
if (!this.isOnline) {
this.updateState({ status: 'offline' });
}
return this.isOnline;
} catch (error) {
console.error('Error checking network status:', error);
this.isOnline = false;
return false;
}
}
/**
* Start network monitoring
*/
private startNetworkMonitoring(): void {
// Check every 30 seconds
this.syncInterval = setInterval(async () => {
const wasOnline = this.isOnline;
await this.checkNetworkStatus();
// If we came back online, trigger sync
if (!wasOnline && this.isOnline) {
console.log('Network restored, starting sync...');
this.sync({ processQueue: true });
}
}, 30000);
}
/**
* Stop network monitoring
*/
stopNetworkMonitoring(): void {
if (this.syncInterval) {
clearInterval(this.syncInterval);
this.syncInterval = null;
}
}
/**
* Perform full sync
*/
async sync(options: SyncOptions = {}): Promise<boolean> {
const {
forceSync = false,
syncTickets = true,
syncTrips = true,
processQueue = true,
} = options;
// Check network
if (!await this.checkNetworkStatus()) {
this.updateState({ status: 'offline', error: 'No network connection' });
return false;
}
// Check if already syncing
if (this.state.status === 'syncing' && !forceSync) {
console.log('Sync already in progress');
return false;
}
this.updateState({ status: 'syncing', error: null });
try {
// Process offline queue first
if (processQueue) {
await this.processOfflineQueue();
}
// Sync tickets
if (syncTickets && this.authToken) {
await this.syncTickets();
}
// Sync trips (popular routes)
if (syncTrips) {
await this.syncPopularTrips();
}
// Update state
const pendingActions = await offlineQueue.getPendingCount();
this.updateState({
status: 'success',
lastSyncTime: Date.now(),
pendingActions,
});
// Store last sync time
await database.setMetadata('last_sync', String(Date.now()));
console.log('Sync completed successfully');
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Sync failed';
this.updateState({ status: 'error', error: errorMessage });
console.error('Sync error:', error);
return false;
}
}
/**
* Sync user tickets from server
*/
private async syncTickets(): Promise<void> {
if (!this.authToken) return;
try {
const response = await fetch(`${this.apiUrl}/api/v1/tickets`, {
headers: {
Authorization: `Bearer ${this.authToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to fetch tickets');
}
const data = await response.json();
if (data.tickets && Array.isArray(data.tickets)) {
await ticketRepository.saveTickets(data.tickets);
console.log(`Synced ${data.tickets.length} tickets`);
}
} catch (error) {
console.error('Error syncing tickets:', error);
throw error;
}
}
/**
* Sync popular trips for offline search
*/
private async syncPopularTrips(): Promise<void> {
try {
const response = await fetch(`${this.apiUrl}/api/v1/trips/popular`, {
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
// Non-critical, just log
console.log('Could not fetch popular trips');
return;
}
const data = await response.json();
if (data.trips && Array.isArray(data.trips)) {
await ticketRepository.saveTrips(data.trips);
console.log(`Cached ${data.trips.length} popular trips`);
}
} catch (error) {
// Non-critical error
console.log('Error caching popular trips:', error);
}
}
/**
* Process offline action queue
*/
private async processOfflineQueue(): Promise<void> {
const pendingActions = await offlineQueue.getPendingActions();
if (pendingActions.length === 0) {
console.log('No pending offline actions');
return;
}
console.log(`Processing ${pendingActions.length} offline actions`);
for (const action of pendingActions) {
try {
await this.processAction(action);
await offlineQueue.complete(action.id);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
await offlineQueue.fail(action.id, errorMessage);
}
}
// Update pending count
this.state.pendingActions = await offlineQueue.getPendingCount();
}
/**
* Process single offline action
*/
private async processAction(action: QueuedAction): Promise<void> {
const payload: ActionPayload = JSON.parse(action.payload);
console.log(`Processing action: ${action.action_type}`);
switch (action.action_type) {
case 'CREATE_BOOKING':
await this.processCreateBooking(payload);
break;
case 'CANCEL_TICKET':
await this.processCancelTicket(action.entity_id!, payload);
break;
case 'UPDATE_PROFILE':
await this.processUpdateProfile(payload);
break;
case 'CHECK_IN':
await this.processCheckIn(action.entity_id!);
break;
default:
console.warn(`Unknown action type: ${action.action_type}`);
}
}
/**
* Process create booking action
*/
private async processCreateBooking(payload: ActionPayload): Promise<void> {
const response = await fetch(`${this.apiUrl}/api/v1/bookings`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create booking');
}
// Save the new ticket locally
const data = await response.json();
if (data.ticket) {
await ticketRepository.saveTicket(data.ticket);
}
}
/**
* Process cancel ticket action
*/
private async processCancelTicket(
ticketId: number,
payload: ActionPayload
): Promise<void> {
const response = await fetch(
`${this.apiUrl}/api/v1/tickets/${ticketId}/cancel`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${this.authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to cancel ticket');
}
// Update local ticket status
await ticketRepository.updateTicketStatus(ticketId, 'cancelled');
}
/**
* Process profile update action
*/
private async processUpdateProfile(payload: ActionPayload): Promise<void> {
const response = await fetch(`${this.apiUrl}/api/v1/profile`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${this.authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to update profile');
}
}
/**
* Process check-in action
*/
private async processCheckIn(ticketId: number): Promise<void> {
const response = await fetch(
`${this.apiUrl}/api/v1/tickets/${ticketId}/check-in`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${this.authToken}`,
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to check in');
}
// Update local ticket status
await ticketRepository.updateTicketStatus(ticketId, 'checked_in');
}
/**
* Queue action for offline execution
*/
async queueAction(
actionType: QueuedAction['action_type'],
entityType: QueuedAction['entity_type'],
entityId: number | null,
payload: ActionPayload
): Promise<number> {
const actionId = await offlineQueue.enqueue(
actionType,
entityType,
entityId,
payload
);
this.state.pendingActions = await offlineQueue.getPendingCount();
this.notifyListeners();
// Try to process immediately if online
if (this.isOnline) {
this.sync({ processQueue: true, syncTickets: false, syncTrips: false });
}
return actionId;
}
/**
* Get tickets (from cache if offline)
*/
async getTickets(): Promise<Ticket[]> {
if (this.isOnline && this.authToken) {
try {
await this.syncTickets();
} catch (error) {
console.log('Using cached tickets due to sync error');
}
}
return ticketRepository.getAllTickets();
}
/**
* Get ticket by ID
*/
async getTicket(id: number): Promise<Ticket | null> {
return ticketRepository.getTicket(id);
}
/**
* Search trips (from cache if offline)
*/
async searchTrips(
origin: string,
destination: string,
date?: string
): Promise<Trip[]> {
// Try online first
if (this.isOnline) {
try {
const params = new URLSearchParams({
origin,
destination,
...(date && { date }),
});
const response = await fetch(
`${this.apiUrl}/api/v1/trips/search?${params}`
);
if (response.ok) {
const data = await response.json();
if (data.trips) {
// Cache results
await ticketRepository.saveTrips(data.trips);
return data.trips;
}
}
} catch (error) {
console.log('Using cached trips due to search error');
}
}
// Fall back to cache
return ticketRepository.getTripsByRoute(origin, destination, date);
}
/**
* Update state and notify listeners
*/
private updateState(partial: Partial<SyncState>): void {
this.state = { ...this.state, ...partial };
this.notifyListeners();
}
/**
* Notify all listeners of state change
*/
private notifyListeners(): void {
this.listeners.forEach((listener) => listener(this.state));
}
/**
* Cleanup old data
*/
async cleanup(): Promise<void> {
const deletedTickets = await ticketRepository.cleanupOldTickets();
console.log(`Cleaned up ${deletedTickets} old tickets`);
}
}
export const syncService = SyncService.getInstance();

View File

@ -0,0 +1,404 @@
/**
* BUS-Tickets - Two-Factor Authentication Service
* Copyright (c) 2024-2026 IT Enterprise
*/
import { Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
export type TwoFactorMethod = 'totp' | 'sms' | 'email';
interface TwoFactorConfig {
enabled: boolean;
method: TwoFactorMethod;
phone?: string;
email?: string;
}
interface TwoFactorSetupResult {
success: boolean;
secret?: string;
qrCodeUrl?: string;
backupCodes?: string[];
error?: string;
}
interface TwoFactorVerifyResult {
success: boolean;
error?: string;
}
const TWO_FACTOR_KEY = '@bus_tickets_2fa';
class TwoFactorService {
private apiUrl: string = '';
/**
* Configure the API URL
*/
setApiUrl(url: string) {
this.apiUrl = url;
}
/**
* Ensure HTTPS for web
*/
private ensureHttps(url: string): string {
if (!url) return url;
if (Platform.OS === 'web' && url.startsWith('http://')) {
return url.replace('http://', 'https://');
}
return url;
}
/**
* Check if 2FA is enabled for the current user
*/
async isEnabled(): Promise<boolean> {
try {
const stored = await AsyncStorage.getItem(TWO_FACTOR_KEY);
if (stored) {
const config: TwoFactorConfig = JSON.parse(stored);
return config.enabled;
}
return false;
} catch (error) {
console.error('Error checking 2FA status:', error);
return false;
}
}
/**
* Get 2FA configuration
*/
async getConfig(): Promise<TwoFactorConfig | null> {
try {
const stored = await AsyncStorage.getItem(TWO_FACTOR_KEY);
return stored ? JSON.parse(stored) : null;
} catch (error) {
console.error('Error getting 2FA config:', error);
return null;
}
}
/**
* Save 2FA configuration locally
*/
async saveConfig(config: TwoFactorConfig): Promise<void> {
try {
await AsyncStorage.setItem(TWO_FACTOR_KEY, JSON.stringify(config));
} catch (error) {
console.error('Error saving 2FA config:', error);
}
}
/**
* Enable 2FA with TOTP (authenticator app)
*/
async setupTOTP(accessToken: string): Promise<TwoFactorSetupResult> {
try {
const url = this.ensureHttps(`${this.apiUrl}/api/v1/auth/2fa/setup`);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ method: 'totp' }),
});
const data = await response.json();
if (data.success) {
return {
success: true,
secret: data.data.secret,
qrCodeUrl: data.data.qrCodeUrl,
backupCodes: data.data.backupCodes,
};
}
return {
success: false,
error: data.error || 'Failed to setup 2FA',
};
} catch (error) {
console.error('Error setting up TOTP:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Network error',
};
}
}
/**
* Enable 2FA with SMS
*/
async setupSMS(accessToken: string, phone: string): Promise<TwoFactorSetupResult> {
try {
const url = this.ensureHttps(`${this.apiUrl}/api/v1/auth/2fa/setup`);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ method: 'sms', phone }),
});
const data = await response.json();
if (data.success) {
await this.saveConfig({
enabled: true,
method: 'sms',
phone,
});
return { success: true };
}
return {
success: false,
error: data.error || 'Failed to setup SMS 2FA',
};
} catch (error) {
console.error('Error setting up SMS 2FA:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Network error',
};
}
}
/**
* Enable 2FA with email
*/
async setupEmail(accessToken: string, email: string): Promise<TwoFactorSetupResult> {
try {
const url = this.ensureHttps(`${this.apiUrl}/api/v1/auth/2fa/setup`);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ method: 'email', email }),
});
const data = await response.json();
if (data.success) {
await this.saveConfig({
enabled: true,
method: 'email',
email,
});
return { success: true };
}
return {
success: false,
error: data.error || 'Failed to setup email 2FA',
};
} catch (error) {
console.error('Error setting up email 2FA:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Network error',
};
}
}
/**
* Confirm TOTP setup with verification code
*/
async confirmTOTP(accessToken: string, code: string): Promise<TwoFactorVerifyResult> {
try {
const url = this.ensureHttps(`${this.apiUrl}/api/v1/auth/2fa/confirm`);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ code }),
});
const data = await response.json();
if (data.success) {
await this.saveConfig({
enabled: true,
method: 'totp',
});
return { success: true };
}
return {
success: false,
error: data.error || 'Invalid verification code',
};
} catch (error) {
console.error('Error confirming TOTP:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Network error',
};
}
}
/**
* Verify 2FA code during login
*/
async verifyCode(
tempToken: string,
code: string,
method: TwoFactorMethod = 'totp'
): Promise<TwoFactorVerifyResult & { accessToken?: string; refreshToken?: string }> {
try {
const url = this.ensureHttps(`${this.apiUrl}/api/v1/auth/2fa/verify`);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
tempToken,
code,
method,
}),
});
const data = await response.json();
if (data.success) {
return {
success: true,
accessToken: data.data.accessToken,
refreshToken: data.data.refreshToken,
};
}
return {
success: false,
error: data.error || 'Invalid verification code',
};
} catch (error) {
console.error('Error verifying 2FA code:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Network error',
};
}
}
/**
* Request new 2FA code (for SMS/email methods)
*/
async requestCode(tempToken: string): Promise<TwoFactorVerifyResult> {
try {
const url = this.ensureHttps(`${this.apiUrl}/api/v1/auth/2fa/request-code`);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ tempToken }),
});
const data = await response.json();
if (data.success) {
return { success: true };
}
return {
success: false,
error: data.error || 'Failed to send code',
};
} catch (error) {
console.error('Error requesting 2FA code:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Network error',
};
}
}
/**
* Disable 2FA
*/
async disable(accessToken: string, code: string): Promise<TwoFactorVerifyResult> {
try {
const url = this.ensureHttps(`${this.apiUrl}/api/v1/auth/2fa/disable`);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ code }),
});
const data = await response.json();
if (data.success) {
await AsyncStorage.removeItem(TWO_FACTOR_KEY);
return { success: true };
}
return {
success: false,
error: data.error || 'Failed to disable 2FA',
};
} catch (error) {
console.error('Error disabling 2FA:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Network error',
};
}
}
/**
* Use backup code
*/
async useBackupCode(tempToken: string, backupCode: string): Promise<TwoFactorVerifyResult & { accessToken?: string; refreshToken?: string }> {
try {
const url = this.ensureHttps(`${this.apiUrl}/api/v1/auth/2fa/backup`);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
tempToken,
backupCode,
}),
});
const data = await response.json();
if (data.success) {
return {
success: true,
accessToken: data.data.accessToken,
refreshToken: data.data.refreshToken,
};
}
return {
success: false,
error: data.error || 'Invalid backup code',
};
} catch (error) {
console.error('Error using backup code:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Network error',
};
}
}
}
export const twoFactorService = new TwoFactorService();

16
src/services/index.ts Normal file
View File

@ -0,0 +1,16 @@
/**
* BUS-Tickets - Services Exports
* Copyright (c) 2024-2026 IT Enterprise
*/
export { notificationService } from './NotificationService';
export type { NotificationSettings, NotificationData } from './NotificationService';
export { syncService } from './SyncService';
export type { SyncStatus, SyncState, SyncOptions } from './SyncService';
export { oAuthService } from './OAuthService';
export type { OAuthProvider } from './OAuthService';
export { twoFactorService } from './TwoFactorService';
export type { TwoFactorMethod } from './TwoFactorService';

274
src/types/index.ts Normal file
View File

@ -0,0 +1,274 @@
/**
* BUS-Tickets Mobile - Type Definitions
* Copyright (c) 2024-2026 IT Enterprise
*/
// ============================================
// USER & AUTH TYPES
// ============================================
export interface User {
id: number;
email: string;
name: string;
phone: string;
language: Language;
isLoggedIn: boolean;
avatar?: string;
createdAt: string;
updatedAt: string;
}
export type Language = 'uk_UA' | 'cs_CZ' | 'en_US';
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
tokenType: 'Bearer';
}
export type AuthProvider = 'email' | 'phone' | 'google' | 'facebook' | 'apple';
export interface LoginRequest {
provider: AuthProvider;
email?: string;
phone?: string;
password?: string;
otp?: string;
idToken?: string;
}
export interface LoginResponse {
user: User;
tokens: AuthTokens;
}
// ============================================
// TRIP & ROUTE TYPES
// ============================================
export interface Location {
id: number;
name: string;
city?: string;
country?: string;
address?: string;
latitude?: number;
longitude?: number;
}
export interface Route {
id: number;
name: string;
origin: Location;
destination: Location;
distance?: number;
duration?: number;
stops?: Location[];
}
export interface Trip {
id: number;
route: Route;
departureTime: string;
arrivalTime: string;
bus?: Bus;
availableSeats: number;
totalSeats: number;
price: Price;
status: TripStatus;
}
export type TripStatus = 'scheduled' | 'boarding' | 'departed' | 'arrived' | 'cancelled';
export interface Bus {
id: number;
name: string;
plateNumber: string;
capacity: number;
amenities: BusAmenity[];
seatLayout?: SeatLayout;
}
export type BusAmenity = 'wifi' | 'ac' | 'toilet' | 'usb' | 'tv' | 'snacks' | 'sleeper' | 'power' | 'recliner';
export interface SeatLayout {
rows: number;
seatsPerRow: number;
unavailableSeats: number[];
reservedSeats: number[];
}
// ============================================
// TICKET TYPES
// ============================================
export interface Ticket {
id: number;
ticketNumber: string;
trip: Trip;
passenger: PassengerInfo;
seat?: number;
price: Price;
status: TicketStatus;
qrCode: string;
purchasedAt: string;
checkedInAt?: string;
}
export type TicketStatus = 'reserved' | 'paid' | 'checked_in' | 'used' | 'cancelled' | 'refunded';
export interface PassengerInfo {
name: string;
email: string;
phone: string;
documentType?: 'passport' | 'id_card';
documentNumber?: string;
}
export interface Passenger {
name: string;
email: string;
phone: string;
}
// ============================================
// PRICE & PAYMENT TYPES
// ============================================
export interface Price {
amount: number;
currency: Currency;
originalAmount?: number;
discount?: Discount;
}
export type Currency = 'UAH' | 'CZK' | 'EUR' | 'USD';
export interface Discount {
type: 'percentage' | 'fixed';
value: number;
code?: string;
description?: string;
}
export type PaymentMethod = 'monobank' | 'stripe' | 'paypal' | 'liqpay' | 'cash';
export interface PaymentRequest {
ticketId: number;
method: PaymentMethod;
returnUrl: string;
}
export interface PaymentResponse {
paymentId: string;
status: PaymentStatus;
redirectUrl?: string;
}
export type PaymentStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'refunded';
// ============================================
// CONFIG TYPES
// ============================================
export interface AppConfig {
version: string;
backend: BackendConfig;
features: FeatureFlags;
oauth?: OAuthProviderConfig[];
payment?: PaymentConfig;
branding?: BrandingConfig;
// Additional app config fields
instanceName?: string;
authProviders?: OAuthProviderConfig[];
legal?: {
termsUrl?: string;
privacyUrl?: string;
};
}
export interface BackendConfig {
id: string;
name: string;
type: 'odoo' | 'custom';
url: string;
apiUrl?: string;
apiVersion?: string;
timeout?: number;
isActive: boolean;
features?: FeatureFlags;
}
export interface FeatureFlags {
booking?: boolean;
payments?: boolean;
userAccounts?: boolean;
multiLanguage?: boolean;
pushNotifications?: boolean;
offlineMode?: boolean;
seatSelection?: boolean;
qrTickets?: boolean;
}
export interface OAuthProviderConfig {
id: string;
name: string;
provider: 'google' | 'facebook' | 'apple';
enabled: boolean;
clientId?: string;
redirectUri?: string;
}
export interface PaymentConfig {
id?: string;
name?: string;
enabled: boolean;
provider?: PaymentMethod;
providers: PaymentProviderConfig[];
defaultCurrency: Currency;
testMode?: boolean;
supportsApplePay?: boolean;
supportsGooglePay?: boolean;
}
export interface PaymentProviderConfig {
id: string;
name: string;
type: PaymentMethod;
enabled: boolean;
testMode?: boolean;
}
export interface BrandingConfig {
appName: string;
primaryColor?: string;
secondaryColor?: string;
logo?: string;
favicon?: string;
}
// ============================================
// API RESPONSE TYPES
// ============================================
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: ApiError;
meta?: ApiMeta;
}
export interface ApiError {
code: string;
message: string;
details?: Record<string, string>;
}
export interface ApiMeta {
page?: number;
perPage?: number;
total?: number;
totalPages?: number;
}

19
tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"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"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}