feat(MCH-022,MCH-025): Add offline UI components and deep linking
- ConnectionIndicator: Visual indicator for online/offline/syncing status - OfflineBanner: Animated banner shown when offline - SyncProgress: Modal showing detailed sync status and pending operations - deepLinking: Complete deep linking service with URL scheme michangarrito:// Sprint 7 completion - Modo Offline UI + Widgets/Atajos Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a71600df2f
commit
10f586e7e7
77
src/components/ConnectionIndicator.tsx
Normal file
77
src/components/ConnectionIndicator.tsx
Normal file
@ -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 = (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={[styles.indicator, { backgroundColor: getStatusColor() }]}>
|
||||||
|
{isSyncing && <View style={styles.pulseRing} />}
|
||||||
|
</View>
|
||||||
|
{showLabel && (
|
||||||
|
<Text style={[styles.label, { color: getStatusColor() }]}>
|
||||||
|
{getStatusText()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onPress) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||||
|
{content}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
123
src/components/OfflineBanner.tsx
Normal file
123
src/components/OfflineBanner.tsx
Normal file
@ -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 (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
getBannerStyle(),
|
||||||
|
{ transform: [{ translateY: slideAnim }] }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.icon}>{error ? '!' : '!'}</Text>
|
||||||
|
<Text style={styles.message} numberOfLines={2}>
|
||||||
|
{getMessage()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{!isOnline && pendingSalesCount > 0 && onSyncPress && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.syncButton}
|
||||||
|
onPress={onSyncPress}
|
||||||
|
disabled={isSyncing}
|
||||||
|
>
|
||||||
|
<Text style={styles.syncButtonText}>
|
||||||
|
{isSyncing ? 'Sincronizando...' : 'Sincronizar'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
284
src/components/SyncProgress.tsx
Normal file
284
src/components/SyncProgress.tsx
Normal file
@ -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 (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<View style={styles.modal}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Estado de Sincronizacion</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||||
|
<Text style={styles.closeText}>X</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statusCard}>
|
||||||
|
<View style={styles.statusRow}>
|
||||||
|
<Text style={styles.statusLabel}>Conexion:</Text>
|
||||||
|
<View style={styles.statusValue}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusDot,
|
||||||
|
{ backgroundColor: isOnline ? '#10B981' : '#EF4444' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Text style={styles.statusText}>
|
||||||
|
{isOnline ? 'Conectado' : 'Sin conexion'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statusRow}>
|
||||||
|
<Text style={styles.statusLabel}>Ultima sincronizacion:</Text>
|
||||||
|
<Text style={styles.statusText}>{formatDate(lastSyncAt)}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statusRow}>
|
||||||
|
<Text style={styles.statusLabel}>Ventas pendientes:</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.statusText,
|
||||||
|
pendingSalesCount > 0 && styles.pendingText,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{pendingSalesCount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View style={styles.errorCard}>
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSyncing && (
|
||||||
|
<View style={styles.syncingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#F97316" />
|
||||||
|
<Text style={styles.syncingText}>Sincronizando datos...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.downloadButton]}
|
||||||
|
onPress={downloadDataForOffline}
|
||||||
|
disabled={isSyncing || !isOnline}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>
|
||||||
|
{isSyncing ? 'Descargando...' : 'Descargar datos'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{pendingSalesCount > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.syncButton]}
|
||||||
|
onPress={syncNow}
|
||||||
|
disabled={isSyncing || !isOnline}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>
|
||||||
|
{isSyncing ? 'Sincronizando...' : `Sincronizar (${pendingSalesCount})`}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.infoSection}>
|
||||||
|
<Text style={styles.infoTitle}>Datos disponibles offline:</Text>
|
||||||
|
<View style={styles.infoList}>
|
||||||
|
<Text style={styles.infoItem}>• Productos y precios</Text>
|
||||||
|
<Text style={styles.infoItem}>• Clientes</Text>
|
||||||
|
<Text style={styles.infoItem}>• Ventas en efectivo</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.infoNote}>
|
||||||
|
Las ventas con tarjeta requieren conexion a internet.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
3
src/components/index.ts
Normal file
3
src/components/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { ConnectionIndicator } from './ConnectionIndicator';
|
||||||
|
export { OfflineBanner } from './OfflineBanner';
|
||||||
|
export { SyncProgress } from './SyncProgress';
|
||||||
204
src/services/deepLinking.ts
Normal file
204
src/services/deepLinking.ts
Normal file
@ -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<string, { screen: string; nested?: string }> = {
|
||||||
|
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<any> | null = null;
|
||||||
|
|
||||||
|
export function setNavigationRef(ref: NavigationContainerRef<any>) {
|
||||||
|
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<string, string>;
|
||||||
|
} | null {
|
||||||
|
try {
|
||||||
|
const parsed = Linking.parse(url);
|
||||||
|
|
||||||
|
if (!parsed.path) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = parsed.path;
|
||||||
|
const params = parsed.queryParams as Record<string, string> || {};
|
||||||
|
|
||||||
|
// 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<string | null> {
|
||||||
|
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, string>): 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,
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user