diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fa383c9 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# API +EXPO_PUBLIC_API_URL=http://localhost:3142/api/v1 + +# Environment +EXPO_PUBLIC_ENV=development diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..119d533 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,47 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2021, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, + plugins: ['@typescript-eslint', 'react', 'react-hooks'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + ], + env: { + browser: true, + es2021: true, + node: true, + jest: true, + }, + settings: { + react: { + version: 'detect', + }, + }, + ignorePatterns: [ + '.eslintrc.js', + 'babel.config.js', + 'metro.config.js', + 'node_modules', + '.expo', + ], + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_' }, + ], + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + 'no-console': ['warn', { allow: ['warn', 'error'] }], + }, +}; diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0063d2c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 80, + "tabWidth": 2, + "semi": true, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf", + "jsxSingleQuote": false +} diff --git a/README.md b/README.md deleted file mode 100644 index fdf32ae..0000000 --- a/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# miinventario-mobile-v2 - -Mobile de miinventario - Workspace V2 \ No newline at end of file diff --git a/app.json b/app.json new file mode 100644 index 0000000..5971906 --- /dev/null +++ b/app.json @@ -0,0 +1,55 @@ +{ + "expo": { + "name": "MiInventario", + "slug": "miinventario", + "version": "0.1.0", + "orientation": "portrait", + "icon": "./src/assets/icon.png", + "userInterfaceStyle": "light", + "splash": { + "image": "./src/assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.miinventario.app", + "infoPlist": { + "NSCameraUsageDescription": "MiInventario necesita acceso a la camara para grabar videos de tus anaqueles y generar inventario automatico.", + "NSMicrophoneUsageDescription": "MiInventario necesita acceso al microfono para grabar videos." + } + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./src/assets/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "package": "com.miinventario.app", + "permissions": [ + "android.permission.CAMERA", + "android.permission.RECORD_AUDIO" + ] + }, + "web": { + "bundler": "metro", + "output": "static", + "favicon": "./src/assets/favicon.png" + }, + "plugins": [ + "expo-router", + [ + "expo-camera", + { + "cameraPermission": "Permite acceso a la camara para escanear inventario." + } + ] + ], + "experiments": { + "typedRoutes": true + }, + "scheme": "miinventario" + } +} diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..eccb337 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,25 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: [ + 'react-native-reanimated/plugin', + [ + 'module-resolver', + { + root: ['./src'], + alias: { + '@': './src', + '@screens': './src/screens', + '@components': './src/components', + '@hooks': './src/hooks', + '@stores': './src/stores', + '@services': './src/services', + '@utils': './src/utils', + '@types': './src/types', + }, + }, + ], + ], + }; +}; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..3e223b1 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,37 @@ +module.exports = { + preset: 'react-native', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$', + transformIgnorePatterns: [ + 'node_modules/(?!(react-native|@react-native|expo|@expo|expo-.*|@react-native-async-storage|zustand|react-native-.*|@react-navigation)/)', + ], + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + '^@services/(.*)$': '/src/services/$1', + '^@stores/(.*)$': '/src/stores/$1', + '^@components/(.*)$': '/src/components/$1', + '^@hooks/(.*)$': '/src/hooks/$1', + '^@utils/(.*)$': '/src/utils/$1', + '^@theme/(.*)$': '/src/theme/$1', + '^@types/(.*)$': '/src/types/$1', + }, + setupFilesAfterEnv: ['/jest.setup.js'], + testEnvironment: 'node', + collectCoverageFrom: [ + 'src/stores/**/*.{ts,tsx}', + 'src/services/api/**/*.{ts,tsx}', + '!src/**/*.d.ts', + '!src/**/__tests__/**', + '!src/**/__mocks__/**', + ], + coverageThreshold: { + global: { + branches: 70, + functions: 70, + lines: 70, + statements: 70, + }, + }, + coverageReporters: ['text', 'lcov', 'html'], + reporters: ['default', 'jest-junit'], +}; diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..fd10c1d --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,71 @@ +// Mock expo-secure-store +jest.mock('expo-secure-store', () => ({ + getItemAsync: jest.fn(() => Promise.resolve(null)), + setItemAsync: jest.fn(() => Promise.resolve()), + deleteItemAsync: jest.fn(() => Promise.resolve()), +})); + +// Mock expo-router +jest.mock('expo-router', () => ({ + useRouter: jest.fn(() => ({ + push: jest.fn(), + replace: jest.fn(), + back: jest.fn(), + })), + useLocalSearchParams: jest.fn(() => ({})), + usePathname: jest.fn(() => '/'), + useSegments: jest.fn(() => []), + Stack: { + Screen: jest.fn(() => null), + }, + Tabs: { + Screen: jest.fn(() => null), + }, + Link: jest.fn(() => null), +})); + +// Mock @react-native-async-storage/async-storage +jest.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: jest.fn(() => Promise.resolve(null)), + setItem: jest.fn(() => Promise.resolve()), + removeItem: jest.fn(() => Promise.resolve()), + clear: jest.fn(() => Promise.resolve()), + getAllKeys: jest.fn(() => Promise.resolve([])), + }, +})); + +// Mock react-native-reanimated +jest.mock('react-native-reanimated', () => { + const Reanimated = require('react-native-reanimated/mock'); + Reanimated.default.call = () => {}; + return Reanimated; +}); + +// Mock @react-native-community/netinfo +jest.mock('@react-native-community/netinfo', () => ({ + addEventListener: jest.fn(() => jest.fn()), + fetch: jest.fn(() => Promise.resolve({ isConnected: true })), +})); + +// Global fetch mock +global.fetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({}), + ok: true, + status: 200, + }) +); + +// Console error suppression for known issues +const originalError = console.error; +console.error = (...args) => { + if ( + typeof args[0] === 'string' && + (args[0].includes('Warning: ReactDOM.render') || + args[0].includes('Warning: An update to')) + ) { + return; + } + originalError.call(console, ...args); +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..b3b9e67 --- /dev/null +++ b/package.json @@ -0,0 +1,63 @@ +{ + "name": "@miinventario/mobile", + "version": "0.1.0", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "start:dev": "expo start --dev-client", + "android": "expo run:android", + "ios": "expo run:ios", + "web": "expo start --web", + "lint": "eslint . --ext .ts,.tsx", + "format": "prettier --write \"src/**/*.{ts,tsx}\"", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "test:ci": "jest --ci --coverage --reporters=default --reporters=jest-junit" + }, + "dependencies": { + "@hookform/resolvers": "^3.3.0", + "@react-native-async-storage/async-storage": "1.21.0", + "@react-native-community/netinfo": "11.1.0", + "@react-navigation/bottom-tabs": "^6.5.0", + "@react-navigation/native": "^6.1.0", + "@react-navigation/native-stack": "^6.9.0", + "@tanstack/react-query": "^5.0.0", + "axios": "^1.6.0", + "expo": "~50.0.0", + "expo-av": "~13.10.0", + "expo-camera": "~14.0.0", + "expo-clipboard": "^8.0.8", + "expo-file-system": "~16.0.0", + "expo-image-picker": "~14.7.0", + "expo-router": "~3.4.0", + "expo-secure-store": "~12.8.0", + "expo-splash-screen": "~0.26.0", + "expo-status-bar": "~1.11.0", + "react": "18.2.0", + "react-hook-form": "^7.48.0", + "react-native": "0.73.0", + "react-native-gesture-handler": "~2.14.0", + "react-native-reanimated": "~3.6.0", + "react-native-safe-area-context": "4.8.2", + "react-native-screens": "~3.29.0", + "zod": "^3.22.0", + "zustand": "^4.4.0" + }, + "devDependencies": { + "@babel/core": "^7.20.0", + "@testing-library/react-native": "^12.0.0", + "@types/react": "~18.2.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.42.0", + "eslint-plugin-react": "^7.32.0", + "eslint-plugin-react-hooks": "^4.6.0", + "jest": "^29.5.0", + "jest-junit": "^16.0.0", + "prettier": "^3.0.0", + "react-test-renderer": "18.2.0", + "typescript": "^5.1.0" + } +} diff --git a/src/__mocks__/apiClient.mock.ts b/src/__mocks__/apiClient.mock.ts new file mode 100644 index 0000000..0a8d67d --- /dev/null +++ b/src/__mocks__/apiClient.mock.ts @@ -0,0 +1,49 @@ +import { jest } from '@jest/globals'; + +export const mockApiClient = { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + interceptors: { + request: { + use: jest.fn(), + }, + response: { + use: jest.fn(), + }, + }, +}; + +export const resetApiClientMocks = () => { + mockApiClient.get.mockReset(); + mockApiClient.post.mockReset(); + mockApiClient.put.mockReset(); + mockApiClient.patch.mockReset(); + mockApiClient.delete.mockReset(); +}; + +export const mockApiResponse = (data: T) => ({ + data, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, +}); + +export const mockApiError = ( + message: string, + status = 400, + data: unknown = {} +) => { + const error = new Error(message) as Error & { + response: { data: unknown; status: number }; + isAxiosError: boolean; + }; + error.response = { data, status }; + error.isAxiosError = true; + return error; +}; + +export default mockApiClient; diff --git a/src/app/(auth)/_layout.tsx b/src/app/(auth)/_layout.tsx new file mode 100644 index 0000000..7b5288c --- /dev/null +++ b/src/app/(auth)/_layout.tsx @@ -0,0 +1,16 @@ +import { Stack } from 'expo-router'; + +export default function AuthLayout() { + return ( + + + + + + ); +} diff --git a/src/app/(auth)/login.tsx b/src/app/(auth)/login.tsx new file mode 100644 index 0000000..7260433 --- /dev/null +++ b/src/app/(auth)/login.tsx @@ -0,0 +1,134 @@ +import { View, Text, StyleSheet, TouchableOpacity, TextInput } from 'react-native'; +import { Link, router } from 'expo-router'; +import { useState } from 'react'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useAuthStore } from '@stores/auth.store'; + +export default function LoginScreen() { + const [phone, setPhone] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const { login } = useAuthStore(); + + const handleLogin = async () => { + if (!phone || !password) return; + + setLoading(true); + try { + await login(phone, password); + router.replace('/(tabs)'); + } catch (error) { + console.error('Login error:', error); + } finally { + setLoading(false); + } + }; + + return ( + + + MiInventario + Inicia sesion para continuar + + + + + + + + {loading ? 'Iniciando...' : 'Iniciar Sesion'} + + + + + + No tienes cuenta? + + + Registrate + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + }, + content: { + flex: 1, + padding: 24, + justifyContent: 'center', + }, + title: { + fontSize: 32, + fontWeight: 'bold', + textAlign: 'center', + marginBottom: 8, + color: '#1a1a1a', + }, + subtitle: { + fontSize: 16, + textAlign: 'center', + marginBottom: 32, + color: '#666', + }, + form: { + gap: 16, + }, + input: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 12, + padding: 16, + fontSize: 16, + }, + button: { + backgroundColor: '#2563eb', + padding: 16, + borderRadius: 12, + alignItems: 'center', + marginTop: 8, + }, + buttonDisabled: { + opacity: 0.6, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + footer: { + flexDirection: 'row', + justifyContent: 'center', + marginTop: 24, + }, + footerText: { + color: '#666', + }, + link: { + color: '#2563eb', + fontWeight: '600', + }, +}); diff --git a/src/app/(auth)/register.tsx b/src/app/(auth)/register.tsx new file mode 100644 index 0000000..799d6f9 --- /dev/null +++ b/src/app/(auth)/register.tsx @@ -0,0 +1,136 @@ +import { View, Text, StyleSheet, TouchableOpacity, TextInput } from 'react-native'; +import { Link, router } from 'expo-router'; +import { useState } from 'react'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useAuthStore } from '@stores/auth.store'; + +export default function RegisterScreen() { + const [phone, setPhone] = useState(''); + const [name, setName] = useState(''); + const [loading, setLoading] = useState(false); + const { initiateRegistration } = useAuthStore(); + + const handleRegister = async () => { + if (!phone || !name) return; + + setLoading(true); + try { + await initiateRegistration(phone, name); + router.push({ + pathname: '/(auth)/verify-otp', + params: { phone }, + }); + } catch (error) { + console.error('Registration error:', error); + } finally { + setLoading(false); + } + }; + + return ( + + + Crear Cuenta + Ingresa tus datos para registrarte + + + + + + + + {loading ? 'Enviando...' : 'Continuar'} + + + + + + Ya tienes cuenta? + + + Inicia Sesion + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + }, + content: { + flex: 1, + padding: 24, + justifyContent: 'center', + }, + title: { + fontSize: 32, + fontWeight: 'bold', + textAlign: 'center', + marginBottom: 8, + color: '#1a1a1a', + }, + subtitle: { + fontSize: 16, + textAlign: 'center', + marginBottom: 32, + color: '#666', + }, + form: { + gap: 16, + }, + input: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 12, + padding: 16, + fontSize: 16, + }, + button: { + backgroundColor: '#2563eb', + padding: 16, + borderRadius: 12, + alignItems: 'center', + marginTop: 8, + }, + buttonDisabled: { + opacity: 0.6, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + footer: { + flexDirection: 'row', + justifyContent: 'center', + marginTop: 24, + }, + footerText: { + color: '#666', + }, + link: { + color: '#2563eb', + fontWeight: '600', + }, +}); diff --git a/src/app/(auth)/verify-otp.tsx b/src/app/(auth)/verify-otp.tsx new file mode 100644 index 0000000..8b987ee --- /dev/null +++ b/src/app/(auth)/verify-otp.tsx @@ -0,0 +1,129 @@ +import { View, Text, StyleSheet, TouchableOpacity, TextInput } from 'react-native'; +import { router, useLocalSearchParams } from 'expo-router'; +import { useState } from 'react'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useAuthStore } from '@stores/auth.store'; + +export default function VerifyOtpScreen() { + const { phone } = useLocalSearchParams<{ phone: string }>(); + const [otp, setOtp] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const { verifyOtp } = useAuthStore(); + + const handleVerify = async () => { + if (!otp || !password || !phone) return; + + setLoading(true); + try { + await verifyOtp(phone, otp, password); + router.replace('/(tabs)'); + } catch (error) { + console.error('Verification error:', error); + } finally { + setLoading(false); + } + }; + + return ( + + + Verificar Codigo + + Ingresa el codigo enviado a {phone} + + + + + + + + + {loading ? 'Verificando...' : 'Verificar y Crear Cuenta'} + + + + + + Reenviar codigo + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + }, + content: { + flex: 1, + padding: 24, + justifyContent: 'center', + }, + title: { + fontSize: 32, + fontWeight: 'bold', + textAlign: 'center', + marginBottom: 8, + color: '#1a1a1a', + }, + subtitle: { + fontSize: 16, + textAlign: 'center', + marginBottom: 32, + color: '#666', + }, + form: { + gap: 16, + }, + input: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 12, + padding: 16, + fontSize: 16, + textAlign: 'center', + }, + button: { + backgroundColor: '#2563eb', + padding: 16, + borderRadius: 12, + alignItems: 'center', + marginTop: 8, + }, + buttonDisabled: { + opacity: 0.6, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + resendButton: { + alignItems: 'center', + marginTop: 24, + }, + resendText: { + color: '#2563eb', + fontSize: 14, + }, +}); diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..768e66b --- /dev/null +++ b/src/app/(tabs)/_layout.tsx @@ -0,0 +1,48 @@ +import { Tabs } from 'expo-router'; +import { Text } from 'react-native'; + +export default function TabsLayout() { + return ( + + 🏠, + }} + /> + 📷, + }} + /> + 📦, + }} + /> + 👤, + }} + /> + + ); +} diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx new file mode 100644 index 0000000..776dbb9 --- /dev/null +++ b/src/app/(tabs)/index.tsx @@ -0,0 +1,542 @@ +import { View, Text, StyleSheet, ScrollView, TouchableOpacity, RefreshControl } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router } from 'expo-router'; +import { useEffect, useCallback, useState } from 'react'; +import Animated, { FadeIn, FadeInDown, FadeInRight, Layout } from 'react-native-reanimated'; +import { useAuthStore } from '@stores/auth.store'; +import { useCreditsStore } from '@stores/credits.store'; +import { useStoresStore } from '@stores/stores.store'; +import { useInventoryStore } from '@stores/inventory.store'; +import { useNotificationsStore } from '@stores/notifications.store'; +import { useFadeIn, usePressScale } from '../../hooks/useAnimations'; +import { Skeleton, SkeletonText, SkeletonStat } from '../../components/ui/Skeleton'; + +const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); + +function ActionCard({ + icon, + title, + description, + onPress, + index, +}: { + icon: string; + title: string; + description: string; + onPress: () => void; + index: number; +}) { + const { animatedStyle, onPressIn, onPressOut } = usePressScale(0.98); + + return ( + + + + {icon} + + + {title} + {description} + + + + + ); +} + +function StatCard({ + value, + label, + index, +}: { + value: number; + label: string; + index: number; +}) { + return ( + + {value} + {label} + + ); +} + +function HomeSkeleton() { + return ( + + {/* Header Skeleton */} + + + + + + + + {/* Credits Card Skeleton */} + + + {/* Actions Skeleton */} + + + + + + {/* Stats Skeleton */} + + + + + + + + ); +} + +export default function HomeScreen() { + const { user } = useAuthStore(); + const { balance, fetchBalance, isLoading: creditsLoading } = useCreditsStore(); + const { stores, currentStore, fetchStores, isLoading: storesLoading } = useStoresStore(); + const { items, fetchItems, isLoading: inventoryLoading } = useInventoryStore(); + const { unreadCount, fetchUnreadCount } = useNotificationsStore(); + const [refreshing, setRefreshing] = useState(false); + const [initialLoad, setInitialLoad] = useState(true); + + const loadData = useCallback(async () => { + await Promise.all([ + fetchBalance(), + fetchStores(true), + fetchUnreadCount(), + ]); + setInitialLoad(false); + }, [fetchBalance, fetchStores, fetchUnreadCount]); + + useEffect(() => { + loadData(); + }, [loadData]); + + useEffect(() => { + if (currentStore) { + fetchItems(currentStore.id, true); + } + }, [currentStore, fetchItems]); + + const onRefresh = async () => { + setRefreshing(true); + await loadData(); + setRefreshing(false); + }; + + const isLoading = initialLoad && (creditsLoading || storesLoading); + + if (isLoading) { + return ( + + + + + + ); + } + + return ( + + + } + > + {/* Header */} + + + + Hola, {user?.name || 'Usuario'} + + {currentStore ? currentStore.name : 'Selecciona una tienda'} + + + router.push('/notifications')} + > + 🔔 + {unreadCount > 0 && ( + + + {unreadCount > 9 ? '9+' : unreadCount} + + + )} + + + + + {/* Credits Card */} + + + Creditos disponibles + router.push('/credits/history')}> + Ver historial + + + {balance?.balance ?? 0} + router.push('/credits/buy')} + > + Comprar Creditos + + + + {/* Store Selector */} + {stores.length > 1 && ( + + Tienda Activa + + {stores.map((store, index) => ( + + useStoresStore.getState().selectStore(store)} + > + + {store.name} + + + + ))} + + + )} + + {/* Quick Actions */} + + + Acciones Rapidas + + + router.push('/(tabs)/scan')} + index={0} + /> + + router.push('/(tabs)/inventory')} + index={1} + /> + + router.push('/referrals')} + index={2} + /> + + + {/* Stats */} + + + Resumen + + + + + + + + + {/* Low Stock Alert */} + {items.filter(i => i.quantity < 5).length > 0 && ( + + ⚠️ + + Stock Bajo + + {items.filter(i => i.quantity < 5).length} productos con menos de 5 unidades + + + router.push('/(tabs)/inventory?filter=low-stock')}> + Ver + + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + scroll: { + flex: 1, + }, + content: { + padding: 16, + paddingBottom: 32, + }, + header: { + marginBottom: 20, + }, + headerTop: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + greeting: { + fontSize: 28, + fontWeight: 'bold', + color: '#1a1a1a', + }, + subtitle: { + fontSize: 16, + color: '#666', + marginTop: 4, + }, + notificationButton: { + position: 'relative', + padding: 8, + }, + notificationIcon: { + fontSize: 24, + }, + notificationBadge: { + position: 'absolute', + top: 4, + right: 4, + backgroundColor: '#ef4444', + borderRadius: 10, + minWidth: 18, + height: 18, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 4, + }, + notificationBadgeText: { + color: '#fff', + fontSize: 10, + fontWeight: 'bold', + }, + creditsCard: { + backgroundColor: '#2563eb', + borderRadius: 16, + padding: 20, + marginBottom: 20, + }, + creditsHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + creditsLabel: { + color: 'rgba(255,255,255,0.8)', + fontSize: 14, + }, + creditsHistoryLink: { + color: 'rgba(255,255,255,0.8)', + fontSize: 14, + textDecorationLine: 'underline', + }, + creditsAmount: { + color: '#fff', + fontSize: 48, + fontWeight: 'bold', + marginVertical: 8, + }, + buyButton: { + backgroundColor: 'rgba(255,255,255,0.2)', + paddingVertical: 12, + paddingHorizontal: 20, + borderRadius: 8, + alignSelf: 'flex-start', + }, + buyButtonText: { + color: '#fff', + fontWeight: '600', + }, + storeSelector: { + marginBottom: 20, + }, + storeChip: { + backgroundColor: '#fff', + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 20, + marginRight: 8, + borderWidth: 1, + borderColor: '#e5e5e5', + }, + storeChipActive: { + backgroundColor: '#2563eb', + borderColor: '#2563eb', + }, + storeChipText: { + color: '#666', + fontWeight: '500', + }, + storeChipTextActive: { + color: '#fff', + }, + actionsSection: { + marginBottom: 20, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + color: '#1a1a1a', + marginBottom: 12, + }, + actionCard: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + actionIconContainer: { + width: 48, + height: 48, + borderRadius: 12, + backgroundColor: '#f0f9ff', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + actionIcon: { + fontSize: 24, + }, + actionContent: { + flex: 1, + }, + actionTitle: { + fontSize: 16, + fontWeight: '600', + color: '#1a1a1a', + }, + actionDescription: { + fontSize: 14, + color: '#666', + marginTop: 2, + }, + actionArrow: { + fontSize: 24, + color: '#ccc', + }, + statsSection: { + marginBottom: 20, + }, + statsGrid: { + flexDirection: 'row', + gap: 12, + }, + statCard: { + flex: 1, + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + statValue: { + fontSize: 28, + fontWeight: 'bold', + color: '#1a1a1a', + }, + statLabel: { + fontSize: 12, + color: '#666', + marginTop: 4, + }, + alertCard: { + backgroundColor: '#fef3c7', + borderRadius: 12, + padding: 16, + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderColor: '#fcd34d', + }, + alertIcon: { + fontSize: 24, + marginRight: 12, + }, + alertContent: { + flex: 1, + }, + alertTitle: { + fontSize: 14, + fontWeight: '600', + color: '#92400e', + }, + alertDescription: { + fontSize: 12, + color: '#a16207', + marginTop: 2, + }, + alertAction: { + color: '#2563eb', + fontWeight: '600', + fontSize: 14, + }, +}); diff --git a/src/app/(tabs)/inventory.tsx b/src/app/(tabs)/inventory.tsx new file mode 100644 index 0000000..7631170 --- /dev/null +++ b/src/app/(tabs)/inventory.tsx @@ -0,0 +1,554 @@ +import { + View, + Text, + StyleSheet, + FlatList, + TouchableOpacity, + TextInput, + RefreshControl, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useEffect, useState, useCallback, useMemo } from 'react'; +import { router } from 'expo-router'; +import Animated, { FadeIn, FadeInDown, FadeInRight, FadeOut, Layout } from 'react-native-reanimated'; +import { useInventoryStore } from '@stores/inventory.store'; +import { useStoresStore } from '@stores/stores.store'; +import { usePressScale } from '../../hooks/useAnimations'; +import { InventoryListSkeleton } from '../../components/skeletons/InventoryItemSkeleton'; + +const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); + +interface InventoryItem { + id: string; + name: string; + quantity: number; + category?: string; + barcode?: string; + price?: number; + detectionConfidence?: number; + isManuallyEdited?: boolean; +} + +function InventoryItemCard({ + item, + index, +}: { + item: InventoryItem; + index: number; +}) { + const { animatedStyle, onPressIn, onPressOut } = usePressScale(0.98); + + return ( + + router.push(`/inventory/${item.id}`)} + onPressIn={onPressIn} + onPressOut={onPressOut} + activeOpacity={1} + > + + + + {item.name} + + {item.isManuallyEdited && ( + + Editado + + )} + + {item.category || 'Sin categoria'} + {item.barcode && ( + Codigo: {item.barcode} + )} + {item.detectionConfidence && ( + + + + )} + + + + {item.quantity} + + + unidades + + + + + ); +} + +export default function InventoryScreen() { + const { items, total, isLoading, error, fetchItems, searchQuery, setSearchQuery } = + useInventoryStore(); + const { currentStore } = useStoresStore(); + const [refreshing, setRefreshing] = useState(false); + const [filter, setFilter] = useState<'all' | 'low-stock'>('all'); + const [initialLoad, setInitialLoad] = useState(true); + + useEffect(() => { + if (currentStore) { + fetchItems(currentStore.id, true).then(() => setInitialLoad(false)); + } + }, [currentStore, fetchItems]); + + const onRefresh = useCallback(async () => { + if (!currentStore) return; + setRefreshing(true); + await fetchItems(currentStore.id, true); + setRefreshing(false); + }, [currentStore, fetchItems]); + + const filteredItems = useMemo(() => { + let result = items; + + // Apply search filter + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (item) => + item.name.toLowerCase().includes(query) || + item.category?.toLowerCase().includes(query) || + item.barcode?.includes(query) + ); + } + + // Apply low stock filter + if (filter === 'low-stock') { + result = result.filter((item) => item.quantity < 5); + } + + return result; + }, [items, searchQuery, filter]); + + const renderItem = useCallback( + ({ item, index }: { item: InventoryItem; index: number }) => ( + + ), + [] + ); + + const EmptyState = () => ( + + 📦 + + {searchQuery ? 'Sin resultados' : 'Sin inventario'} + + + {searchQuery + ? `No se encontraron productos que coincidan con "${searchQuery}"` + : 'Escanea tu primer anaquel para comenzar a registrar tu inventario'} + + {!searchQuery && ( + router.push('/(tabs)/scan')} + > + Escanear Anaquel + + )} + + ); + + if (!currentStore) { + return ( + + + 🏪 + Sin tienda seleccionada + + Crea o selecciona una tienda para ver su inventario + + router.push('/stores/new')} + > + Crear Tienda + + + + ); + } + + const showSkeleton = isLoading && initialLoad && items.length === 0; + + return ( + + {/* Header */} + + + + Inventario + + {currentStore.name} - {total} productos + + + + + {/* Search */} + + + {searchQuery && ( + setSearchQuery('')} + > + + + )} + + + {/* Filters */} + + setFilter('all')} + > + + Todos + + + setFilter('low-stock')} + > + + Stock bajo + + + + + + {/* List */} + {showSkeleton ? ( + + + + ) : filteredItems.length === 0 ? ( + + ) : ( + item.id} + contentContainerStyle={styles.list} + showsVerticalScrollIndicator={false} + refreshControl={ + + } + /> + )} + + {/* Error */} + {error && ( + + {error} + + Reintentar + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + header: { + padding: 16, + backgroundColor: '#fff', + borderBottomWidth: 1, + borderBottomColor: '#eee', + }, + headerTop: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + title: { + fontSize: 28, + fontWeight: 'bold', + color: '#1a1a1a', + }, + subtitle: { + fontSize: 14, + color: '#666', + marginTop: 4, + }, + searchContainer: { + position: 'relative', + marginTop: 16, + }, + searchInput: { + backgroundColor: '#f5f5f5', + borderRadius: 8, + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: 16, + color: '#1a1a1a', + }, + clearSearch: { + position: 'absolute', + right: 12, + top: 12, + padding: 4, + }, + clearSearchText: { + fontSize: 16, + color: '#999', + }, + filtersContainer: { + flexDirection: 'row', + marginTop: 12, + gap: 8, + }, + filterChip: { + paddingVertical: 6, + paddingHorizontal: 12, + borderRadius: 16, + backgroundColor: '#f5f5f5', + borderWidth: 1, + borderColor: '#e5e5e5', + }, + filterChipActive: { + backgroundColor: '#2563eb', + borderColor: '#2563eb', + }, + filterChipText: { + fontSize: 14, + color: '#666', + }, + filterChipTextActive: { + color: '#fff', + }, + list: { + padding: 16, + }, + itemCard: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + marginBottom: 12, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + itemInfo: { + flex: 1, + marginRight: 12, + }, + itemHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + itemName: { + fontSize: 16, + fontWeight: '600', + color: '#1a1a1a', + flex: 1, + }, + editedBadge: { + backgroundColor: '#dbeafe', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + editedBadgeText: { + fontSize: 10, + color: '#2563eb', + fontWeight: '500', + }, + itemCategory: { + fontSize: 14, + color: '#666', + marginTop: 4, + }, + itemBarcode: { + fontSize: 12, + color: '#999', + marginTop: 2, + }, + confidenceContainer: { + height: 4, + backgroundColor: '#e5e5e5', + borderRadius: 2, + marginTop: 8, + overflow: 'hidden', + }, + confidenceBar: { + height: '100%', + backgroundColor: '#22c55e', + borderRadius: 2, + }, + itemQuantity: { + alignItems: 'center', + backgroundColor: '#f0f9ff', + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 8, + minWidth: 70, + }, + itemQuantityLow: { + backgroundColor: '#fef2f2', + }, + quantityValue: { + fontSize: 20, + fontWeight: 'bold', + color: '#2563eb', + }, + quantityValueLow: { + color: '#ef4444', + }, + quantityLabel: { + fontSize: 12, + color: '#666', + }, + quantityLabelLow: { + color: '#ef4444', + }, + emptyState: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + emptyIcon: { + fontSize: 64, + marginBottom: 16, + }, + emptyTitle: { + fontSize: 24, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 8, + }, + emptyDescription: { + fontSize: 16, + color: '#666', + textAlign: 'center', + marginBottom: 24, + }, + emptyButton: { + backgroundColor: '#2563eb', + paddingVertical: 12, + paddingHorizontal: 24, + borderRadius: 8, + }, + emptyButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 12, + fontSize: 16, + color: '#666', + }, + footerLoader: { + paddingVertical: 16, + }, + errorBanner: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + backgroundColor: '#fef2f2', + padding: 16, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + borderTopWidth: 1, + borderTopColor: '#fecaca', + }, + errorText: { + color: '#ef4444', + flex: 1, + }, + errorRetry: { + color: '#2563eb', + fontWeight: '600', + }, +}); diff --git a/src/app/(tabs)/profile.tsx b/src/app/(tabs)/profile.tsx new file mode 100644 index 0000000..601493f --- /dev/null +++ b/src/app/(tabs)/profile.tsx @@ -0,0 +1,531 @@ +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Alert, + RefreshControl, + Share, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router } from 'expo-router'; +import { useEffect, useState, useCallback } from 'react'; +import * as Clipboard from 'expo-clipboard'; +import { useAuthStore } from '@stores/auth.store'; +import { useCreditsStore } from '@stores/credits.store'; +import { useReferralsStore } from '@stores/referrals.store'; + +export default function ProfileScreen() { + const { user, logout } = useAuthStore(); + const { balance, fetchBalance, isLoading: creditsLoading } = useCreditsStore(); + const { stats, fetchStats, isLoading: referralsLoading } = useReferralsStore(); + const [refreshing, setRefreshing] = useState(false); + const [copied, setCopied] = useState(false); + + const loadData = useCallback(async () => { + await Promise.all([fetchBalance(), fetchStats()]); + }, [fetchBalance, fetchStats]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const onRefresh = async () => { + setRefreshing(true); + await loadData(); + setRefreshing(false); + }; + + const handleLogout = () => { + Alert.alert( + 'Cerrar Sesion', + 'Estas seguro que deseas cerrar sesion?', + [ + { text: 'Cancelar', style: 'cancel' }, + { + text: 'Cerrar Sesion', + style: 'destructive', + onPress: async () => { + await logout(); + router.replace('/(auth)/login'); + }, + }, + ] + ); + }; + + const copyReferralCode = async () => { + if (stats?.referralCode) { + await Clipboard.setStringAsync(stats.referralCode); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const shareReferralCode = async () => { + if (stats?.referralCode) { + try { + await Share.share({ + message: `Te invito a usar MiInventario, la app que te ayuda a controlar tu inventario con solo grabar un video. Usa mi codigo ${stats.referralCode} y ambos ganaremos creditos gratis!`, + }); + } catch { + // User cancelled share + } + } + }; + + const MenuItem = ({ + icon, + label, + value, + onPress, + destructive = false, + }: { + icon: string; + label: string; + value?: string; + onPress: () => void; + destructive?: boolean; + }) => ( + + {icon} + + {label} + + {value && {value}} + + + ); + + return ( + + + } + > + {/* Header */} + + + + {user?.name?.charAt(0).toUpperCase() || 'U'} + + + {user?.name || 'Usuario'} + {user?.phone || ''} + + + {/* Credits Card */} + + + Tu Balance + {balance?.balance ?? 0} + creditos + + + + {balance?.totalPurchased ?? 0} + Comprados + + + + {balance?.totalFromReferrals ?? 0} + Por referidos + + + + {balance?.totalConsumed ?? 0} + Usados + + + router.push('/credits/buy')} + > + Comprar Creditos + + + + {/* Referral Card */} + + Invita y Gana + + Comparte tu codigo y gana 5 creditos por cada amigo que se registre + + + Tu codigo: + + {stats?.referralCode || '---'} + + + + + {copied ? '✓' : '📋'} + + {copied ? 'Copiado!' : 'Copiar'} + + + + 📤 + + Compartir + + + + + + {stats?.totalReferrals ?? 0} + Invitados + + + {stats?.completedReferrals ?? 0} + Completados + + + {stats?.totalCreditsEarned ?? 0} + Creditos ganados + + + + + {/* Menu Sections */} + + Cuenta + + router.push('/profile/edit')} + /> + router.push('/stores')} + /> + router.push('/payments/methods')} + /> + + + + + Creditos + + router.push('/credits/buy')} + /> + router.push('/credits/history')} + /> + router.push('/referrals')} + /> + + + + + Soporte + + router.push('/help')} + /> + router.push('/support')} + /> + router.push('/legal/terms')} + /> + router.push('/legal/privacy')} + /> + + + + + + + + + + MiInventario v1.0.0 + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + scroll: { + flex: 1, + }, + header: { + backgroundColor: '#fff', + alignItems: 'center', + paddingVertical: 24, + borderBottomWidth: 1, + borderBottomColor: '#eee', + }, + avatar: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: '#2563eb', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 12, + }, + avatarText: { + fontSize: 32, + fontWeight: 'bold', + color: '#fff', + }, + name: { + fontSize: 24, + fontWeight: 'bold', + color: '#1a1a1a', + }, + phone: { + fontSize: 16, + color: '#666', + marginTop: 4, + }, + creditsCard: { + backgroundColor: '#2563eb', + margin: 16, + borderRadius: 16, + padding: 20, + marginBottom: 8, + }, + creditsMain: { + alignItems: 'center', + marginBottom: 16, + }, + creditsLabel: { + color: 'rgba(255,255,255,0.8)', + fontSize: 14, + }, + creditsAmount: { + color: '#fff', + fontSize: 56, + fontWeight: 'bold', + marginVertical: 4, + }, + creditsUnit: { + color: 'rgba(255,255,255,0.8)', + fontSize: 16, + }, + creditsStats: { + flexDirection: 'row', + justifyContent: 'space-around', + borderTopWidth: 1, + borderTopColor: 'rgba(255,255,255,0.2)', + paddingTop: 16, + marginBottom: 16, + }, + creditsStat: { + alignItems: 'center', + flex: 1, + }, + creditsDivider: { + width: 1, + backgroundColor: 'rgba(255,255,255,0.2)', + }, + creditsStatValue: { + color: '#fff', + fontSize: 18, + fontWeight: 'bold', + }, + creditsStatLabel: { + color: 'rgba(255,255,255,0.7)', + fontSize: 12, + marginTop: 2, + }, + buyCreditsButton: { + backgroundColor: 'rgba(255,255,255,0.2)', + paddingVertical: 12, + borderRadius: 8, + alignItems: 'center', + }, + buyCreditsButtonText: { + color: '#fff', + fontWeight: '600', + fontSize: 16, + }, + referralCard: { + backgroundColor: '#fff', + margin: 16, + marginTop: 8, + borderRadius: 16, + padding: 20, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.05, + shadowRadius: 4, + elevation: 3, + }, + referralTitle: { + fontSize: 20, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 4, + }, + referralDescription: { + fontSize: 14, + color: '#666', + marginBottom: 16, + }, + referralCodeContainer: { + marginBottom: 16, + }, + referralCodeLabel: { + fontSize: 12, + color: '#666', + marginBottom: 4, + }, + referralCodeBox: { + backgroundColor: '#f5f5f5', + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + alignItems: 'center', + }, + referralCode: { + fontSize: 24, + fontWeight: 'bold', + color: '#2563eb', + letterSpacing: 2, + }, + referralActions: { + flexDirection: 'row', + gap: 12, + marginBottom: 16, + }, + referralActionButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + borderRadius: 8, + backgroundColor: '#f5f5f5', + gap: 8, + }, + referralActionButtonPrimary: { + backgroundColor: '#2563eb', + }, + referralActionIcon: { + fontSize: 16, + }, + referralActionText: { + fontSize: 14, + fontWeight: '600', + color: '#666', + }, + referralActionTextPrimary: { + color: '#fff', + }, + referralStats: { + flexDirection: 'row', + justifyContent: 'space-around', + borderTopWidth: 1, + borderTopColor: '#eee', + paddingTop: 16, + }, + referralStat: { + alignItems: 'center', + }, + referralStatValue: { + fontSize: 20, + fontWeight: 'bold', + color: '#1a1a1a', + }, + referralStatLabel: { + fontSize: 12, + color: '#666', + marginTop: 2, + }, + section: { + marginTop: 16, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + color: '#666', + marginBottom: 8, + paddingHorizontal: 16, + textTransform: 'uppercase', + }, + menuGroup: { + backgroundColor: '#fff', + borderTopWidth: 1, + borderBottomWidth: 1, + borderColor: '#eee', + }, + menuItem: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: '#eee', + }, + menuIcon: { + fontSize: 20, + marginRight: 12, + }, + menuLabel: { + flex: 1, + fontSize: 16, + color: '#1a1a1a', + }, + menuLabelDestructive: { + color: '#ef4444', + }, + menuValue: { + fontSize: 14, + color: '#666', + marginRight: 8, + }, + menuArrow: { + fontSize: 20, + color: '#ccc', + }, + version: { + textAlign: 'center', + color: '#999', + fontSize: 14, + marginVertical: 24, + }, +}); diff --git a/src/app/(tabs)/scan.tsx b/src/app/(tabs)/scan.tsx new file mode 100644 index 0000000..43c288c --- /dev/null +++ b/src/app/(tabs)/scan.tsx @@ -0,0 +1,467 @@ +import { View, Text, StyleSheet, TouchableOpacity, Alert, ActivityIndicator } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Camera, CameraType, CameraRecordingOptions } from 'expo-camera'; +import { useState, useRef, useEffect } from 'react'; +import { router } from 'expo-router'; +import * as FileSystem from 'expo-file-system'; +import { videosService } from '@services/api/videos.service'; +import { useStoresStore } from '@stores/stores.store'; +import { useCreditsStore } from '@stores/credits.store'; + +type ProcessingStatus = 'idle' | 'recording' | 'uploading' | 'processing' | 'completed' | 'failed'; + +export default function ScanScreen() { + const [permission, requestPermission] = Camera.useCameraPermissions(); + const [audioPermission, requestAudioPermission] = Camera.useMicrophonePermissions(); + const [status, setStatus] = useState('idle'); + const [progress, setProgress] = useState(0); + const [recordingDuration, setRecordingDuration] = useState(0); + const [errorMessage, setErrorMessage] = useState(null); + const cameraRef = useRef(null); + const recordingTimer = useRef(null); + const pollingTimer = useRef(null); + + const { currentStore, fetchStores } = useStoresStore(); + const { fetchBalance } = useCreditsStore(); + + useEffect(() => { + fetchStores(); + return () => { + if (recordingTimer.current) clearInterval(recordingTimer.current); + if (pollingTimer.current) clearInterval(pollingTimer.current); + }; + }, []); + + if (!permission || !audioPermission) { + return ( + + + + Cargando permisos... + + + ); + } + + if (!permission.granted || !audioPermission.granted) { + return ( + + + + Necesitamos acceso a la camara y microfono para escanear tu inventario + + { + await requestPermission(); + await requestAudioPermission(); + }} + > + Dar Permisos + + + + ); + } + + if (!currentStore) { + return ( + + + + Primero debes crear o seleccionar una tienda + + router.push('/stores/new')} + > + Crear Tienda + + + + ); + } + + const startRecording = async () => { + if (!cameraRef.current) return; + + setStatus('recording'); + setRecordingDuration(0); + setErrorMessage(null); + + // Start duration timer + recordingTimer.current = setInterval(() => { + setRecordingDuration(prev => prev + 1); + }, 1000); + + try { + const options: CameraRecordingOptions = { + maxDuration: 30, // Max 30 seconds + }; + + const video = await cameraRef.current.recordAsync(options); + + if (recordingTimer.current) { + clearInterval(recordingTimer.current); + } + + await processVideo(video.uri); + } catch (error) { + console.error('Recording error:', error); + setStatus('failed'); + setErrorMessage('Error al grabar video'); + if (recordingTimer.current) { + clearInterval(recordingTimer.current); + } + } + }; + + const stopRecording = async () => { + if (!cameraRef.current) return; + + try { + cameraRef.current.stopRecording(); + } catch (error) { + console.error('Stop recording error:', error); + } + }; + + const processVideo = async (videoUri: string) => { + if (!currentStore) return; + + setStatus('uploading'); + setProgress(0); + + try { + // Get file info + const fileInfo = await FileSystem.getInfoAsync(videoUri); + const fileSize = (fileInfo as any).size || 0; + const fileName = `scan_${Date.now()}.mp4`; + + // Initiate upload + const { videoId, uploadUrl } = await videosService.initiateUpload( + currentStore.id, + fileName, + fileSize + ); + + // Upload video + await videosService.uploadVideo(uploadUrl, videoUri, (uploadProgress) => { + setProgress(Math.round(uploadProgress * 50)); // 0-50% for upload + }); + + // Confirm upload + await videosService.confirmUpload(currentStore.id, videoId); + + setStatus('processing'); + setProgress(50); + + // Poll for processing status + await pollProcessingStatus(currentStore.id, videoId); + + } catch (error) { + console.error('Processing error:', error); + setStatus('failed'); + setErrorMessage(error instanceof Error ? error.message : 'Error al procesar video'); + } + }; + + const pollProcessingStatus = async (storeId: string, videoId: string) => { + const maxAttempts = 60; // 2 minutes max + let attempts = 0; + + return new Promise((resolve, reject) => { + pollingTimer.current = setInterval(async () => { + attempts++; + + if (attempts > maxAttempts) { + if (pollingTimer.current) clearInterval(pollingTimer.current); + setStatus('failed'); + setErrorMessage('Tiempo de espera agotado'); + reject(new Error('Timeout')); + return; + } + + try { + const result = await videosService.getStatus(storeId, videoId); + + // Update progress (50-100% for processing) + const processingProgress = 50 + (result.progress / 2); + setProgress(Math.round(processingProgress)); + + if (result.status === 'completed') { + if (pollingTimer.current) clearInterval(pollingTimer.current); + setStatus('completed'); + setProgress(100); + + // Refresh credits balance + await fetchBalance(); + + // Show success and navigate + Alert.alert( + 'Escaneo Completado', + `Se detectaron ${result.resultItems || 0} productos`, + [ + { + text: 'Ver Inventario', + onPress: () => router.replace('/(tabs)/inventory'), + }, + { + text: 'Nuevo Escaneo', + onPress: () => resetState(), + }, + ] + ); + + resolve(); + } else if (result.status === 'failed') { + if (pollingTimer.current) clearInterval(pollingTimer.current); + setStatus('failed'); + setErrorMessage(result.errorMessage || 'Error al procesar'); + reject(new Error(result.errorMessage)); + } + } catch (error) { + console.error('Polling error:', error); + } + }, 2000); + }); + }; + + const resetState = () => { + setStatus('idle'); + setProgress(0); + setRecordingDuration(0); + setErrorMessage(null); + }; + + const formatDuration = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + const isRecording = status === 'recording'; + const isProcessing = status === 'uploading' || status === 'processing'; + + return ( + + + + {/* Header */} + + {currentStore.name} + + {isRecording ? `Grabando ${formatDuration(recordingDuration)}` : + isProcessing ? 'Procesando...' : + status === 'completed' ? 'Completado' : + status === 'failed' ? 'Error' : + 'Escanear Anaquel'} + + + {isRecording ? 'Toca para detener' : + isProcessing ? `${progress}% completado` : + 'Mueve la camara lentamente por el anaquel'} + + + + {/* Progress bar for processing */} + {isProcessing && ( + + + + + + {status === 'uploading' ? 'Subiendo video...' : 'Detectando productos...'} + + + )} + + {/* Error message */} + {status === 'failed' && errorMessage && ( + + {errorMessage} + + Intentar de nuevo + + + )} + + {/* Controls */} + + {status === 'idle' && ( + <> + + + + Iniciar Grabacion + + )} + + {isRecording && ( + <> + + + + Detener (max 30s) + + )} + + {isProcessing && ( + + )} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#000', + }, + centered: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + backgroundColor: '#fff', + }, + loadingText: { + marginTop: 12, + fontSize: 16, + color: '#666', + }, + permissionText: { + fontSize: 16, + textAlign: 'center', + color: '#666', + marginBottom: 24, + }, + permissionButton: { + backgroundColor: '#2563eb', + paddingVertical: 12, + paddingHorizontal: 24, + borderRadius: 8, + }, + permissionButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + camera: { + flex: 1, + }, + overlay: { + flex: 1, + justifyContent: 'space-between', + }, + header: { + padding: 16, + backgroundColor: 'rgba(0,0,0,0.6)', + }, + storeName: { + color: 'rgba(255,255,255,0.7)', + fontSize: 12, + textAlign: 'center', + marginBottom: 4, + }, + headerText: { + color: '#fff', + fontSize: 20, + fontWeight: 'bold', + textAlign: 'center', + }, + headerSubtext: { + color: 'rgba(255,255,255,0.8)', + fontSize: 14, + textAlign: 'center', + marginTop: 4, + }, + progressContainer: { + padding: 20, + alignItems: 'center', + }, + progressBar: { + width: '80%', + height: 8, + backgroundColor: 'rgba(255,255,255,0.3)', + borderRadius: 4, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + backgroundColor: '#22c55e', + borderRadius: 4, + }, + progressText: { + color: '#fff', + fontSize: 14, + marginTop: 8, + }, + errorContainer: { + padding: 20, + alignItems: 'center', + }, + errorText: { + color: '#ef4444', + fontSize: 16, + textAlign: 'center', + marginBottom: 12, + }, + retryButton: { + backgroundColor: '#ef4444', + paddingVertical: 10, + paddingHorizontal: 20, + borderRadius: 8, + }, + retryButtonText: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, + controls: { + alignItems: 'center', + paddingBottom: 40, + }, + recordButton: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: 'rgba(255,255,255,0.3)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 4, + borderColor: '#fff', + }, + recordButtonActive: { + borderColor: '#ef4444', + }, + recordInner: { + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: '#ef4444', + }, + recordInnerActive: { + width: 30, + height: 30, + borderRadius: 4, + }, + recordText: { + color: '#fff', + fontSize: 14, + marginTop: 12, + }, +}); diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx new file mode 100644 index 0000000..2455fd3 --- /dev/null +++ b/src/app/_layout.tsx @@ -0,0 +1,50 @@ +import { Stack } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { StyleSheet, View } from 'react-native'; +import { OfflineBanner } from '../components/ui/OfflineBanner'; +import { ThemeProvider } from '../theme/ThemeContext'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + retry: 2, + }, + }, +}); + +export default function RootLayout() { + return ( + + + + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/src/app/credits/_layout.tsx b/src/app/credits/_layout.tsx new file mode 100644 index 0000000..c1a2e09 --- /dev/null +++ b/src/app/credits/_layout.tsx @@ -0,0 +1,23 @@ +import { Stack } from 'expo-router'; + +export default function CreditsLayout() { + return ( + + + + + ); +} diff --git a/src/app/credits/buy.tsx b/src/app/credits/buy.tsx new file mode 100644 index 0000000..1a9e3a1 --- /dev/null +++ b/src/app/credits/buy.tsx @@ -0,0 +1,434 @@ +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + ActivityIndicator, + Alert, + Linking, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useEffect, useState } from 'react'; +import { router } from 'expo-router'; +import { usePaymentsStore } from '@stores/payments.store'; +import { useCreditsStore } from '@stores/credits.store'; +import { CreditPackage } from '@services/api/payments.service'; + +type PaymentMethod = 'card' | 'oxxo' | '7eleven'; + +export default function BuyCreditsScreen() { + const { packages, fetchPackages, createPayment, isLoading, isProcessing, error } = + usePaymentsStore(); + const { fetchBalance } = useCreditsStore(); + const [selectedPackage, setSelectedPackage] = useState(null); + const [selectedMethod, setSelectedMethod] = useState('oxxo'); + + useEffect(() => { + fetchPackages(); + }, [fetchPackages]); + + const handlePurchase = async () => { + if (!selectedPackage) { + Alert.alert('Error', 'Selecciona un paquete de creditos'); + return; + } + + const response = await createPayment({ + packageId: selectedPackage.id, + method: selectedMethod, + }); + + if (response) { + if (response.status === 'completed') { + await fetchBalance(); + Alert.alert( + 'Compra Exitosa', + `Se agregaron ${selectedPackage.credits} creditos a tu cuenta`, + [{ text: 'OK', onPress: () => router.back() }] + ); + } else if (response.voucherUrl) { + Alert.alert( + 'Pago Pendiente', + `Tu ficha de pago esta lista. Codigo: ${response.voucherCode}\n\nTienes hasta ${new Date(response.expiresAt || '').toLocaleDateString()} para pagar.`, + [ + { text: 'Cancelar', style: 'cancel' }, + { + text: 'Ver Ficha', + onPress: () => Linking.openURL(response.voucherUrl!), + }, + ] + ); + } + } + }; + + const formatPrice = (price: number) => { + return `$${price.toFixed(2)} MXN`; + }; + + const PaymentMethodButton = ({ + method, + label, + icon, + description, + }: { + method: PaymentMethod; + label: string; + icon: string; + description: string; + }) => ( + setSelectedMethod(method)} + > + {icon} + + + {label} + + {description} + + + {selectedMethod === method && } + + + ); + + if (isLoading && packages.length === 0) { + return ( + + + + Cargando paquetes... + + + ); + } + + return ( + + + {/* Packages */} + Selecciona un paquete + + {packages.map((pkg) => ( + setSelectedPackage(pkg)} + > + {pkg.popular && ( + + Popular + + )} + {pkg.credits} + creditos + {formatPrice(pkg.priceMXN)} + + ${(pkg.priceMXN / pkg.credits).toFixed(2)}/credito + + + ))} + + + {/* Payment Methods */} + Metodo de pago + + + + + + + {/* Info */} + + ℹ️ + + {selectedMethod === 'card' + ? 'El pago con tarjeta se procesa inmediatamente y los creditos se agregan al instante.' + : 'Recibiras una ficha de pago. Los creditos se agregan automaticamente al pagar.'} + + + + {/* Error */} + {error && ( + + {error} + + )} + + + {/* Footer */} + + {selectedPackage && ( + + Total a pagar: + + {formatPrice(selectedPackage.priceMXN)} + + + )} + + {isProcessing ? ( + + ) : ( + + {selectedMethod === 'card' ? 'Pagar Ahora' : 'Generar Ficha de Pago'} + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + scroll: { + flex: 1, + }, + content: { + padding: 16, + paddingBottom: 32, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 12, + fontSize: 16, + color: '#666', + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + color: '#1a1a1a', + marginBottom: 12, + marginTop: 8, + }, + packagesGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + marginBottom: 24, + }, + packageCard: { + width: '47%', + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + alignItems: 'center', + borderWidth: 2, + borderColor: 'transparent', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + packageCardSelected: { + borderColor: '#2563eb', + backgroundColor: '#f0f9ff', + }, + packageCardPopular: { + borderColor: '#f59e0b', + }, + popularBadge: { + position: 'absolute', + top: -8, + backgroundColor: '#f59e0b', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 8, + }, + popularBadgeText: { + color: '#fff', + fontSize: 10, + fontWeight: 'bold', + }, + packageCredits: { + fontSize: 36, + fontWeight: 'bold', + color: '#2563eb', + }, + packageCreditsLabel: { + fontSize: 14, + color: '#666', + marginBottom: 8, + }, + packagePrice: { + fontSize: 18, + fontWeight: '600', + color: '#1a1a1a', + }, + packagePerCredit: { + fontSize: 12, + color: '#999', + marginTop: 4, + }, + methodsContainer: { + gap: 12, + marginBottom: 24, + }, + methodButton: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + borderWidth: 2, + borderColor: 'transparent', + }, + methodButtonSelected: { + borderColor: '#2563eb', + backgroundColor: '#f0f9ff', + }, + methodIcon: { + fontSize: 28, + marginRight: 12, + }, + methodInfo: { + flex: 1, + }, + methodLabel: { + fontSize: 16, + fontWeight: '600', + color: '#1a1a1a', + }, + methodLabelSelected: { + color: '#2563eb', + }, + methodDescription: { + fontSize: 13, + color: '#666', + marginTop: 2, + }, + methodRadio: { + width: 24, + height: 24, + borderRadius: 12, + borderWidth: 2, + borderColor: '#ccc', + justifyContent: 'center', + alignItems: 'center', + }, + methodRadioSelected: { + borderColor: '#2563eb', + }, + methodRadioInner: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: '#2563eb', + }, + infoCard: { + flexDirection: 'row', + backgroundColor: '#f0f9ff', + borderRadius: 12, + padding: 16, + borderWidth: 1, + borderColor: '#bfdbfe', + }, + infoIcon: { + fontSize: 20, + marginRight: 12, + }, + infoText: { + flex: 1, + fontSize: 14, + color: '#1e40af', + lineHeight: 20, + }, + errorCard: { + backgroundColor: '#fef2f2', + borderRadius: 12, + padding: 16, + marginTop: 16, + borderWidth: 1, + borderColor: '#fecaca', + }, + errorText: { + color: '#ef4444', + fontSize: 14, + }, + footer: { + backgroundColor: '#fff', + padding: 16, + borderTopWidth: 1, + borderTopColor: '#eee', + }, + footerSummary: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + footerLabel: { + fontSize: 16, + color: '#666', + }, + footerPrice: { + fontSize: 24, + fontWeight: 'bold', + color: '#1a1a1a', + }, + purchaseButton: { + backgroundColor: '#2563eb', + borderRadius: 12, + paddingVertical: 16, + alignItems: 'center', + }, + purchaseButtonDisabled: { + backgroundColor: '#93c5fd', + }, + purchaseButtonText: { + color: '#fff', + fontSize: 18, + fontWeight: '600', + }, +}); diff --git a/src/app/credits/history.tsx b/src/app/credits/history.tsx new file mode 100644 index 0000000..6c3d229 --- /dev/null +++ b/src/app/credits/history.tsx @@ -0,0 +1,221 @@ +import { + View, + Text, + StyleSheet, + FlatList, + TouchableOpacity, + RefreshControl, + ActivityIndicator, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useEffect, useState, useCallback } from 'react'; +import { useCreditsStore } from '@stores/credits.store'; + +interface Transaction { + id: string; + type: string; + amount: number; + description: string; + createdAt: string; +} + +export default function CreditsHistoryScreen() { + const { + transactions, + transactionsHasMore, + fetchTransactions, + isLoading, + } = useCreditsStore(); + const [refreshing, setRefreshing] = useState(false); + + useEffect(() => { + fetchTransactions(true); + }, [fetchTransactions]); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + await fetchTransactions(true); + setRefreshing(false); + }, [fetchTransactions]); + + const loadMore = () => { + if (transactionsHasMore && !isLoading) { + fetchTransactions(false); + } + }; + + const getTransactionIcon = (type: string) => { + switch (type) { + case 'purchase': + return '💰'; + case 'consumption': + return '📷'; + case 'referral_bonus': + return '🎁'; + default: + return '📝'; + } + }; + + const getTransactionColor = (type: string, amount: number) => { + if (amount > 0) return '#22c55e'; + return '#ef4444'; + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('es-MX', { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const renderItem = ({ item }: { item: Transaction }) => ( + + + + {getTransactionIcon(item.type)} + + + + {item.description} + {formatDate(item.createdAt)} + + + {item.amount > 0 ? '+' : ''}{item.amount} + + + ); + + const EmptyState = () => ( + + 📋 + Sin transacciones + + Aqui veras tu historial de creditos comprados y utilizados + + + ); + + return ( + + {isLoading && transactions.length === 0 ? ( + + + Cargando historial... + + ) : transactions.length === 0 ? ( + + ) : ( + item.id} + contentContainerStyle={styles.list} + refreshControl={ + + } + onEndReached={loadMore} + onEndReachedThreshold={0.5} + ListFooterComponent={ + isLoading ? ( + + ) : null + } + ItemSeparatorComponent={() => } + /> + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 12, + fontSize: 16, + color: '#666', + }, + list: { + padding: 16, + }, + transactionCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + }, + transactionIcon: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: '#f5f5f5', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + transactionIconText: { + fontSize: 20, + }, + transactionInfo: { + flex: 1, + }, + transactionDescription: { + fontSize: 15, + fontWeight: '500', + color: '#1a1a1a', + }, + transactionDate: { + fontSize: 13, + color: '#666', + marginTop: 2, + }, + transactionAmount: { + fontSize: 18, + fontWeight: 'bold', + }, + separator: { + height: 8, + }, + emptyState: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + emptyIcon: { + fontSize: 64, + marginBottom: 16, + }, + emptyTitle: { + fontSize: 24, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 8, + }, + emptyDescription: { + fontSize: 16, + color: '#666', + textAlign: 'center', + }, + footerLoader: { + paddingVertical: 16, + }, +}); diff --git a/src/app/help/index.tsx b/src/app/help/index.tsx new file mode 100644 index 0000000..44d127a --- /dev/null +++ b/src/app/help/index.tsx @@ -0,0 +1,292 @@ +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Stack, router } from 'expo-router'; +import { useState } from 'react'; + +interface FAQItem { + id: string; + question: string; + answer: string; + category: string; +} + +const faqs: FAQItem[] = [ + { + id: '1', + question: 'Como escaneo mi inventario?', + answer: 'Ve a la pestana "Escanear" y graba un video moviendo tu telefono lentamente por los anaqueles. La IA detectara automaticamente los productos y los agregara a tu inventario.', + category: 'escaneo', + }, + { + id: '2', + question: 'Cuantos creditos necesito por escaneo?', + answer: 'Cada escaneo de video consume 1 credito. Al registrarte recibes 5 creditos gratis para que pruebes la app.', + category: 'creditos', + }, + { + id: '3', + question: 'Como compro mas creditos?', + answer: 'Ve a tu perfil y toca "Comprar Creditos". Puedes pagar con tarjeta de credito/debito o en efectivo en OXXO.', + category: 'creditos', + }, + { + id: '4', + question: 'Como gano creditos gratis?', + answer: 'Invita a tus amigos usando tu codigo de referido. Por cada amigo que se registre, ambos reciben 5 creditos gratis.', + category: 'creditos', + }, + { + id: '5', + question: 'Como creo una tienda?', + answer: 'Ve a la pestana "Tiendas" y toca el boton "Nueva Tienda". Llena los datos de tu negocio y listo.', + category: 'tiendas', + }, + { + id: '6', + question: 'Puedo tener varias tiendas?', + answer: 'Si, puedes crear multiples tiendas y cambiar entre ellas. Cada tienda tiene su propio inventario.', + category: 'tiendas', + }, + { + id: '7', + question: 'Como edito mi inventario?', + answer: 'En la pestana "Inventario" puedes ver todos los productos detectados. Toca cualquier producto para editar su cantidad, precio o nombre.', + category: 'inventario', + }, + { + id: '8', + question: 'Que tan precisa es la deteccion?', + answer: 'La IA tiene una precision del 90-95%. Te recomendamos revisar los productos detectados y hacer ajustes si es necesario.', + category: 'escaneo', + }, + { + id: '9', + question: 'El pago en OXXO es seguro?', + answer: 'Si, utilizamos Stripe para procesar todos los pagos de forma segura. Al elegir OXXO recibiras un codigo para pagar en cualquier tienda.', + category: 'pagos', + }, + { + id: '10', + question: 'Cuando recibo mis creditos al pagar en OXXO?', + answer: 'Los creditos se acreditan automaticamente entre 24-48 horas despues de realizar el pago en tienda.', + category: 'pagos', + }, +]; + +const categories = [ + { id: 'todos', label: 'Todos' }, + { id: 'escaneo', label: 'Escaneo' }, + { id: 'creditos', label: 'Creditos' }, + { id: 'tiendas', label: 'Tiendas' }, + { id: 'inventario', label: 'Inventario' }, + { id: 'pagos', label: 'Pagos' }, +]; + +export default function HelpScreen() { + const [selectedCategory, setSelectedCategory] = useState('todos'); + const [expandedId, setExpandedId] = useState(null); + + const filteredFaqs = + selectedCategory === 'todos' + ? faqs + : faqs.filter((faq) => faq.category === selectedCategory); + + const toggleExpand = (id: string) => { + setExpandedId(expandedId === id ? null : id); + }; + + return ( + + + + + Como podemos ayudarte? + + Encuentra respuestas a las preguntas mas frecuentes + + + + + {categories.map((category) => ( + setSelectedCategory(category.id)} + > + + {category.label} + + + ))} + + + + {filteredFaqs.map((faq) => ( + toggleExpand(faq.id)} + activeOpacity={0.7} + > + + {faq.question} + + {expandedId === faq.id ? '−' : '+'} + + + {expandedId === faq.id && ( + {faq.answer} + )} + + ))} + + + + No encontraste lo que buscabas? + router.push('/support')} + > + Contactar Soporte + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + scroll: { + flex: 1, + }, + header: { + padding: 20, + backgroundColor: '#2563eb', + }, + headerTitle: { + fontSize: 24, + fontWeight: 'bold', + color: '#fff', + marginBottom: 4, + }, + headerSubtitle: { + fontSize: 14, + color: 'rgba(255,255,255,0.8)', + }, + categoriesContainer: { + backgroundColor: '#fff', + borderBottomWidth: 1, + borderBottomColor: '#eee', + }, + categoriesContent: { + padding: 12, + gap: 8, + }, + categoryChip: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + backgroundColor: '#f5f5f5', + marginRight: 8, + }, + categoryChipActive: { + backgroundColor: '#2563eb', + }, + categoryChipText: { + fontSize: 14, + color: '#666', + fontWeight: '500', + }, + categoryChipTextActive: { + color: '#fff', + }, + faqsList: { + padding: 16, + gap: 8, + }, + faqItem: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + faqHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + faqQuestion: { + flex: 1, + fontSize: 15, + fontWeight: '600', + color: '#1a1a1a', + marginRight: 12, + }, + faqArrow: { + fontSize: 20, + color: '#2563eb', + fontWeight: 'bold', + }, + faqAnswer: { + marginTop: 12, + fontSize: 14, + color: '#666', + lineHeight: 20, + }, + supportSection: { + margin: 16, + padding: 20, + backgroundColor: '#fff', + borderRadius: 12, + alignItems: 'center', + }, + supportTitle: { + fontSize: 16, + color: '#1a1a1a', + marginBottom: 12, + textAlign: 'center', + }, + supportButton: { + backgroundColor: '#2563eb', + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 8, + }, + supportButtonText: { + fontSize: 14, + fontWeight: '600', + color: '#fff', + }, +}); diff --git a/src/app/index.tsx b/src/app/index.tsx new file mode 100644 index 0000000..988d40a --- /dev/null +++ b/src/app/index.tsx @@ -0,0 +1,12 @@ +import { Redirect } from 'expo-router'; +import { useAuthStore } from '@stores/auth.store'; + +export default function Index() { + const { isAuthenticated } = useAuthStore(); + + if (isAuthenticated) { + return ; + } + + return ; +} diff --git a/src/app/inventory/[id].tsx b/src/app/inventory/[id].tsx new file mode 100644 index 0000000..acbbe62 --- /dev/null +++ b/src/app/inventory/[id].tsx @@ -0,0 +1,603 @@ +import { + View, + Text, + StyleSheet, + ScrollView, + TextInput, + TouchableOpacity, + ActivityIndicator, + Alert, + KeyboardAvoidingView, + Platform, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useState, useEffect } from 'react'; +import { router, useLocalSearchParams, Stack } from 'expo-router'; +import { useInventoryStore } from '@stores/inventory.store'; +import { InventoryItem } from '@services/api/inventory.service'; + +export default function InventoryDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const { items, updateItem, deleteItem, isLoading, error } = useInventoryStore(); + const [item, setItem] = useState(null); + const [name, setName] = useState(''); + const [quantity, setQuantity] = useState(''); + const [category, setCategory] = useState(''); + const [barcode, setBarcode] = useState(''); + const [price, setPrice] = useState(''); + const [isEditing, setIsEditing] = useState(false); + + useEffect(() => { + const foundItem = items.find((i) => i.id === id); + if (foundItem) { + setItem(foundItem); + setName(foundItem.name); + setQuantity(foundItem.quantity.toString()); + setCategory(foundItem.category || ''); + setBarcode(foundItem.barcode || ''); + setPrice(foundItem.price?.toString() || ''); + } + }, [id, items]); + + const handleSave = async () => { + if (!name.trim()) { + Alert.alert('Error', 'El nombre del producto es requerido'); + return; + } + + if (!id) return; + + try { + await updateItem(id, { + name: name.trim(), + quantity: parseInt(quantity, 10) || 0, + category: category.trim() || undefined, + barcode: barcode.trim() || undefined, + price: price ? parseFloat(price) : undefined, + }); + + Alert.alert('Listo', 'El producto ha sido actualizado'); + setIsEditing(false); + } catch { + // Error handled by store + } + }; + + const handleDelete = () => { + Alert.alert( + 'Eliminar Producto', + 'Estas seguro de eliminar este producto del inventario?', + [ + { text: 'Cancelar', style: 'cancel' }, + { + text: 'Eliminar', + style: 'destructive', + onPress: async () => { + if (!id) return; + try { + await deleteItem(id); + Alert.alert('Listo', 'El producto ha sido eliminado', [ + { text: 'OK', onPress: () => router.back() }, + ]); + } catch { + // Error handled by store + } + }, + }, + ] + ); + }; + + const adjustQuantity = (delta: number) => { + const current = parseInt(quantity, 10) || 0; + const newValue = Math.max(0, current + delta); + setQuantity(newValue.toString()); + }; + + if (!item) { + return ( + + + + + + ); + } + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('es-MX', { + day: 'numeric', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + return ( + <> + + !isEditing ? ( + setIsEditing(true)} + > + Editar + + ) : null, + }} + /> + + + + {/* Quantity Card */} + + Cantidad en inventario + {isEditing ? ( + + adjustQuantity(-1)} + > + - + + + adjustQuantity(1)} + > + + + + + ) : ( + + {item.quantity} + + )} + {item.quantity < 5 && !isEditing && ( + + Stock bajo + + )} + + + {/* Details */} + + Informacion del producto + + + Nombre + {isEditing ? ( + + ) : ( + {item.name} + )} + + + + Categoria + {isEditing ? ( + + ) : ( + + {item.category || 'Sin categoria'} + + )} + + + + Codigo de barras + {isEditing ? ( + + ) : ( + + {item.barcode || 'Sin codigo'} + + )} + + + + Precio + {isEditing ? ( + + ) : ( + + {item.price ? `$${item.price.toFixed(2)}` : 'Sin precio'} + + )} + + + + {/* Detection Info */} + {item.detectionConfidence && ( + + Deteccion automatica + + + Confianza + + {(item.detectionConfidence * 100).toFixed(0)}% + + + + + + {item.isManuallyEdited && ( + + + Editado manualmente + + + )} + + + )} + + {/* Metadata */} + + Historial + + + Creado + + {formatDate(item.createdAt)} + + + + Actualizado + + {formatDate(item.updatedAt)} + + + {item.lastDetectedAt && ( + + Ultima deteccion + + {formatDate(item.lastDetectedAt)} + + + )} + + + + {/* Error */} + {error && ( + + {error} + + )} + + {/* Delete Button */} + {isEditing && ( + + Eliminar Producto + + )} + + + {/* Footer */} + {isEditing && ( + + { + setName(item.name); + setQuantity(item.quantity.toString()); + setCategory(item.category || ''); + setBarcode(item.barcode || ''); + setPrice(item.price?.toString() || ''); + setIsEditing(false); + }} + > + Cancelar + + + {isLoading ? ( + + ) : ( + Guardar + )} + + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + keyboardAvoid: { + flex: 1, + }, + scroll: { + flex: 1, + }, + content: { + padding: 16, + }, + editHeaderButton: { + marginRight: 8, + }, + editHeaderButtonText: { + color: '#2563eb', + fontSize: 16, + fontWeight: '600', + }, + quantityCard: { + backgroundColor: '#fff', + borderRadius: 16, + padding: 24, + alignItems: 'center', + marginBottom: 16, + }, + quantityLabel: { + fontSize: 14, + color: '#666', + marginBottom: 8, + }, + quantityValue: { + fontSize: 64, + fontWeight: 'bold', + color: '#1a1a1a', + }, + quantityValueLow: { + color: '#ef4444', + }, + quantityEditor: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + }, + quantityButton: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: '#2563eb', + justifyContent: 'center', + alignItems: 'center', + }, + quantityButtonText: { + color: '#fff', + fontSize: 24, + fontWeight: 'bold', + }, + quantityInput: { + width: 100, + fontSize: 48, + fontWeight: 'bold', + color: '#1a1a1a', + }, + lowStockBadge: { + backgroundColor: '#fef2f2', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 8, + marginTop: 12, + }, + lowStockBadgeText: { + color: '#ef4444', + fontSize: 14, + fontWeight: '600', + }, + section: { + marginBottom: 16, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + color: '#666', + marginBottom: 8, + textTransform: 'uppercase', + }, + field: { + backgroundColor: '#fff', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#f5f5f5', + }, + fieldLabel: { + fontSize: 12, + color: '#666', + marginBottom: 4, + }, + fieldValue: { + fontSize: 16, + color: '#1a1a1a', + }, + fieldInput: { + fontSize: 16, + color: '#1a1a1a', + padding: 0, + }, + detectionCard: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + }, + detectionRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + detectionLabel: { + fontSize: 14, + color: '#666', + }, + detectionValue: { + fontSize: 16, + fontWeight: '600', + color: '#22c55e', + }, + confidenceBar: { + height: 8, + backgroundColor: '#e5e5e5', + borderRadius: 4, + overflow: 'hidden', + }, + confidenceBarFill: { + height: '100%', + backgroundColor: '#22c55e', + borderRadius: 4, + }, + editedBadge: { + backgroundColor: '#dbeafe', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 4, + alignSelf: 'flex-start', + marginTop: 12, + }, + editedBadgeText: { + color: '#2563eb', + fontSize: 12, + fontWeight: '500', + }, + metaCard: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + }, + metaRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 8, + borderBottomWidth: 1, + borderBottomColor: '#f5f5f5', + }, + metaLabel: { + fontSize: 14, + color: '#666', + }, + metaValue: { + fontSize: 14, + color: '#1a1a1a', + }, + errorCard: { + backgroundColor: '#fef2f2', + borderRadius: 12, + padding: 16, + marginBottom: 16, + borderWidth: 1, + borderColor: '#fecaca', + }, + errorText: { + color: '#ef4444', + fontSize: 14, + }, + deleteButton: { + alignItems: 'center', + paddingVertical: 16, + marginTop: 16, + }, + deleteButtonText: { + color: '#ef4444', + fontSize: 16, + fontWeight: '600', + }, + footer: { + flexDirection: 'row', + gap: 12, + backgroundColor: '#fff', + padding: 16, + borderTopWidth: 1, + borderTopColor: '#eee', + }, + cancelButton: { + flex: 1, + backgroundColor: '#f5f5f5', + borderRadius: 12, + paddingVertical: 16, + alignItems: 'center', + }, + cancelButtonText: { + color: '#666', + fontSize: 16, + fontWeight: '600', + }, + saveButton: { + flex: 1, + backgroundColor: '#2563eb', + borderRadius: 12, + paddingVertical: 16, + alignItems: 'center', + }, + saveButtonDisabled: { + backgroundColor: '#93c5fd', + }, + saveButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/src/app/inventory/_layout.tsx b/src/app/inventory/_layout.tsx new file mode 100644 index 0000000..321d889 --- /dev/null +++ b/src/app/inventory/_layout.tsx @@ -0,0 +1,23 @@ +import { Stack } from 'expo-router'; + +export default function InventoryLayout() { + return ( + + + + + ); +} diff --git a/src/app/inventory/export.tsx b/src/app/inventory/export.tsx new file mode 100644 index 0000000..6f62c37 --- /dev/null +++ b/src/app/inventory/export.tsx @@ -0,0 +1,492 @@ +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + ActivityIndicator, + Alert, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useState } from 'react'; +import * as Linking from 'expo-linking'; +import * as Sharing from 'expo-sharing'; +import * as FileSystem from 'expo-file-system'; +import { useStoresStore } from '@stores/stores.store'; +import { + exportsService, + ExportFormat, + ExportStatusResponse, +} from '@services/api/exports.service'; + +type ExportStep = 'select' | 'processing' | 'complete' | 'error'; + +export default function ExportInventoryScreen() { + const { currentStore } = useStoresStore(); + const [format, setFormat] = useState('CSV'); + const [lowStockOnly, setLowStockOnly] = useState(false); + const [step, setStep] = useState('select'); + const [progress, setProgress] = useState(null); + const [downloadUrl, setDownloadUrl] = useState(null); + const [filename, setFilename] = useState(''); + const [errorMessage, setErrorMessage] = useState(null); + + const handleExport = async () => { + if (!currentStore) { + Alert.alert('Error', 'No hay tienda seleccionada'); + return; + } + + setStep('processing'); + setErrorMessage(null); + + try { + // Request export + const { jobId } = await exportsService.requestInventoryExport( + currentStore.id, + format, + lowStockOnly ? { lowStockOnly: true } : undefined, + ); + + // Poll for completion + const status = await exportsService.pollExportStatus( + currentStore.id, + jobId, + (s) => setProgress(s), + ); + + if (status.status === 'FAILED') { + setStep('error'); + setErrorMessage(status.errorMessage || 'Error desconocido'); + return; + } + + // Get download URL + const download = await exportsService.getDownloadUrl(currentStore.id, jobId); + setDownloadUrl(download.url); + setFilename(download.filename); + setStep('complete'); + } catch (error) { + setStep('error'); + setErrorMessage(error instanceof Error ? error.message : 'Error al exportar'); + } + }; + + const handleDownload = async () => { + if (!downloadUrl) return; + + try { + await Linking.openURL(downloadUrl); + } catch { + Alert.alert('Error', 'No se pudo abrir el enlace de descarga'); + } + }; + + const handleShare = async () => { + if (!downloadUrl || !filename) return; + + try { + // Download file first + const localUri = FileSystem.documentDirectory + filename; + const download = await FileSystem.downloadAsync(downloadUrl, localUri); + + // Share + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(download.uri); + } else { + Alert.alert('Error', 'Compartir no esta disponible en este dispositivo'); + } + } catch { + Alert.alert('Error', 'No se pudo compartir el archivo'); + } + }; + + const handleReset = () => { + setStep('select'); + setProgress(null); + setDownloadUrl(null); + setFilename(''); + setErrorMessage(null); + }; + + const renderFormatOption = (value: ExportFormat, label: string, description: string) => ( + setFormat(value)} + > + + + {format === value && } + + {label} + + {description} + + ); + + if (step === 'processing') { + return ( + + + + Generando exportacion... + {progress && ( + + Estado: {progress.status} + {progress.totalRows !== undefined && ` (${progress.totalRows} productos)`} + + )} + + + ); + } + + if (step === 'complete') { + return ( + + + + + + Exportacion lista + {filename} + + + + Descargar + + + + Compartir + + + + + Nueva exportacion + + + + ); + } + + if (step === 'error') { + return ( + + + + ! + + Error al exportar + {errorMessage} + + + Intentar de nuevo + + + + ); + } + + return ( + + + Formato de exportacion + {renderFormatOption( + 'CSV', + 'CSV', + 'Archivo de texto separado por comas. Compatible con Excel, Google Sheets y otros.', + )} + {renderFormatOption( + 'EXCEL', + 'Excel (.xlsx)', + 'Archivo de Excel con formato y estilos. Ideal para reportes profesionales.', + )} + + Filtros + setLowStockOnly(!lowStockOnly)} + > + + {lowStockOnly && } + + + Solo productos con stock bajo + + Incluir unicamente productos que necesitan reabastecimiento + + + + + + Que incluye el archivo? + + • Nombre del producto{'\n'} + • Cantidad en inventario{'\n'} + • Categoria{'\n'} + • Codigo de barras{'\n'} + • Precio y costo{'\n'} + • Fecha de ultima actualizacion + + + + + + + Exportar Inventario + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + scroll: { + flex: 1, + }, + content: { + padding: 16, + }, + centerContent: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + color: '#666', + marginBottom: 12, + textTransform: 'uppercase', + }, + sectionTitleMargin: { + marginTop: 24, + }, + optionCard: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + marginBottom: 12, + borderWidth: 2, + borderColor: 'transparent', + }, + optionCardSelected: { + borderColor: '#2563eb', + backgroundColor: '#eff6ff', + }, + optionHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + radio: { + width: 24, + height: 24, + borderRadius: 12, + borderWidth: 2, + borderColor: '#ccc', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + radioSelected: { + borderColor: '#2563eb', + }, + radioInner: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: '#2563eb', + }, + optionLabel: { + fontSize: 16, + fontWeight: '600', + color: '#1a1a1a', + }, + optionDescription: { + fontSize: 14, + color: '#666', + marginLeft: 36, + }, + checkboxRow: { + flexDirection: 'row', + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + }, + checkbox: { + width: 24, + height: 24, + borderRadius: 6, + borderWidth: 2, + borderColor: '#ccc', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + checkboxChecked: { + borderColor: '#2563eb', + backgroundColor: '#2563eb', + }, + checkboxCheck: { + color: '#fff', + fontSize: 14, + fontWeight: 'bold', + }, + checkboxContent: { + flex: 1, + }, + checkboxLabel: { + fontSize: 16, + fontWeight: '500', + color: '#1a1a1a', + marginBottom: 4, + }, + checkboxDescription: { + fontSize: 14, + color: '#666', + }, + infoCard: { + backgroundColor: '#eff6ff', + borderRadius: 12, + padding: 16, + marginTop: 24, + }, + infoTitle: { + fontSize: 14, + fontWeight: '600', + color: '#1e40af', + marginBottom: 8, + }, + infoText: { + fontSize: 14, + color: '#1e40af', + lineHeight: 22, + }, + footer: { + backgroundColor: '#fff', + padding: 16, + borderTopWidth: 1, + borderTopColor: '#eee', + }, + exportButton: { + backgroundColor: '#2563eb', + borderRadius: 12, + paddingVertical: 16, + alignItems: 'center', + }, + exportButtonDisabled: { + backgroundColor: '#93c5fd', + }, + exportButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + processingTitle: { + fontSize: 18, + fontWeight: '600', + color: '#1a1a1a', + marginTop: 24, + }, + processingStatus: { + fontSize: 14, + color: '#666', + marginTop: 8, + }, + successIcon: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: '#dcfce7', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 24, + }, + successIconText: { + fontSize: 40, + color: '#22c55e', + }, + successTitle: { + fontSize: 24, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 8, + }, + successFilename: { + fontSize: 14, + color: '#666', + marginBottom: 32, + }, + actionButtons: { + width: '100%', + gap: 12, + marginBottom: 24, + }, + primaryButton: { + backgroundColor: '#2563eb', + borderRadius: 12, + paddingVertical: 16, + alignItems: 'center', + }, + primaryButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + secondaryButton: { + backgroundColor: '#f5f5f5', + borderRadius: 12, + paddingVertical: 16, + alignItems: 'center', + }, + secondaryButtonText: { + color: '#1a1a1a', + fontSize: 16, + fontWeight: '600', + }, + linkButton: { + paddingVertical: 12, + }, + linkButtonText: { + color: '#2563eb', + fontSize: 16, + fontWeight: '500', + }, + errorIcon: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: '#fef2f2', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 24, + }, + errorIconText: { + fontSize: 40, + color: '#ef4444', + fontWeight: 'bold', + }, + errorTitle: { + fontSize: 24, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 8, + }, + errorMessage: { + fontSize: 14, + color: '#666', + textAlign: 'center', + marginBottom: 32, + }, +}); diff --git a/src/app/legal/privacy.tsx b/src/app/legal/privacy.tsx new file mode 100644 index 0000000..bf4c7b0 --- /dev/null +++ b/src/app/legal/privacy.tsx @@ -0,0 +1,197 @@ +import { + View, + Text, + StyleSheet, + ScrollView, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Stack } from 'expo-router'; + +export default function PrivacyScreen() { + return ( + + + + Ultima actualizacion: Enero 2025 + + + 1. Informacion que Recopilamos + + Recopilamos la siguiente informacion:{'\n\n'} + Informacion de cuenta:{'\n'} + - Numero de telefono{'\n'} + - Nombre{'\n'} + - Correo electronico (opcional){'\n\n'} + Informacion de negocio:{'\n'} + - Nombre de la tienda{'\n'} + - Ubicacion{'\n'} + - Giro del negocio{'\n\n'} + Videos e imagenes:{'\n'} + - Videos grabados para escaneo de inventario{'\n'} + - Imagenes extraidas para procesamiento de IA + + + + + 2. Como Usamos tu Informacion + + Usamos tu informacion para:{'\n\n'} + - Procesar y detectar productos en tus videos{'\n'} + - Gestionar tu cuenta y tiendas{'\n'} + - Procesar pagos de creditos{'\n'} + - Enviarte notificaciones sobre tu cuenta{'\n'} + - Mejorar nuestros servicios y algoritmos de IA{'\n'} + - Responder a tus solicitudes de soporte + + + + + 3. Almacenamiento de Videos + + Los videos que subes son procesados para detectar productos y luego se eliminan + automaticamente despues de 30 dias. Las imagenes extraidas para el procesamiento + de IA se eliminan inmediatamente despues del analisis. + + + + + 4. Compartir Informacion + + No vendemos tu informacion personal. Compartimos datos solo con:{'\n\n'} + - Stripe: Para procesar pagos de forma segura{'\n'} + - Proveedores de IA: OpenAI/Anthropic para detectar productos{'\n'} + - Firebase: Para enviar notificaciones push{'\n'} + - AWS/MinIO: Para almacenar videos temporalmente + + + + + 5. Seguridad + + Implementamos medidas de seguridad para proteger tu informacion:{'\n\n'} + - Conexiones cifradas (HTTPS/TLS){'\n'} + - Almacenamiento seguro de contrasenas (hash + salt){'\n'} + - Tokens de autenticacion JWT{'\n'} + - Acceso restringido a datos personales + + + + + 6. Tus Derechos + + Tienes derecho a:{'\n\n'} + - Acceder a tu informacion personal{'\n'} + - Corregir datos inexactos{'\n'} + - Solicitar la eliminacion de tu cuenta{'\n'} + - Revocar el consentimiento para notificaciones{'\n'} + - Exportar tus datos de inventario + + + + + 7. Retencion de Datos + + Conservamos tu informacion mientras mantengas una cuenta activa. Si eliminas tu cuenta, + eliminaremos tus datos personales dentro de 30 dias, excepto cuando la ley requiera + mantener ciertos registros. + + + + + 8. Cookies y Tecnologias + + Usamos tecnologias como almacenamiento local para mantener tu sesion activa y + recordar tus preferencias. No usamos cookies de terceros para publicidad. + + + + + 9. Menores de Edad + + MiInventario no esta dirigido a menores de 18 anos. No recopilamos intencionalmente + informacion de menores de edad. + + + + + 10. Cambios a esta Politica + + Podemos actualizar esta politica periodicamente. Te notificaremos sobre cambios + significativos a traves de la aplicacion o por correo electronico. + + + + + 11. Contacto + + Para preguntas sobre privacidad, contactanos en:{'\n'} + Email: privacidad@miinventario.com{'\n'} + Telefono: +52 55 1234 5678 + + + + + + Al usar MiInventario, aceptas esta Politica de Privacidad y el procesamiento de + tu informacion como se describe aqui. + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + }, + scroll: { + flex: 1, + }, + content: { + padding: 16, + paddingBottom: 32, + }, + lastUpdated: { + fontSize: 12, + color: '#666', + marginBottom: 20, + fontStyle: 'italic', + }, + section: { + marginBottom: 24, + }, + sectionTitle: { + fontSize: 16, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 8, + }, + paragraph: { + fontSize: 14, + color: '#444', + lineHeight: 22, + }, + bold: { + fontWeight: '600', + color: '#1a1a1a', + }, + footer: { + marginTop: 20, + padding: 16, + backgroundColor: '#f5f5f5', + borderRadius: 8, + }, + footerText: { + fontSize: 13, + color: '#666', + textAlign: 'center', + fontStyle: 'italic', + }, +}); diff --git a/src/app/legal/terms.tsx b/src/app/legal/terms.tsx new file mode 100644 index 0000000..6cdb371 --- /dev/null +++ b/src/app/legal/terms.tsx @@ -0,0 +1,164 @@ +import { + View, + Text, + StyleSheet, + ScrollView, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Stack } from 'expo-router'; + +export default function TermsScreen() { + return ( + + + + Ultima actualizacion: Enero 2025 + + + 1. Aceptacion de Terminos + + Al descargar, instalar o usar la aplicacion MiInventario, aceptas estos Terminos y Condiciones. + Si no estas de acuerdo con alguno de estos terminos, no debes usar la aplicacion. + + + + + 2. Descripcion del Servicio + + MiInventario es una aplicacion movil que utiliza inteligencia artificial para ayudarte a + gestionar el inventario de tu negocio mediante el escaneo de video de tus productos. + + + + + 3. Registro y Cuenta + + Para usar MiInventario debes crear una cuenta proporcionando informacion veraz y actualizada. + Eres responsable de mantener la confidencialidad de tu cuenta y contrasena. + + + + + 4. Sistema de Creditos + + MiInventario utiliza un sistema de creditos para el procesamiento de videos:{'\n\n'} + - Cada escaneo de video consume 1 credito{'\n'} + - Los creditos comprados no son reembolsables{'\n'} + - Los creditos no tienen fecha de expiracion{'\n'} + - Los creditos no son transferibles entre cuentas + + + + + 5. Pagos + + Los pagos se procesan a traves de Stripe de forma segura. Aceptamos tarjetas de credito/debito + y pagos en efectivo a traves de OXXO. Los precios estan en pesos mexicanos (MXN) e incluyen IVA. + + + + + 6. Uso Aceptable + + Te comprometes a:{'\n\n'} + - Usar la aplicacion solo para fines legales{'\n'} + - No intentar acceder a cuentas de otros usuarios{'\n'} + - No interferir con el funcionamiento del servicio{'\n'} + - No usar la aplicacion para fines fraudulentos + + + + + 7. Propiedad Intelectual + + MiInventario y todo su contenido, caracteristicas y funcionalidad son propiedad de + MiInventario y estan protegidos por leyes de propiedad intelectual. + + + + + 8. Limitacion de Responsabilidad + + MiInventario se proporciona "tal cual" sin garantias de ningun tipo. No garantizamos + la precision del 100% en la deteccion de productos. Debes verificar la informacion generada + por la aplicacion. + + + + + 9. Modificaciones + + Nos reservamos el derecho de modificar estos terminos en cualquier momento. + Te notificaremos sobre cambios importantes a traves de la aplicacion o por correo electronico. + + + + + 10. Contacto + + Para preguntas sobre estos terminos, contactanos en:{'\n'} + Email: legal@miinventario.com{'\n'} + Telefono: +52 55 1234 5678 + + + + + + Al usar MiInventario, confirmas que has leido y aceptado estos Terminos y Condiciones. + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + }, + scroll: { + flex: 1, + }, + content: { + padding: 16, + paddingBottom: 32, + }, + lastUpdated: { + fontSize: 12, + color: '#666', + marginBottom: 20, + fontStyle: 'italic', + }, + section: { + marginBottom: 24, + }, + sectionTitle: { + fontSize: 16, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 8, + }, + paragraph: { + fontSize: 14, + color: '#444', + lineHeight: 22, + }, + footer: { + marginTop: 20, + padding: 16, + backgroundColor: '#f5f5f5', + borderRadius: 8, + }, + footerText: { + fontSize: 13, + color: '#666', + textAlign: 'center', + fontStyle: 'italic', + }, +}); diff --git a/src/app/notifications/_layout.tsx b/src/app/notifications/_layout.tsx new file mode 100644 index 0000000..02b718f --- /dev/null +++ b/src/app/notifications/_layout.tsx @@ -0,0 +1,19 @@ +import { Stack } from 'expo-router'; + +export default function NotificationsLayout() { + return ( + + + + ); +} diff --git a/src/app/notifications/index.tsx b/src/app/notifications/index.tsx new file mode 100644 index 0000000..1addd41 --- /dev/null +++ b/src/app/notifications/index.tsx @@ -0,0 +1,312 @@ +import { + View, + Text, + StyleSheet, + FlatList, + TouchableOpacity, + RefreshControl, + ActivityIndicator, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useEffect, useState, useCallback } from 'react'; +import { router, Stack } from 'expo-router'; +import { useNotificationsStore } from '@stores/notifications.store'; +import { Notification, NotificationType } from '@services/api/notifications.service'; + +export default function NotificationsScreen() { + const { + notifications, + unreadCount, + hasMore, + fetchNotifications, + markAsRead, + markAllAsRead, + isLoading, + } = useNotificationsStore(); + const [refreshing, setRefreshing] = useState(false); + + useEffect(() => { + fetchNotifications(true); + }, [fetchNotifications]); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + await fetchNotifications(true); + setRefreshing(false); + }, [fetchNotifications]); + + const loadMore = () => { + if (hasMore && !isLoading) { + fetchNotifications(false); + } + }; + + const handleNotificationPress = async (notification: Notification) => { + if (!notification.isRead) { + await markAsRead(notification.id); + } + + // Navigate based on notification type + const data = notification.data as Record | undefined; + switch (notification.type) { + case 'VIDEO_PROCESSING_COMPLETE': + if (data?.videoId && data?.storeId) { + router.push(`/inventory?storeId=${data.storeId}`); + } + break; + case 'PAYMENT_COMPLETE': + router.push('/credits/history'); + break; + case 'REFERRAL_BONUS': + router.push('/referrals'); + break; + case 'LOW_CREDITS': + router.push('/credits/buy'); + break; + default: + break; + } + }; + + const getNotificationIcon = (type: NotificationType) => { + switch (type) { + case 'VIDEO_PROCESSING_COMPLETE': + return '✅'; + case 'VIDEO_PROCESSING_FAILED': + return '❌'; + case 'LOW_CREDITS': + return '⚠️'; + case 'PAYMENT_COMPLETE': + return '💰'; + case 'PAYMENT_FAILED': + return '💳'; + case 'REFERRAL_BONUS': + return '🎁'; + default: + return '📢'; + } + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMins < 60) { + return diffMins <= 1 ? 'Hace un momento' : `Hace ${diffMins} min`; + } + if (diffHours < 24) { + return diffHours === 1 ? 'Hace 1 hora' : `Hace ${diffHours} horas`; + } + if (diffDays < 7) { + return diffDays === 1 ? 'Ayer' : `Hace ${diffDays} dias`; + } + return date.toLocaleDateString('es-MX', { + day: 'numeric', + month: 'short', + }); + }; + + const renderNotification = ({ item }: { item: Notification }) => ( + handleNotificationPress(item)} + > + + + {getNotificationIcon(item.type)} + + + + + + {item.title} + + {!item.isRead && } + + + {item.body} + + {formatDate(item.createdAt)} + + + ); + + const EmptyState = () => ( + + 🔔 + Sin notificaciones + + Aqui veras las notificaciones sobre tus escaneos, pagos y referidos + + + ); + + return ( + <> + + unreadCount > 0 ? ( + + Marcar leidas + + ) : null, + }} + /> + + {isLoading && notifications.length === 0 ? ( + + + Cargando notificaciones... + + ) : notifications.length === 0 ? ( + + ) : ( + item.id} + contentContainerStyle={styles.list} + refreshControl={ + + } + onEndReached={loadMore} + onEndReachedThreshold={0.5} + ListFooterComponent={ + isLoading ? ( + + ) : null + } + ItemSeparatorComponent={() => } + /> + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 12, + fontSize: 16, + color: '#666', + }, + markAllButton: { + marginRight: 8, + }, + markAllButtonText: { + color: '#2563eb', + fontSize: 14, + fontWeight: '600', + }, + list: { + padding: 16, + }, + notificationCard: { + flexDirection: 'row', + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + }, + notificationCardUnread: { + backgroundColor: '#f0f9ff', + }, + notificationIcon: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: '#f5f5f5', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + notificationIconText: { + fontSize: 20, + }, + notificationContent: { + flex: 1, + }, + notificationHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 4, + }, + notificationTitle: { + flex: 1, + fontSize: 15, + fontWeight: '500', + color: '#1a1a1a', + }, + notificationTitleUnread: { + fontWeight: '600', + }, + unreadDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#2563eb', + marginLeft: 8, + }, + notificationBody: { + fontSize: 14, + color: '#666', + lineHeight: 20, + marginBottom: 4, + }, + notificationTime: { + fontSize: 12, + color: '#999', + }, + separator: { + height: 8, + }, + emptyState: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + emptyIcon: { + fontSize: 64, + marginBottom: 16, + }, + emptyTitle: { + fontSize: 24, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 8, + }, + emptyDescription: { + fontSize: 16, + color: '#666', + textAlign: 'center', + }, + footerLoader: { + paddingVertical: 16, + }, +}); diff --git a/src/app/payments/methods.tsx b/src/app/payments/methods.tsx new file mode 100644 index 0000000..3da8c7c --- /dev/null +++ b/src/app/payments/methods.tsx @@ -0,0 +1,288 @@ +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Stack, router } from 'expo-router'; + +interface PaymentMethod { + id: string; + type: 'card' | 'oxxo' | '7eleven'; + name: string; + description: string; + icon: string; + available: boolean; +} + +const paymentMethods: PaymentMethod[] = [ + { + id: 'card', + type: 'card', + name: 'Tarjeta de Credito/Debito', + description: 'Visa, Mastercard, American Express', + icon: '💳', + available: true, + }, + { + id: 'oxxo', + type: 'oxxo', + name: 'OXXO', + description: 'Paga en efectivo en cualquier OXXO', + icon: '🏪', + available: true, + }, + { + id: '7eleven', + type: '7eleven', + name: '7-Eleven', + description: 'Proximamente disponible', + icon: '🏬', + available: false, + }, +]; + +export default function PaymentMethodsScreen() { + return ( + + + + + Metodos Disponibles + + Selecciona tu metodo de pago preferido al comprar creditos + + + + + {paymentMethods.map((method) => ( + + + {method.icon} + + + + {method.name} + + {method.description} + + {method.available ? ( + + + + ) : ( + + Pronto + + )} + + ))} + + + + Sobre los pagos + + + 🔒 + + Pagos Seguros + + Todos los pagos son procesados de forma segura a traves de Stripe + + + + + + + + Creditos Instantaneos + + Los creditos se acreditan inmediatamente al pagar con tarjeta + + + + + + 🏪 + + Pago en Efectivo + + Recibe un voucher para pagar en OXXO. Los creditos se acreditan en 24-48 horas + + + + + + router.push('/credits/buy')} + > + Comprar Creditos + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + scroll: { + flex: 1, + }, + section: { + padding: 16, + backgroundColor: '#fff', + borderBottomWidth: 1, + borderBottomColor: '#eee', + }, + sectionTitle: { + fontSize: 20, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 4, + }, + sectionDescription: { + fontSize: 14, + color: '#666', + }, + methodsList: { + padding: 16, + gap: 12, + }, + methodCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#fff', + padding: 16, + borderRadius: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + methodCardDisabled: { + opacity: 0.6, + }, + methodIcon: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: '#f5f5f5', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + methodIconText: { + fontSize: 24, + }, + methodInfo: { + flex: 1, + }, + methodName: { + fontSize: 16, + fontWeight: '600', + color: '#1a1a1a', + marginBottom: 2, + }, + methodNameDisabled: { + color: '#999', + }, + methodDescription: { + fontSize: 13, + color: '#666', + }, + checkmark: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: '#22c55e', + justifyContent: 'center', + alignItems: 'center', + }, + checkmarkText: { + color: '#fff', + fontSize: 14, + fontWeight: 'bold', + }, + comingSoon: { + backgroundColor: '#f5f5f5', + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + }, + comingSoonText: { + fontSize: 12, + color: '#999', + fontWeight: '500', + }, + infoSection: { + margin: 16, + marginTop: 8, + padding: 16, + backgroundColor: '#fff', + borderRadius: 12, + }, + infoTitle: { + fontSize: 16, + fontWeight: '600', + color: '#1a1a1a', + marginBottom: 16, + }, + infoItem: { + flexDirection: 'row', + marginBottom: 16, + }, + infoIcon: { + fontSize: 20, + marginRight: 12, + }, + infoContent: { + flex: 1, + }, + infoLabel: { + fontSize: 14, + fontWeight: '600', + color: '#1a1a1a', + marginBottom: 2, + }, + infoText: { + fontSize: 13, + color: '#666', + lineHeight: 18, + }, + buyButton: { + margin: 16, + marginTop: 8, + backgroundColor: '#2563eb', + paddingVertical: 16, + borderRadius: 12, + alignItems: 'center', + }, + buyButtonText: { + fontSize: 16, + fontWeight: '600', + color: '#fff', + }, +}); diff --git a/src/app/profile/edit.tsx b/src/app/profile/edit.tsx new file mode 100644 index 0000000..dfe6618 --- /dev/null +++ b/src/app/profile/edit.tsx @@ -0,0 +1,316 @@ +import { + View, + Text, + StyleSheet, + TextInput, + TouchableOpacity, + ScrollView, + KeyboardAvoidingView, + Platform, + Alert, + ActivityIndicator, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router, Stack } from 'expo-router'; +import { useState, useEffect } from 'react'; +import { useAuthStore } from '@stores/auth.store'; +import { usersService, UpdateProfileRequest } from '@services/api/users.service'; + +export default function EditProfileScreen() { + const { user, setUser } = useAuthStore(); + const [name, setName] = useState(user?.name || ''); + const [email, setEmail] = useState(user?.email || ''); + const [isLoading, setIsLoading] = useState(false); + const [isFetching, setIsFetching] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + loadProfile(); + }, []); + + const loadProfile = async () => { + try { + const profile = await usersService.getProfile(); + setName(profile.name); + setEmail(profile.email || ''); + } catch (err) { + console.error('Error loading profile:', err); + } finally { + setIsFetching(false); + } + }; + + const validateEmail = (email: string) => { + if (!email) return true; // Email is optional + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const handleSave = async () => { + setError(null); + + if (!name.trim()) { + setError('El nombre es requerido'); + return; + } + + if (!validateEmail(email)) { + setError('Email invalido'); + return; + } + + setIsLoading(true); + + try { + const updateData: UpdateProfileRequest = { + name: name.trim(), + }; + + if (email.trim()) { + updateData.email = email.trim(); + } + + const updatedProfile = await usersService.updateProfile(updateData); + + setUser({ + id: updatedProfile.id, + phone: updatedProfile.phone, + name: updatedProfile.name, + email: updatedProfile.email, + }); + + Alert.alert('Exito', 'Perfil actualizado correctamente', [ + { text: 'OK', onPress: () => router.back() }, + ]); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al actualizar perfil'); + } finally { + setIsLoading(false); + } + }; + + if (isFetching) { + return ( + + + + + + + ); + } + + return ( + + + + + + + + {name?.charAt(0).toUpperCase() || 'U'} + + + + + + + Nombre + + + + + Telefono + + {user?.phone} + + + El numero de telefono no puede ser modificado + + + + + Email (opcional) + + + + {error && ( + + {error} + + )} + + + + + router.back()} + disabled={isLoading} + > + Cancelar + + + {isLoading ? ( + + ) : ( + Guardar + )} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + keyboardView: { + flex: 1, + }, + scroll: { + flex: 1, + }, + scrollContent: { + paddingBottom: 20, + }, + avatarContainer: { + alignItems: 'center', + paddingVertical: 24, + backgroundColor: '#fff', + }, + avatar: { + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: '#2563eb', + justifyContent: 'center', + alignItems: 'center', + }, + avatarText: { + fontSize: 40, + fontWeight: 'bold', + color: '#fff', + }, + form: { + padding: 16, + }, + inputGroup: { + marginBottom: 20, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: '#666', + marginBottom: 8, + }, + input: { + backgroundColor: '#fff', + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 8, + fontSize: 16, + borderWidth: 1, + borderColor: '#e5e5e5', + }, + disabledInput: { + backgroundColor: '#f5f5f5', + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 8, + borderWidth: 1, + borderColor: '#e5e5e5', + }, + disabledInputText: { + fontSize: 16, + color: '#999', + }, + helperText: { + fontSize: 12, + color: '#999', + marginTop: 4, + }, + errorContainer: { + backgroundColor: '#fef2f2', + padding: 12, + borderRadius: 8, + marginTop: 8, + }, + errorText: { + color: '#ef4444', + fontSize: 14, + }, + footer: { + flexDirection: 'row', + padding: 16, + backgroundColor: '#fff', + borderTopWidth: 1, + borderTopColor: '#eee', + gap: 12, + }, + cancelButton: { + flex: 1, + paddingVertical: 14, + borderRadius: 8, + alignItems: 'center', + backgroundColor: '#f5f5f5', + }, + cancelButtonText: { + fontSize: 16, + fontWeight: '600', + color: '#666', + }, + saveButton: { + flex: 1, + paddingVertical: 14, + borderRadius: 8, + alignItems: 'center', + backgroundColor: '#2563eb', + }, + saveButtonDisabled: { + opacity: 0.6, + }, + saveButtonText: { + fontSize: 16, + fontWeight: '600', + color: '#fff', + }, +}); diff --git a/src/app/referrals/_layout.tsx b/src/app/referrals/_layout.tsx new file mode 100644 index 0000000..b6f51b0 --- /dev/null +++ b/src/app/referrals/_layout.tsx @@ -0,0 +1,19 @@ +import { Stack } from 'expo-router'; + +export default function ReferralsLayout() { + return ( + + + + ); +} diff --git a/src/app/referrals/index.tsx b/src/app/referrals/index.tsx new file mode 100644 index 0000000..11e1b92 --- /dev/null +++ b/src/app/referrals/index.tsx @@ -0,0 +1,460 @@ +import { + View, + Text, + StyleSheet, + FlatList, + TouchableOpacity, + RefreshControl, + ActivityIndicator, + Share, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useEffect, useState, useCallback } from 'react'; +import * as Clipboard from 'expo-clipboard'; +import { useReferralsStore } from '@stores/referrals.store'; +import { Referral } from '@services/api/referrals.service'; + +export default function ReferralsScreen() { + const { + stats, + referrals, + hasMore, + fetchStats, + fetchReferrals, + isLoading, + } = useReferralsStore(); + const [refreshing, setRefreshing] = useState(false); + const [copied, setCopied] = useState(false); + + useEffect(() => { + fetchStats(); + fetchReferrals(true); + }, [fetchStats, fetchReferrals]); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + await Promise.all([fetchStats(), fetchReferrals(true)]); + setRefreshing(false); + }, [fetchStats, fetchReferrals]); + + const loadMore = () => { + if (hasMore && !isLoading) { + fetchReferrals(false); + } + }; + + const copyCode = async () => { + if (stats?.referralCode) { + await Clipboard.setStringAsync(stats.referralCode); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const shareCode = async () => { + if (stats?.referralCode) { + try { + await Share.share({ + message: `Te invito a usar MiInventario, la app que te ayuda a controlar tu inventario con solo grabar un video. Usa mi codigo ${stats.referralCode} y ambos ganaremos creditos gratis!`, + }); + } catch { + // User cancelled + } + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'REWARDED': + return '#22c55e'; + case 'QUALIFIED': + return '#3b82f6'; + case 'REGISTERED': + return '#f59e0b'; + default: + return '#9ca3af'; + } + }; + + const getStatusLabel = (status: string) => { + switch (status) { + case 'REWARDED': + return 'Completado'; + case 'QUALIFIED': + return 'Calificado'; + case 'REGISTERED': + return 'Registrado'; + default: + return 'Pendiente'; + } + }; + + const formatDate = (dateString?: string) => { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString('es-MX', { + day: 'numeric', + month: 'short', + year: 'numeric', + }); + }; + + const renderReferral = ({ item }: { item: Referral }) => ( + + + + {item.referred?.name?.charAt(0).toUpperCase() || '?'} + + + + + {item.referred?.name || 'Usuario'} + + + Registrado: {formatDate(item.registeredAt || item.createdAt)} + + + + + + {getStatusLabel(item.status)} + + + {item.status === 'REWARDED' && ( + +{item.referrerBonusCredits} + )} + + + ); + + const ListHeader = () => ( + + {/* Share Card */} + + Tu codigo de referido + + {stats?.referralCode || '---'} + + + + {copied ? '✓' : '📋'} + + {copied ? 'Copiado!' : 'Copiar'} + + + + 📤 + + Compartir + + + + + + {/* Stats */} + + + {stats?.totalReferrals ?? 0} + Invitados + + + {stats?.completedReferrals ?? 0} + Completados + + + + {stats?.totalCreditsEarned ?? 0} + + Creditos + + + + {/* How it works */} + + Como funciona + + + 1 + + Comparte tu codigo con amigos + + + + 2 + + Tu amigo se registra con el codigo + + + + 3 + + + Ambos reciben 5 creditos cuando tu amigo hace su primer escaneo + + + + + {referrals.length > 0 && ( + Tus referidos + )} + + ); + + const EmptyReferrals = () => ( + + 👥 + Sin referidos aun + + Comparte tu codigo y empieza a ganar creditos + + + ); + + return ( + + item.id} + ListHeaderComponent={ListHeader} + ListEmptyComponent={ + isLoading ? null : + } + contentContainerStyle={styles.list} + refreshControl={ + + } + onEndReached={loadMore} + onEndReachedThreshold={0.5} + ListFooterComponent={ + isLoading && referrals.length > 0 ? ( + + ) : null + } + ItemSeparatorComponent={() => } + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + list: { + padding: 16, + }, + header: { + marginBottom: 16, + }, + shareCard: { + backgroundColor: '#2563eb', + borderRadius: 16, + padding: 20, + marginBottom: 16, + }, + shareTitle: { + color: 'rgba(255,255,255,0.8)', + fontSize: 14, + textAlign: 'center', + marginBottom: 8, + }, + codeContainer: { + backgroundColor: 'rgba(255,255,255,0.15)', + borderRadius: 8, + paddingVertical: 12, + marginBottom: 16, + }, + codeText: { + color: '#fff', + fontSize: 28, + fontWeight: 'bold', + textAlign: 'center', + letterSpacing: 3, + }, + shareActions: { + flexDirection: 'row', + gap: 12, + }, + shareButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.15)', + borderRadius: 8, + paddingVertical: 12, + gap: 8, + }, + shareButtonPrimary: { + backgroundColor: '#fff', + }, + shareButtonIcon: { + fontSize: 16, + }, + shareButtonText: { + color: '#fff', + fontWeight: '600', + }, + shareButtonTextPrimary: { + color: '#2563eb', + }, + statsContainer: { + flexDirection: 'row', + gap: 12, + marginBottom: 16, + }, + statCard: { + flex: 1, + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + alignItems: 'center', + }, + statValue: { + fontSize: 24, + fontWeight: 'bold', + color: '#1a1a1a', + }, + statValueHighlight: { + color: '#22c55e', + }, + statLabel: { + fontSize: 12, + color: '#666', + marginTop: 4, + }, + howItWorks: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + marginBottom: 16, + }, + howItWorksTitle: { + fontSize: 16, + fontWeight: '600', + color: '#1a1a1a', + marginBottom: 12, + }, + step: { + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: 12, + }, + stepNumber: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: '#2563eb', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + stepNumberText: { + color: '#fff', + fontSize: 12, + fontWeight: 'bold', + }, + stepText: { + flex: 1, + fontSize: 14, + color: '#666', + lineHeight: 20, + }, + listTitle: { + fontSize: 18, + fontWeight: '600', + color: '#1a1a1a', + marginBottom: 12, + }, + referralCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + }, + referralAvatar: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: '#e0e7ff', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + referralAvatarText: { + fontSize: 18, + fontWeight: 'bold', + color: '#4f46e5', + }, + referralInfo: { + flex: 1, + }, + referralName: { + fontSize: 16, + fontWeight: '500', + color: '#1a1a1a', + }, + referralDate: { + fontSize: 13, + color: '#666', + marginTop: 2, + }, + referralStatus: { + alignItems: 'flex-end', + }, + statusBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 6, + }, + statusBadgeText: { + fontSize: 12, + fontWeight: '600', + }, + bonusText: { + fontSize: 14, + fontWeight: 'bold', + color: '#22c55e', + marginTop: 4, + }, + separator: { + height: 8, + }, + emptyReferrals: { + alignItems: 'center', + paddingVertical: 32, + }, + emptyIcon: { + fontSize: 48, + marginBottom: 12, + }, + emptyTitle: { + fontSize: 18, + fontWeight: '600', + color: '#1a1a1a', + marginBottom: 4, + }, + emptyDescription: { + fontSize: 14, + color: '#666', + }, + footerLoader: { + paddingVertical: 16, + }, +}); diff --git a/src/app/reports/_layout.tsx b/src/app/reports/_layout.tsx new file mode 100644 index 0000000..3e2bb84 --- /dev/null +++ b/src/app/reports/_layout.tsx @@ -0,0 +1,31 @@ +import { Stack } from 'expo-router'; + +export default function ReportsLayout() { + return ( + + + + + + + ); +} diff --git a/src/app/reports/categories.tsx b/src/app/reports/categories.tsx new file mode 100644 index 0000000..dd4f041 --- /dev/null +++ b/src/app/reports/categories.tsx @@ -0,0 +1,479 @@ +import { + View, + Text, + StyleSheet, + ScrollView, + ActivityIndicator, + TouchableOpacity, + RefreshControl, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useState, useEffect, useCallback } from 'react'; +import { useStoresStore } from '@stores/stores.store'; +import { reportsService, CategoriesReport, CategoryDetail } from '@services/api/reports.service'; + +const CATEGORY_COLORS = [ + '#3b82f6', + '#22c55e', + '#f59e0b', + '#ef4444', + '#8b5cf6', + '#06b6d4', + '#ec4899', + '#84cc16', +]; + +export default function CategoriesReportScreen() { + const { currentStore } = useStoresStore(); + const [report, setReport] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + const [expandedCategory, setExpandedCategory] = useState(null); + + const fetchReport = useCallback(async (showRefresh = false) => { + if (!currentStore) return; + + if (showRefresh) { + setIsRefreshing(true); + } else { + setIsLoading(true); + } + setError(null); + + try { + const data = await reportsService.getCategoriesReport(currentStore.id); + setReport(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar reporte'); + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, [currentStore]); + + useEffect(() => { + fetchReport(); + }, [fetchReport]); + + const formatCurrency = (value: number) => { + return `$${value.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + }; + + const formatPercent = (value: number) => { + return `${value.toFixed(1)}%`; + }; + + const toggleCategory = (name: string) => { + setExpandedCategory(expandedCategory === name ? null : name); + }; + + const renderCategoryBar = (categories: CategoryDetail[]) => { + return ( + + {categories.map((cat, index) => ( + + ))} + + ); + }; + + const renderCategoryCard = (category: CategoryDetail, index: number) => { + const isExpanded = expandedCategory === category.name; + const color = CATEGORY_COLORS[index % CATEGORY_COLORS.length]; + + return ( + toggleCategory(category.name)} + activeOpacity={0.7} + > + + + + + {category.name || 'Sin categoria'} + {category.itemCount} productos + + + + {formatPercent(category.percentOfTotal)} + {isExpanded ? '▲' : '▼'} + + + + {isExpanded && ( + + + + Valor total + {formatCurrency(category.totalValue)} + + + Precio promedio + {formatCurrency(category.averagePrice)} + + + + {category.lowStockCount > 0 && ( + + + + {category.lowStockCount} productos con stock bajo + + + + )} + + {category.topItems.length > 0 && ( + + Productos principales: + {category.topItems.map((item, i) => ( + + {item.name} + x{item.quantity} + + ))} + + )} + + )} + + ); + }; + + if (isLoading) { + return ( + + + + + + ); + } + + if (error) { + return ( + + + {error} + fetchReport()}> + Reintentar + + + + ); + } + + if (!report) { + return ( + + + No hay datos disponibles + + + ); + } + + return ( + + fetchReport(true)} /> + } + > + {/* Summary Card */} + + + + {report.summary.totalCategories} + Categorias + + + + {report.summary.totalItems} + Productos + + + + {formatCurrency(report.summary.totalValue)} + Valor Total + + + + + {/* Distribution Bar */} + Distribucion + {renderCategoryBar(report.categories)} + + {/* Legend */} + + {report.categories.slice(0, 4).map((cat, index) => ( + + + {cat.name || 'Sin cat.'} + + ))} + {report.categories.length > 4 && ( + +{report.categories.length - 4} mas + )} + + + {/* Category Cards */} + Desglose por categoria + {report.categories.map((category, index) => renderCategoryCard(category, index))} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + errorText: { + fontSize: 16, + color: '#ef4444', + textAlign: 'center', + marginBottom: 16, + }, + retryButton: { + backgroundColor: '#2563eb', + borderRadius: 8, + paddingHorizontal: 24, + paddingVertical: 12, + }, + retryButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + emptyText: { + fontSize: 16, + color: '#666', + }, + scroll: { + flex: 1, + }, + content: { + padding: 16, + }, + summaryCard: { + backgroundColor: '#fff', + borderRadius: 16, + padding: 20, + marginBottom: 24, + }, + summaryStats: { + flexDirection: 'row', + alignItems: 'center', + }, + summaryStat: { + flex: 1, + alignItems: 'center', + }, + summaryDivider: { + width: 1, + height: 40, + backgroundColor: '#e5e5e5', + }, + summaryStatValue: { + fontSize: 20, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 4, + }, + summaryStatLabel: { + fontSize: 12, + color: '#666', + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + color: '#666', + marginBottom: 12, + textTransform: 'uppercase', + }, + sectionTitleMargin: { + marginTop: 16, + }, + barContainer: { + flexDirection: 'row', + height: 24, + borderRadius: 12, + overflow: 'hidden', + backgroundColor: '#e5e5e5', + }, + barSegment: { + height: '100%', + }, + legend: { + flexDirection: 'row', + flexWrap: 'wrap', + marginTop: 12, + gap: 12, + }, + legendItem: { + flexDirection: 'row', + alignItems: 'center', + }, + legendDot: { + width: 12, + height: 12, + borderRadius: 6, + marginRight: 6, + }, + legendText: { + fontSize: 12, + color: '#666', + maxWidth: 80, + }, + legendMore: { + fontSize: 12, + color: '#666', + fontStyle: 'italic', + }, + categoryCard: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + marginBottom: 8, + }, + categoryHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + categoryLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + categoryDot: { + width: 16, + height: 16, + borderRadius: 8, + marginRight: 12, + }, + categoryInfo: { + flex: 1, + }, + categoryName: { + fontSize: 16, + fontWeight: '600', + color: '#1a1a1a', + }, + categoryCount: { + fontSize: 12, + color: '#666', + }, + categoryRight: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + categoryPercent: { + fontSize: 16, + fontWeight: '600', + color: '#1a1a1a', + }, + expandIcon: { + fontSize: 10, + color: '#666', + }, + categoryExpanded: { + marginTop: 16, + paddingTop: 16, + borderTopWidth: 1, + borderTopColor: '#f5f5f5', + }, + statRow: { + flexDirection: 'row', + marginBottom: 12, + }, + stat: { + flex: 1, + }, + statLabel: { + fontSize: 12, + color: '#666', + marginBottom: 4, + }, + statValue: { + fontSize: 16, + fontWeight: '600', + color: '#1a1a1a', + }, + alertRow: { + marginBottom: 12, + }, + alertBadge: { + backgroundColor: '#fef2f2', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 6, + alignSelf: 'flex-start', + }, + alertBadgeText: { + fontSize: 12, + color: '#ef4444', + fontWeight: '500', + }, + topItems: { + backgroundColor: '#f9fafb', + borderRadius: 8, + padding: 12, + }, + topItemsTitle: { + fontSize: 12, + fontWeight: '600', + color: '#666', + marginBottom: 8, + }, + topItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 4, + }, + topItemName: { + fontSize: 14, + color: '#1a1a1a', + flex: 1, + marginRight: 12, + }, + topItemQuantity: { + fontSize: 14, + color: '#666', + fontWeight: '500', + }, +}); diff --git a/src/app/reports/index.tsx b/src/app/reports/index.tsx new file mode 100644 index 0000000..2de0ec5 --- /dev/null +++ b/src/app/reports/index.tsx @@ -0,0 +1,150 @@ +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router } from 'expo-router'; + +interface ReportCardProps { + title: string; + description: string; + icon: string; + route: string; + color: string; +} + +const ReportCard = ({ title, description, icon, route, color }: ReportCardProps) => ( + router.push(route as any)} + activeOpacity={0.7} + > + + {icon} + + + {title} + {description} + + + +); + +export default function ReportsIndexScreen() { + return ( + + + Reportes disponibles + + + + + + + + + Exportar reportes + + Todos los reportes pueden exportarse en formato CSV o Excel desde la + pantalla de cada reporte. + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + scroll: { + flex: 1, + }, + content: { + padding: 16, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + color: '#666', + marginBottom: 12, + textTransform: 'uppercase', + }, + card: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + marginBottom: 12, + }, + iconContainer: { + width: 48, + height: 48, + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + icon: { + fontSize: 24, + }, + cardContent: { + flex: 1, + }, + cardTitle: { + fontSize: 16, + fontWeight: '600', + color: '#1a1a1a', + marginBottom: 4, + }, + cardDescription: { + fontSize: 14, + color: '#666', + }, + chevron: { + fontSize: 24, + color: '#ccc', + marginLeft: 8, + }, + infoCard: { + backgroundColor: '#eff6ff', + borderRadius: 12, + padding: 16, + marginTop: 12, + }, + infoTitle: { + fontSize: 14, + fontWeight: '600', + color: '#1e40af', + marginBottom: 4, + }, + infoText: { + fontSize: 14, + color: '#1e40af', + lineHeight: 20, + }, +}); diff --git a/src/app/reports/movements.tsx b/src/app/reports/movements.tsx new file mode 100644 index 0000000..20a756e --- /dev/null +++ b/src/app/reports/movements.tsx @@ -0,0 +1,371 @@ +import { + View, + Text, + StyleSheet, + FlatList, + ActivityIndicator, + TouchableOpacity, + RefreshControl, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useState, useEffect, useCallback } from 'react'; +import { useStoresStore } from '@stores/stores.store'; +import { reportsService, MovementsReport, MovementRecord } from '@services/api/reports.service'; + +const MOVEMENT_TYPES: Record = { + DETECTION: { label: 'Deteccion', color: '#2563eb', bgColor: '#dbeafe' }, + MANUAL_ADJUST: { label: 'Ajuste', color: '#7c3aed', bgColor: '#ede9fe' }, + SALE: { label: 'Venta', color: '#ef4444', bgColor: '#fef2f2' }, + PURCHASE: { label: 'Compra', color: '#22c55e', bgColor: '#dcfce7' }, +}; + +export default function MovementsReportScreen() { + const { currentStore } = useStoresStore(); + const [report, setReport] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + + const fetchReport = useCallback(async (pageNum = 1, refresh = false) => { + if (!currentStore) return; + + if (pageNum === 1) { + if (refresh) { + setIsRefreshing(true); + } else { + setIsLoading(true); + } + } else { + setIsLoadingMore(true); + } + setError(null); + + try { + const data = await reportsService.getMovementsReport(currentStore.id, { + page: pageNum, + limit: 20, + }); + + if (pageNum === 1) { + setReport(data); + } else if (report) { + setReport({ + ...data, + movements: [...report.movements, ...data.movements], + }); + } + setPage(pageNum); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar reporte'); + } finally { + setIsLoading(false); + setIsRefreshing(false); + setIsLoadingMore(false); + } + }, [currentStore, report]); + + useEffect(() => { + fetchReport(); + }, [currentStore]); + + const handleLoadMore = () => { + if (!report?.hasMore || isLoadingMore) return; + fetchReport(page + 1); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('es-MX', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const renderMovementItem = ({ item }: { item: MovementRecord }) => { + const typeConfig = MOVEMENT_TYPES[item.type] || MOVEMENT_TYPES.MANUAL_ADJUST; + const isPositive = item.change > 0; + + return ( + + + + + {typeConfig.label} + + + {formatDate(item.date)} + + {item.itemName} + + + + {item.quantityBefore} → {item.quantityAfter} + + + + {isPositive ? '+' : ''}{item.change} + + + {item.reason && ( + {item.reason} + )} + + ); + }; + + const renderHeader = () => { + if (!report) return null; + + return ( + + {/* Summary Card */} + + + + {report.summary.totalMovements} + Movimientos + + + + = 0 ? styles.changePositive : styles.changeNegative, + ]}> + {report.summary.netChange >= 0 ? '+' : ''}{report.summary.netChange} + + Cambio neto + + + + + + +{report.summary.itemsIncreased} + + Aumentos + + + + + -{report.summary.itemsDecreased} + + Disminuciones + + + + + Historial de movimientos + + ); + }; + + const renderFooter = () => { + if (!isLoadingMore) return null; + return ( + + + + ); + }; + + if (isLoading) { + return ( + + + + + + ); + } + + if (error) { + return ( + + + {error} + fetchReport()}> + Reintentar + + + + ); + } + + return ( + + item.id} + ListHeaderComponent={renderHeader} + ListFooterComponent={renderFooter} + contentContainerStyle={styles.content} + refreshControl={ + fetchReport(1, true)} /> + } + onEndReached={handleLoadMore} + onEndReachedThreshold={0.3} + ListEmptyComponent={ + + No hay movimientos registrados + + } + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + errorText: { + fontSize: 16, + color: '#ef4444', + textAlign: 'center', + marginBottom: 16, + }, + retryButton: { + backgroundColor: '#2563eb', + borderRadius: 8, + paddingHorizontal: 24, + paddingVertical: 12, + }, + retryButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + emptyContainer: { + paddingVertical: 48, + alignItems: 'center', + }, + emptyText: { + fontSize: 16, + color: '#666', + }, + content: { + padding: 16, + }, + headerSection: { + marginBottom: 8, + }, + summaryCard: { + backgroundColor: '#fff', + borderRadius: 16, + padding: 20, + marginBottom: 24, + }, + summaryRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + }, + summaryItem: { + flex: 1, + alignItems: 'center', + }, + summaryDivider: { + width: 1, + height: 40, + backgroundColor: '#e5e5e5', + }, + summaryValue: { + fontSize: 24, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 4, + }, + summaryLabel: { + fontSize: 12, + color: '#666', + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + color: '#666', + marginBottom: 12, + textTransform: 'uppercase', + }, + movementCard: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + marginBottom: 8, + }, + movementHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + typeBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 4, + }, + typeBadgeText: { + fontSize: 12, + fontWeight: '600', + }, + movementDate: { + fontSize: 12, + color: '#666', + }, + movementItem: { + fontSize: 16, + fontWeight: '500', + color: '#1a1a1a', + marginBottom: 8, + }, + movementDetails: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + quantityChange: { + flexDirection: 'row', + alignItems: 'center', + }, + quantityLabel: { + fontSize: 14, + color: '#666', + }, + changeValue: { + fontSize: 18, + fontWeight: '700', + }, + changePositive: { + color: '#22c55e', + }, + changeNegative: { + color: '#ef4444', + }, + reasonText: { + fontSize: 12, + color: '#666', + fontStyle: 'italic', + marginTop: 8, + }, + loadingMore: { + paddingVertical: 16, + alignItems: 'center', + }, +}); diff --git a/src/app/reports/valuation.tsx b/src/app/reports/valuation.tsx new file mode 100644 index 0000000..265182e --- /dev/null +++ b/src/app/reports/valuation.tsx @@ -0,0 +1,381 @@ +import { + View, + Text, + StyleSheet, + ScrollView, + ActivityIndicator, + TouchableOpacity, + RefreshControl, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useState, useEffect, useCallback } from 'react'; +import { router } from 'expo-router'; +import { useStoresStore } from '@stores/stores.store'; +import { reportsService, ValuationReport } from '@services/api/reports.service'; + +export default function ValuationReportScreen() { + const { currentStore } = useStoresStore(); + const [report, setReport] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + + const fetchReport = useCallback(async (showRefresh = false) => { + if (!currentStore) return; + + if (showRefresh) { + setIsRefreshing(true); + } else { + setIsLoading(true); + } + setError(null); + + try { + const data = await reportsService.getValuationReport(currentStore.id); + setReport(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar reporte'); + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, [currentStore]); + + useEffect(() => { + fetchReport(); + }, [fetchReport]); + + const formatCurrency = (value: number) => { + return `$${value.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + }; + + const formatPercent = (value: number) => { + return `${value.toFixed(1)}%`; + }; + + const handleExport = () => { + router.push('/inventory/export' as any); + }; + + if (isLoading) { + return ( + + + + + + ); + } + + if (error) { + return ( + + + {error} + fetchReport()}> + Reintentar + + + + ); + } + + if (!report) { + return ( + + + No hay datos disponibles + + + ); + } + + return ( + + fetchReport(true)} /> + } + > + {/* Summary Card */} + + Valor Total del Inventario + {formatCurrency(report.summary.totalPrice)} + + + Costo + {formatCurrency(report.summary.totalCost)} + + + + Margen + + {formatPercent(report.summary.potentialMarginPercent)} + + + + {report.summary.totalItems} productos + + + {/* By Category */} + Por Categoria + {report.byCategory.map((cat, index) => ( + + + {cat.category || 'Sin categoria'} + {cat.itemCount} productos + + + + Valor + {formatCurrency(cat.totalPrice)} + + + Costo + {formatCurrency(cat.totalCost)} + + + Margen + + {formatCurrency(cat.margin)} + + + + + ))} + + {/* Top Items */} + Top Productos por Valor + {report.items.slice(0, 10).map((item, index) => ( + + + {index + 1} + + + {item.name} + {item.category || 'Sin categoria'} + + + {formatCurrency(item.totalPrice)} + x{item.quantity} + + + ))} + + + + + Exportar Reporte + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + errorText: { + fontSize: 16, + color: '#ef4444', + textAlign: 'center', + marginBottom: 16, + }, + retryButton: { + backgroundColor: '#2563eb', + borderRadius: 8, + paddingHorizontal: 24, + paddingVertical: 12, + }, + retryButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + emptyText: { + fontSize: 16, + color: '#666', + }, + scroll: { + flex: 1, + }, + content: { + padding: 16, + }, + summaryCard: { + backgroundColor: '#1e40af', + borderRadius: 16, + padding: 24, + marginBottom: 24, + }, + summaryTitle: { + fontSize: 14, + color: 'rgba(255,255,255,0.8)', + marginBottom: 8, + }, + summaryValue: { + fontSize: 36, + fontWeight: 'bold', + color: '#fff', + marginBottom: 24, + }, + summaryRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + }, + summaryItem: { + flex: 1, + alignItems: 'center', + }, + summaryDivider: { + width: 1, + height: 40, + backgroundColor: 'rgba(255,255,255,0.3)', + }, + summaryItemLabel: { + fontSize: 12, + color: 'rgba(255,255,255,0.7)', + marginBottom: 4, + }, + summaryItemValue: { + fontSize: 18, + fontWeight: '600', + color: '#fff', + }, + marginValue: { + color: '#4ade80', + }, + totalItems: { + fontSize: 12, + color: 'rgba(255,255,255,0.6)', + textAlign: 'center', + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + color: '#666', + marginBottom: 12, + textTransform: 'uppercase', + }, + categoryCard: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + marginBottom: 12, + }, + categoryHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + categoryName: { + fontSize: 16, + fontWeight: '600', + color: '#1a1a1a', + }, + categoryCount: { + fontSize: 14, + color: '#666', + }, + categoryStats: { + flexDirection: 'row', + }, + categoryStat: { + flex: 1, + }, + categoryStatLabel: { + fontSize: 12, + color: '#666', + marginBottom: 4, + }, + categoryStatValue: { + fontSize: 14, + fontWeight: '600', + color: '#1a1a1a', + }, + itemRow: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#fff', + borderRadius: 8, + padding: 12, + marginBottom: 8, + }, + itemRank: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: '#f5f5f5', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + itemRankText: { + fontSize: 12, + fontWeight: '600', + color: '#666', + }, + itemInfo: { + flex: 1, + }, + itemName: { + fontSize: 14, + fontWeight: '500', + color: '#1a1a1a', + }, + itemCategory: { + fontSize: 12, + color: '#666', + }, + itemValue: { + alignItems: 'flex-end', + }, + itemValueText: { + fontSize: 14, + fontWeight: '600', + color: '#1a1a1a', + }, + itemQuantity: { + fontSize: 12, + color: '#666', + }, + footer: { + backgroundColor: '#fff', + padding: 16, + borderTopWidth: 1, + borderTopColor: '#eee', + }, + exportButton: { + backgroundColor: '#2563eb', + borderRadius: 12, + paddingVertical: 16, + alignItems: 'center', + }, + exportButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/src/app/stores/[id].tsx b/src/app/stores/[id].tsx new file mode 100644 index 0000000..affc7ad --- /dev/null +++ b/src/app/stores/[id].tsx @@ -0,0 +1,264 @@ +import { + View, + Text, + StyleSheet, + ScrollView, + TextInput, + TouchableOpacity, + ActivityIndicator, + Alert, + KeyboardAvoidingView, + Platform, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useState, useEffect } from 'react'; +import { router, useLocalSearchParams } from 'expo-router'; +import { useStoresStore } from '@stores/stores.store'; + +export default function EditStoreScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const { stores, updateStore, deleteStore, isLoading, error } = useStoresStore(); + const [name, setName] = useState(''); + const [address, setAddress] = useState(''); + const [city, setCity] = useState(''); + const [giro, setGiro] = useState(''); + const [isLoadingData, setIsLoadingData] = useState(true); + + useEffect(() => { + const store = stores.find((s) => s.id === id); + if (store) { + setName(store.name); + setAddress(store.address || ''); + setCity(store.city || ''); + setGiro(store.giro || ''); + } + setIsLoadingData(false); + }, [id, stores]); + + const handleUpdate = async () => { + if (!name.trim()) { + Alert.alert('Error', 'El nombre de la tienda es requerido'); + return; + } + + if (!id) return; + + const store = await updateStore(id, { + name: name.trim(), + address: address.trim() || undefined, + city: city.trim() || undefined, + giro: giro.trim() || undefined, + }); + + if (store) { + Alert.alert('Listo', 'La tienda ha sido actualizada', [ + { text: 'OK', onPress: () => router.back() }, + ]); + } + }; + + const handleDelete = () => { + Alert.alert( + 'Eliminar Tienda', + 'Estas seguro de eliminar esta tienda? Esta accion no se puede deshacer y perderas todo el inventario asociado.', + [ + { text: 'Cancelar', style: 'cancel' }, + { + text: 'Eliminar', + style: 'destructive', + onPress: async () => { + if (!id) return; + const success = await deleteStore(id); + if (success) { + Alert.alert('Listo', 'La tienda ha sido eliminada', [ + { text: 'OK', onPress: () => router.back() }, + ]); + } + }, + }, + ] + ); + }; + + if (isLoadingData) { + return ( + + + + + + ); + } + + return ( + + + + + Nombre de la tienda * + + + + + Direccion + + + + + Ciudad + + + + + Giro + + + + {error && ( + + {error} + + )} + + + Eliminar Tienda + + + + + + {isLoading ? ( + + ) : ( + Guardar Cambios + )} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + keyboardAvoid: { + flex: 1, + }, + scroll: { + flex: 1, + }, + content: { + padding: 16, + }, + formGroup: { + marginBottom: 20, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: '#1a1a1a', + marginBottom: 8, + }, + input: { + backgroundColor: '#fff', + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 14, + fontSize: 16, + color: '#1a1a1a', + borderWidth: 1, + borderColor: '#e5e5e5', + }, + errorCard: { + backgroundColor: '#fef2f2', + borderRadius: 12, + padding: 16, + marginBottom: 16, + borderWidth: 1, + borderColor: '#fecaca', + }, + errorText: { + color: '#ef4444', + fontSize: 14, + }, + deleteButton: { + alignItems: 'center', + paddingVertical: 16, + marginTop: 16, + }, + deleteButtonText: { + color: '#ef4444', + fontSize: 16, + fontWeight: '600', + }, + footer: { + backgroundColor: '#fff', + padding: 16, + borderTopWidth: 1, + borderTopColor: '#eee', + }, + saveButton: { + backgroundColor: '#2563eb', + borderRadius: 12, + paddingVertical: 16, + alignItems: 'center', + }, + saveButtonDisabled: { + backgroundColor: '#93c5fd', + }, + saveButtonText: { + color: '#fff', + fontSize: 18, + fontWeight: '600', + }, +}); diff --git a/src/app/stores/_layout.tsx b/src/app/stores/_layout.tsx new file mode 100644 index 0000000..d65a481 --- /dev/null +++ b/src/app/stores/_layout.tsx @@ -0,0 +1,27 @@ +import { Stack } from 'expo-router'; + +export default function StoresLayout() { + return ( + + + + + + ); +} diff --git a/src/app/stores/index.tsx b/src/app/stores/index.tsx new file mode 100644 index 0000000..0765b60 --- /dev/null +++ b/src/app/stores/index.tsx @@ -0,0 +1,301 @@ +import { + View, + Text, + StyleSheet, + FlatList, + TouchableOpacity, + RefreshControl, + ActivityIndicator, + Alert, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useEffect, useState, useCallback } from 'react'; +import { router, Stack } from 'expo-router'; +import { useStoresStore } from '@stores/stores.store'; +import { Store } from '@services/api/stores.service'; + +export default function StoresScreen() { + const { + stores, + currentStore, + hasMore, + fetchStores, + selectStore, + deleteStore, + isLoading, + } = useStoresStore(); + const [refreshing, setRefreshing] = useState(false); + + useEffect(() => { + fetchStores(true); + }, [fetchStores]); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + await fetchStores(true); + setRefreshing(false); + }, [fetchStores]); + + const loadMore = () => { + if (hasMore && !isLoading) { + fetchStores(false); + } + }; + + const handleSelectStore = (store: Store) => { + selectStore(store); + router.back(); + }; + + const handleDeleteStore = (store: Store) => { + Alert.alert( + 'Eliminar Tienda', + `Estas seguro de eliminar "${store.name}"? Esta accion no se puede deshacer.`, + [ + { text: 'Cancelar', style: 'cancel' }, + { + text: 'Eliminar', + style: 'destructive', + onPress: async () => { + const success = await deleteStore(store.id); + if (success) { + Alert.alert('Listo', 'La tienda ha sido eliminada'); + } + }, + }, + ] + ); + }; + + const renderStore = ({ item }: { item: Store }) => ( + handleSelectStore(item)} + onLongPress={() => handleDeleteStore(item)} + > + + 🏪 + + + {item.name} + {item.address && ( + + {item.address} + + )} + + {currentStore?.id === item.id && ( + + Activa + + )} + router.push(`/stores/${item.id}`)} + > + ✏️ + + + ); + + const EmptyState = () => ( + + 🏪 + Sin tiendas + + Crea tu primera tienda para comenzar a usar MiInventario + + router.push('/stores/new')} + > + Crear Tienda + + + ); + + return ( + <> + ( + router.push('/stores/new')} + > + + Nueva + + ), + }} + /> + + {isLoading && stores.length === 0 ? ( + + + Cargando tiendas... + + ) : stores.length === 0 ? ( + + ) : ( + <> + + Toca para seleccionar, manten presionado para eliminar + + item.id} + contentContainerStyle={styles.list} + refreshControl={ + + } + onEndReached={loadMore} + onEndReachedThreshold={0.5} + ListFooterComponent={ + isLoading ? ( + + ) : null + } + ItemSeparatorComponent={() => } + /> + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 12, + fontSize: 16, + color: '#666', + }, + addButton: { + marginRight: 8, + }, + addButtonText: { + color: '#2563eb', + fontSize: 16, + fontWeight: '600', + }, + hint: { + fontSize: 13, + color: '#666', + textAlign: 'center', + paddingVertical: 12, + backgroundColor: '#fff', + borderBottomWidth: 1, + borderBottomColor: '#eee', + }, + list: { + padding: 16, + }, + storeCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + borderWidth: 2, + borderColor: 'transparent', + }, + storeCardActive: { + borderColor: '#2563eb', + backgroundColor: '#f0f9ff', + }, + storeIcon: { + width: 48, + height: 48, + borderRadius: 12, + backgroundColor: '#f5f5f5', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + storeIconText: { + fontSize: 24, + }, + storeInfo: { + flex: 1, + }, + storeName: { + fontSize: 17, + fontWeight: '600', + color: '#1a1a1a', + }, + storeAddress: { + fontSize: 14, + color: '#666', + marginTop: 2, + }, + activeBadge: { + backgroundColor: '#dcfce7', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 6, + marginRight: 8, + }, + activeBadgeText: { + color: '#16a34a', + fontSize: 12, + fontWeight: '600', + }, + editButton: { + padding: 8, + }, + editButtonText: { + fontSize: 18, + }, + separator: { + height: 8, + }, + emptyState: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + emptyIcon: { + fontSize: 64, + marginBottom: 16, + }, + emptyTitle: { + fontSize: 24, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 8, + }, + emptyDescription: { + fontSize: 16, + color: '#666', + textAlign: 'center', + marginBottom: 24, + }, + emptyButton: { + backgroundColor: '#2563eb', + paddingVertical: 12, + paddingHorizontal: 24, + borderRadius: 8, + }, + emptyButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + footerLoader: { + paddingVertical: 16, + }, +}); diff --git a/src/app/stores/new.tsx b/src/app/stores/new.tsx new file mode 100644 index 0000000..db0c901 --- /dev/null +++ b/src/app/stores/new.tsx @@ -0,0 +1,221 @@ +import { + View, + Text, + StyleSheet, + ScrollView, + TextInput, + TouchableOpacity, + ActivityIndicator, + Alert, + KeyboardAvoidingView, + Platform, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useState } from 'react'; +import { router } from 'expo-router'; +import { useStoresStore } from '@stores/stores.store'; + +export default function NewStoreScreen() { + const { createStore, isLoading, error } = useStoresStore(); + const [name, setName] = useState(''); + const [address, setAddress] = useState(''); + const [city, setCity] = useState(''); + const [giro, setGiro] = useState(''); + + const handleCreate = async () => { + if (!name.trim()) { + Alert.alert('Error', 'El nombre de la tienda es requerido'); + return; + } + + const store = await createStore({ + name: name.trim(), + address: address.trim() || undefined, + city: city.trim() || undefined, + giro: giro.trim() || undefined, + }); + + if (store) { + Alert.alert('Listo', 'Tu tienda ha sido creada', [ + { text: 'OK', onPress: () => router.back() }, + ]); + } + }; + + return ( + + + + + Nombre de la tienda * + + + + + Direccion + + + + + Ciudad + + + + + Giro + + + + {error && ( + + {error} + + )} + + + 💡 + + Puedes agregar mas tiendas despues desde tu perfil. Cada tienda + tiene su propio inventario independiente. + + + + + + + {isLoading ? ( + + ) : ( + Crear Tienda + )} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + keyboardAvoid: { + flex: 1, + }, + scroll: { + flex: 1, + }, + content: { + padding: 16, + }, + formGroup: { + marginBottom: 20, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: '#1a1a1a', + marginBottom: 8, + }, + input: { + backgroundColor: '#fff', + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 14, + fontSize: 16, + color: '#1a1a1a', + borderWidth: 1, + borderColor: '#e5e5e5', + }, + errorCard: { + backgroundColor: '#fef2f2', + borderRadius: 12, + padding: 16, + marginBottom: 16, + borderWidth: 1, + borderColor: '#fecaca', + }, + errorText: { + color: '#ef4444', + fontSize: 14, + }, + infoCard: { + flexDirection: 'row', + backgroundColor: '#f0f9ff', + borderRadius: 12, + padding: 16, + borderWidth: 1, + borderColor: '#bfdbfe', + }, + infoIcon: { + fontSize: 20, + marginRight: 12, + }, + infoText: { + flex: 1, + fontSize: 14, + color: '#1e40af', + lineHeight: 20, + }, + footer: { + backgroundColor: '#fff', + padding: 16, + borderTopWidth: 1, + borderTopColor: '#eee', + }, + createButton: { + backgroundColor: '#2563eb', + borderRadius: 12, + paddingVertical: 16, + alignItems: 'center', + }, + createButtonDisabled: { + backgroundColor: '#93c5fd', + }, + createButtonText: { + color: '#fff', + fontSize: 18, + fontWeight: '600', + }, +}); diff --git a/src/app/support/index.tsx b/src/app/support/index.tsx new file mode 100644 index 0000000..0a3afff --- /dev/null +++ b/src/app/support/index.tsx @@ -0,0 +1,317 @@ +import { + View, + Text, + StyleSheet, + ScrollView, + TextInput, + TouchableOpacity, + Alert, + Linking, + KeyboardAvoidingView, + Platform, + ActivityIndicator, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Stack, router } from 'expo-router'; +import { useState } from 'react'; +import { useAuthStore } from '@stores/auth.store'; + +type ContactMethod = 'whatsapp' | 'email' | 'form'; + +export default function SupportScreen() { + const { user } = useAuthStore(); + const [subject, setSubject] = useState(''); + const [message, setMessage] = useState(''); + const [isSending, setIsSending] = useState(false); + + const handleWhatsApp = () => { + const phone = '5215512345678'; // Replace with actual support number + const text = `Hola, necesito ayuda con MiInventario.\n\nUsuario: ${user?.name}\nTelefono: ${user?.phone}`; + Linking.openURL(`whatsapp://send?phone=${phone}&text=${encodeURIComponent(text)}`); + }; + + const handleEmail = () => { + const email = 'soporte@miinventario.com'; + const emailSubject = 'Soporte MiInventario'; + const body = `\n\n---\nUsuario: ${user?.name}\nTelefono: ${user?.phone}`; + Linking.openURL(`mailto:${email}?subject=${encodeURIComponent(emailSubject)}&body=${encodeURIComponent(body)}`); + }; + + const handleSubmit = async () => { + if (!subject.trim() || !message.trim()) { + Alert.alert('Error', 'Por favor completa todos los campos'); + return; + } + + setIsSending(true); + + // Simulate sending the message + await new Promise((resolve) => setTimeout(resolve, 1500)); + + setIsSending(false); + Alert.alert( + 'Mensaje Enviado', + 'Hemos recibido tu mensaje. Te responderemos lo antes posible.', + [{ text: 'OK', onPress: () => router.back() }] + ); + }; + + const ContactCard = ({ + icon, + title, + description, + onPress, + }: { + icon: string; + title: string; + description: string; + onPress: () => void; + }) => ( + + + {icon} + + + {title} + {description} + + + + ); + + return ( + + + + + + Necesitas ayuda? + + Estamos aqui para ayudarte. Elige como prefieres contactarnos. + + + + + Contacto Rapido + + + + + + + + Enviar Mensaje + + + Asunto + + + + + Mensaje + + + + + {isSending ? ( + + ) : ( + Enviar Mensaje + )} + + + + + + + Horario de Atencion + + Lunes a Viernes: 9:00 AM - 6:00 PM{'\n'} + Sabado: 9:00 AM - 2:00 PM + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + keyboardView: { + flex: 1, + }, + scroll: { + flex: 1, + }, + header: { + padding: 20, + backgroundColor: '#2563eb', + }, + headerTitle: { + fontSize: 24, + fontWeight: 'bold', + color: '#fff', + marginBottom: 4, + }, + headerSubtitle: { + fontSize: 14, + color: 'rgba(255,255,255,0.8)', + }, + section: { + padding: 16, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + color: '#1a1a1a', + marginBottom: 12, + }, + contactCards: { + gap: 12, + }, + contactCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#fff', + padding: 16, + borderRadius: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + contactIcon: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: '#f0f7ff', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + contactIconText: { + fontSize: 24, + }, + contactInfo: { + flex: 1, + }, + contactTitle: { + fontSize: 16, + fontWeight: '600', + color: '#1a1a1a', + marginBottom: 2, + }, + contactDescription: { + fontSize: 13, + color: '#666', + }, + contactArrow: { + fontSize: 20, + color: '#ccc', + }, + form: { + backgroundColor: '#fff', + padding: 16, + borderRadius: 12, + }, + inputGroup: { + marginBottom: 16, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: '#666', + marginBottom: 8, + }, + input: { + backgroundColor: '#f5f5f5', + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 8, + fontSize: 16, + borderWidth: 1, + borderColor: '#e5e5e5', + }, + textArea: { + minHeight: 120, + paddingTop: 12, + }, + submitButton: { + backgroundColor: '#2563eb', + paddingVertical: 14, + borderRadius: 8, + alignItems: 'center', + marginTop: 8, + }, + submitButtonDisabled: { + opacity: 0.6, + }, + submitButtonText: { + fontSize: 16, + fontWeight: '600', + color: '#fff', + }, + infoSection: { + margin: 16, + padding: 20, + backgroundColor: '#fff', + borderRadius: 12, + alignItems: 'center', + }, + infoIcon: { + fontSize: 32, + marginBottom: 8, + }, + infoTitle: { + fontSize: 16, + fontWeight: '600', + color: '#1a1a1a', + marginBottom: 8, + }, + infoText: { + fontSize: 14, + color: '#666', + textAlign: 'center', + lineHeight: 20, + }, +}); diff --git a/src/app/validation/_layout.tsx b/src/app/validation/_layout.tsx new file mode 100644 index 0000000..245a0ac --- /dev/null +++ b/src/app/validation/_layout.tsx @@ -0,0 +1,27 @@ +import { Stack } from 'expo-router'; + +export default function ValidationLayout() { + return ( + + + + + ); +} diff --git a/src/app/validation/complete.tsx b/src/app/validation/complete.tsx new file mode 100644 index 0000000..48251a3 --- /dev/null +++ b/src/app/validation/complete.tsx @@ -0,0 +1,165 @@ +import React, { useEffect } from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useValidationsStore } from '../../stores/validations.store'; + +export default function ValidationCompleteScreen() { + const router = useRouter(); + const { creditsRewarded, reset } = useValidationsStore(); + + useEffect(() => { + return () => { + reset(); + }; + }, []); + + const handleContinue = () => { + router.replace('/'); + }; + + return ( + + + + + + + Gracias! + Tu validacion nos ayuda a mejorar + + {creditsRewarded !== null && creditsRewarded > 0 && ( + + + + Recompensa + +{creditsRewarded} credito + + + )} + + + Con tu ayuda: + + + + Mejoramos la deteccion de productos + + + + + + Entrenamos mejor nuestros modelos + + + + + + Tu inventario sera mas preciso + + + + + + + + Continuar + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + }, + content: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 24, + }, + iconContainer: { + width: 120, + height: 120, + borderRadius: 60, + backgroundColor: '#e8f5e9', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 24, + }, + title: { + fontSize: 28, + fontWeight: 'bold', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + color: '#666', + marginBottom: 24, + textAlign: 'center', + }, + rewardCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#fff8e1', + padding: 16, + borderRadius: 12, + marginBottom: 32, + width: '100%', + gap: 16, + }, + rewardInfo: { + flex: 1, + }, + rewardLabel: { + fontSize: 12, + color: '#666', + }, + rewardValue: { + fontSize: 20, + fontWeight: 'bold', + color: '#f0ad4e', + }, + benefits: { + width: '100%', + }, + benefitsTitle: { + fontSize: 14, + color: '#666', + marginBottom: 12, + }, + benefitItem: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + marginBottom: 8, + }, + benefitText: { + fontSize: 14, + color: '#333', + flex: 1, + }, + footer: { + padding: 16, + borderTopWidth: 1, + borderTopColor: '#eee', + }, + button: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#007AFF', + padding: 16, + borderRadius: 8, + gap: 8, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/src/app/validation/items.tsx b/src/app/validation/items.tsx new file mode 100644 index 0000000..87b2507 --- /dev/null +++ b/src/app/validation/items.tsx @@ -0,0 +1,301 @@ +import React, { useEffect } from 'react'; +import { + View, + Text, + ScrollView, + TouchableOpacity, + StyleSheet, + ActivityIndicator, + Alert, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useValidationsStore } from '../../stores/validations.store'; +import { ValidationItemCard } from '../../components/validation/ValidationItemCard'; +import { ValidationProgressBar } from '../../components/validation/ValidationProgressBar'; +import { ValidationItemResponse } from '../../services/api/validations.service'; + +export default function ValidationItemsScreen() { + const router = useRouter(); + const { + pendingRequest, + items, + responses, + currentItemIndex, + isLoading, + error, + addResponse, + nextItem, + previousItem, + submitValidation, + skipValidation, + } = useValidationsStore(); + + useEffect(() => { + if (!pendingRequest || items.length === 0) { + router.replace('/'); + } + }, [pendingRequest, items]); + + const handleResponse = (response: Omit) => { + if (!items[currentItemIndex]) return; + + addResponse({ + ...response, + inventoryItemId: items[currentItemIndex].id, + }); + + // Auto-advance to next item + if (currentItemIndex < items.length - 1) { + setTimeout(() => nextItem(), 300); + } + }; + + const handleSubmit = async () => { + if (responses.length < items.length) { + Alert.alert( + 'Faltan items', + 'Por favor valida todos los productos antes de continuar.', + ); + return; + } + + try { + await submitValidation(); + router.replace('/validation/complete'); + } catch { + Alert.alert('Error', 'No se pudo enviar la validacion. Intenta de nuevo.'); + } + }; + + const handleSkip = () => { + Alert.alert( + 'Omitir validacion', + 'Estas seguro? No recibiras el credito de recompensa.', + [ + { text: 'Cancelar', style: 'cancel' }, + { + text: 'Omitir', + style: 'destructive', + onPress: async () => { + await skipValidation(); + router.replace('/'); + }, + }, + ], + ); + }; + + if (!pendingRequest || items.length === 0) { + return ( + + + + ); + } + + const currentItem = items[currentItemIndex]; + const currentResponse = responses.find( + (r) => r.inventoryItemId === currentItem?.id, + ); + + return ( + + + + + {currentItem && ( + + )} + + {error && ( + + {error} + + )} + + + + + + + + Anterior + + + + + + Siguiente + + + + + + + + Omitir + + + + {isLoading ? ( + + ) : ( + <> + Enviar + + + {responses.length}/{items.length} + + + + )} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + loading: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + content: { + flex: 1, + }, + scrollContent: { + padding: 16, + }, + errorContainer: { + backgroundColor: '#ffebee', + padding: 12, + borderRadius: 8, + marginTop: 12, + }, + errorText: { + color: '#dc3545', + textAlign: 'center', + }, + footer: { + backgroundColor: '#fff', + borderTopWidth: 1, + borderTopColor: '#eee', + padding: 16, + }, + navigation: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 16, + }, + navButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 8, + gap: 4, + }, + navButtonDisabled: { + opacity: 0.5, + }, + navText: { + color: '#007AFF', + fontSize: 16, + }, + navTextDisabled: { + color: '#ccc', + }, + actions: { + flexDirection: 'row', + gap: 12, + }, + skipButton: { + flex: 1, + padding: 14, + borderRadius: 8, + backgroundColor: '#f5f5f5', + alignItems: 'center', + }, + skipText: { + color: '#666', + fontWeight: '600', + }, + submitButton: { + flex: 2, + padding: 14, + borderRadius: 8, + backgroundColor: '#007AFF', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + submitButtonDisabled: { + backgroundColor: '#ccc', + }, + submitText: { + color: '#fff', + fontWeight: '600', + fontSize: 16, + }, + badge: { + backgroundColor: 'rgba(255,255,255,0.3)', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 10, + }, + badgeText: { + color: '#fff', + fontSize: 12, + fontWeight: '600', + }, +}); diff --git a/src/components/feedback/ConfirmItemButton.tsx b/src/components/feedback/ConfirmItemButton.tsx new file mode 100644 index 0000000..f4846c3 --- /dev/null +++ b/src/components/feedback/ConfirmItemButton.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { TouchableOpacity, Text, StyleSheet, ActivityIndicator } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useFeedbackStore } from '../../stores/feedback.store'; + +interface Props { + storeId: string; + itemId: string; + onSuccess?: () => void; +} + +export function ConfirmItemButton({ storeId, itemId, onSuccess }: Props) { + const { confirmItem, isLoading } = useFeedbackStore(); + + const handleConfirm = async () => { + try { + await confirmItem(storeId, itemId); + onSuccess?.(); + } catch { + // Error is handled in store + } + }; + + return ( + + {isLoading ? ( + + ) : ( + <> + + Confirmar + + )} + + ); +} + +const styles = StyleSheet.create({ + button: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#e8f5e9', + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 8, + gap: 8, + }, + text: { + color: '#28a745', + fontWeight: '600', + fontSize: 14, + }, +}); diff --git a/src/components/feedback/CorrectQuantityModal.tsx b/src/components/feedback/CorrectQuantityModal.tsx new file mode 100644 index 0000000..e07f29e --- /dev/null +++ b/src/components/feedback/CorrectQuantityModal.tsx @@ -0,0 +1,203 @@ +import React, { useState } from 'react'; +import { + View, + Text, + Modal, + TextInput, + TouchableOpacity, + StyleSheet, + ActivityIndicator, +} from 'react-native'; +import { useFeedbackStore } from '../../stores/feedback.store'; + +interface Props { + visible: boolean; + onClose: () => void; + storeId: string; + itemId: string; + currentQuantity: number; + itemName: string; + onSuccess?: () => void; +} + +export function CorrectQuantityModal({ + visible, + onClose, + storeId, + itemId, + currentQuantity, + itemName, + onSuccess, +}: Props) { + const [quantity, setQuantity] = useState(currentQuantity.toString()); + const [reason, setReason] = useState(''); + const { correctQuantity, isLoading, error } = useFeedbackStore(); + + const handleSubmit = async () => { + const newQuantity = parseInt(quantity, 10); + if (isNaN(newQuantity) || newQuantity < 0) return; + + try { + await correctQuantity(storeId, itemId, { + quantity: newQuantity, + reason: reason || undefined, + }); + onSuccess?.(); + onClose(); + } catch { + // Error is handled in store + } + }; + + const handleClose = () => { + setQuantity(currentQuantity.toString()); + setReason(''); + onClose(); + }; + + return ( + + + + Corregir Cantidad + {itemName} + + + Cantidad actual: + {currentQuantity} + + + Nueva cantidad: + + + Razon (opcional): + + + {error && {error}} + + + + Cancelar + + + {isLoading ? ( + + ) : ( + Guardar + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', + alignItems: 'center', + }, + container: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 20, + width: '90%', + maxWidth: 400, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 4, + }, + subtitle: { + fontSize: 14, + color: '#666', + marginBottom: 16, + }, + currentValue: { + flexDirection: 'row', + backgroundColor: '#f5f5f5', + padding: 12, + borderRadius: 8, + marginBottom: 16, + }, + label: { + fontSize: 14, + color: '#666', + }, + value: { + fontSize: 14, + fontWeight: 'bold', + marginLeft: 8, + }, + inputLabel: { + fontSize: 14, + color: '#333', + marginBottom: 4, + }, + input: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + padding: 12, + fontSize: 16, + marginBottom: 12, + }, + textArea: { + height: 60, + textAlignVertical: 'top', + }, + error: { + color: '#dc3545', + fontSize: 14, + marginBottom: 12, + }, + buttons: { + flexDirection: 'row', + gap: 12, + }, + button: { + flex: 1, + padding: 14, + borderRadius: 8, + alignItems: 'center', + }, + cancelButton: { + backgroundColor: '#f5f5f5', + }, + submitButton: { + backgroundColor: '#007AFF', + }, + cancelText: { + color: '#333', + fontWeight: '600', + }, + submitText: { + color: '#fff', + fontWeight: '600', + }, +}); diff --git a/src/components/feedback/CorrectSkuModal.tsx b/src/components/feedback/CorrectSkuModal.tsx new file mode 100644 index 0000000..f374551 --- /dev/null +++ b/src/components/feedback/CorrectSkuModal.tsx @@ -0,0 +1,220 @@ +import React, { useState } from 'react'; +import { + View, + Text, + Modal, + TextInput, + TouchableOpacity, + StyleSheet, + ActivityIndicator, +} from 'react-native'; +import { useFeedbackStore } from '../../stores/feedback.store'; + +interface Props { + visible: boolean; + onClose: () => void; + storeId: string; + itemId: string; + currentName: string; + currentCategory?: string; + currentBarcode?: string; + onSuccess?: () => void; +} + +export function CorrectSkuModal({ + visible, + onClose, + storeId, + itemId, + currentName, + currentCategory, + currentBarcode, + onSuccess, +}: Props) { + const [name, setName] = useState(currentName); + const [category, setCategory] = useState(currentCategory || ''); + const [barcode, setBarcode] = useState(currentBarcode || ''); + const [reason, setReason] = useState(''); + const { correctSku, isLoading, error } = useFeedbackStore(); + + const handleSubmit = async () => { + if (!name.trim()) return; + + try { + await correctSku(storeId, itemId, { + name: name.trim(), + category: category.trim() || undefined, + barcode: barcode.trim() || undefined, + reason: reason.trim() || undefined, + }); + onSuccess?.(); + onClose(); + } catch { + // Error is handled in store + } + }; + + const handleClose = () => { + setName(currentName); + setCategory(currentCategory || ''); + setBarcode(currentBarcode || ''); + setReason(''); + onClose(); + }; + + return ( + + + + Corregir Producto + + + Nombre actual: + {currentName} + + + Nombre correcto: + + + Categoria (opcional): + + + Codigo de barras (opcional): + + + Razon (opcional): + + + {error && {error}} + + + + Cancelar + + + {isLoading ? ( + + ) : ( + Guardar + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', + alignItems: 'center', + }, + container: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 20, + width: '90%', + maxWidth: 400, + maxHeight: '90%', + }, + title: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 16, + }, + currentValue: { + backgroundColor: '#f5f5f5', + padding: 12, + borderRadius: 8, + marginBottom: 16, + }, + label: { + fontSize: 12, + color: '#666', + marginBottom: 4, + }, + value: { + fontSize: 14, + fontWeight: 'bold', + }, + inputLabel: { + fontSize: 14, + color: '#333', + marginBottom: 4, + }, + input: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + padding: 12, + fontSize: 16, + marginBottom: 12, + }, + textArea: { + height: 60, + textAlignVertical: 'top', + }, + error: { + color: '#dc3545', + fontSize: 14, + marginBottom: 12, + }, + buttons: { + flexDirection: 'row', + gap: 12, + }, + button: { + flex: 1, + padding: 14, + borderRadius: 8, + alignItems: 'center', + }, + cancelButton: { + backgroundColor: '#f5f5f5', + }, + submitButton: { + backgroundColor: '#007AFF', + }, + cancelText: { + color: '#333', + fontWeight: '600', + }, + submitText: { + color: '#fff', + fontWeight: '600', + }, +}); diff --git a/src/components/feedback/CorrectionHistoryCard.tsx b/src/components/feedback/CorrectionHistoryCard.tsx new file mode 100644 index 0000000..081dc38 --- /dev/null +++ b/src/components/feedback/CorrectionHistoryCard.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { CorrectionHistoryItem } from '../../services/api/feedback.service'; + +interface Props { + correction: CorrectionHistoryItem; +} + +const typeIcons: Record = { + QUANTITY: 'calculator', + SKU: 'pricetag', + CONFIRMATION: 'checkmark-circle', +}; + +const typeLabels: Record = { + QUANTITY: 'Cantidad', + SKU: 'Nombre/SKU', + CONFIRMATION: 'Confirmacion', +}; + +export function CorrectionHistoryCard({ correction }: Props) { + const icon = typeIcons[correction.type] || 'create'; + const label = typeLabels[correction.type] || correction.type; + const date = new Date(correction.createdAt); + + const renderChange = () => { + if (correction.type === 'CONFIRMATION') { + return Item confirmado como correcto; + } + + if (correction.type === 'QUANTITY') { + return ( + + {correction.previousValue.quantity} → {correction.newValue.quantity} + + ); + } + + if (correction.type === 'SKU') { + return ( + + + "{correction.previousValue.name}" → "{correction.newValue.name}" + + {correction.newValue.category !== correction.previousValue.category && ( + + Categoria: {correction.newValue.category} + + )} + + ); + } + + return null; + }; + + return ( + + + + + + + {label} + + {date.toLocaleDateString('es-MX', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + })} + + + {renderChange()} + {correction.reason && ( + "{correction.reason}" + )} + {correction.user && ( + Por: {correction.user.name} + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + backgroundColor: '#fff', + borderRadius: 8, + padding: 12, + marginBottom: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, + elevation: 2, + }, + iconContainer: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: '#e3f2fd', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + content: { + flex: 1, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 4, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: '#333', + }, + date: { + fontSize: 12, + color: '#999', + }, + change: { + fontSize: 14, + color: '#666', + }, + subChange: { + fontSize: 12, + color: '#999', + marginTop: 2, + }, + reason: { + fontSize: 12, + color: '#666', + fontStyle: 'italic', + marginTop: 4, + }, + user: { + fontSize: 12, + color: '#999', + marginTop: 4, + }, +}); diff --git a/src/components/skeletons/CreditCardSkeleton.tsx b/src/components/skeletons/CreditCardSkeleton.tsx new file mode 100644 index 0000000..6bfef65 --- /dev/null +++ b/src/components/skeletons/CreditCardSkeleton.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { Skeleton, SkeletonText } from '../ui/Skeleton'; +import { useTheme } from '../../theme/ThemeContext'; + +/** + * Skeleton para tarjeta de balance de créditos + */ +export function CreditBalanceSkeleton() { + const { colors } = useTheme(); + + return ( + + + + + + + + + + + + + + + ); +} + +/** + * Skeleton para transacción + */ +export function TransactionSkeleton() { + const { colors } = useTheme(); + + return ( + + + + + + + + + + + ); +} + +/** + * Lista de skeletons de transacciones + */ +export function TransactionListSkeleton({ count = 5 }: { count?: number }) { + return ( + + {Array.from({ length: count }).map((_, index) => ( + + ))} + + ); +} + +/** + * Skeleton para paquete de créditos + */ +export function CreditPackageSkeleton() { + const { colors } = useTheme(); + + return ( + + + + + + + + + ); +} + +/** + * Lista de skeletons de paquetes + */ +export function CreditPackageListSkeleton({ count = 4 }: { count?: number }) { + return ( + + {Array.from({ length: count }).map((_, index) => ( + + ))} + + ); +} + +const styles = StyleSheet.create({ + balanceCard: { + borderRadius: 20, + padding: 24, + margin: 16, + }, + balanceStats: { + flexDirection: 'row', + justifyContent: 'space-around', + marginTop: 20, + paddingTop: 16, + borderTopWidth: 1, + borderTopColor: 'rgba(255,255,255,0.2)', + }, + balanceStat: { + alignItems: 'center', + }, + transaction: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + borderBottomWidth: 1, + }, + transactionIcon: { + marginRight: 12, + }, + transactionContent: { + flex: 1, + }, + package: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 12, + marginBottom: 12, + }, + packageContent: { + flex: 1, + marginLeft: 12, + }, + packageList: { + padding: 16, + }, +}); diff --git a/src/components/skeletons/InventoryItemSkeleton.tsx b/src/components/skeletons/InventoryItemSkeleton.tsx new file mode 100644 index 0000000..42d126b --- /dev/null +++ b/src/components/skeletons/InventoryItemSkeleton.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { Skeleton, SkeletonText, SkeletonImage } from '../ui/Skeleton'; +import { useTheme } from '../../theme/ThemeContext'; + +/** + * Skeleton para un item de inventario + */ +export function InventoryItemSkeleton() { + const { colors } = useTheme(); + + return ( + + + + + + + + + + + + ); +} + +/** + * Lista de skeletons de inventario + */ +export function InventoryListSkeleton({ count = 8 }: { count?: number }) { + return ( + + {Array.from({ length: count }).map((_, index) => ( + + ))} + + ); +} + +/** + * Skeleton para las estadísticas de inventario + */ +export function InventoryStatsSkeleton() { + const { colors } = useTheme(); + + return ( + + {Array.from({ length: 4 }).map((_, index) => ( + + + + + ))} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + padding: 12, + borderBottomWidth: 1, + }, + content: { + flex: 1, + marginLeft: 12, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + statsContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + padding: 16, + gap: 12, + }, + statCard: { + width: '47%', + padding: 16, + borderRadius: 12, + alignItems: 'center', + }, +}); diff --git a/src/components/skeletons/NotificationSkeleton.tsx b/src/components/skeletons/NotificationSkeleton.tsx new file mode 100644 index 0000000..7479ecc --- /dev/null +++ b/src/components/skeletons/NotificationSkeleton.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { Skeleton, SkeletonText, SkeletonCircle } from '../ui/Skeleton'; +import { useTheme } from '../../theme/ThemeContext'; + +/** + * Skeleton para una notificación + */ +export function NotificationItemSkeleton() { + const { colors } = useTheme(); + + return ( + + + + + + + + + + + + ); +} + +/** + * Lista de skeletons de notificaciones + */ +export function NotificationListSkeleton({ count = 6 }: { count?: number }) { + return ( + + {Array.from({ length: count }).map((_, index) => ( + + ))} + + ); +} + +/** + * Skeleton para el header de notificaciones + */ +export function NotificationHeaderSkeleton() { + const { colors } = useTheme(); + + return ( + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + padding: 16, + borderBottomWidth: 1, + }, + content: { + flex: 1, + marginLeft: 12, + }, + indicator: { + justifyContent: 'center', + paddingLeft: 8, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + }, +}); diff --git a/src/components/skeletons/StoreCardSkeleton.tsx b/src/components/skeletons/StoreCardSkeleton.tsx new file mode 100644 index 0000000..19873b2 --- /dev/null +++ b/src/components/skeletons/StoreCardSkeleton.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { Skeleton, SkeletonText, SkeletonCircle } from '../ui/Skeleton'; +import { useTheme } from '../../theme/ThemeContext'; + +/** + * Skeleton para tarjeta de tienda + */ +export function StoreCardSkeleton() { + const { colors } = useTheme(); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +/** + * Lista de skeletons de tiendas + */ +export function StoreListSkeleton({ count = 3 }: { count?: number }) { + return ( + + {Array.from({ length: count }).map((_, index) => ( + + ))} + + ); +} + +const styles = StyleSheet.create({ + container: { + borderRadius: 16, + padding: 16, + marginBottom: 12, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + }, + headerContent: { + flex: 1, + marginLeft: 12, + }, + stats: { + flexDirection: 'row', + justifyContent: 'space-around', + marginTop: 16, + paddingTop: 16, + borderTopWidth: 1, + borderTopColor: 'rgba(0,0,0,0.1)', + }, + stat: { + alignItems: 'center', + }, + list: { + padding: 16, + }, +}); diff --git a/src/components/ui/AnimatedList.tsx b/src/components/ui/AnimatedList.tsx new file mode 100644 index 0000000..fcfe25a --- /dev/null +++ b/src/components/ui/AnimatedList.tsx @@ -0,0 +1,154 @@ +import React, { useCallback } from 'react'; +import { FlatList, FlatListProps, ViewStyle, RefreshControl } from 'react-native'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + withDelay, + withSpring, + FadeIn, + SlideInRight, + Layout, +} from 'react-native-reanimated'; +import { useTheme } from '../../theme/ThemeContext'; + +const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); + +interface AnimatedListProps extends Omit, 'renderItem'> { + renderItem: (info: { item: T; index: number }) => React.ReactElement; + /** Delay base entre items en ms */ + staggerDelay?: number; + /** Tipo de animación de entrada */ + animationType?: 'fade' | 'slide' | 'spring'; + /** Mostrar animación al refrescar */ + animateOnRefresh?: boolean; + /** Callback de refresh */ + onRefresh?: () => Promise | void; + /** Está refrescando */ + isRefreshing?: boolean; +} + +/** + * Item animado wrapper + */ +function AnimatedItem({ + children, + index, + staggerDelay, + animationType, +}: { + children: React.ReactNode; + index: number; + staggerDelay: number; + animationType: 'fade' | 'slide' | 'spring'; +}) { + const delay = Math.min(index * staggerDelay, 500); // Cap máximo de delay + + const entering = (() => { + switch (animationType) { + case 'slide': + return SlideInRight.delay(delay).duration(300); + case 'spring': + return FadeIn.delay(delay).springify(); + case 'fade': + default: + return FadeIn.delay(delay).duration(300); + } + })(); + + return ( + + {children} + + ); +} + +/** + * FlatList con animaciones de entrada staggered + */ +export function AnimatedList({ + data, + renderItem, + staggerDelay = 50, + animationType = 'fade', + animateOnRefresh = true, + onRefresh, + isRefreshing = false, + ...props +}: AnimatedListProps) { + const { colors } = useTheme(); + const [key, setKey] = React.useState(0); + + const handleRefresh = useCallback(async () => { + if (onRefresh) { + await onRefresh(); + if (animateOnRefresh) { + setKey((prev) => prev + 1); + } + } + }, [onRefresh, animateOnRefresh]); + + const animatedRenderItem = useCallback( + ({ item, index }: { item: T; index: number }) => ( + + {renderItem({ item, index })} + + ), + [renderItem, staggerDelay, animationType] + ); + + return ( + + ) : undefined + } + {...(props as any)} + /> + ); +} + +/** + * Hook para crear estilos animados de item de lista + */ +export function useListItemEntering(index: number, baseDelay = 50) { + const delay = Math.min(index * baseDelay, 500); + + return FadeIn.delay(delay).duration(300); +} + +/** + * Componente para animar un item individual + */ +export function AnimatedListItem({ + children, + index, + baseDelay = 50, + style, +}: { + children: React.ReactNode; + index: number; + baseDelay?: number; + style?: ViewStyle; +}) { + const entering = useListItemEntering(index, baseDelay); + + return ( + + {children} + + ); +} diff --git a/src/components/ui/OfflineBanner.tsx b/src/components/ui/OfflineBanner.tsx new file mode 100644 index 0000000..677b399 --- /dev/null +++ b/src/components/ui/OfflineBanner.tsx @@ -0,0 +1,114 @@ +import React, { useEffect } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + withSpring, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useIsOffline } from '../../hooks/useNetworkStatus'; +import { Ionicons } from '@expo/vector-icons'; + +interface OfflineBannerProps { + /** Mensaje personalizado */ + message?: string; + /** Mostrar icono de wifi */ + showIcon?: boolean; +} + +/** + * Banner que aparece cuando no hay conexión a internet + * Se muestra en la parte superior de la pantalla con animación slide + */ +export function OfflineBanner({ + message = 'Sin conexión a internet', + showIcon = true, +}: OfflineBannerProps) { + const isOffline = useIsOffline(); + const insets = useSafeAreaInsets(); + + const translateY = useSharedValue(-100); + const opacity = useSharedValue(0); + + useEffect(() => { + if (isOffline) { + translateY.value = withSpring(0, { damping: 15, stiffness: 150 }); + opacity.value = withTiming(1, { duration: 200 }); + } else { + translateY.value = withTiming(-100, { duration: 300 }); + opacity.value = withTiming(0, { duration: 200 }); + } + }, [isOffline]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], + opacity: opacity.value, + })); + + // No renderizar nada si está online + if (!isOffline) { + return null; + } + + return ( + + + {showIcon && ( + + )} + {message} + + + ); +} + +/** + * Componente wrapper que incluye el banner offline + * Útil para envolver contenido principal de la app + */ +export function WithOfflineBanner({ children }: { children: React.ReactNode }) { + return ( + <> + + {children} + + ); +} + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + backgroundColor: '#EF4444', // Rojo para indicar problema + zIndex: 9999, + paddingBottom: 12, + }, + content: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 16, + }, + icon: { + marginRight: 8, + }, + text: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '600', + }, +}); diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000..33dd9fe --- /dev/null +++ b/src/components/ui/Skeleton.tsx @@ -0,0 +1,215 @@ +import React from 'react'; +import { View, StyleSheet, ViewStyle } from 'react-native'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withRepeat, + withTiming, + interpolate, +} from 'react-native-reanimated'; +import { useEffect } from 'react'; +import { useTheme } from '../../theme/ThemeContext'; + +interface SkeletonProps { + width?: number | string; + height?: number; + borderRadius?: number; + style?: ViewStyle; +} + +/** + * Componente base de Skeleton con animación shimmer + */ +export function Skeleton({ + width = '100%', + height = 16, + borderRadius = 4, + style +}: SkeletonProps) { + const { colors } = useTheme(); + const shimmerValue = useSharedValue(0); + + useEffect(() => { + shimmerValue.value = withRepeat( + withTiming(1, { duration: 1200 }), + -1, + false + ); + }, []); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: interpolate(shimmerValue.value, [0, 0.5, 1], [0.3, 0.6, 0.3]), + })); + + return ( + + ); +} + +/** + * Skeleton para texto - línea simple + */ +export function SkeletonText({ + width = '80%', + height = 14, + style +}: SkeletonProps) { + return ( + + ); +} + +/** + * Skeleton circular - para avatares + */ +export function SkeletonCircle({ + size = 40, + style +}: { size?: number; style?: ViewStyle }) { + return ( + + ); +} + +/** + * Skeleton para imagen cuadrada + */ +export function SkeletonImage({ + width = 80, + height = 80, + borderRadius = 8, + style +}: SkeletonProps) { + return ( + + ); +} + +/** + * Skeleton para tarjeta completa + */ +export function SkeletonCard({ style }: { style?: ViewStyle }) { + const { colors } = useTheme(); + + return ( + + + + + + + + + + + + + ); +} + +/** + * Skeleton para item de lista + */ +export function SkeletonListItem({ style }: { style?: ViewStyle }) { + const { colors } = useTheme(); + + return ( + + + + + + + + + ); +} + +/** + * Skeleton para estadística/métrica + */ +export function SkeletonStat({ style }: { style?: ViewStyle }) { + const { colors } = useTheme(); + + return ( + + + + + ); +} + +/** + * Grupo de skeletons de lista + */ +export function SkeletonList({ + count = 5, + style +}: { count?: number; style?: ViewStyle }) { + return ( + + {Array.from({ length: count }).map((_, index) => ( + + ))} + + ); +} + +const styles = StyleSheet.create({ + card: { + padding: 16, + borderRadius: 12, + marginVertical: 8, + }, + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + }, + cardHeaderText: { + flex: 1, + marginLeft: 12, + }, + listItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: 1, + }, + listItemContent: { + flex: 1, + marginLeft: 12, + }, + stat: { + padding: 16, + borderRadius: 12, + alignItems: 'center', + minWidth: 100, + }, +}); diff --git a/src/components/validation/ValidationItemCard.tsx b/src/components/validation/ValidationItemCard.tsx new file mode 100644 index 0000000..6d91803 --- /dev/null +++ b/src/components/validation/ValidationItemCard.tsx @@ -0,0 +1,317 @@ +import React, { useState } from 'react'; +import { + View, + Text, + Image, + TextInput, + TouchableOpacity, + StyleSheet, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { ValidationItem, ValidationItemResponse } from '../../services/api/validations.service'; + +interface Props { + item: ValidationItem; + onResponse: (response: Omit) => void; + existingResponse?: ValidationItemResponse; +} + +export function ValidationItemCard({ item, onResponse, existingResponse }: Props) { + const [showCorrection, setShowCorrection] = useState(false); + const [correctedQuantity, setCorrectedQuantity] = useState( + existingResponse?.correctedQuantity?.toString() || item.quantity.toString(), + ); + const [correctedName, setCorrectedName] = useState( + existingResponse?.correctedName || item.name, + ); + const [startTime] = useState(Date.now()); + + const handleCorrect = () => { + setShowCorrection(!showCorrection); + }; + + const handleMarkCorrect = () => { + onResponse({ + isCorrect: true, + responseTimeMs: Date.now() - startTime, + }); + }; + + const handleSubmitCorrection = () => { + const qty = parseInt(correctedQuantity, 10); + onResponse({ + isCorrect: false, + correctedQuantity: isNaN(qty) ? undefined : qty, + correctedName: correctedName !== item.name ? correctedName : undefined, + responseTimeMs: Date.now() - startTime, + }); + setShowCorrection(false); + }; + + const confidence = item.detectionConfidence + ? Math.round(Number(item.detectionConfidence) * 100) + : null; + + return ( + + {item.imageUrl && ( + + )} + + + {item.name} + + Cantidad: {item.quantity} + {item.category && ( + {item.category} + )} + + {confidence !== null && ( + + Confianza: + = 80 + ? styles.highConfidence + : confidence >= 60 + ? styles.mediumConfidence + : styles.lowConfidence, + ]} + > + {confidence}% + + + )} + + + {showCorrection ? ( + + Nombre correcto: + + Cantidad correcta: + + + setShowCorrection(false)} + > + Cancelar + + + Guardar + + + + ) : ( + + + + Corregir + + + + Correcto + + + )} + + {existingResponse && !showCorrection && ( + + + + {existingResponse.isCorrect ? 'Marcado correcto' : 'Corregido'} + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + image: { + width: '100%', + height: 150, + borderRadius: 8, + marginBottom: 12, + backgroundColor: '#f5f5f5', + }, + content: { + marginBottom: 12, + }, + name: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 4, + }, + details: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + quantity: { + fontSize: 16, + color: '#333', + }, + category: { + fontSize: 14, + color: '#666', + backgroundColor: '#f0f0f0', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 4, + }, + confidenceContainer: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 8, + gap: 8, + }, + confidenceLabel: { + fontSize: 12, + color: '#999', + }, + confidenceBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 4, + }, + highConfidence: { + backgroundColor: '#e8f5e9', + }, + mediumConfidence: { + backgroundColor: '#fff3e0', + }, + lowConfidence: { + backgroundColor: '#ffebee', + }, + confidenceText: { + fontSize: 12, + fontWeight: '600', + }, + actions: { + flexDirection: 'row', + gap: 12, + }, + actionButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 12, + borderRadius: 8, + gap: 6, + }, + incorrectButton: { + backgroundColor: '#ffebee', + }, + correctButton: { + backgroundColor: '#e8f5e9', + }, + incorrectText: { + color: '#dc3545', + fontWeight: '600', + }, + correctText: { + color: '#28a745', + fontWeight: '600', + }, + correctionForm: { + borderTopWidth: 1, + borderTopColor: '#eee', + paddingTop: 12, + }, + correctionLabel: { + fontSize: 14, + color: '#333', + marginBottom: 4, + }, + input: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + padding: 10, + fontSize: 16, + marginBottom: 12, + }, + correctionButtons: { + flexDirection: 'row', + gap: 12, + }, + cancelCorrectionButton: { + flex: 1, + padding: 12, + borderRadius: 8, + backgroundColor: '#f5f5f5', + alignItems: 'center', + }, + saveCorrectionButton: { + flex: 1, + padding: 12, + borderRadius: 8, + backgroundColor: '#007AFF', + alignItems: 'center', + }, + cancelCorrectionText: { + color: '#666', + fontWeight: '600', + }, + saveCorrectionText: { + color: '#fff', + fontWeight: '600', + }, + responseIndicator: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginTop: 12, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: '#eee', + gap: 6, + }, + responseText: { + fontSize: 14, + fontWeight: '500', + }, +}); diff --git a/src/components/validation/ValidationProgressBar.tsx b/src/components/validation/ValidationProgressBar.tsx new file mode 100644 index 0000000..3b3ac0d --- /dev/null +++ b/src/components/validation/ValidationProgressBar.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; + +interface Props { + current: number; + total: number; + validated: number; +} + +export function ValidationProgressBar({ current, total, validated }: Props) { + const progress = (validated / total) * 100; + + return ( + + + + Producto {current + 1} de {total} + + + {validated} validados + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: '#fff', + borderBottomWidth: 1, + borderBottomColor: '#eee', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 8, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: '#333', + }, + validated: { + fontSize: 14, + color: '#28a745', + }, + track: { + height: 4, + backgroundColor: '#e0e0e0', + borderRadius: 2, + overflow: 'hidden', + }, + fill: { + height: '100%', + backgroundColor: '#007AFF', + borderRadius: 2, + }, +}); diff --git a/src/components/validation/ValidationPromptModal.tsx b/src/components/validation/ValidationPromptModal.tsx new file mode 100644 index 0000000..5a29e38 --- /dev/null +++ b/src/components/validation/ValidationPromptModal.tsx @@ -0,0 +1,184 @@ +import React from 'react'; +import { + View, + Text, + Modal, + TouchableOpacity, + StyleSheet, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import { useValidationsStore } from '../../stores/validations.store'; + +interface Props { + visible: boolean; + onClose: () => void; + requestId: string; + creditsReward: number; + itemsCount: number; +} + +export function ValidationPromptModal({ + visible, + onClose, + requestId, + creditsReward, + itemsCount, +}: Props) { + const router = useRouter(); + const { skipValidation } = useValidationsStore(); + + const handleAccept = () => { + onClose(); + router.push('/validation/items'); + }; + + const handleSkip = async () => { + await skipValidation(); + onClose(); + }; + + return ( + + + + + + + + Ayudanos a mejorar + + Validando algunos productos nos ayudas a detectar mejor tu inventario + en el futuro. + + + + + + {itemsCount} + productos + + + + + +{creditsReward} + + credito + + + + Toma menos de 1 minuto + + + + Ahora no + + + Validar + + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', + alignItems: 'center', + }, + container: { + backgroundColor: '#fff', + borderRadius: 16, + padding: 24, + width: '85%', + maxWidth: 340, + alignItems: 'center', + }, + iconContainer: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: '#e3f2fd', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + title: { + fontSize: 22, + fontWeight: 'bold', + marginBottom: 8, + textAlign: 'center', + }, + description: { + fontSize: 14, + color: '#666', + textAlign: 'center', + lineHeight: 20, + marginBottom: 20, + }, + stats: { + flexDirection: 'row', + justifyContent: 'center', + gap: 40, + marginBottom: 16, + }, + statItem: { + alignItems: 'center', + }, + statValue: { + fontSize: 24, + fontWeight: 'bold', + marginTop: 4, + }, + rewardValue: { + color: '#28a745', + }, + statLabel: { + fontSize: 12, + color: '#999', + }, + time: { + fontSize: 12, + color: '#999', + marginBottom: 20, + }, + buttons: { + flexDirection: 'row', + gap: 12, + width: '100%', + }, + button: { + flex: 1, + padding: 14, + borderRadius: 8, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: 6, + }, + skipButton: { + backgroundColor: '#f5f5f5', + }, + acceptButton: { + backgroundColor: '#007AFF', + }, + skipText: { + color: '#666', + fontWeight: '600', + }, + acceptText: { + color: '#fff', + fontWeight: '600', + }, +}); diff --git a/src/hooks/useAnimations.ts b/src/hooks/useAnimations.ts new file mode 100644 index 0000000..f4c4296 --- /dev/null +++ b/src/hooks/useAnimations.ts @@ -0,0 +1,186 @@ +import { useEffect } from 'react'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + withSpring, + withDelay, + Easing, + interpolate, + WithTimingConfig, + WithSpringConfig, +} from 'react-native-reanimated'; + +const DEFAULT_TIMING: WithTimingConfig = { + duration: 300, + easing: Easing.bezier(0.25, 0.1, 0.25, 1), +}; + +const DEFAULT_SPRING: WithSpringConfig = { + damping: 15, + stiffness: 150, +}; + +/** + * Hook para animación de fade in + */ +export function useFadeIn(delay = 0) { + const opacity = useSharedValue(0); + + useEffect(() => { + opacity.value = withDelay(delay, withTiming(1, DEFAULT_TIMING)); + }, [delay]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); + + return { animatedStyle, opacity }; +} + +/** + * Hook para animación de slide desde abajo + */ +export function useSlideIn(delay = 0, distance = 20) { + const opacity = useSharedValue(0); + const translateY = useSharedValue(distance); + + useEffect(() => { + opacity.value = withDelay(delay, withTiming(1, DEFAULT_TIMING)); + translateY.value = withDelay(delay, withSpring(0, DEFAULT_SPRING)); + }, [delay, distance]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ translateY: translateY.value }], + })); + + return { animatedStyle, opacity, translateY }; +} + +/** + * Hook para animación de slide desde la derecha + */ +export function useSlideFromRight(delay = 0, distance = 30) { + const opacity = useSharedValue(0); + const translateX = useSharedValue(distance); + + useEffect(() => { + opacity.value = withDelay(delay, withTiming(1, DEFAULT_TIMING)); + translateX.value = withDelay(delay, withSpring(0, DEFAULT_SPRING)); + }, [delay, distance]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ translateX: translateX.value }], + })); + + return { animatedStyle, opacity, translateX }; +} + +/** + * Hook para efecto de escala al presionar + */ +export function usePressScale(pressedScale = 0.97) { + const scale = useSharedValue(1); + + const onPressIn = () => { + scale.value = withSpring(pressedScale, { damping: 20, stiffness: 300 }); + }; + + const onPressOut = () => { + scale.value = withSpring(1, { damping: 20, stiffness: 300 }); + }; + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + return { animatedStyle, onPressIn, onPressOut, scale }; +} + +/** + * Hook para animación stagger en listas + * Retorna un delay calculado basado en el índice + */ +export function useListItemAnimation(index: number, baseDelay = 50) { + const delay = index * baseDelay; + return useSlideIn(delay); +} + +/** + * Hook para animación de shimmer (skeleton loader) + */ +export function useShimmer() { + const shimmerValue = useSharedValue(0); + + useEffect(() => { + const animate = () => { + shimmerValue.value = withTiming(1, { duration: 1000 }, () => { + shimmerValue.value = 0; + animate(); + }); + }; + animate(); + + return () => { + shimmerValue.value = 0; + }; + }, []); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: interpolate(shimmerValue.value, [0, 0.5, 1], [0.3, 0.7, 0.3]), + })); + + return { animatedStyle, shimmerValue }; +} + +/** + * Hook para animación de pulso + */ +export function usePulse(minScale = 0.98, maxScale = 1.02) { + const scale = useSharedValue(1); + + useEffect(() => { + const animate = () => { + scale.value = withTiming(maxScale, { duration: 800 }, () => { + scale.value = withTiming(minScale, { duration: 800 }, () => { + animate(); + }); + }); + }; + animate(); + + return () => { + scale.value = 1; + }; + }, [minScale, maxScale]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + return { animatedStyle, scale }; +} + +/** + * Hook para animar entrada/salida de un elemento + */ +export function useToggleAnimation(isVisible: boolean) { + const opacity = useSharedValue(isVisible ? 1 : 0); + const translateY = useSharedValue(isVisible ? 0 : -20); + + useEffect(() => { + opacity.value = withTiming(isVisible ? 1 : 0, { duration: 200 }); + translateY.value = withSpring(isVisible ? 0 : -20); + }, [isVisible]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ translateY: translateY.value }], + })); + + return { animatedStyle }; +} + +export { Animated }; diff --git a/src/hooks/useNetworkStatus.ts b/src/hooks/useNetworkStatus.ts new file mode 100644 index 0000000..a7a53f9 --- /dev/null +++ b/src/hooks/useNetworkStatus.ts @@ -0,0 +1,73 @@ +import { useState, useEffect } from 'react'; +import NetInfo, { NetInfoState, NetInfoStateType } from '@react-native-community/netinfo'; + +export interface NetworkStatus { + isConnected: boolean; + isInternetReachable: boolean | null; + type: NetInfoStateType; + isWifi: boolean; + isCellular: boolean; +} + +/** + * Hook para detectar el estado de la conexión de red + */ +export function useNetworkStatus(): NetworkStatus { + const [networkStatus, setNetworkStatus] = useState({ + isConnected: true, + isInternetReachable: true, + type: NetInfoStateType.unknown, + isWifi: false, + isCellular: false, + }); + + useEffect(() => { + const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => { + setNetworkStatus({ + isConnected: state.isConnected ?? false, + isInternetReachable: state.isInternetReachable, + type: state.type, + isWifi: state.type === NetInfoStateType.wifi, + isCellular: state.type === NetInfoStateType.cellular, + }); + }); + + // Obtener estado inicial + NetInfo.fetch().then((state) => { + setNetworkStatus({ + isConnected: state.isConnected ?? false, + isInternetReachable: state.isInternetReachable, + type: state.type, + isWifi: state.type === NetInfoStateType.wifi, + isCellular: state.type === NetInfoStateType.cellular, + }); + }); + + return () => { + unsubscribe(); + }; + }, []); + + return networkStatus; +} + +/** + * Hook simplificado para verificar si hay conexión + */ +export function useIsOnline(): boolean { + const { isConnected, isInternetReachable } = useNetworkStatus(); + + // isInternetReachable puede ser null mientras se determina + if (isInternetReachable === null) { + return isConnected; + } + + return isConnected && isInternetReachable; +} + +/** + * Hook simplificado para verificar si está offline + */ +export function useIsOffline(): boolean { + return !useIsOnline(); +} diff --git a/src/services/api/__tests__/auth.service.spec.ts b/src/services/api/__tests__/auth.service.spec.ts new file mode 100644 index 0000000..0950934 --- /dev/null +++ b/src/services/api/__tests__/auth.service.spec.ts @@ -0,0 +1,112 @@ +import { authService } from '../auth.service'; +import apiClient from '../client'; + +jest.mock('../client'); + +const mockApiClient = apiClient as jest.Mocked; + +describe('Auth Service', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('login', () => { + it('should call login endpoint with credentials', async () => { + const mockResponse = { + data: { + user: { id: '1', phone: '+1234567890', name: 'Test' }, + accessToken: 'access-token', + refreshToken: 'refresh-token', + }, + }; + + mockApiClient.post.mockResolvedValue(mockResponse); + + const result = await authService.login({ + phone: '+1234567890', + password: 'password123', + }); + + expect(mockApiClient.post).toHaveBeenCalledWith('/auth/login', { + phone: '+1234567890', + password: 'password123', + }); + expect(result).toEqual(mockResponse.data); + }); + }); + + describe('initiateRegistration', () => { + it('should call registration endpoint', async () => { + mockApiClient.post.mockResolvedValue({ data: { message: 'OTP sent' } }); + + await authService.initiateRegistration({ + phone: '+1234567890', + name: 'Test User', + }); + + expect(mockApiClient.post).toHaveBeenCalledWith('/auth/register/initiate', { + phone: '+1234567890', + name: 'Test User', + }); + }); + }); + + describe('verifyOtp', () => { + it('should call OTP verification endpoint', async () => { + const mockResponse = { + data: { + user: { id: '1', phone: '+1234567890', name: 'Test' }, + accessToken: 'access-token', + refreshToken: 'refresh-token', + }, + }; + + mockApiClient.post.mockResolvedValue(mockResponse); + + const result = await authService.verifyOtp({ + phone: '+1234567890', + otp: '123456', + password: 'password123', + }); + + expect(mockApiClient.post).toHaveBeenCalledWith('/auth/register/verify', { + phone: '+1234567890', + otp: '123456', + password: 'password123', + }); + expect(result).toEqual(mockResponse.data); + }); + }); + + describe('refreshTokens', () => { + it('should call refresh endpoint', async () => { + const mockResponse = { + data: { + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + }, + }; + + mockApiClient.post.mockResolvedValue(mockResponse); + + const result = await authService.refreshTokens('old-refresh-token'); + + expect(mockApiClient.post).toHaveBeenCalledWith('/auth/refresh', { + refreshToken: 'old-refresh-token', + }); + expect(result).toEqual(mockResponse.data); + }); + }); + + describe('logout', () => { + it('should call logout endpoint', async () => { + mockApiClient.post.mockResolvedValue({ data: { success: true } }); + + await authService.logout('refresh-token'); + + expect(mockApiClient.post).toHaveBeenCalledWith('/auth/logout', { + refreshToken: 'refresh-token', + }); + }); + }); +}); diff --git a/src/services/api/__tests__/inventory.service.spec.ts b/src/services/api/__tests__/inventory.service.spec.ts new file mode 100644 index 0000000..52fb7b3 --- /dev/null +++ b/src/services/api/__tests__/inventory.service.spec.ts @@ -0,0 +1,119 @@ +import { inventoryService } from '../inventory.service'; +import apiClient from '../client'; + +jest.mock('../client'); + +const mockApiClient = apiClient as jest.Mocked; + +describe('Inventory Service', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getItems', () => { + it('should fetch items with pagination', async () => { + const mockResponse = { + data: { + items: [ + { id: '1', name: 'Item 1', quantity: 10 }, + { id: '2', name: 'Item 2', quantity: 5 }, + ], + total: 2, + page: 1, + limit: 50, + hasMore: false, + }, + }; + + mockApiClient.get.mockResolvedValue(mockResponse); + + const result = await inventoryService.getItems('store-1', { page: 1, limit: 50 }); + + expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/inventory', { + params: { page: 1, limit: 50 }, + }); + expect(result).toEqual(mockResponse.data); + }); + + it('should pass category filter', async () => { + mockApiClient.get.mockResolvedValue({ + data: { items: [], total: 0, page: 1, limit: 50, hasMore: false }, + }); + + await inventoryService.getItems('store-1', { category: 'Electronics' }); + + expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/inventory', { + params: { category: 'Electronics' }, + }); + }); + }); + + describe('getItem', () => { + it('should fetch single item', async () => { + const mockItem = { id: '1', name: 'Item 1', quantity: 10 }; + mockApiClient.get.mockResolvedValue({ data: mockItem }); + + const result = await inventoryService.getItem('store-1', '1'); + + expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/inventory/1'); + expect(result).toEqual(mockItem); + }); + }); + + describe('updateItem', () => { + it('should send PATCH request with updates', async () => { + const updatedItem = { id: '1', name: 'Updated', quantity: 20 }; + mockApiClient.patch.mockResolvedValue({ data: updatedItem }); + + const result = await inventoryService.updateItem('store-1', '1', { + name: 'Updated', + quantity: 20, + }); + + expect(mockApiClient.patch).toHaveBeenCalledWith('/stores/store-1/inventory/1', { + name: 'Updated', + quantity: 20, + }); + expect(result).toEqual(updatedItem); + }); + }); + + describe('deleteItem', () => { + it('should send DELETE request', async () => { + mockApiClient.delete.mockResolvedValue({ data: { success: true } }); + + await inventoryService.deleteItem('store-1', '1'); + + expect(mockApiClient.delete).toHaveBeenCalledWith('/stores/store-1/inventory/1'); + }); + }); + + describe('getStatistics', () => { + it('should fetch inventory statistics', async () => { + const mockStats = { + totalItems: 100, + totalValue: 5000, + lowStockCount: 5, + categoryBreakdown: [], + }; + mockApiClient.get.mockResolvedValue({ data: mockStats }); + + const result = await inventoryService.getStatistics('store-1'); + + expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/inventory/statistics'); + expect(result).toEqual(mockStats); + }); + }); + + describe('getCategories', () => { + it('should fetch categories list', async () => { + const mockCategories = ['Electronics', 'Clothing', 'Food']; + mockApiClient.get.mockResolvedValue({ data: mockCategories }); + + const result = await inventoryService.getCategories('store-1'); + + expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/inventory/categories'); + expect(result).toEqual(mockCategories); + }); + }); +}); diff --git a/src/services/api/__tests__/reports.service.spec.ts b/src/services/api/__tests__/reports.service.spec.ts new file mode 100644 index 0000000..53d8464 --- /dev/null +++ b/src/services/api/__tests__/reports.service.spec.ts @@ -0,0 +1,175 @@ +import { reportsService } from '../reports.service'; +import apiClient from '../client'; + +jest.mock('../client'); + +const mockApiClient = apiClient as jest.Mocked; + +describe('Reports Service', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getValuationReport', () => { + it('should fetch valuation report', async () => { + const mockReport = { + summary: { + totalItems: 100, + totalCost: 1000, + totalPrice: 2000, + potentialMargin: 1000, + potentialMarginPercent: 50, + }, + byCategory: [], + items: [], + }; + + mockApiClient.get.mockResolvedValue({ data: mockReport }); + + const result = await reportsService.getValuationReport('store-1'); + + expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/reports/valuation'); + expect(result).toEqual(mockReport); + }); + }); + + describe('getMovementsReport', () => { + it('should fetch movements report without params', async () => { + const mockReport = { + summary: { + period: { start: '2024-01-01', end: '2024-01-31' }, + totalMovements: 50, + netChange: 10, + itemsIncreased: 30, + itemsDecreased: 20, + }, + movements: [], + byItem: [], + total: 50, + page: 1, + limit: 50, + hasMore: false, + }; + + mockApiClient.get.mockResolvedValue({ data: mockReport }); + + const result = await reportsService.getMovementsReport('store-1'); + + expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/reports/movements', { + params: undefined, + }); + expect(result).toEqual(mockReport); + }); + + it('should pass date range params', async () => { + mockApiClient.get.mockResolvedValue({ + data: { + summary: {}, + movements: [], + byItem: [], + total: 0, + page: 1, + limit: 50, + hasMore: false, + }, + }); + + await reportsService.getMovementsReport('store-1', { + startDate: '2024-01-01', + endDate: '2024-01-31', + }); + + expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/reports/movements', { + params: { + startDate: '2024-01-01', + endDate: '2024-01-31', + }, + }); + }); + + it('should pass pagination params', async () => { + mockApiClient.get.mockResolvedValue({ + data: { + summary: {}, + movements: [], + byItem: [], + total: 100, + page: 2, + limit: 20, + hasMore: true, + }, + }); + + await reportsService.getMovementsReport('store-1', { + page: 2, + limit: 20, + }); + + expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/reports/movements', { + params: { page: 2, limit: 20 }, + }); + }); + }); + + describe('getCategoriesReport', () => { + it('should fetch categories report', async () => { + const mockReport = { + summary: { + totalCategories: 5, + totalItems: 100, + totalValue: 10000, + }, + categories: [ + { + name: 'Electronics', + itemCount: 50, + percentOfTotal: 50, + totalValue: 5000, + lowStockCount: 2, + averagePrice: 100, + topItems: [], + }, + ], + }; + + mockApiClient.get.mockResolvedValue({ data: mockReport }); + + const result = await reportsService.getCategoriesReport('store-1'); + + expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/reports/categories'); + expect(result).toEqual(mockReport); + }); + }); + + describe('getLowStockReport', () => { + it('should fetch low stock report', async () => { + const mockReport = { + summary: { + totalAlerts: 10, + criticalCount: 3, + warningCount: 7, + totalValueAtRisk: 500, + }, + items: [ + { + id: '1', + name: 'Low Stock Item', + category: 'Electronics', + quantity: 2, + minStock: 10, + shortage: 8, + estimatedReorderCost: 80, + priority: 'critical', + }, + ], + }; + + mockApiClient.get.mockResolvedValue({ data: mockReport }); + + const result = await reportsService.getLowStockReport('store-1'); + + expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/reports/low-stock'); + expect(result).toEqual(mockReport); + }); + }); +}); diff --git a/src/services/api/auth.service.ts b/src/services/api/auth.service.ts new file mode 100644 index 0000000..57c8806 --- /dev/null +++ b/src/services/api/auth.service.ts @@ -0,0 +1,62 @@ +import apiClient from './client'; + +interface LoginRequest { + phone: string; + password: string; +} + +interface RegisterRequest { + phone: string; + name: string; +} + +interface VerifyOtpRequest { + phone: string; + otp: string; + password: string; +} + +interface AuthResponse { + user: { + id: string; + phone: string; + name: string; + email?: string; + }; + accessToken: string; + refreshToken: string; + expiresIn: number; +} + +interface TokenResponse { + accessToken: string; + refreshToken: string; + expiresIn: number; +} + +export const authService = { + login: async (data: LoginRequest): Promise => { + const response = await apiClient.post('/auth/login', data); + return response.data; + }, + + initiateRegistration: async (data: RegisterRequest): Promise => { + await apiClient.post('/auth/register', data); + }, + + verifyOtp: async (data: VerifyOtpRequest): Promise => { + const response = await apiClient.post('/auth/verify-otp', data); + return response.data; + }, + + refreshTokens: async (refreshToken: string): Promise => { + const response = await apiClient.post('/auth/refresh', { + refreshToken, + }); + return response.data; + }, + + logout: async (refreshToken: string): Promise => { + await apiClient.post('/auth/logout', { refreshToken }); + }, +}; diff --git a/src/services/api/client.ts b/src/services/api/client.ts new file mode 100644 index 0000000..621a693 --- /dev/null +++ b/src/services/api/client.ts @@ -0,0 +1,58 @@ +import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'; +import { useAuthStore } from '@stores/auth.store'; + +const API_URL = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:3142/api/v1'; + +export const apiClient: AxiosInstance = axios.create({ + baseURL: API_URL, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor - add auth token +apiClient.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + const { accessToken } = useAuthStore.getState(); + if (accessToken && config.headers) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + return config; + }, + (error) => Promise.reject(error) +); + +// Response interceptor - handle token refresh +apiClient.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { + _retry?: boolean; + }; + + // If 401 and not already retrying, try to refresh token + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + await useAuthStore.getState().refreshTokens(); + const { accessToken } = useAuthStore.getState(); + + if (accessToken && originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + } + + return apiClient(originalRequest); + } catch { + // Refresh failed, logout + useAuthStore.getState().logout(); + return Promise.reject(error); + } + } + + return Promise.reject(error); + } +); + +export default apiClient; diff --git a/src/services/api/credits.service.ts b/src/services/api/credits.service.ts new file mode 100644 index 0000000..d0d72be --- /dev/null +++ b/src/services/api/credits.service.ts @@ -0,0 +1,61 @@ +import apiClient from './client'; + +interface BalanceResponse { + balance: number; + totalPurchased: number; + totalConsumed: number; + totalFromReferrals: number; +} + +interface Transaction { + id: string; + type: 'purchase' | 'consumption' | 'referral_bonus'; + amount: number; + description: string; + createdAt: string; +} + +interface TransactionsResponse { + transactions: Transaction[]; + total: number; + page: number; + limit: number; +} + +interface PurchaseRequest { + packageId: string; + paymentMethodId: string; +} + +interface PurchaseResponse { + transactionId: string; + newBalance: number; + paymentStatus: 'completed' | 'pending' | 'failed'; + paymentUrl?: string; // For OXXO/7-Eleven vouchers +} + +export const creditsService = { + getBalance: async (): Promise => { + const response = await apiClient.get('/credits/balance'); + return response.data; + }, + + getTransactions: async ( + page = 1, + limit = 20 + ): Promise => { + const response = await apiClient.get( + '/credits/transactions', + { params: { page, limit } } + ); + return response.data; + }, + + purchase: async (data: PurchaseRequest): Promise => { + const response = await apiClient.post( + '/credits/purchase', + data + ); + return response.data; + }, +}; diff --git a/src/services/api/exports.service.ts b/src/services/api/exports.service.ts new file mode 100644 index 0000000..6844fc4 --- /dev/null +++ b/src/services/api/exports.service.ts @@ -0,0 +1,143 @@ +import apiClient from './client'; + +export type ExportFormat = 'CSV' | 'EXCEL'; + +export type ExportType = + | 'INVENTORY' + | 'REPORT_VALUATION' + | 'REPORT_MOVEMENTS' + | 'REPORT_CATEGORIES' + | 'REPORT_LOW_STOCK'; + +export type ExportStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; + +export interface ExportFilters { + category?: string; + lowStockOnly?: boolean; + startDate?: string; + endDate?: string; +} + +export interface ExportJobResponse { + jobId: string; + message: string; +} + +export interface ExportStatusResponse { + id: string; + status: ExportStatus; + format: ExportFormat; + type: ExportType; + filters?: ExportFilters; + totalRows?: number; + errorMessage?: string; + createdAt: string; + expiresAt?: string; +} + +export interface ExportDownloadResponse { + url: string; + expiresAt: string; + filename: string; +} + +export const exportsService = { + /** + * Request inventory export + */ + requestInventoryExport: async ( + storeId: string, + format: ExportFormat, + filters?: { category?: string; lowStockOnly?: boolean }, + ): Promise => { + const response = await apiClient.post( + `/stores/${storeId}/exports/inventory`, + { format, ...filters }, + ); + return response.data; + }, + + /** + * Request report export + */ + requestReportExport: async ( + storeId: string, + type: ExportType, + format: ExportFormat, + filters?: { startDate?: string; endDate?: string }, + ): Promise => { + const response = await apiClient.post( + `/stores/${storeId}/exports/report`, + { type, format, ...filters }, + ); + return response.data; + }, + + /** + * Get export status + */ + getExportStatus: async ( + storeId: string, + jobId: string, + ): Promise => { + const response = await apiClient.get( + `/stores/${storeId}/exports/${jobId}`, + ); + return response.data; + }, + + /** + * Get download URL for completed export + */ + getDownloadUrl: async ( + storeId: string, + jobId: string, + ): Promise => { + const response = await apiClient.get( + `/stores/${storeId}/exports/${jobId}/download`, + ); + return response.data; + }, + + /** + * Poll export status until complete or failed + */ + pollExportStatus: async ( + storeId: string, + jobId: string, + onProgress?: (status: ExportStatusResponse) => void, + maxAttempts = 60, + intervalMs = 2000, + ): Promise => { + let attempts = 0; + + return new Promise((resolve, reject) => { + const poll = async () => { + try { + const status = await exportsService.getExportStatus(storeId, jobId); + + if (onProgress) { + onProgress(status); + } + + if (status.status === 'COMPLETED' || status.status === 'FAILED') { + resolve(status); + return; + } + + attempts++; + if (attempts >= maxAttempts) { + reject(new Error('Export timed out')); + return; + } + + setTimeout(poll, intervalMs); + } catch (error) { + reject(error); + } + }; + + poll(); + }); + }, +}; diff --git a/src/services/api/feedback.service.ts b/src/services/api/feedback.service.ts new file mode 100644 index 0000000..bb29568 --- /dev/null +++ b/src/services/api/feedback.service.ts @@ -0,0 +1,115 @@ +import apiClient from './client'; + +export interface CorrectQuantityRequest { + quantity: number; + reason?: string; +} + +export interface CorrectSkuRequest { + name: string; + category?: string; + barcode?: string; + reason?: string; +} + +export interface CorrectionResponse { + id: string; + type: 'QUANTITY' | 'SKU' | 'CONFIRMATION'; + previousValue: Record; + newValue: Record; + createdAt: string; +} + +export interface CorrectionHistoryItem { + id: string; + type: 'QUANTITY' | 'SKU' | 'CONFIRMATION'; + previousValue: Record; + newValue: Record; + reason?: string; + createdAt: string; + user?: { + id: string; + name: string; + }; +} + +export interface SubmitProductRequest { + storeId: string; + videoId?: string; + name: string; + category?: string; + barcode?: string; + imageUrl?: string; + frameTimestamp?: number; + boundingBox?: Record; +} + +export interface ProductSearchResult { + id: string; + name: string; + category?: string; + barcode?: string; + imageUrl?: string; +} + +const feedbackService = { + async correctQuantity( + storeId: string, + itemId: string, + data: CorrectQuantityRequest, + ): Promise<{ correction: CorrectionResponse; item: { id: string; name: string; quantity: number } }> { + const response = await apiClient.patch( + `/stores/${storeId}/inventory/${itemId}/correct-quantity`, + data, + ); + return response.data; + }, + + async correctSku( + storeId: string, + itemId: string, + data: CorrectSkuRequest, + ): Promise<{ correction: CorrectionResponse; item: { id: string; name: string; category?: string; barcode?: string } }> { + const response = await apiClient.patch( + `/stores/${storeId}/inventory/${itemId}/correct-sku`, + data, + ); + return response.data; + }, + + async confirmItem( + storeId: string, + itemId: string, + ): Promise<{ correction: CorrectionResponse; item: { id: string; name: string; quantity: number } }> { + const response = await apiClient.post( + `/stores/${storeId}/inventory/${itemId}/confirm`, + ); + return response.data; + }, + + async getCorrectionHistory( + storeId: string, + itemId: string, + ): Promise<{ corrections: CorrectionHistoryItem[] }> { + const response = await apiClient.get( + `/stores/${storeId}/inventory/${itemId}/history`, + ); + return response.data; + }, + + async submitProduct(data: SubmitProductRequest): Promise<{ + submission: { id: string; name: string; status: string; createdAt: string }; + }> { + const response = await apiClient.post('/products/submit', data); + return response.data; + }, + + async searchProducts(query: string, limit = 10): Promise<{ products: ProductSearchResult[] }> { + const response = await apiClient.get('/products/search', { + params: { q: query, limit }, + }); + return response.data; + }, +}; + +export default feedbackService; diff --git a/src/services/api/inventory.service.ts b/src/services/api/inventory.service.ts new file mode 100644 index 0000000..33dce7c --- /dev/null +++ b/src/services/api/inventory.service.ts @@ -0,0 +1,61 @@ +import apiClient from './client'; + +export interface InventoryItem { + id: string; + name: string; + quantity: number; + category?: string; + barcode?: string; + price?: number; + imageUrl?: string; + detectionConfidence?: number; + isManuallyEdited?: boolean; + lastDetectedAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface InventoryResponse { + items: InventoryItem[]; + total: number; + page: number; + limit: number; + hasMore: boolean; +} + +export const inventoryService = { + getInventory: async ( + storeId: string, + page = 1, + limit = 50 + ): Promise => { + const response = await apiClient.get( + `/stores/${storeId}/inventory`, + { params: { page, limit } } + ); + return response.data; + }, + + getItem: async (storeId: string, itemId: string): Promise => { + const response = await apiClient.get( + `/stores/${storeId}/inventory/${itemId}` + ); + return response.data; + }, + + updateItem: async ( + storeId: string, + itemId: string, + data: Partial + ): Promise => { + const response = await apiClient.patch( + `/stores/${storeId}/inventory/${itemId}`, + data + ); + return response.data; + }, + + deleteItem: async (storeId: string, itemId: string): Promise => { + await apiClient.delete(`/stores/${storeId}/inventory/${itemId}`); + }, +}; diff --git a/src/services/api/notifications.service.ts b/src/services/api/notifications.service.ts new file mode 100644 index 0000000..2690141 --- /dev/null +++ b/src/services/api/notifications.service.ts @@ -0,0 +1,66 @@ +import apiClient from './client'; + +export type NotificationType = + | 'VIDEO_PROCESSING_COMPLETE' + | 'VIDEO_PROCESSING_FAILED' + | 'LOW_CREDITS' + | 'PAYMENT_COMPLETE' + | 'PAYMENT_FAILED' + | 'REFERRAL_BONUS' + | 'SYSTEM'; + +export interface Notification { + id: string; + userId: string; + type: NotificationType; + title: string; + body: string; + data?: Record; + isRead: boolean; + isPushSent: boolean; + createdAt: string; +} + +export interface NotificationsListResponse { + notifications: Notification[]; + total: number; + page: number; + limit: number; + hasMore: boolean; +} + +export const notificationsService = { + getNotifications: async (page = 1, limit = 20): Promise => { + const response = await apiClient.get('/notifications', { + params: { page, limit }, + }); + return response.data; + }, + + getUnreadCount: async (): Promise<{ count: number }> => { + const response = await apiClient.get<{ count: number }>('/notifications/unread-count'); + return response.data; + }, + + markAsRead: async (notificationId: string): Promise<{ success: boolean }> => { + const response = await apiClient.patch<{ success: boolean }>( + `/notifications/${notificationId}/read` + ); + return response.data; + }, + + markAllAsRead: async (): Promise<{ success: boolean }> => { + const response = await apiClient.post<{ success: boolean }>( + '/notifications/mark-all-read' + ); + return response.data; + }, + + registerFcmToken: async (token: string): Promise<{ success: boolean }> => { + const response = await apiClient.post<{ success: boolean }>( + '/notifications/register-token', + { token } + ); + return response.data; + }, +}; diff --git a/src/services/api/payments.service.ts b/src/services/api/payments.service.ts new file mode 100644 index 0000000..8be60ed --- /dev/null +++ b/src/services/api/payments.service.ts @@ -0,0 +1,76 @@ +import apiClient from './client'; + +export interface CreditPackage { + id: string; + name: string; + credits: number; + priceMXN: number; + popular?: boolean; +} + +export interface Payment { + id: string; + userId: string; + packageId: string; + amountMXN: number; + creditsGranted: number; + method: 'CARD' | 'OXXO' | '7ELEVEN'; + status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'REFUNDED'; + stripePaymentIntentId?: string; + voucherCode?: string; + voucherUrl?: string; + expiresAt?: string; + completedAt?: string; + createdAt: string; +} + +export interface CreatePaymentRequest { + packageId: string; + method: 'card' | 'oxxo' | '7eleven'; + paymentMethodId?: string; // Required for card payments +} + +export interface PaymentResponse { + paymentId: string; + status: 'pending' | 'completed'; + method: string; + // Card payment fields + clientSecret?: string; + // OXXO/7-Eleven fields + voucherCode?: string; + voucherUrl?: string; + expiresAt?: string; + amountMXN: number; +} + +export interface PaymentsListResponse { + payments: Payment[]; + total: number; + page: number; + limit: number; + hasMore: boolean; +} + +export const paymentsService = { + getPackages: async (): Promise => { + const response = await apiClient.get('/credits/packages'); + return response.data; + }, + + createPayment: async (data: CreatePaymentRequest): Promise => { + const response = await apiClient.post('/payments', data); + return response.data; + }, + + getPaymentHistory: async (page = 1, limit = 20): Promise => { + const response = await apiClient.get('/payments', { + params: { page, limit }, + }); + return response.data; + }, + + getPaymentById: async (paymentId: string): Promise => { + const response = await apiClient.get(`/payments/${paymentId}`); + return response.data; + }, +}; diff --git a/src/services/api/referrals.service.ts b/src/services/api/referrals.service.ts new file mode 100644 index 0000000..bdbb419 --- /dev/null +++ b/src/services/api/referrals.service.ts @@ -0,0 +1,75 @@ +import apiClient from './client'; + +export interface ReferralStats { + referralCode: string; + totalReferrals: number; + completedReferrals: number; + pendingReferrals: number; + totalCreditsEarned: number; +} + +export interface Referral { + id: string; + referrerId: string; + referredId: string; + referralCode: string; + status: 'PENDING' | 'REGISTERED' | 'QUALIFIED' | 'REWARDED'; + referrerBonusCredits: number; + referredBonusCredits: number; + registeredAt?: string; + qualifiedAt?: string; + rewardedAt?: string; + createdAt: string; + referred?: { + id: string; + name: string; + createdAt: string; + }; +} + +export interface ReferralsListResponse { + referrals: Referral[]; + total: number; + page: number; + limit: number; + hasMore: boolean; +} + +export interface ValidateCodeResponse { + valid: boolean; + referrerName?: string; +} + +export const referralsService = { + getMyCode: async (): Promise<{ referralCode: string }> => { + const response = await apiClient.get<{ referralCode: string }>('/referrals/my-code'); + return response.data; + }, + + getStats: async (): Promise => { + const response = await apiClient.get('/referrals/stats'); + return response.data; + }, + + getReferrals: async (page = 1, limit = 20): Promise => { + const response = await apiClient.get('/referrals', { + params: { page, limit }, + }); + return response.data; + }, + + validateCode: async (code: string): Promise => { + const response = await apiClient.get('/referrals/validate', { + params: { code }, + }); + return response.data; + }, + + applyCode: async (code: string): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post<{ success: boolean; message: string }>( + '/referrals/apply', + { code } + ); + return response.data; + }, +}; diff --git a/src/services/api/reports.service.ts b/src/services/api/reports.service.ts new file mode 100644 index 0000000..541375b --- /dev/null +++ b/src/services/api/reports.service.ts @@ -0,0 +1,171 @@ +import apiClient from './client'; + +// Report Types +export interface ValuationSummary { + totalItems: number; + totalCost: number; + totalPrice: number; + potentialMargin: number; + potentialMarginPercent: number; +} + +export interface ValuationByCategory { + category: string; + itemCount: number; + totalCost: number; + totalPrice: number; + margin: number; +} + +export interface ValuationItem { + id: string; + name: string; + category: string; + quantity: number; + cost: number; + price: number; + totalCost: number; + totalPrice: number; + margin: number; +} + +export interface ValuationReport { + summary: ValuationSummary; + byCategory: ValuationByCategory[]; + items: ValuationItem[]; +} + +export interface MovementsSummary { + period: { start: string; end: string }; + totalMovements: number; + netChange: number; + itemsIncreased: number; + itemsDecreased: number; +} + +export interface MovementRecord { + id: string; + date: string; + itemId: string; + itemName: string; + type: string; + change: number; + quantityBefore: number; + quantityAfter: number; + reason?: string; +} + +export interface MovementsByItem { + itemId: string; + itemName: string; + netChange: number; + movementCount: number; +} + +export interface MovementsReport { + summary: MovementsSummary; + movements: MovementRecord[]; + byItem: MovementsByItem[]; + total: number; + page: number; + limit: number; + hasMore: boolean; +} + +export interface CategorySummary { + totalCategories: number; + totalItems: number; + totalValue: number; +} + +export interface CategoryDetail { + name: string; + itemCount: number; + percentOfTotal: number; + totalValue: number; + lowStockCount: number; + averagePrice: number; + topItems: { name: string; quantity: number }[]; +} + +export interface CategoriesReport { + summary: CategorySummary; + categories: CategoryDetail[]; +} + +export interface LowStockSummary { + totalAlerts: number; + criticalCount: number; + warningCount: number; + totalValueAtRisk: number; +} + +export interface LowStockItem { + id: string; + name: string; + category: string; + quantity: number; + minStock: number; + shortage: number; + estimatedReorderCost: number; + lastMovementDate?: string; + priority: 'critical' | 'warning' | 'watch'; +} + +export interface LowStockReport { + summary: LowStockSummary; + items: LowStockItem[]; +} + +export interface MovementsQueryParams { + startDate?: string; + endDate?: string; + page?: number; + limit?: number; +} + +export const reportsService = { + /** + * Get valuation report + */ + getValuationReport: async (storeId: string): Promise => { + const response = await apiClient.get( + `/stores/${storeId}/reports/valuation`, + ); + return response.data; + }, + + /** + * Get movements report + */ + getMovementsReport: async ( + storeId: string, + params?: MovementsQueryParams, + ): Promise => { + const response = await apiClient.get( + `/stores/${storeId}/reports/movements`, + { params }, + ); + return response.data; + }, + + /** + * Get categories report + */ + getCategoriesReport: async (storeId: string): Promise => { + const response = await apiClient.get( + `/stores/${storeId}/reports/categories`, + ); + return response.data; + }, + + /** + * Get low stock report + */ + getLowStockReport: async (storeId: string): Promise => { + const response = await apiClient.get( + `/stores/${storeId}/reports/low-stock`, + ); + return response.data; + }, +}; diff --git a/src/services/api/stores.service.ts b/src/services/api/stores.service.ts new file mode 100644 index 0000000..4e85b87 --- /dev/null +++ b/src/services/api/stores.service.ts @@ -0,0 +1,70 @@ +import apiClient from './client'; + +export interface Store { + id: string; + name: string; + address?: string; + city?: string; + state?: string; + zipCode?: string; + giro?: string; + ownerId: string; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreateStoreRequest { + name: string; + address?: string; + city?: string; + state?: string; + zipCode?: string; + giro?: string; +} + +export interface UpdateStoreRequest { + name?: string; + address?: string; + city?: string; + state?: string; + zipCode?: string; + giro?: string; +} + +export interface StoresListResponse { + stores: Store[]; + total: number; + page: number; + limit: number; + hasMore: boolean; +} + +export const storesService = { + getStores: async (page = 1, limit = 20): Promise => { + const response = await apiClient.get('/stores', { + params: { page, limit }, + }); + return response.data; + }, + + getStoreById: async (storeId: string): Promise => { + const response = await apiClient.get(`/stores/${storeId}`); + return response.data; + }, + + createStore: async (data: CreateStoreRequest): Promise => { + const response = await apiClient.post('/stores', data); + return response.data; + }, + + updateStore: async (storeId: string, data: UpdateStoreRequest): Promise => { + const response = await apiClient.patch(`/stores/${storeId}`, data); + return response.data; + }, + + deleteStore: async (storeId: string): Promise<{ success: boolean }> => { + const response = await apiClient.delete<{ success: boolean }>(`/stores/${storeId}`); + return response.data; + }, +}; diff --git a/src/services/api/users.service.ts b/src/services/api/users.service.ts new file mode 100644 index 0000000..01a716f --- /dev/null +++ b/src/services/api/users.service.ts @@ -0,0 +1,35 @@ +import apiClient from './client'; + +export interface UserProfile { + id: string; + name: string; + email?: string; + phone: string; + businessName?: string; + location?: string; + giro?: string; +} + +export interface UpdateProfileRequest { + name?: string; + email?: string; + businessName?: string; + location?: string; + giro?: string; +} + +export const usersService = { + getProfile: async (): Promise => { + const response = await apiClient.get('/users/me'); + return response.data; + }, + + updateProfile: async (data: UpdateProfileRequest): Promise => { + const response = await apiClient.patch('/users/me', data); + return response.data; + }, + + updateFcmToken: async (fcmToken: string): Promise => { + await apiClient.patch('/users/me/fcm-token', { fcmToken }); + }, +}; diff --git a/src/services/api/validations.service.ts b/src/services/api/validations.service.ts new file mode 100644 index 0000000..90f6244 --- /dev/null +++ b/src/services/api/validations.service.ts @@ -0,0 +1,70 @@ +import apiClient from './client'; + +export interface ValidationRequest { + id: string; + totalItems: number; + itemsValidated: number; + expiresAt: string; + creditsRewarded: number; +} + +export interface ValidationItem { + id: string; + name: string; + quantity: number; + category?: string; + imageUrl?: string; + detectionConfidence?: number; +} + +export interface ValidationItemResponse { + inventoryItemId: string; + isCorrect: boolean; + correctedQuantity?: number; + correctedName?: string; + responseTimeMs?: number; +} + +export interface SubmitValidationRequest { + responses: ValidationItemResponse[]; +} + +export interface SubmitValidationResponse { + creditsRewarded: number; + itemsValidated: number; +} + +const validationsService = { + async check(videoId: string): Promise<{ + validationRequired: boolean; + requestId?: string; + }> { + const response = await apiClient.get(`/validations/check/${videoId}`); + return response.data; + }, + + async getItems(requestId: string): Promise<{ + request: ValidationRequest; + items: ValidationItem[]; + }> { + const response = await apiClient.get(`/validations/${requestId}/items`); + return response.data; + }, + + async submit( + requestId: string, + data: SubmitValidationRequest, + ): Promise { + const response = await apiClient.post( + `/validations/${requestId}/submit`, + data, + ); + return response.data; + }, + + async skip(requestId: string): Promise { + await apiClient.post(`/validations/${requestId}/skip`); + }, +}; + +export default validationsService; diff --git a/src/services/api/videos.service.ts b/src/services/api/videos.service.ts new file mode 100644 index 0000000..a1c44d1 --- /dev/null +++ b/src/services/api/videos.service.ts @@ -0,0 +1,87 @@ +import apiClient from './client'; +import * as FileSystem from 'expo-file-system'; + +interface UploadResponse { + videoId: string; + uploadUrl: string; + status: 'pending' | 'uploading' | 'processing' | 'completed' | 'failed'; +} + +interface VideoStatus { + id: string; + status: 'pending' | 'uploading' | 'processing' | 'completed' | 'failed'; + progress: number; + resultItems?: number; + errorMessage?: string; +} + +interface ProcessingResult { + videoId: string; + itemsDetected: number; + items: Array<{ + name: string; + quantity: number; + confidence: number; + category?: string; + }>; + creditsUsed: number; +} + +export const videosService = { + initiateUpload: async ( + storeId: string, + fileName: string, + fileSize: number + ): Promise => { + const response = await apiClient.post( + `/stores/${storeId}/videos/initiate`, + { fileName, fileSize } + ); + return response.data; + }, + + uploadVideo: async ( + uploadUrl: string, + localUri: string, + onProgress?: (progress: number) => void + ): Promise => { + const uploadTask = FileSystem.createUploadTask( + uploadUrl, + localUri, + { + httpMethod: 'PUT', + headers: { + 'Content-Type': 'video/mp4', + }, + }, + (progressEvent) => { + const progress = + progressEvent.totalBytesSent / progressEvent.totalBytesExpectedToSend; + onProgress?.(progress); + } + ); + + await uploadTask.uploadAsync(); + }, + + confirmUpload: async (storeId: string, videoId: string): Promise => { + await apiClient.post(`/stores/${storeId}/videos/${videoId}/confirm`); + }, + + getStatus: async (storeId: string, videoId: string): Promise => { + const response = await apiClient.get( + `/stores/${storeId}/videos/${videoId}/status` + ); + return response.data; + }, + + getResult: async ( + storeId: string, + videoId: string + ): Promise => { + const response = await apiClient.get( + `/stores/${storeId}/videos/${videoId}/result` + ); + return response.data; + }, +}; diff --git a/src/stores/__tests__/auth.store.spec.ts b/src/stores/__tests__/auth.store.spec.ts new file mode 100644 index 0000000..8f23acf --- /dev/null +++ b/src/stores/__tests__/auth.store.spec.ts @@ -0,0 +1,198 @@ +import { useAuthStore } from '../auth.store'; +import { authService } from '@services/api/auth.service'; + +// Mock the auth service +jest.mock('@services/api/auth.service'); + +const mockAuthService = authService as jest.Mocked; + +describe('Auth Store', () => { + beforeEach(() => { + // Reset store state + useAuthStore.setState({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + isLoading: false, + }); + jest.clearAllMocks(); + }); + + describe('login', () => { + it('should set user and tokens on successful login', async () => { + const mockUser = { id: '1', phone: '+1234567890', name: 'Test User' }; + const mockResponse = { + user: mockUser, + accessToken: 'access-token', + refreshToken: 'refresh-token', + }; + + mockAuthService.login.mockResolvedValue(mockResponse); + + await useAuthStore.getState().login('+1234567890', 'password123'); + + const state = useAuthStore.getState(); + expect(state.user).toEqual(mockUser); + expect(state.accessToken).toBe('access-token'); + expect(state.refreshToken).toBe('refresh-token'); + expect(state.isAuthenticated).toBe(true); + expect(state.isLoading).toBe(false); + }); + + it('should set isLoading during login', async () => { + mockAuthService.login.mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + user: { id: '1', phone: '+1234567890', name: 'Test' }, + accessToken: 'token', + refreshToken: 'refresh', + }), + 100 + ) + ) + ); + + const loginPromise = useAuthStore.getState().login('+1234567890', 'pass'); + + // Check loading state during request + expect(useAuthStore.getState().isLoading).toBe(true); + + await loginPromise; + + expect(useAuthStore.getState().isLoading).toBe(false); + }); + + it('should reset isLoading on login failure', async () => { + mockAuthService.login.mockRejectedValue(new Error('Invalid credentials')); + + await expect( + useAuthStore.getState().login('+1234567890', 'wrong') + ).rejects.toThrow('Invalid credentials'); + + expect(useAuthStore.getState().isLoading).toBe(false); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); + }); + + describe('initiateRegistration', () => { + it('should call authService.initiateRegistration', async () => { + mockAuthService.initiateRegistration.mockResolvedValue(undefined); + + await useAuthStore.getState().initiateRegistration('+1234567890', 'Test'); + + expect(mockAuthService.initiateRegistration).toHaveBeenCalledWith({ + phone: '+1234567890', + name: 'Test', + }); + }); + }); + + describe('verifyOtp', () => { + it('should set user and tokens on successful verification', async () => { + const mockUser = { id: '1', phone: '+1234567890', name: 'Test User' }; + mockAuthService.verifyOtp.mockResolvedValue({ + user: mockUser, + accessToken: 'access-token', + refreshToken: 'refresh-token', + }); + + await useAuthStore.getState().verifyOtp('+1234567890', '123456', 'pass'); + + const state = useAuthStore.getState(); + expect(state.user).toEqual(mockUser); + expect(state.isAuthenticated).toBe(true); + }); + }); + + describe('logout', () => { + it('should clear all auth state', async () => { + // Set initial authenticated state + useAuthStore.setState({ + user: { id: '1', phone: '+1234567890', name: 'Test' }, + accessToken: 'access-token', + refreshToken: 'refresh-token', + isAuthenticated: true, + }); + + mockAuthService.logout.mockResolvedValue(undefined); + + await useAuthStore.getState().logout(); + + const state = useAuthStore.getState(); + expect(state.user).toBeNull(); + expect(state.accessToken).toBeNull(); + expect(state.refreshToken).toBeNull(); + expect(state.isAuthenticated).toBe(false); + }); + + it('should still clear state if logout API fails', async () => { + useAuthStore.setState({ + user: { id: '1', phone: '+1234567890', name: 'Test' }, + accessToken: 'access-token', + refreshToken: 'refresh-token', + isAuthenticated: true, + }); + + mockAuthService.logout.mockRejectedValue(new Error('Network error')); + + await useAuthStore.getState().logout(); + + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); + }); + + describe('refreshTokens', () => { + it('should update tokens on successful refresh', async () => { + useAuthStore.setState({ + refreshToken: 'old-refresh-token', + accessToken: 'old-access-token', + }); + + mockAuthService.refreshTokens.mockResolvedValue({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + }); + + await useAuthStore.getState().refreshTokens(); + + const state = useAuthStore.getState(); + expect(state.accessToken).toBe('new-access-token'); + expect(state.refreshToken).toBe('new-refresh-token'); + }); + + it('should logout on refresh failure', async () => { + useAuthStore.setState({ + refreshToken: 'expired-token', + isAuthenticated: true, + }); + + mockAuthService.refreshTokens.mockRejectedValue(new Error('Invalid token')); + + await useAuthStore.getState().refreshTokens(); + + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); + + it('should not call API if no refresh token', async () => { + useAuthStore.setState({ refreshToken: null }); + + await useAuthStore.getState().refreshTokens(); + + expect(mockAuthService.refreshTokens).not.toHaveBeenCalled(); + }); + }); + + describe('setUser', () => { + it('should update user', () => { + const newUser = { id: '2', phone: '+9876543210', name: 'Updated User' }; + + useAuthStore.getState().setUser(newUser); + + expect(useAuthStore.getState().user).toEqual(newUser); + }); + }); +}); diff --git a/src/stores/__tests__/credits.store.spec.ts b/src/stores/__tests__/credits.store.spec.ts new file mode 100644 index 0000000..c754f1a --- /dev/null +++ b/src/stores/__tests__/credits.store.spec.ts @@ -0,0 +1,98 @@ +import { useCreditsStore } from '../credits.store'; +import { creditsService } from '@services/api/credits.service'; + +jest.mock('@services/api/credits.service'); + +const mockCreditsService = creditsService as jest.Mocked; + +describe('Credits Store', () => { + beforeEach(() => { + useCreditsStore.setState({ + balance: 0, + transactions: [], + isLoading: false, + error: null, + }); + jest.clearAllMocks(); + }); + + describe('fetchBalance', () => { + it('should load current balance', async () => { + mockCreditsService.getBalance.mockResolvedValue({ balance: 100 }); + + await useCreditsStore.getState().fetchBalance(); + + expect(useCreditsStore.getState().balance).toBe(100); + }); + + it('should handle errors', async () => { + mockCreditsService.getBalance.mockRejectedValue(new Error('Failed')); + + await useCreditsStore.getState().fetchBalance(); + + expect(useCreditsStore.getState().error).toBe('Failed'); + }); + }); + + describe('fetchTransactions', () => { + it('should load transaction history', async () => { + const mockTransactions = [ + { id: '1', type: 'PURCHASE', amount: 50, createdAt: new Date().toISOString() }, + { id: '2', type: 'USAGE', amount: -10, createdAt: new Date().toISOString() }, + ]; + + mockCreditsService.getTransactions.mockResolvedValue({ + transactions: mockTransactions, + total: 2, + }); + + await useCreditsStore.getState().fetchTransactions(); + + expect(useCreditsStore.getState().transactions).toHaveLength(2); + }); + }); + + describe('purchaseCredits', () => { + it('should update balance after purchase', async () => { + useCreditsStore.setState({ balance: 50 }); + + mockCreditsService.purchaseCredits.mockResolvedValue({ + newBalance: 150, + transaction: { id: '1', type: 'PURCHASE', amount: 100, createdAt: new Date().toISOString() }, + }); + + await useCreditsStore.getState().purchaseCredits('package-1', 'payment-method-1'); + + expect(useCreditsStore.getState().balance).toBe(150); + }); + + it('should add transaction to history', async () => { + const transaction = { id: '1', type: 'PURCHASE', amount: 100, createdAt: new Date().toISOString() }; + + mockCreditsService.purchaseCredits.mockResolvedValue({ + newBalance: 100, + transaction, + }); + + await useCreditsStore.getState().purchaseCredits('package-1', 'payment-method-1'); + + const transactions = useCreditsStore.getState().transactions; + expect(transactions[0]).toEqual(transaction); + }); + }); + + describe('consumeCredits', () => { + it('should decrease balance', async () => { + useCreditsStore.setState({ balance: 100 }); + + mockCreditsService.consumeCredits.mockResolvedValue({ + newBalance: 90, + transaction: { id: '1', type: 'USAGE', amount: -10, createdAt: new Date().toISOString() }, + }); + + await useCreditsStore.getState().consumeCredits(10, 'Video processing'); + + expect(useCreditsStore.getState().balance).toBe(90); + }); + }); +}); diff --git a/src/stores/__tests__/feedback.store.spec.ts b/src/stores/__tests__/feedback.store.spec.ts new file mode 100644 index 0000000..f65f4d0 --- /dev/null +++ b/src/stores/__tests__/feedback.store.spec.ts @@ -0,0 +1,162 @@ +import { useFeedbackStore } from '../feedback.store'; +import { feedbackService } from '@services/api/feedback.service'; + +jest.mock('@services/api/feedback.service'); + +const mockFeedbackService = feedbackService as jest.Mocked; + +describe('Feedback Store', () => { + beforeEach(() => { + useFeedbackStore.setState({ + corrections: [], + isLoading: false, + isSubmitting: false, + error: null, + }); + jest.clearAllMocks(); + }); + + describe('fetchCorrections', () => { + it('should load corrections history', async () => { + const mockCorrections = [ + { id: '1', itemId: 'item-1', type: 'QUANTITY', originalValue: 10, correctedValue: 15, createdAt: new Date().toISOString() }, + { id: '2', itemId: 'item-2', type: 'SKU', originalValue: 'OLD123', correctedValue: 'NEW456', createdAt: new Date().toISOString() }, + ]; + + mockFeedbackService.getCorrections.mockResolvedValue({ corrections: mockCorrections }); + + await useFeedbackStore.getState().fetchCorrections('store-1'); + + expect(useFeedbackStore.getState().corrections).toEqual(mockCorrections); + }); + + it('should handle errors', async () => { + mockFeedbackService.getCorrections.mockRejectedValue(new Error('Failed to load')); + + await useFeedbackStore.getState().fetchCorrections('store-1'); + + expect(useFeedbackStore.getState().error).toBe('Failed to load'); + }); + }); + + describe('submitQuantityCorrection', () => { + it('should submit quantity correction', async () => { + mockFeedbackService.submitCorrection.mockResolvedValue({ + id: '1', + itemId: 'item-1', + type: 'QUANTITY', + originalValue: 10, + correctedValue: 15, + createdAt: new Date().toISOString(), + }); + + const result = await useFeedbackStore.getState().submitQuantityCorrection( + 'store-1', + 'item-1', + 10, + 15, + ); + + expect(result).toBe(true); + expect(mockFeedbackService.submitCorrection).toHaveBeenCalledWith('store-1', { + itemId: 'item-1', + type: 'QUANTITY', + originalValue: 10, + correctedValue: 15, + }); + }); + + it('should add correction to list', async () => { + const newCorrection = { + id: '1', + itemId: 'item-1', + type: 'QUANTITY', + originalValue: 10, + correctedValue: 15, + createdAt: new Date().toISOString(), + }; + + mockFeedbackService.submitCorrection.mockResolvedValue(newCorrection); + + await useFeedbackStore.getState().submitQuantityCorrection( + 'store-1', + 'item-1', + 10, + 15, + ); + + expect(useFeedbackStore.getState().corrections).toContainEqual(newCorrection); + }); + + it('should handle submission errors', async () => { + mockFeedbackService.submitCorrection.mockRejectedValue(new Error('Submission failed')); + + const result = await useFeedbackStore.getState().submitQuantityCorrection( + 'store-1', + 'item-1', + 10, + 15, + ); + + expect(result).toBe(false); + expect(useFeedbackStore.getState().error).toBe('Submission failed'); + }); + }); + + describe('submitSkuCorrection', () => { + it('should submit SKU correction', async () => { + mockFeedbackService.submitCorrection.mockResolvedValue({ + id: '1', + itemId: 'item-1', + type: 'SKU', + originalValue: 'OLD123', + correctedValue: 'NEW456', + createdAt: new Date().toISOString(), + }); + + const result = await useFeedbackStore.getState().submitSkuCorrection( + 'store-1', + 'item-1', + 'OLD123', + 'NEW456', + ); + + expect(result).toBe(true); + expect(mockFeedbackService.submitCorrection).toHaveBeenCalledWith('store-1', { + itemId: 'item-1', + type: 'SKU', + originalValue: 'OLD123', + correctedValue: 'NEW456', + }); + }); + }); + + describe('confirmItem', () => { + it('should confirm item detection', async () => { + mockFeedbackService.confirmItem.mockResolvedValue({ success: true }); + + const result = await useFeedbackStore.getState().confirmItem('store-1', 'item-1'); + + expect(result).toBe(true); + expect(mockFeedbackService.confirmItem).toHaveBeenCalledWith('store-1', 'item-1'); + }); + + it('should handle confirmation errors', async () => { + mockFeedbackService.confirmItem.mockRejectedValue(new Error('Failed')); + + const result = await useFeedbackStore.getState().confirmItem('store-1', 'item-1'); + + expect(result).toBe(false); + }); + }); + + describe('clearError', () => { + it('should clear error state', () => { + useFeedbackStore.setState({ error: 'Some error' }); + + useFeedbackStore.getState().clearError(); + + expect(useFeedbackStore.getState().error).toBeNull(); + }); + }); +}); diff --git a/src/stores/__tests__/inventory.store.spec.ts b/src/stores/__tests__/inventory.store.spec.ts new file mode 100644 index 0000000..dad6957 --- /dev/null +++ b/src/stores/__tests__/inventory.store.spec.ts @@ -0,0 +1,200 @@ +import { useInventoryStore } from '../inventory.store'; +import { inventoryService } from '@services/api/inventory.service'; + +jest.mock('@services/api/inventory.service'); + +const mockInventoryService = inventoryService as jest.Mocked< + typeof inventoryService +>; + +describe('Inventory Store', () => { + beforeEach(() => { + useInventoryStore.setState({ + items: [], + isLoading: false, + error: null, + currentPage: 1, + hasMore: true, + searchQuery: '', + categoryFilter: null, + }); + jest.clearAllMocks(); + }); + + describe('fetchItems', () => { + it('should load inventory items', async () => { + const mockItems = [ + { id: '1', name: 'Item 1', quantity: 10, storeId: 'store-1' }, + { id: '2', name: 'Item 2', quantity: 5, storeId: 'store-1' }, + ]; + + mockInventoryService.getItems.mockResolvedValue({ + items: mockItems, + total: 2, + page: 1, + limit: 50, + hasMore: false, + }); + + await useInventoryStore.getState().fetchItems('store-1'); + + const state = useInventoryStore.getState(); + expect(state.items).toHaveLength(2); + expect(state.hasMore).toBe(false); + expect(state.error).toBeNull(); + }); + + it('should handle fetch errors', async () => { + mockInventoryService.getItems.mockRejectedValue( + new Error('Failed to fetch') + ); + + await useInventoryStore.getState().fetchItems('store-1'); + + expect(useInventoryStore.getState().error).toBe('Failed to fetch'); + }); + + it('should set loading state during fetch', async () => { + mockInventoryService.getItems.mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + items: [], + total: 0, + page: 1, + limit: 50, + hasMore: false, + }), + 100 + ) + ) + ); + + const fetchPromise = useInventoryStore.getState().fetchItems('store-1'); + expect(useInventoryStore.getState().isLoading).toBe(true); + + await fetchPromise; + expect(useInventoryStore.getState().isLoading).toBe(false); + }); + }); + + describe('loadMore', () => { + it('should load next page and append items', async () => { + useInventoryStore.setState({ + items: [{ id: '1', name: 'Item 1', quantity: 10, storeId: 'store-1' }], + currentPage: 1, + hasMore: true, + }); + + mockInventoryService.getItems.mockResolvedValue({ + items: [{ id: '2', name: 'Item 2', quantity: 5, storeId: 'store-1' }], + total: 2, + page: 2, + limit: 50, + hasMore: false, + }); + + await useInventoryStore.getState().loadMore('store-1'); + + const state = useInventoryStore.getState(); + expect(state.items).toHaveLength(2); + expect(state.currentPage).toBe(2); + }); + + it('should not load if hasMore is false', async () => { + useInventoryStore.setState({ hasMore: false }); + + await useInventoryStore.getState().loadMore('store-1'); + + expect(mockInventoryService.getItems).not.toHaveBeenCalled(); + }); + }); + + describe('updateItem', () => { + it('should update an item in the list', async () => { + useInventoryStore.setState({ + items: [ + { id: '1', name: 'Item 1', quantity: 10, storeId: 'store-1' }, + { id: '2', name: 'Item 2', quantity: 5, storeId: 'store-1' }, + ], + }); + + const updatedItem = { + id: '1', + name: 'Updated Item', + quantity: 20, + storeId: 'store-1', + }; + mockInventoryService.updateItem.mockResolvedValue(updatedItem); + + await useInventoryStore + .getState() + .updateItem('store-1', '1', { name: 'Updated Item', quantity: 20 }); + + const items = useInventoryStore.getState().items; + expect(items[0].name).toBe('Updated Item'); + expect(items[0].quantity).toBe(20); + }); + }); + + describe('deleteItem', () => { + it('should remove item from the list', async () => { + useInventoryStore.setState({ + items: [ + { id: '1', name: 'Item 1', quantity: 10, storeId: 'store-1' }, + { id: '2', name: 'Item 2', quantity: 5, storeId: 'store-1' }, + ], + }); + + mockInventoryService.deleteItem.mockResolvedValue(undefined); + + await useInventoryStore.getState().deleteItem('store-1', '1'); + + const items = useInventoryStore.getState().items; + expect(items).toHaveLength(1); + expect(items[0].id).toBe('2'); + }); + }); + + describe('setSearchQuery', () => { + it('should update search query', () => { + useInventoryStore.getState().setSearchQuery('test search'); + + expect(useInventoryStore.getState().searchQuery).toBe('test search'); + }); + }); + + describe('setCategoryFilter', () => { + it('should update category filter', () => { + useInventoryStore.getState().setCategoryFilter('Electronics'); + + expect(useInventoryStore.getState().categoryFilter).toBe('Electronics'); + }); + + it('should allow null filter', () => { + useInventoryStore.setState({ categoryFilter: 'Electronics' }); + useInventoryStore.getState().setCategoryFilter(null); + + expect(useInventoryStore.getState().categoryFilter).toBeNull(); + }); + }); + + describe('clearItems', () => { + it('should reset items and pagination', () => { + useInventoryStore.setState({ + items: [{ id: '1', name: 'Item', quantity: 10, storeId: 'store-1' }], + currentPage: 5, + hasMore: false, + }); + + useInventoryStore.getState().clearItems(); + + const state = useInventoryStore.getState(); + expect(state.items).toHaveLength(0); + expect(state.currentPage).toBe(1); + expect(state.hasMore).toBe(true); + }); + }); +}); diff --git a/src/stores/__tests__/notifications.store.spec.ts b/src/stores/__tests__/notifications.store.spec.ts new file mode 100644 index 0000000..1f88031 --- /dev/null +++ b/src/stores/__tests__/notifications.store.spec.ts @@ -0,0 +1,100 @@ +import { useNotificationsStore } from '../notifications.store'; +import { notificationsService } from '@services/api/notifications.service'; + +jest.mock('@services/api/notifications.service'); + +const mockNotificationsService = notificationsService as jest.Mocked< + typeof notificationsService +>; + +describe('Notifications Store', () => { + beforeEach(() => { + useNotificationsStore.setState({ + notifications: [], + unreadCount: 0, + isLoading: false, + error: null, + }); + jest.clearAllMocks(); + }); + + describe('fetchNotifications', () => { + it('should load notifications', async () => { + const mockNotifications = [ + { id: '1', title: 'Notification 1', read: false, createdAt: new Date().toISOString() }, + { id: '2', title: 'Notification 2', read: true, createdAt: new Date().toISOString() }, + ]; + + mockNotificationsService.getNotifications.mockResolvedValue({ + notifications: mockNotifications, + unreadCount: 1, + }); + + await useNotificationsStore.getState().fetchNotifications(); + + const state = useNotificationsStore.getState(); + expect(state.notifications).toHaveLength(2); + expect(state.unreadCount).toBe(1); + }); + }); + + describe('markAsRead', () => { + it('should mark notification as read', async () => { + useNotificationsStore.setState({ + notifications: [ + { id: '1', title: 'Notification 1', read: false, createdAt: new Date().toISOString() }, + ], + unreadCount: 1, + }); + + mockNotificationsService.markAsRead.mockResolvedValue(undefined); + + await useNotificationsStore.getState().markAsRead('1'); + + const state = useNotificationsStore.getState(); + expect(state.notifications[0].read).toBe(true); + expect(state.unreadCount).toBe(0); + }); + }); + + describe('markAllAsRead', () => { + it('should mark all notifications as read', async () => { + useNotificationsStore.setState({ + notifications: [ + { id: '1', title: 'N1', read: false, createdAt: new Date().toISOString() }, + { id: '2', title: 'N2', read: false, createdAt: new Date().toISOString() }, + ], + unreadCount: 2, + }); + + mockNotificationsService.markAllAsRead.mockResolvedValue(undefined); + + await useNotificationsStore.getState().markAllAsRead(); + + const state = useNotificationsStore.getState(); + expect(state.notifications.every((n) => n.read)).toBe(true); + expect(state.unreadCount).toBe(0); + }); + }); + + describe('deleteNotification', () => { + it('should remove notification from list', async () => { + useNotificationsStore.setState({ + notifications: [ + { id: '1', title: 'N1', read: false, createdAt: new Date().toISOString() }, + { id: '2', title: 'N2', read: true, createdAt: new Date().toISOString() }, + ], + unreadCount: 1, + }); + + mockNotificationsService.deleteNotification.mockResolvedValue(undefined); + + await useNotificationsStore.getState().deleteNotification('1'); + + const state = useNotificationsStore.getState(); + expect(state.notifications).toHaveLength(1); + expect(state.notifications[0].id).toBe('2'); + expect(state.unreadCount).toBe(0); + }); + }); +}); diff --git a/src/stores/__tests__/payments.store.spec.ts b/src/stores/__tests__/payments.store.spec.ts new file mode 100644 index 0000000..e4f0dfa --- /dev/null +++ b/src/stores/__tests__/payments.store.spec.ts @@ -0,0 +1,152 @@ +import { usePaymentsStore } from '../payments.store'; +import { paymentsService } from '@services/api/payments.service'; + +jest.mock('@services/api/payments.service'); + +const mockPaymentsService = paymentsService as jest.Mocked; + +describe('Payments Store', () => { + beforeEach(() => { + usePaymentsStore.setState({ + packages: [], + payments: [], + currentPayment: null, + total: 0, + page: 1, + hasMore: false, + isLoading: false, + isProcessing: false, + error: null, + }); + jest.clearAllMocks(); + }); + + describe('fetchPackages', () => { + it('should load available packages', async () => { + const mockPackages = [ + { id: '1', name: 'Basic', credits: 100, price: 9.99 }, + { id: '2', name: 'Pro', credits: 500, price: 39.99 }, + ]; + + mockPaymentsService.getPackages.mockResolvedValue(mockPackages); + + await usePaymentsStore.getState().fetchPackages(); + + expect(usePaymentsStore.getState().packages).toEqual(mockPackages); + expect(usePaymentsStore.getState().error).toBeNull(); + }); + + it('should handle errors', async () => { + mockPaymentsService.getPackages.mockRejectedValue(new Error('Network error')); + + await usePaymentsStore.getState().fetchPackages(); + + expect(usePaymentsStore.getState().error).toBe('Network error'); + }); + }); + + describe('fetchPayments', () => { + it('should load payment history', async () => { + const mockPayments = [ + { id: '1', amount: 9.99, status: 'COMPLETED', createdAt: new Date().toISOString() }, + { id: '2', amount: 39.99, status: 'COMPLETED', createdAt: new Date().toISOString() }, + ]; + + mockPaymentsService.getPaymentHistory.mockResolvedValue({ + payments: mockPayments, + total: 2, + page: 1, + hasMore: false, + }); + + await usePaymentsStore.getState().fetchPayments(true); + + expect(usePaymentsStore.getState().payments).toEqual(mockPayments); + expect(usePaymentsStore.getState().total).toBe(2); + }); + + it('should append payments when not refreshing', async () => { + usePaymentsStore.setState({ + payments: [{ id: '1', amount: 9.99, status: 'COMPLETED', createdAt: new Date().toISOString() }], + page: 2, + }); + + mockPaymentsService.getPaymentHistory.mockResolvedValue({ + payments: [{ id: '2', amount: 39.99, status: 'COMPLETED', createdAt: new Date().toISOString() }], + total: 2, + page: 2, + hasMore: false, + }); + + await usePaymentsStore.getState().fetchPayments(false); + + expect(usePaymentsStore.getState().payments).toHaveLength(2); + }); + }); + + describe('createPayment', () => { + it('should create payment and store response', async () => { + const mockResponse = { + paymentId: 'payment-1', + checkoutUrl: 'https://checkout.example.com', + status: 'PENDING', + }; + + mockPaymentsService.createPayment.mockResolvedValue(mockResponse); + + const result = await usePaymentsStore.getState().createPayment({ + packageId: 'package-1', + paymentMethod: 'card', + }); + + expect(result).toEqual(mockResponse); + expect(usePaymentsStore.getState().currentPayment).toEqual(mockResponse); + expect(usePaymentsStore.getState().isProcessing).toBe(false); + }); + + it('should handle payment errors', async () => { + mockPaymentsService.createPayment.mockRejectedValue(new Error('Payment failed')); + + const result = await usePaymentsStore.getState().createPayment({ + packageId: 'package-1', + paymentMethod: 'card', + }); + + expect(result).toBeNull(); + expect(usePaymentsStore.getState().error).toBe('Payment failed'); + }); + }); + + describe('getPaymentById', () => { + it('should fetch payment by ID', async () => { + const mockPayment = { id: '1', amount: 9.99, status: 'COMPLETED', createdAt: new Date().toISOString() }; + mockPaymentsService.getPaymentById.mockResolvedValue(mockPayment); + + const result = await usePaymentsStore.getState().getPaymentById('1'); + + expect(result).toEqual(mockPayment); + }); + }); + + describe('clearCurrentPayment', () => { + it('should clear current payment', () => { + usePaymentsStore.setState({ + currentPayment: { paymentId: '1', checkoutUrl: 'url', status: 'PENDING' }, + }); + + usePaymentsStore.getState().clearCurrentPayment(); + + expect(usePaymentsStore.getState().currentPayment).toBeNull(); + }); + }); + + describe('clearError', () => { + it('should clear error state', () => { + usePaymentsStore.setState({ error: 'Some error' }); + + usePaymentsStore.getState().clearError(); + + expect(usePaymentsStore.getState().error).toBeNull(); + }); + }); +}); diff --git a/src/stores/__tests__/referrals.store.spec.ts b/src/stores/__tests__/referrals.store.spec.ts new file mode 100644 index 0000000..312c1a1 --- /dev/null +++ b/src/stores/__tests__/referrals.store.spec.ts @@ -0,0 +1,95 @@ +import { useReferralsStore } from '../referrals.store'; +import { referralsService } from '@services/api/referrals.service'; + +jest.mock('@services/api/referrals.service'); + +const mockReferralsService = referralsService as jest.Mocked; + +describe('Referrals Store', () => { + beforeEach(() => { + useReferralsStore.setState({ + referralCode: null, + referrals: [], + stats: null, + isLoading: false, + error: null, + }); + jest.clearAllMocks(); + }); + + describe('fetchReferralCode', () => { + it('should load referral code', async () => { + mockReferralsService.getReferralCode.mockResolvedValue({ + code: 'REF123', + shareUrl: 'https://app.example.com/r/REF123', + }); + + await useReferralsStore.getState().fetchReferralCode(); + + expect(useReferralsStore.getState().referralCode).toBe('REF123'); + }); + + it('should handle errors', async () => { + mockReferralsService.getReferralCode.mockRejectedValue(new Error('Failed')); + + await useReferralsStore.getState().fetchReferralCode(); + + expect(useReferralsStore.getState().error).toBe('Failed'); + }); + }); + + describe('fetchReferrals', () => { + it('should load referral list', async () => { + const mockReferrals = [ + { id: '1', referredUserId: 'user-1', status: 'COMPLETED', creditsEarned: 50, createdAt: new Date().toISOString() }, + { id: '2', referredUserId: 'user-2', status: 'PENDING', creditsEarned: 0, createdAt: new Date().toISOString() }, + ]; + + mockReferralsService.getReferrals.mockResolvedValue({ referrals: mockReferrals }); + + await useReferralsStore.getState().fetchReferrals(); + + expect(useReferralsStore.getState().referrals).toEqual(mockReferrals); + }); + }); + + describe('fetchStats', () => { + it('should load referral statistics', async () => { + const mockStats = { + totalReferrals: 10, + completedReferrals: 8, + pendingReferrals: 2, + totalCreditsEarned: 400, + }; + + mockReferralsService.getReferralStats.mockResolvedValue(mockStats); + + await useReferralsStore.getState().fetchStats(); + + expect(useReferralsStore.getState().stats).toEqual(mockStats); + }); + }); + + describe('applyReferralCode', () => { + it('should apply referral code successfully', async () => { + mockReferralsService.applyReferralCode.mockResolvedValue({ + success: true, + creditsAwarded: 25, + }); + + const result = await useReferralsStore.getState().applyReferralCode('FRIEND123'); + + expect(result).toBe(true); + expect(mockReferralsService.applyReferralCode).toHaveBeenCalledWith('FRIEND123'); + }); + + it('should handle invalid referral code', async () => { + mockReferralsService.applyReferralCode.mockRejectedValue(new Error('Invalid code')); + + const result = await useReferralsStore.getState().applyReferralCode('INVALID'); + + expect(result).toBe(false); + expect(useReferralsStore.getState().error).toBe('Invalid code'); + }); + }); +}); diff --git a/src/stores/__tests__/stores.store.spec.ts b/src/stores/__tests__/stores.store.spec.ts new file mode 100644 index 0000000..4062e9c --- /dev/null +++ b/src/stores/__tests__/stores.store.spec.ts @@ -0,0 +1,149 @@ +import { useStoresStore } from '../stores.store'; +import { storesService } from '@services/api/stores.service'; + +jest.mock('@services/api/stores.service'); + +const mockStoresService = storesService as jest.Mocked; + +describe('Stores Store', () => { + beforeEach(() => { + useStoresStore.setState({ + stores: [], + currentStore: null, + isLoading: false, + error: null, + }); + jest.clearAllMocks(); + }); + + describe('fetchStores', () => { + it('should load all stores', async () => { + const mockStores = [ + { id: '1', name: 'Store 1', ownerId: 'user-1' }, + { id: '2', name: 'Store 2', ownerId: 'user-1' }, + ]; + + mockStoresService.getStores.mockResolvedValue(mockStores); + + await useStoresStore.getState().fetchStores(); + + expect(useStoresStore.getState().stores).toEqual(mockStores); + }); + + it('should set first store as current if none selected', async () => { + const mockStores = [ + { id: '1', name: 'Store 1', ownerId: 'user-1' }, + { id: '2', name: 'Store 2', ownerId: 'user-1' }, + ]; + + mockStoresService.getStores.mockResolvedValue(mockStores); + + await useStoresStore.getState().fetchStores(); + + expect(useStoresStore.getState().currentStore).toEqual(mockStores[0]); + }); + + it('should handle errors', async () => { + mockStoresService.getStores.mockRejectedValue(new Error('Network error')); + + await useStoresStore.getState().fetchStores(); + + expect(useStoresStore.getState().error).toBe('Network error'); + }); + }); + + describe('createStore', () => { + it('should add new store to list', async () => { + const newStore = { id: '3', name: 'New Store', ownerId: 'user-1' }; + mockStoresService.createStore.mockResolvedValue(newStore); + + await useStoresStore.getState().createStore({ name: 'New Store' }); + + const stores = useStoresStore.getState().stores; + expect(stores).toContainEqual(newStore); + }); + + it('should set new store as current', async () => { + const newStore = { id: '3', name: 'New Store', ownerId: 'user-1' }; + mockStoresService.createStore.mockResolvedValue(newStore); + + await useStoresStore.getState().createStore({ name: 'New Store' }); + + expect(useStoresStore.getState().currentStore).toEqual(newStore); + }); + }); + + describe('updateStore', () => { + it('should update store in list', async () => { + useStoresStore.setState({ + stores: [{ id: '1', name: 'Store 1', ownerId: 'user-1' }], + }); + + const updatedStore = { id: '1', name: 'Updated Store', ownerId: 'user-1' }; + mockStoresService.updateStore.mockResolvedValue(updatedStore); + + await useStoresStore.getState().updateStore('1', { name: 'Updated Store' }); + + expect(useStoresStore.getState().stores[0].name).toBe('Updated Store'); + }); + + it('should update currentStore if it was updated', async () => { + const currentStore = { id: '1', name: 'Store 1', ownerId: 'user-1' }; + useStoresStore.setState({ + stores: [currentStore], + currentStore, + }); + + const updatedStore = { id: '1', name: 'Updated Store', ownerId: 'user-1' }; + mockStoresService.updateStore.mockResolvedValue(updatedStore); + + await useStoresStore.getState().updateStore('1', { name: 'Updated Store' }); + + expect(useStoresStore.getState().currentStore?.name).toBe('Updated Store'); + }); + }); + + describe('deleteStore', () => { + it('should remove store from list', async () => { + useStoresStore.setState({ + stores: [ + { id: '1', name: 'Store 1', ownerId: 'user-1' }, + { id: '2', name: 'Store 2', ownerId: 'user-1' }, + ], + }); + + mockStoresService.deleteStore.mockResolvedValue(undefined); + + await useStoresStore.getState().deleteStore('1'); + + const stores = useStoresStore.getState().stores; + expect(stores).toHaveLength(1); + expect(stores[0].id).toBe('2'); + }); + + it('should clear currentStore if deleted', async () => { + const storeToDelete = { id: '1', name: 'Store 1', ownerId: 'user-1' }; + useStoresStore.setState({ + stores: [storeToDelete], + currentStore: storeToDelete, + }); + + mockStoresService.deleteStore.mockResolvedValue(undefined); + + await useStoresStore.getState().deleteStore('1'); + + expect(useStoresStore.getState().currentStore).toBeNull(); + }); + }); + + describe('setCurrentStore', () => { + it('should set current store', () => { + const store = { id: '1', name: 'Store 1', ownerId: 'user-1' }; + useStoresStore.setState({ stores: [store] }); + + useStoresStore.getState().setCurrentStore(store); + + expect(useStoresStore.getState().currentStore).toEqual(store); + }); + }); +}); diff --git a/src/stores/__tests__/validations.store.spec.ts b/src/stores/__tests__/validations.store.spec.ts new file mode 100644 index 0000000..116f534 --- /dev/null +++ b/src/stores/__tests__/validations.store.spec.ts @@ -0,0 +1,146 @@ +import { useValidationsStore } from '../validations.store'; +import { validationsService } from '@services/api/validations.service'; + +jest.mock('@services/api/validations.service'); + +const mockValidationsService = validationsService as jest.Mocked; + +describe('Validations Store', () => { + beforeEach(() => { + useValidationsStore.setState({ + currentValidation: null, + pendingItems: [], + validatedItems: [], + progress: 0, + isLoading: false, + error: null, + }); + jest.clearAllMocks(); + }); + + describe('startValidation', () => { + it('should start a new validation session', async () => { + const mockValidation = { + id: 'validation-1', + storeId: 'store-1', + videoId: 'video-1', + status: 'IN_PROGRESS', + totalItems: 10, + validatedItems: 0, + }; + + mockValidationsService.startValidation.mockResolvedValue(mockValidation); + + await useValidationsStore.getState().startValidation('store-1', 'video-1'); + + expect(useValidationsStore.getState().currentValidation).toEqual(mockValidation); + }); + + it('should handle errors', async () => { + mockValidationsService.startValidation.mockRejectedValue(new Error('Failed to start')); + + await useValidationsStore.getState().startValidation('store-1', 'video-1'); + + expect(useValidationsStore.getState().error).toBe('Failed to start'); + }); + }); + + describe('fetchPendingItems', () => { + it('should load pending items for validation', async () => { + const mockItems = [ + { id: '1', name: 'Item 1', quantity: 10, status: 'PENDING' }, + { id: '2', name: 'Item 2', quantity: 5, status: 'PENDING' }, + ]; + + mockValidationsService.getPendingItems.mockResolvedValue({ items: mockItems }); + + await useValidationsStore.getState().fetchPendingItems('validation-1'); + + expect(useValidationsStore.getState().pendingItems).toEqual(mockItems); + }); + }); + + describe('validateItem', () => { + it('should validate an item as correct', async () => { + useValidationsStore.setState({ + pendingItems: [ + { id: '1', name: 'Item 1', quantity: 10, status: 'PENDING' }, + { id: '2', name: 'Item 2', quantity: 5, status: 'PENDING' }, + ], + validatedItems: [], + progress: 0, + }); + + mockValidationsService.validateItem.mockResolvedValue({ + success: true, + item: { id: '1', name: 'Item 1', quantity: 10, status: 'VALIDATED' }, + }); + + await useValidationsStore.getState().validateItem('validation-1', '1', true); + + const state = useValidationsStore.getState(); + expect(state.pendingItems).toHaveLength(1); + expect(state.validatedItems).toHaveLength(1); + expect(state.progress).toBe(50); + }); + + it('should validate an item with correction', async () => { + useValidationsStore.setState({ + pendingItems: [{ id: '1', name: 'Item 1', quantity: 10, status: 'PENDING' }], + validatedItems: [], + }); + + mockValidationsService.validateItem.mockResolvedValue({ + success: true, + item: { id: '1', name: 'Item 1', quantity: 15, status: 'CORRECTED' }, + }); + + await useValidationsStore.getState().validateItem('validation-1', '1', false, 15); + + expect(mockValidationsService.validateItem).toHaveBeenCalledWith( + 'validation-1', + '1', + false, + 15, + ); + }); + }); + + describe('completeValidation', () => { + it('should complete the validation session', async () => { + useValidationsStore.setState({ + currentValidation: { id: 'validation-1', status: 'IN_PROGRESS', totalItems: 10, validatedItems: 10 }, + }); + + mockValidationsService.completeValidation.mockResolvedValue({ + id: 'validation-1', + status: 'COMPLETED', + totalItems: 10, + validatedItems: 10, + }); + + await useValidationsStore.getState().completeValidation('validation-1'); + + expect(useValidationsStore.getState().currentValidation?.status).toBe('COMPLETED'); + }); + }); + + describe('clearValidation', () => { + it('should reset all validation state', () => { + useValidationsStore.setState({ + currentValidation: { id: '1', status: 'IN_PROGRESS', totalItems: 10, validatedItems: 5 }, + pendingItems: [{ id: '1', name: 'Item', quantity: 10, status: 'PENDING' }], + validatedItems: [{ id: '2', name: 'Item 2', quantity: 5, status: 'VALIDATED' }], + progress: 50, + }); + + useValidationsStore.getState().clearValidation(); + + const state = useValidationsStore.getState(); + expect(state.currentValidation).toBeNull(); + expect(state.pendingItems).toHaveLength(0); + expect(state.validatedItems).toHaveLength(0); + expect(state.progress).toBe(0); + }); + }); +}); diff --git a/src/stores/auth.store.ts b/src/stores/auth.store.ts new file mode 100644 index 0000000..4a1e659 --- /dev/null +++ b/src/stores/auth.store.ts @@ -0,0 +1,137 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import * as SecureStore from 'expo-secure-store'; +import { authService } from '@services/api/auth.service'; + +interface User { + id: string; + phone: string; + name: string; + email?: string; +} + +interface AuthState { + user: User | null; + accessToken: string | null; + refreshToken: string | null; + isAuthenticated: boolean; + isLoading: boolean; + + // Actions + login: (phone: string, password: string) => Promise; + initiateRegistration: (phone: string, name: string) => Promise; + verifyOtp: (phone: string, otp: string, password: string) => Promise; + logout: () => Promise; + refreshTokens: () => Promise; + setUser: (user: User) => void; +} + +const secureStorage = { + getItem: async (name: string): Promise => { + return await SecureStore.getItemAsync(name); + }, + setItem: async (name: string, value: string): Promise => { + await SecureStore.setItemAsync(name, value); + }, + removeItem: async (name: string): Promise => { + await SecureStore.deleteItemAsync(name); + }, +}; + +export const useAuthStore = create()( + persist( + (set, get) => ({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + isLoading: false, + + login: async (phone: string, password: string) => { + set({ isLoading: true }); + try { + const response = await authService.login({ phone, password }); + set({ + user: response.user, + accessToken: response.accessToken, + refreshToken: response.refreshToken, + isAuthenticated: true, + }); + } finally { + set({ isLoading: false }); + } + }, + + initiateRegistration: async (phone: string, name: string) => { + set({ isLoading: true }); + try { + await authService.initiateRegistration({ phone, name }); + } finally { + set({ isLoading: false }); + } + }, + + verifyOtp: async (phone: string, otp: string, password: string) => { + set({ isLoading: true }); + try { + const response = await authService.verifyOtp({ phone, otp, password }); + set({ + user: response.user, + accessToken: response.accessToken, + refreshToken: response.refreshToken, + isAuthenticated: true, + }); + } finally { + set({ isLoading: false }); + } + }, + + logout: async () => { + const { refreshToken } = get(); + if (refreshToken) { + try { + await authService.logout(refreshToken); + } catch { + // Ignore logout errors + } + } + set({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + }); + }, + + refreshTokens: async () => { + const { refreshToken } = get(); + if (!refreshToken) return; + + try { + const response = await authService.refreshTokens(refreshToken); + set({ + accessToken: response.accessToken, + refreshToken: response.refreshToken, + }); + } catch { + // If refresh fails, logout + get().logout(); + } + }, + + setUser: (user: User) => { + set({ user }); + }, + }), + { + name: 'auth-storage', + storage: createJSONStorage(() => secureStorage), + partialize: (state) => ({ + user: state.user, + accessToken: state.accessToken, + refreshToken: state.refreshToken, + isAuthenticated: state.isAuthenticated, + }), + } + ) +); diff --git a/src/stores/credits.store.ts b/src/stores/credits.store.ts new file mode 100644 index 0000000..372defa --- /dev/null +++ b/src/stores/credits.store.ts @@ -0,0 +1,138 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { creditsService } from '@services/api/credits.service'; + +interface CreditBalance { + balance: number; + totalPurchased: number; + totalConsumed: number; + totalFromReferrals: number; +} + +interface Transaction { + id: string; + type: string; + amount: number; + description: string; + createdAt: string; +} + +interface CreditsState { + balance: CreditBalance | null; + transactions: Transaction[]; + transactionsTotal: number; + transactionsPage: number; + transactionsHasMore: boolean; + isLoading: boolean; + error: string | null; + lastFetched: number | null; + + // Actions + fetchBalance: () => Promise; + fetchTransactions: (refresh?: boolean) => Promise; + deductCredits: (amount: number) => void; + addCredits: (amount: number) => void; + clearError: () => void; +} + +const MAX_CACHED_TRANSACTIONS = 50; + +export const useCreditsStore = create()( + persist( + (set, get) => ({ + balance: null, + transactions: [], + transactionsTotal: 0, + transactionsPage: 1, + transactionsHasMore: false, + isLoading: false, + error: null, + lastFetched: null, + + fetchBalance: async () => { + set({ isLoading: true, error: null }); + try { + const response = await creditsService.getBalance(); + set({ + balance: { + balance: response.balance, + totalPurchased: response.totalPurchased || 0, + totalConsumed: response.totalConsumed || 0, + totalFromReferrals: response.totalFromReferrals || 0, + }, + isLoading: false, + lastFetched: Date.now(), + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al cargar balance'; + set({ error: message, isLoading: false }); + } + }, + + fetchTransactions: async (refresh = false) => { + const state = get(); + if (state.isLoading && !refresh) return; + + const page = refresh ? 1 : state.transactionsPage; + + set({ isLoading: true, error: null }); + + try { + const response = await creditsService.getTransactions(page, 20); + const newTransactions = refresh + ? response.transactions + : [...state.transactions, ...response.transactions]; + + set({ + transactions: newTransactions.slice(0, MAX_CACHED_TRANSACTIONS), + transactionsTotal: response.total, + transactionsPage: page + 1, + transactionsHasMore: page * 20 < response.total, + isLoading: false, + lastFetched: Date.now(), + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al cargar transacciones'; + set({ error: message, isLoading: false }); + } + }, + + deductCredits: (amount: number) => { + set((state) => ({ + balance: state.balance + ? { + ...state.balance, + balance: Math.max(0, state.balance.balance - amount), + totalConsumed: state.balance.totalConsumed + amount, + } + : null, + })); + }, + + addCredits: (amount: number) => { + set((state) => ({ + balance: state.balance + ? { + ...state.balance, + balance: state.balance.balance + amount, + totalPurchased: state.balance.totalPurchased + amount, + } + : null, + })); + }, + + clearError: () => set({ error: null }), + }), + { + name: 'miinventario-credits', + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + balance: state.balance, + transactions: state.transactions.slice(0, MAX_CACHED_TRANSACTIONS), + transactionsTotal: state.transactionsTotal, + lastFetched: state.lastFetched, + }), + } + ) +); diff --git a/src/stores/feedback.store.ts b/src/stores/feedback.store.ts new file mode 100644 index 0000000..b3730ca --- /dev/null +++ b/src/stores/feedback.store.ts @@ -0,0 +1,129 @@ +import { create } from 'zustand'; +import feedbackService, { + CorrectionHistoryItem, + CorrectQuantityRequest, + CorrectSkuRequest, + SubmitProductRequest, + ProductSearchResult, +} from '../services/api/feedback.service'; + +interface FeedbackState { + correctionHistory: CorrectionHistoryItem[]; + searchResults: ProductSearchResult[]; + isLoading: boolean; + error: string | null; + + // Actions + correctQuantity: ( + storeId: string, + itemId: string, + data: CorrectQuantityRequest, + ) => Promise; + correctSku: ( + storeId: string, + itemId: string, + data: CorrectSkuRequest, + ) => Promise; + confirmItem: (storeId: string, itemId: string) => Promise; + fetchCorrectionHistory: (storeId: string, itemId: string) => Promise; + submitProduct: (data: SubmitProductRequest) => Promise; + searchProducts: (query: string) => Promise; + clearError: () => void; + reset: () => void; +} + +export const useFeedbackStore = create((set, get) => ({ + correctionHistory: [], + searchResults: [], + isLoading: false, + error: null, + + correctQuantity: async (storeId, itemId, data) => { + set({ isLoading: true, error: null }); + try { + await feedbackService.correctQuantity(storeId, itemId, data); + // Refresh history after correction + await get().fetchCorrectionHistory(storeId, itemId); + } catch (error: any) { + set({ error: error.response?.data?.message || 'Error al corregir cantidad' }); + throw error; + } finally { + set({ isLoading: false }); + } + }, + + correctSku: async (storeId, itemId, data) => { + set({ isLoading: true, error: null }); + try { + await feedbackService.correctSku(storeId, itemId, data); + await get().fetchCorrectionHistory(storeId, itemId); + } catch (error: any) { + set({ error: error.response?.data?.message || 'Error al corregir nombre' }); + throw error; + } finally { + set({ isLoading: false }); + } + }, + + confirmItem: async (storeId, itemId) => { + set({ isLoading: true, error: null }); + try { + await feedbackService.confirmItem(storeId, itemId); + await get().fetchCorrectionHistory(storeId, itemId); + } catch (error: any) { + set({ error: error.response?.data?.message || 'Error al confirmar item' }); + throw error; + } finally { + set({ isLoading: false }); + } + }, + + fetchCorrectionHistory: async (storeId, itemId) => { + set({ isLoading: true, error: null }); + try { + const { corrections } = await feedbackService.getCorrectionHistory( + storeId, + itemId, + ); + set({ correctionHistory: corrections }); + } catch (error: any) { + set({ error: error.response?.data?.message || 'Error al cargar historial' }); + } finally { + set({ isLoading: false }); + } + }, + + submitProduct: async (data) => { + set({ isLoading: true, error: null }); + try { + await feedbackService.submitProduct(data); + } catch (error: any) { + set({ error: error.response?.data?.message || 'Error al enviar producto' }); + throw error; + } finally { + set({ isLoading: false }); + } + }, + + searchProducts: async (query) => { + set({ isLoading: true, error: null }); + try { + const { products } = await feedbackService.searchProducts(query); + set({ searchResults: products }); + } catch (error: any) { + set({ error: error.response?.data?.message || 'Error en busqueda' }); + } finally { + set({ isLoading: false }); + } + }, + + clearError: () => set({ error: null }), + + reset: () => + set({ + correctionHistory: [], + searchResults: [], + isLoading: false, + error: null, + }), +})); diff --git a/src/stores/inventory.store.ts b/src/stores/inventory.store.ts new file mode 100644 index 0000000..51cbb86 --- /dev/null +++ b/src/stores/inventory.store.ts @@ -0,0 +1,141 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { inventoryService, InventoryItem } from '@services/api/inventory.service'; + +interface InventoryState { + items: InventoryItem[]; + total: number; + page: number; + hasMore: boolean; + isLoading: boolean; + error: string | null; + selectedStoreId: string | null; + searchQuery: string; + selectedCategory: string | null; + lastFetched: number | null; + + // Actions + fetchItems: (storeId: string, refresh?: boolean) => Promise; + fetchInventory: (storeId: string) => Promise; + updateItem: (itemId: string, data: Partial) => Promise; + deleteItem: (itemId: string) => Promise; + setSelectedStore: (storeId: string) => void; + setSearchQuery: (query: string) => void; + setSelectedCategory: (category: string | null) => void; + clearError: () => void; +} + +const MAX_CACHED_ITEMS = 100; + +export const useInventoryStore = create()( + persist( + (set, get) => ({ + items: [], + total: 0, + page: 1, + hasMore: false, + isLoading: false, + error: null, + selectedStoreId: null, + searchQuery: '', + selectedCategory: null, + lastFetched: null, + + fetchItems: async (storeId: string, refresh = false) => { + const state = get(); + if (state.isLoading && !refresh) return; + + const page = refresh ? 1 : state.page; + + set({ isLoading: true, error: null, selectedStoreId: storeId }); + + try { + const response = await inventoryService.getInventory(storeId, page, 50); + const newItems = refresh ? response.items : [...state.items, ...response.items]; + + set({ + items: newItems.slice(0, MAX_CACHED_ITEMS), + total: response.total, + page: page + 1, + hasMore: response.hasMore, + isLoading: false, + lastFetched: Date.now(), + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al cargar inventario'; + set({ error: message, isLoading: false }); + } + }, + + fetchInventory: async (storeId: string) => { + return get().fetchItems(storeId, true); + }, + + updateItem: async (itemId: string, data: Partial) => { + const { selectedStoreId } = get(); + if (!selectedStoreId) return; + + set({ isLoading: true, error: null }); + + try { + await inventoryService.updateItem(selectedStoreId, itemId, data); + set((state) => ({ + items: state.items.map((item) => + item.id === itemId ? { ...item, ...data, isManuallyEdited: true } : item + ), + isLoading: false, + })); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al actualizar producto'; + set({ error: message, isLoading: false }); + throw error; + } + }, + + deleteItem: async (itemId: string) => { + const { selectedStoreId } = get(); + if (!selectedStoreId) return; + + set({ isLoading: true, error: null }); + + try { + await inventoryService.deleteItem(selectedStoreId, itemId); + set((state) => ({ + items: state.items.filter((item) => item.id !== itemId), + total: state.total - 1, + isLoading: false, + })); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al eliminar producto'; + set({ error: message, isLoading: false }); + throw error; + } + }, + + setSelectedStore: (storeId: string) => { + set({ selectedStoreId: storeId }); + }, + + setSearchQuery: (query: string) => { + set({ searchQuery: query }); + }, + + setSelectedCategory: (category: string | null) => { + set({ selectedCategory: category }); + }, + + clearError: () => set({ error: null }), + }), + { + name: 'miinventario-inventory', + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + items: state.items.slice(0, MAX_CACHED_ITEMS), + total: state.total, + selectedStoreId: state.selectedStoreId, + lastFetched: state.lastFetched, + }), + } + ) +); diff --git a/src/stores/notifications.store.ts b/src/stores/notifications.store.ts new file mode 100644 index 0000000..2746cc0 --- /dev/null +++ b/src/stores/notifications.store.ts @@ -0,0 +1,129 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + notificationsService, + Notification, +} from '@services/api/notifications.service'; + +interface NotificationsState { + notifications: Notification[]; + unreadCount: number; + total: number; + page: number; + hasMore: boolean; + isLoading: boolean; + error: string | null; + lastFetched: number | null; + // Actions + fetchNotifications: (refresh?: boolean) => Promise; + fetchUnreadCount: () => Promise; + markAsRead: (notificationId: string) => Promise; + markAllAsRead: () => Promise; + registerFcmToken: (token: string) => Promise; + clearError: () => void; +} + +const MAX_CACHED_NOTIFICATIONS = 50; + +export const useNotificationsStore = create()( + persist( + (set, get) => ({ + notifications: [], + unreadCount: 0, + total: 0, + page: 1, + hasMore: false, + isLoading: false, + error: null, + lastFetched: null, + + fetchNotifications: async (refresh = false) => { + const state = get(); + if (state.isLoading) return; + + const page = refresh ? 1 : state.page; + + set({ isLoading: true, error: null }); + + try { + const response = await notificationsService.getNotifications(page, 20); + const newNotifications = refresh + ? response.notifications + : [...state.notifications, ...response.notifications]; + + set({ + notifications: newNotifications.slice(0, MAX_CACHED_NOTIFICATIONS), + total: response.total, + page: response.page + 1, + hasMore: response.hasMore, + isLoading: false, + lastFetched: Date.now(), + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al cargar notificaciones'; + set({ error: message, isLoading: false }); + } + }, + + fetchUnreadCount: async () => { + try { + const response = await notificationsService.getUnreadCount(); + set({ unreadCount: response.count }); + } catch { + // Silently fail for badge count + } + }, + + markAsRead: async (notificationId: string) => { + try { + await notificationsService.markAsRead(notificationId); + const state = get(); + set({ + notifications: state.notifications.map((n) => + n.id === notificationId ? { ...n, isRead: true } : n + ), + unreadCount: Math.max(0, state.unreadCount - 1), + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al marcar notificacion'; + set({ error: message }); + } + }, + + markAllAsRead: async () => { + try { + await notificationsService.markAllAsRead(); + const state = get(); + set({ + notifications: state.notifications.map((n) => ({ ...n, isRead: true })), + unreadCount: 0, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al marcar notificaciones'; + set({ error: message }); + } + }, + + registerFcmToken: async (token: string) => { + try { + await notificationsService.registerFcmToken(token); + } catch { + // Silently fail for token registration + } + }, + + clearError: () => set({ error: null }), + }), + { + name: 'miinventario-notifications', + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + notifications: state.notifications.slice(0, MAX_CACHED_NOTIFICATIONS), + unreadCount: state.unreadCount, + total: state.total, + lastFetched: state.lastFetched, + }), + } + ) +); diff --git a/src/stores/payments.store.ts b/src/stores/payments.store.ts new file mode 100644 index 0000000..89267d8 --- /dev/null +++ b/src/stores/payments.store.ts @@ -0,0 +1,105 @@ +import { create } from 'zustand'; +import { + paymentsService, + CreditPackage, + Payment, + CreatePaymentRequest, + PaymentResponse, +} from '@services/api/payments.service'; + +interface PaymentsState { + packages: CreditPackage[]; + payments: Payment[]; + currentPayment: PaymentResponse | null; + total: number; + page: number; + hasMore: boolean; + isLoading: boolean; + isProcessing: boolean; + error: string | null; + // Actions + fetchPackages: () => Promise; + fetchPayments: (refresh?: boolean) => Promise; + createPayment: (data: CreatePaymentRequest) => Promise; + getPaymentById: (paymentId: string) => Promise; + clearCurrentPayment: () => void; + clearError: () => void; +} + +export const usePaymentsStore = create((set, get) => ({ + packages: [], + payments: [], + currentPayment: null, + total: 0, + page: 1, + hasMore: false, + isLoading: false, + isProcessing: false, + error: null, + + fetchPackages: async () => { + set({ isLoading: true, error: null }); + + try { + const packages = await paymentsService.getPackages(); + set({ packages, isLoading: false }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al cargar paquetes'; + set({ error: message, isLoading: false }); + } + }, + + fetchPayments: async (refresh = false) => { + const state = get(); + if (state.isLoading) return; + + const page = refresh ? 1 : state.page; + + set({ isLoading: true, error: null }); + + try { + const response = await paymentsService.getPaymentHistory(page, 20); + set({ + payments: refresh + ? response.payments + : [...state.payments, ...response.payments], + total: response.total, + page: response.page + 1, + hasMore: response.hasMore, + isLoading: false, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al cargar pagos'; + set({ error: message, isLoading: false }); + } + }, + + createPayment: async (data: CreatePaymentRequest) => { + set({ isProcessing: true, error: null }); + + try { + const response = await paymentsService.createPayment(data); + set({ currentPayment: response, isProcessing: false }); + return response; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al procesar pago'; + set({ error: message, isProcessing: false }); + return null; + } + }, + + getPaymentById: async (paymentId: string) => { + try { + const payment = await paymentsService.getPaymentById(paymentId); + return payment; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al obtener pago'; + set({ error: message }); + return null; + } + }, + + clearCurrentPayment: () => set({ currentPayment: null }), + + clearError: () => set({ error: null }), +})); diff --git a/src/stores/referrals.store.ts b/src/stores/referrals.store.ts new file mode 100644 index 0000000..2638304 --- /dev/null +++ b/src/stores/referrals.store.ts @@ -0,0 +1,101 @@ +import { create } from 'zustand'; +import { + referralsService, + ReferralStats, + Referral, +} from '@services/api/referrals.service'; + +interface ReferralsState { + stats: ReferralStats | null; + referrals: Referral[]; + total: number; + page: number; + hasMore: boolean; + isLoading: boolean; + isValidating: boolean; + error: string | null; + // Actions + fetchStats: () => Promise; + fetchReferrals: (refresh?: boolean) => Promise; + validateCode: (code: string) => Promise<{ valid: boolean; referrerName?: string }>; + applyCode: (code: string) => Promise<{ success: boolean; message: string }>; + clearError: () => void; +} + +export const useReferralsStore = create((set, get) => ({ + stats: null, + referrals: [], + total: 0, + page: 1, + hasMore: false, + isLoading: false, + isValidating: false, + error: null, + + fetchStats: async () => { + set({ isLoading: true, error: null }); + + try { + const stats = await referralsService.getStats(); + set({ stats, isLoading: false }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al cargar estadisticas'; + set({ error: message, isLoading: false }); + } + }, + + fetchReferrals: async (refresh = false) => { + const state = get(); + if (state.isLoading) return; + + const page = refresh ? 1 : state.page; + + set({ isLoading: true, error: null }); + + try { + const response = await referralsService.getReferrals(page, 20); + set({ + referrals: refresh + ? response.referrals + : [...state.referrals, ...response.referrals], + total: response.total, + page: response.page + 1, + hasMore: response.hasMore, + isLoading: false, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al cargar referidos'; + set({ error: message, isLoading: false }); + } + }, + + validateCode: async (code: string) => { + set({ isValidating: true, error: null }); + + try { + const result = await referralsService.validateCode(code); + set({ isValidating: false }); + return result; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al validar codigo'; + set({ error: message, isValidating: false }); + return { valid: false }; + } + }, + + applyCode: async (code: string) => { + set({ isLoading: true, error: null }); + + try { + const result = await referralsService.applyCode(code); + set({ isLoading: false }); + return result; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al aplicar codigo'; + set({ error: message, isLoading: false }); + return { success: false, message }; + } + }, + + clearError: () => set({ error: null }), +})); diff --git a/src/stores/stores.store.ts b/src/stores/stores.store.ts new file mode 100644 index 0000000..6383985 --- /dev/null +++ b/src/stores/stores.store.ts @@ -0,0 +1,164 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + storesService, + Store, + CreateStoreRequest, + UpdateStoreRequest, +} from '@services/api/stores.service'; + +interface StoresState { + stores: Store[]; + currentStore: Store | null; + total: number; + page: number; + hasMore: boolean; + isLoading: boolean; + error: string | null; + lastFetched: number | null; + // Actions + fetchStores: (refresh?: boolean) => Promise; + selectStore: (store: Store) => void; + getStoreById: (storeId: string) => Promise; + createStore: (data: CreateStoreRequest) => Promise; + updateStore: (storeId: string, data: UpdateStoreRequest) => Promise; + deleteStore: (storeId: string) => Promise; + clearError: () => void; +} + +export const useStoresStore = create()( + persist( + (set, get) => ({ + stores: [], + currentStore: null, + total: 0, + page: 1, + hasMore: false, + isLoading: false, + error: null, + lastFetched: null, + + fetchStores: async (refresh = false) => { + const state = get(); + if (state.isLoading) return; + + const page = refresh ? 1 : state.page; + + set({ isLoading: true, error: null }); + + try { + const response = await storesService.getStores(page, 20); + const newStores = refresh + ? response.stores + : [...state.stores, ...response.stores]; + + set({ + stores: newStores, + total: response.total, + page: response.page + 1, + hasMore: response.hasMore, + isLoading: false, + lastFetched: Date.now(), + // Auto-select first store if none selected + currentStore: state.currentStore || newStores[0] || null, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al cargar tiendas'; + set({ error: message, isLoading: false }); + } + }, + + selectStore: (store: Store) => { + set({ currentStore: store }); + }, + + getStoreById: async (storeId: string) => { + try { + const store = await storesService.getStoreById(storeId); + return store; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al obtener tienda'; + set({ error: message }); + return null; + } + }, + + createStore: async (data: CreateStoreRequest) => { + set({ isLoading: true, error: null }); + + try { + const store = await storesService.createStore(data); + const state = get(); + set({ + stores: [store, ...state.stores], + currentStore: store, + isLoading: false, + }); + return store; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al crear tienda'; + set({ error: message, isLoading: false }); + return null; + } + }, + + updateStore: async (storeId: string, data: UpdateStoreRequest) => { + set({ isLoading: true, error: null }); + + try { + const updatedStore = await storesService.updateStore(storeId, data); + const state = get(); + set({ + stores: state.stores.map((s) => + s.id === storeId ? updatedStore : s + ), + currentStore: + state.currentStore?.id === storeId ? updatedStore : state.currentStore, + isLoading: false, + }); + return updatedStore; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al actualizar tienda'; + set({ error: message, isLoading: false }); + return null; + } + }, + + deleteStore: async (storeId: string) => { + set({ isLoading: true, error: null }); + + try { + await storesService.deleteStore(storeId); + const state = get(); + const newStores = state.stores.filter((s) => s.id !== storeId); + set({ + stores: newStores, + currentStore: + state.currentStore?.id === storeId + ? newStores[0] || null + : state.currentStore, + isLoading: false, + }); + return true; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Error al eliminar tienda'; + set({ error: message, isLoading: false }); + return false; + } + }, + + clearError: () => set({ error: null }), + }), + { + name: 'miinventario-stores', + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + stores: state.stores, + currentStore: state.currentStore, + total: state.total, + lastFetched: state.lastFetched, + }), + } + ) +); diff --git a/src/stores/validations.store.ts b/src/stores/validations.store.ts new file mode 100644 index 0000000..8c68a80 --- /dev/null +++ b/src/stores/validations.store.ts @@ -0,0 +1,158 @@ +import { create } from 'zustand'; +import validationsService, { + ValidationRequest, + ValidationItem, + ValidationItemResponse, +} from '../services/api/validations.service'; + +interface ValidationsState { + pendingRequest: ValidationRequest | null; + items: ValidationItem[]; + responses: ValidationItemResponse[]; + currentItemIndex: number; + isLoading: boolean; + error: string | null; + creditsRewarded: number | null; + + // Actions + checkForValidation: (videoId: string) => Promise; + fetchValidationItems: (requestId: string) => Promise; + addResponse: (response: ValidationItemResponse) => void; + nextItem: () => void; + previousItem: () => void; + submitValidation: () => Promise; + skipValidation: () => Promise; + clearError: () => void; + reset: () => void; +} + +export const useValidationsStore = create((set, get) => ({ + pendingRequest: null, + items: [], + responses: [], + currentItemIndex: 0, + isLoading: false, + error: null, + creditsRewarded: null, + + checkForValidation: async (videoId) => { + set({ isLoading: true, error: null }); + try { + const result = await validationsService.check(videoId); + if (result.validationRequired && result.requestId) { + await get().fetchValidationItems(result.requestId); + return true; + } + return false; + } catch (error: any) { + set({ error: error.response?.data?.message || 'Error al verificar validacion' }); + return false; + } finally { + set({ isLoading: false }); + } + }, + + fetchValidationItems: async (requestId) => { + set({ isLoading: true, error: null }); + try { + const { request, items } = await validationsService.getItems(requestId); + set({ + pendingRequest: request, + items, + responses: [], + currentItemIndex: 0, + }); + } catch (error: any) { + set({ error: error.response?.data?.message || 'Error al cargar items' }); + } finally { + set({ isLoading: false }); + } + }, + + addResponse: (response) => { + const { responses } = get(); + const existingIndex = responses.findIndex( + (r) => r.inventoryItemId === response.inventoryItemId, + ); + if (existingIndex >= 0) { + const updated = [...responses]; + updated[existingIndex] = response; + set({ responses: updated }); + } else { + set({ responses: [...responses, response] }); + } + }, + + nextItem: () => { + const { currentItemIndex, items } = get(); + if (currentItemIndex < items.length - 1) { + set({ currentItemIndex: currentItemIndex + 1 }); + } + }, + + previousItem: () => { + const { currentItemIndex } = get(); + if (currentItemIndex > 0) { + set({ currentItemIndex: currentItemIndex - 1 }); + } + }, + + submitValidation: async () => { + const { pendingRequest, responses } = get(); + if (!pendingRequest) { + set({ error: 'No hay validacion pendiente' }); + return; + } + + set({ isLoading: true, error: null }); + try { + const result = await validationsService.submit(pendingRequest.id, { + responses, + }); + set({ + creditsRewarded: result.creditsRewarded, + pendingRequest: null, + items: [], + responses: [], + currentItemIndex: 0, + }); + } catch (error: any) { + set({ error: error.response?.data?.message || 'Error al enviar validacion' }); + } finally { + set({ isLoading: false }); + } + }, + + skipValidation: async () => { + const { pendingRequest } = get(); + if (!pendingRequest) return; + + set({ isLoading: true, error: null }); + try { + await validationsService.skip(pendingRequest.id); + set({ + pendingRequest: null, + items: [], + responses: [], + currentItemIndex: 0, + }); + } catch (error: any) { + set({ error: error.response?.data?.message || 'Error al omitir validacion' }); + } finally { + set({ isLoading: false }); + } + }, + + clearError: () => set({ error: null }), + + reset: () => + set({ + pendingRequest: null, + items: [], + responses: [], + currentItemIndex: 0, + isLoading: false, + error: null, + creditsRewarded: null, + }), +})); diff --git a/src/theme/ThemeContext.tsx b/src/theme/ThemeContext.tsx new file mode 100644 index 0000000..1450f99 --- /dev/null +++ b/src/theme/ThemeContext.tsx @@ -0,0 +1,76 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import { useColorScheme } from 'react-native'; + +export interface ThemeColors { + primary: string; + primaryLight: string; + background: string; + card: string; + text: string; + textSecondary: string; + border: string; + error: string; + success: string; + warning: string; +} + +export interface Theme { + colors: ThemeColors; + isDark: boolean; +} + +const lightColors: ThemeColors = { + primary: '#2563eb', + primaryLight: '#f0f9ff', + background: '#f5f5f5', + card: '#ffffff', + text: '#1a1a1a', + textSecondary: '#666666', + border: '#e5e5e5', + error: '#ef4444', + success: '#22c55e', + warning: '#f59e0b', +}; + +const darkColors: ThemeColors = { + primary: '#3b82f6', + primaryLight: '#1e3a5f', + background: '#0f0f0f', + card: '#1a1a1a', + text: '#ffffff', + textSecondary: '#a3a3a3', + border: '#2d2d2d', + error: '#f87171', + success: '#4ade80', + warning: '#fbbf24', +}; + +const ThemeContext = createContext({ + colors: lightColors, + isDark: false, +}); + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const colorScheme = useColorScheme(); + const isDark = colorScheme === 'dark'; + + const theme = useMemo(() => ({ + colors: isDark ? darkColors : lightColors, + isDark, + }), [isDark]); + + return ( + + {children} + + ); +} + +export function useTheme(): Theme { + return useContext(ThemeContext); +} + +export function useColors(): ThemeColors { + const { colors } = useTheme(); + return colors; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..4682686 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,234 @@ +// User types +export interface User { + id: string; + phone: string; + name: string; + email?: string; + businessName?: string; + location?: string; + giro?: string; + fcmToken?: string; + createdAt: string; +} + +// Store types +export interface Store { + id: string; + name: string; + address?: string; + city?: string; + state?: string; + zipCode?: string; + giro?: string; + ownerId: string; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +// Inventory types +export interface InventoryItem { + id: string; + storeId: string; + name: string; + quantity: number; + category?: string; + barcode?: string; + price?: number; + imageUrl?: string; + detectionConfidence?: number; + isManuallyEdited: boolean; + lastDetectedAt?: string; + createdAt: string; + updatedAt: string; +} + +// Video types +export type VideoStatus = + | 'PENDING' + | 'UPLOADING' + | 'UPLOADED' + | 'PROCESSING' + | 'COMPLETED' + | 'FAILED'; + +export interface Video { + id: string; + storeId: string; + userId: string; + fileName: string; + originalFileName: string; + mimeType: string; + fileSize: number; + status: VideoStatus; + s3Key?: string; + processingStartedAt?: string; + processingCompletedAt?: string; + itemsDetected?: number; + creditsConsumed?: number; + errorMessage?: string; + createdAt: string; + updatedAt: string; +} + +// Credit types +export type TransactionType = + | 'PURCHASE' + | 'CONSUMPTION' + | 'REFERRAL_BONUS' + | 'PROMO' + | 'REFUND'; + +export interface CreditBalance { + id: string; + userId: string; + balance: number; + totalPurchased: number; + totalConsumed: number; + totalFromReferrals: number; + updatedAt: string; +} + +export interface CreditTransaction { + id: string; + userId: string; + type: TransactionType; + amount: number; + balanceAfter: number; + description: string; + referenceId?: string; + referenceType?: string; + createdAt: string; +} + +export interface CreditPackage { + id: string; + name: string; + credits: number; + priceMXN: number; + discount?: number; + isPopular: boolean; + isActive: boolean; + sortOrder: number; +} + +// Payment types +export type PaymentStatus = + | 'PENDING' + | 'PROCESSING' + | 'COMPLETED' + | 'FAILED' + | 'REFUNDED'; + +export type PaymentMethod = 'CARD' | 'OXXO' | '7ELEVEN'; + +export interface Payment { + id: string; + userId: string; + packageId: string; + amountMXN: number; + creditsGranted: number; + method: PaymentMethod; + status: PaymentStatus; + stripePaymentIntentId?: string; + stripeCustomerId?: string; + voucherCode?: string; + voucherUrl?: string; + expiresAt?: string; + completedAt?: string; + createdAt: string; + updatedAt: string; +} + +// Referral types +export type ReferralStatus = 'PENDING' | 'REGISTERED' | 'QUALIFIED' | 'REWARDED'; + +export interface Referral { + id: string; + referrerId: string; + referredId?: string; + referralCode: string; + status: ReferralStatus; + referrerBonusCredits: number; + referredBonusCredits: number; + registeredAt?: string; + qualifiedAt?: string; + rewardedAt?: string; + createdAt: string; + updatedAt: string; + referred?: { + id: string; + name: string; + createdAt: string; + }; +} + +export interface ReferralStats { + referralCode: string; + totalReferrals: number; + completedReferrals: number; + pendingReferrals: number; + totalCreditsEarned: number; +} + +// Notification types +export type NotificationType = + | 'VIDEO_PROCESSING_COMPLETE' + | 'VIDEO_PROCESSING_FAILED' + | 'LOW_CREDITS' + | 'PAYMENT_COMPLETE' + | 'PAYMENT_FAILED' + | 'REFERRAL_BONUS' + | 'SYSTEM'; + +export interface Notification { + id: string; + userId: string; + type: NotificationType; + title: string; + body: string; + data?: Record; + isRead: boolean; + isPushSent: boolean; + createdAt: string; +} + +// API response types +export interface ApiError { + message: string; + code?: string; + statusCode: number; +} + +export interface PaginatedResponse { + data: T[]; + total: number; + page: number; + limit: number; + hasMore: boolean; +} + +// Auth types +export interface AuthTokens { + accessToken: string; + refreshToken: string; + expiresIn: number; +} + +export interface LoginResponse { + user: User; + tokens: AuthTokens; +} + +export interface RegisterRequest { + phone: string; + name: string; + password: string; + referralCode?: string; +} + +export interface VerifyOtpRequest { + phone: string; + code: string; + purpose: 'REGISTRATION' | 'LOGIN' | 'PASSWORD_RESET'; +} diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts new file mode 100644 index 0000000..05ccb93 --- /dev/null +++ b/src/utils/formatters.ts @@ -0,0 +1,58 @@ +export function formatPhoneNumber(phone: string): string { + // Format Mexican phone number: +52 55 1234 5678 + const cleaned = phone.replace(/\D/g, ''); + + if (cleaned.length === 10) { + return `${cleaned.slice(0, 2)} ${cleaned.slice(2, 6)} ${cleaned.slice(6)}`; + } + + if (cleaned.length === 12 && cleaned.startsWith('52')) { + return `+52 ${cleaned.slice(2, 4)} ${cleaned.slice(4, 8)} ${cleaned.slice(8)}`; + } + + return phone; +} + +export function formatCurrency(amount: number, currency = 'MXN'): string { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(amount); +} + +export function formatCredits(credits: number): string { + if (credits >= 1000000) { + return `${(credits / 1000000).toFixed(1)}M`; + } + if (credits >= 1000) { + return `${(credits / 1000).toFixed(1)}K`; + } + return credits.toString(); +} + +export function formatDate(date: string | Date): string { + const d = typeof date === 'string' ? new Date(date) : date; + return new Intl.DateTimeFormat('es-MX', { + day: 'numeric', + month: 'short', + year: 'numeric', + }).format(d); +} + +export function formatRelativeTime(date: string | Date): string { + const d = typeof date === 'string' ? new Date(date) : date; + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'Ahora'; + if (diffMins < 60) return `Hace ${diffMins} min`; + if (diffHours < 24) return `Hace ${diffHours}h`; + if (diffDays < 7) return `Hace ${diffDays}d`; + + return formatDate(d); +} diff --git a/src/utils/validators.ts b/src/utils/validators.ts new file mode 100644 index 0000000..20a059f --- /dev/null +++ b/src/utils/validators.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +export const phoneSchema = z + .string() + .min(10, 'El telefono debe tener al menos 10 digitos') + .max(15, 'El telefono es muy largo') + .regex(/^[0-9+]+$/, 'Solo numeros permitidos'); + +export const passwordSchema = z + .string() + .min(6, 'La contrasena debe tener al menos 6 caracteres') + .max(50, 'La contrasena es muy larga'); + +export const nameSchema = z + .string() + .min(2, 'El nombre debe tener al menos 2 caracteres') + .max(100, 'El nombre es muy largo'); + +export const otpSchema = z + .string() + .length(6, 'El codigo debe tener 6 digitos') + .regex(/^[0-9]+$/, 'Solo numeros permitidos'); + +export const loginSchema = z.object({ + phone: phoneSchema, + password: passwordSchema, +}); + +export const registerSchema = z.object({ + phone: phoneSchema, + name: nameSchema, +}); + +export const verifyOtpSchema = z.object({ + phone: phoneSchema, + otp: otpSchema, + password: passwordSchema, +}); + +export type LoginFormData = z.infer; +export type RegisterFormData = z.infer; +export type VerifyOtpFormData = z.infer; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c51631 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": ["./src/*"], + "@screens/*": ["./src/screens/*"], + "@components/*": ["./src/components/*"], + "@hooks/*": ["./src/hooks/*"], + "@stores/*": ["./src/stores/*"], + "@services/*": ["./src/services/*"], + "@utils/*": ["./src/utils/*"], + "@types/*": ["./src/types/*"] + } + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ".expo/types/**/*.ts", + "expo-env.d.ts" + ] +}