mirror of
https://github.com/odoobiznes/BUS-Ticket-client.git
synced 2026-05-28 06:24:44 +00:00
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:
commit
87d9bda46a
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
.expo/
|
||||
node_modules/
|
||||
119
app.json
Normal file
119
app.json
Normal 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
78
app/(tabs)/_layout.tsx
Normal 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
623
app/(tabs)/index.tsx
Normal 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
234
app/(tabs)/profile.tsx
Normal 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
494
app/(tabs)/settings.tsx
Normal 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
421
app/(tabs)/tickets.tsx
Normal 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
183
app/_layout.tsx
Normal 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
629
app/auth/signin.tsx
Normal 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
461
app/auth/two-factor.tsx
Normal 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
1035
app/booking/[tripId].tsx
Normal file
File diff suppressed because it is too large
Load Diff
187
app/payment/return.tsx
Normal file
187
app/payment/return.tsx
Normal 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
541
app/search/results.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
409
app/settings/notifications.tsx
Normal file
409
app/settings/notifications.tsx
Normal 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
721
app/settings/providers.tsx
Normal 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
563
app/ticket/[ticketId].tsx
Normal 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
7
assets/.gitkeep
Normal 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
BIN
assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
BIN
assets/favicon.png
Normal file
BIN
assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 141 B |
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
BIN
assets/notification-icon.png
Normal file
BIN
assets/notification-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 294 B |
BIN
assets/splash.png
Normal file
BIN
assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
9
babel.config.js
Normal file
9
babel.config.js
Normal 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
61
eas.json
Normal 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
3
expo-env.d.ts
vendored
Normal 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
10
metro.config.js
Normal 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
20011
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
75
package.json
Normal file
75
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
142
src/components/OfflineBanner.tsx
Normal file
142
src/components/OfflineBanner.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
335
src/components/PaymentMethodPicker.tsx
Normal file
335
src/components/PaymentMethodPicker.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
75
src/components/SyncIndicator.tsx
Normal file
75
src/components/SyncIndicator.tsx
Normal 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
8
src/components/index.ts
Normal 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
155
src/contexts/ApiContext.tsx
Normal 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;
|
||||
}
|
||||
324
src/contexts/AuthContext.tsx
Normal file
324
src/contexts/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
321
src/contexts/ConfigContext.tsx
Normal file
321
src/contexts/ConfigContext.tsx
Normal 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;
|
||||
}
|
||||
180
src/contexts/LocaleContext.tsx
Normal file
180
src/contexts/LocaleContext.tsx
Normal 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;
|
||||
}
|
||||
171
src/contexts/NetworkContext.tsx
Normal file
171
src/contexts/NetworkContext.tsx
Normal 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;
|
||||
}
|
||||
355
src/contexts/ProvidersContext.tsx
Normal file
355
src/contexts/ProvidersContext.tsx
Normal 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;
|
||||
}
|
||||
128
src/contexts/ThemeContext.tsx
Normal file
128
src/contexts/ThemeContext.tsx
Normal 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
13
src/contexts/index.ts
Normal 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
162
src/db/OfflineQueue.ts
Normal 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
374
src/db/TicketRepository.ts
Normal 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
183
src/db/database.ts
Normal 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
10
src/db/index.ts
Normal 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
11
src/hooks/index.ts
Normal 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';
|
||||
173
src/hooks/useNotifications.ts
Normal file
173
src/hooks/useNotifications.ts
Normal 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
199
src/hooks/useOffline.ts
Normal 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
441
src/hooks/usePayment.ts
Normal 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
155
src/hooks/useTickets.ts
Normal 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
86
src/hooks/useTrips.ts
Normal 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
12
src/i18n/index.ts
Normal 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
986
src/i18n/translations.ts
Normal 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: '🇺🇦',
|
||||
};
|
||||
438
src/services/NotificationService.ts
Normal file
438
src/services/NotificationService.ts
Normal 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();
|
||||
436
src/services/OAuthService.ts
Normal file
436
src/services/OAuthService.ts
Normal 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
542
src/services/SyncService.ts
Normal 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();
|
||||
404
src/services/TwoFactorService.ts
Normal file
404
src/services/TwoFactorService.ts
Normal 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
16
src/services/index.ts
Normal 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
274
src/types/index.ts
Normal 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
19
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user