diff --git a/src/components/ConnectionIndicator.tsx b/src/components/ConnectionIndicator.tsx new file mode 100644 index 0000000..d1898c6 --- /dev/null +++ b/src/components/ConnectionIndicator.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { useOfflineSyncContext } from '../contexts/OfflineSyncContext'; + +interface ConnectionIndicatorProps { + onPress?: () => void; + showLabel?: boolean; +} + +export function ConnectionIndicator({ onPress, showLabel = false }: ConnectionIndicatorProps) { + const { isOnline, isSyncing, pendingSalesCount } = useOfflineSyncContext(); + + const getStatusColor = () => { + if (!isOnline) return '#EF4444'; // Red - offline + if (isSyncing || pendingSalesCount > 0) return '#F59E0B'; // Yellow - syncing/pending + return '#10B981'; // Green - online + }; + + const getStatusText = () => { + if (!isOnline) return 'Sin conexion'; + if (isSyncing) return 'Sincronizando...'; + if (pendingSalesCount > 0) return `${pendingSalesCount} pendiente${pendingSalesCount > 1 ? 's' : ''}`; + return 'Conectado'; + }; + + const content = ( + + + {isSyncing && } + + {showLabel && ( + + {getStatusText()} + + )} + + ); + + if (onPress) { + return ( + + {content} + + ); + } + + return content; +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + indicator: { + width: 12, + height: 12, + borderRadius: 6, + position: 'relative', + }, + pulseRing: { + position: 'absolute', + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: 'rgba(245, 158, 11, 0.3)', + top: -4, + left: -4, + }, + label: { + fontSize: 12, + fontWeight: '500', + }, +}); + +export default ConnectionIndicator; diff --git a/src/components/OfflineBanner.tsx b/src/components/OfflineBanner.tsx new file mode 100644 index 0000000..706b997 --- /dev/null +++ b/src/components/OfflineBanner.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useRef } from 'react'; +import { View, Text, StyleSheet, Animated, TouchableOpacity } from 'react-native'; +import { useOfflineSyncContext } from '../contexts/OfflineSyncContext'; + +interface OfflineBannerProps { + onSyncPress?: () => void; +} + +export function OfflineBanner({ onSyncPress }: OfflineBannerProps) { + const { isOnline, isSyncing, pendingSalesCount, error } = useOfflineSyncContext(); + const slideAnim = useRef(new Animated.Value(-60)).current; + + const shouldShow = !isOnline || error; + + useEffect(() => { + Animated.timing(slideAnim, { + toValue: shouldShow ? 0 : -60, + duration: 300, + useNativeDriver: true, + }).start(); + }, [shouldShow, slideAnim]); + + if (!shouldShow) return null; + + const getMessage = () => { + if (error) return error; + if (!isOnline && pendingSalesCount > 0) { + return `Modo offline - ${pendingSalesCount} venta${pendingSalesCount > 1 ? 's' : ''} pendiente${pendingSalesCount > 1 ? 's' : ''}`; + } + return 'Modo offline - los cambios se sincronizaran cuando vuelva la conexion'; + }; + + const getBannerStyle = () => { + if (error) return styles.errorBanner; + return styles.offlineBanner; + }; + + return ( + + + {error ? '!' : '!'} + + {getMessage()} + + + {!isOnline && pendingSalesCount > 0 && onSyncPress && ( + + + {isSyncing ? 'Sincronizando...' : 'Sincronizar'} + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + paddingVertical: 10, + paddingHorizontal: 16, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + zIndex: 1000, + }, + offlineBanner: { + backgroundColor: '#F59E0B', + }, + errorBanner: { + backgroundColor: '#EF4444', + }, + content: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + icon: { + fontSize: 16, + fontWeight: 'bold', + color: '#FFFFFF', + width: 24, + height: 24, + textAlign: 'center', + lineHeight: 24, + backgroundColor: 'rgba(0,0,0,0.2)', + borderRadius: 12, + }, + message: { + flex: 1, + color: '#FFFFFF', + fontSize: 13, + fontWeight: '500', + }, + syncButton: { + backgroundColor: 'rgba(255,255,255,0.25)', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 4, + marginLeft: 8, + }, + syncButtonText: { + color: '#FFFFFF', + fontSize: 12, + fontWeight: '600', + }, +}); + +export default OfflineBanner; diff --git a/src/components/SyncProgress.tsx b/src/components/SyncProgress.tsx new file mode 100644 index 0000000..ca1c715 --- /dev/null +++ b/src/components/SyncProgress.tsx @@ -0,0 +1,284 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + Modal, + TouchableOpacity, + ActivityIndicator, +} from 'react-native'; +import { useOfflineSyncContext } from '../contexts/OfflineSyncContext'; + +interface SyncProgressProps { + visible: boolean; + onClose: () => void; +} + +export function SyncProgress({ visible, onClose }: SyncProgressProps) { + const { + isOnline, + isSyncing, + pendingSalesCount, + lastSyncAt, + error, + syncNow, + downloadDataForOffline, + } = useOfflineSyncContext(); + + const formatDate = (date: Date | null) => { + if (!date) return 'Nunca'; + return date.toLocaleString('es-MX', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + }); + }; + + return ( + + + + + Estado de Sincronizacion + + X + + + + + + Conexion: + + + + {isOnline ? 'Conectado' : 'Sin conexion'} + + + + + + Ultima sincronizacion: + {formatDate(lastSyncAt)} + + + + Ventas pendientes: + 0 && styles.pendingText, + ]} + > + {pendingSalesCount} + + + + + {error && ( + + {error} + + )} + + {isSyncing && ( + + + Sincronizando datos... + + )} + + + + + {isSyncing ? 'Descargando...' : 'Descargar datos'} + + + + {pendingSalesCount > 0 && ( + + + {isSyncing ? 'Sincronizando...' : `Sincronizar (${pendingSalesCount})`} + + + )} + + + + Datos disponibles offline: + + • Productos y precios + • Clientes + • Ventas en efectivo + + + Las ventas con tarjeta requieren conexion a internet. + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + modal: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + width: '100%', + maxWidth: 400, + overflow: 'hidden', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: '#E5E7EB', + }, + title: { + fontSize: 18, + fontWeight: '700', + color: '#111827', + }, + closeButton: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: '#F3F4F6', + justifyContent: 'center', + alignItems: 'center', + }, + closeText: { + fontSize: 16, + fontWeight: '600', + color: '#6B7280', + }, + statusCard: { + padding: 16, + gap: 12, + }, + statusRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + statusLabel: { + fontSize: 14, + color: '#6B7280', + }, + statusValue: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + statusText: { + fontSize: 14, + fontWeight: '600', + color: '#111827', + }, + pendingText: { + color: '#F59E0B', + }, + errorCard: { + margin: 16, + marginTop: 0, + padding: 12, + backgroundColor: '#FEF2F2', + borderRadius: 8, + borderLeftWidth: 4, + borderLeftColor: '#EF4444', + }, + errorText: { + fontSize: 13, + color: '#B91C1C', + }, + syncingContainer: { + padding: 24, + alignItems: 'center', + gap: 12, + }, + syncingText: { + fontSize: 14, + color: '#6B7280', + }, + actions: { + padding: 16, + gap: 10, + }, + button: { + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + alignItems: 'center', + }, + downloadButton: { + backgroundColor: '#F3F4F6', + }, + syncButton: { + backgroundColor: '#F97316', + }, + buttonText: { + fontSize: 14, + fontWeight: '600', + color: '#111827', + }, + infoSection: { + padding: 16, + backgroundColor: '#F9FAFB', + borderTopWidth: 1, + borderTopColor: '#E5E7EB', + }, + infoTitle: { + fontSize: 13, + fontWeight: '600', + color: '#374151', + marginBottom: 8, + }, + infoList: { + gap: 4, + marginBottom: 8, + }, + infoItem: { + fontSize: 13, + color: '#6B7280', + }, + infoNote: { + fontSize: 12, + color: '#9CA3AF', + fontStyle: 'italic', + }, +}); + +export default SyncProgress; diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..cc5bc95 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,3 @@ +export { ConnectionIndicator } from './ConnectionIndicator'; +export { OfflineBanner } from './OfflineBanner'; +export { SyncProgress } from './SyncProgress'; diff --git a/src/services/deepLinking.ts b/src/services/deepLinking.ts new file mode 100644 index 0000000..6f80974 --- /dev/null +++ b/src/services/deepLinking.ts @@ -0,0 +1,204 @@ +import * as Linking from 'expo-linking'; +import { NavigationContainerRef } from '@react-navigation/native'; + +// URL scheme prefix +const SCHEME = 'michangarrito://'; + +// Supported deep link routes +export const DEEP_LINK_ROUTES = { + // Main screens + DASHBOARD: 'dashboard', + POS: 'pos', + POS_NEW: 'pos/new', + PRODUCTS: 'products', + PRODUCT_DETAIL: 'products/:id', + INVENTORY: 'inventory', + ORDERS: 'orders', + ORDER_DETAIL: 'orders/:id', + CUSTOMERS: 'customers', + SCAN: 'scan', + SETTINGS: 'settings', + REPORTS: 'reports', +}; + +// Map deep link paths to screen names +const ROUTE_MAP: Record = { + dashboard: { screen: 'Main', nested: 'Dashboard' }, + pos: { screen: 'Main', nested: 'POS' }, + 'pos/new': { screen: 'Main', nested: 'POS' }, + products: { screen: 'Main', nested: 'Products' }, + inventory: { screen: 'Main', nested: 'Inventory' }, + orders: { screen: 'Main', nested: 'Orders' }, + customers: { screen: 'Main', nested: 'Customers' }, + scan: { screen: 'Scanner' }, + settings: { screen: 'Main', nested: 'Settings' }, + reports: { screen: 'Main', nested: 'Reports' }, +}; + +// Navigation reference (set from App.tsx) +let navigationRef: NavigationContainerRef | null = null; + +export function setNavigationRef(ref: NavigationContainerRef) { + navigationRef = ref; +} + +/** + * Get the linking configuration for React Navigation + */ +export function getLinkingConfig() { + return { + prefixes: [SCHEME, 'https://michangarrito.com'], + config: { + screens: { + Main: { + screens: { + Dashboard: 'dashboard', + POS: 'pos', + Products: 'products', + Inventory: 'inventory', + Orders: 'orders', + Customers: 'customers', + Settings: 'settings', + Reports: 'reports', + }, + }, + Scanner: 'scan', + ProductDetail: 'products/:id', + OrderDetail: 'orders/:id', + }, + }, + }; +} + +/** + * Parse a deep link URL and return the route info + */ +export function parseDeepLink(url: string): { + path: string; + params?: Record; +} | null { + try { + const parsed = Linking.parse(url); + + if (!parsed.path) { + return null; + } + + const path = parsed.path; + const params = parsed.queryParams as Record || {}; + + // Check for path parameters (e.g., products/:id) + const pathParts = path.split('/'); + if (pathParts.length >= 2) { + const basePath = pathParts[0]; + const id = pathParts[1]; + if (id && !id.startsWith(':')) { + params.id = id; + } + } + + return { path, params }; + } catch (error) { + console.error('Error parsing deep link:', error); + return null; + } +} + +/** + * Navigate to a deep link destination + */ +export async function navigateToDeepLink(url: string) { + if (!navigationRef?.isReady()) { + console.warn('Navigation not ready for deep link:', url); + // Store for later processing + pendingDeepLink = url; + return; + } + + const parsed = parseDeepLink(url); + if (!parsed) { + console.warn('Could not parse deep link:', url); + return; + } + + const routeInfo = ROUTE_MAP[parsed.path] || ROUTE_MAP[parsed.path.split('/')[0]]; + if (!routeInfo) { + console.warn('Unknown deep link route:', parsed.path); + return; + } + + try { + if (routeInfo.nested) { + navigationRef.navigate(routeInfo.screen, { + screen: routeInfo.nested, + params: parsed.params, + }); + } else { + navigationRef.navigate(routeInfo.screen, parsed.params); + } + } catch (error) { + console.error('Error navigating to deep link:', error); + } +} + +// Store pending deep link for cold start +let pendingDeepLink: string | null = null; + +/** + * Process any pending deep link (called when navigation is ready) + */ +export function processPendingDeepLink() { + if (pendingDeepLink && navigationRef?.isReady()) { + navigateToDeepLink(pendingDeepLink); + pendingDeepLink = null; + } +} + +/** + * Get the initial deep link URL (for cold start) + */ +export async function getInitialDeepLink(): Promise { + const url = await Linking.getInitialURL(); + return url; +} + +/** + * Subscribe to deep link events + */ +export function subscribeToDeepLinks( + callback: (url: string) => void +): () => void { + const subscription = Linking.addEventListener('url', ({ url }) => { + callback(url); + }); + + return () => subscription.remove(); +} + +/** + * Create a deep link URL for a given path + */ +export function createDeepLink(path: string, params?: Record): string { + let url = `${SCHEME}${path}`; + + if (params && Object.keys(params).length > 0) { + const queryString = Object.entries(params) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&'); + url += `?${queryString}`; + } + + return url; +} + +export default { + setNavigationRef, + getLinkingConfig, + parseDeepLink, + navigateToDeepLink, + processPendingDeepLink, + getInitialDeepLink, + subscribeToDeepLinks, + createDeepLink, + DEEP_LINK_ROUTES, +};