Sincronización desde miinventario/apps/mobile - Estándar multi-repo v2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 08:29:24 -06:00
parent 67fa906b6f
commit eb718a95aa
105 changed files with 16681 additions and 3 deletions

5
.env.example Normal file
View File

@ -0,0 +1,5 @@
# API
EXPO_PUBLIC_API_URL=http://localhost:3142/api/v1
# Environment
EXPO_PUBLIC_ENV=development

47
.eslintrc.js Normal file
View File

@ -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'] }],
},
};

11
.prettierrc Normal file
View File

@ -0,0 +1,11 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2,
"semi": true,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf",
"jsxSingleQuote": false
}

View File

@ -1,3 +0,0 @@
# miinventario-mobile-v2
Mobile de miinventario - Workspace V2

55
app.json Normal file
View File

@ -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"
}
}

25
babel.config.js Normal file
View File

@ -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',
},
},
],
],
};
};

37
jest.config.js Normal file
View File

@ -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: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@services/(.*)$': '<rootDir>/src/services/$1',
'^@stores/(.*)$': '<rootDir>/src/stores/$1',
'^@components/(.*)$': '<rootDir>/src/components/$1',
'^@hooks/(.*)$': '<rootDir>/src/hooks/$1',
'^@utils/(.*)$': '<rootDir>/src/utils/$1',
'^@theme/(.*)$': '<rootDir>/src/theme/$1',
'^@types/(.*)$': '<rootDir>/src/types/$1',
},
setupFilesAfterEnv: ['<rootDir>/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'],
};

71
jest.setup.js Normal file
View File

@ -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);
};

63
package.json Normal file
View File

@ -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"
}
}

View File

@ -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 = <T>(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;

View File

@ -0,0 +1,16 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
animation: 'slide_from_right',
}}
>
<Stack.Screen name="login" />
<Stack.Screen name="register" />
<Stack.Screen name="verify-otp" />
</Stack>
);
}

134
src/app/(auth)/login.tsx Normal file
View File

@ -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 (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>MiInventario</Text>
<Text style={styles.subtitle}>Inicia sesion para continuar</Text>
<View style={styles.form}>
<TextInput
style={styles.input}
placeholder="Telefono"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
autoCapitalize="none"
/>
<TextInput
style={styles.input}
placeholder="Contrasena"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? 'Iniciando...' : 'Iniciar Sesion'}
</Text>
</TouchableOpacity>
</View>
<View style={styles.footer}>
<Text style={styles.footerText}>No tienes cuenta? </Text>
<Link href="/(auth)/register" asChild>
<TouchableOpacity>
<Text style={styles.link}>Registrate</Text>
</TouchableOpacity>
</Link>
</View>
</View>
</SafeAreaView>
);
}
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',
},
});

136
src/app/(auth)/register.tsx Normal file
View File

@ -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 (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>Crear Cuenta</Text>
<Text style={styles.subtitle}>Ingresa tus datos para registrarte</Text>
<View style={styles.form}>
<TextInput
style={styles.input}
placeholder="Nombre"
value={name}
onChangeText={setName}
autoCapitalize="words"
/>
<TextInput
style={styles.input}
placeholder="Telefono"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleRegister}
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? 'Enviando...' : 'Continuar'}
</Text>
</TouchableOpacity>
</View>
<View style={styles.footer}>
<Text style={styles.footerText}>Ya tienes cuenta? </Text>
<Link href="/(auth)/login" asChild>
<TouchableOpacity>
<Text style={styles.link}>Inicia Sesion</Text>
</TouchableOpacity>
</Link>
</View>
</View>
</SafeAreaView>
);
}
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',
},
});

View File

@ -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 (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>Verificar Codigo</Text>
<Text style={styles.subtitle}>
Ingresa el codigo enviado a {phone}
</Text>
<View style={styles.form}>
<TextInput
style={styles.input}
placeholder="Codigo OTP"
value={otp}
onChangeText={setOtp}
keyboardType="number-pad"
maxLength={6}
/>
<TextInput
style={styles.input}
placeholder="Crear Contrasena"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleVerify}
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? 'Verificando...' : 'Verificar y Crear Cuenta'}
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.resendButton}>
<Text style={styles.resendText}>Reenviar codigo</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
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,
},
});

View File

@ -0,0 +1,48 @@
import { Tabs } from 'expo-router';
import { Text } from 'react-native';
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: '#2563eb',
tabBarInactiveTintColor: '#666',
tabBarStyle: {
paddingBottom: 8,
paddingTop: 8,
height: 60,
},
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Inicio',
tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}>🏠</Text>,
}}
/>
<Tabs.Screen
name="scan"
options={{
title: 'Escanear',
tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}>📷</Text>,
}}
/>
<Tabs.Screen
name="inventory"
options={{
title: 'Inventario',
tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}>📦</Text>,
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Perfil',
tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}>👤</Text>,
}}
/>
</Tabs>
);
}

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

@ -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 (
<Animated.View
entering={FadeInRight.delay(200 + index * 100).duration(400)}
layout={Layout.springify()}
>
<AnimatedTouchable
style={[styles.actionCard, animatedStyle]}
onPress={onPress}
onPressIn={onPressIn}
onPressOut={onPressOut}
activeOpacity={1}
>
<View style={styles.actionIconContainer}>
<Text style={styles.actionIcon}>{icon}</Text>
</View>
<View style={styles.actionContent}>
<Text style={styles.actionTitle}>{title}</Text>
<Text style={styles.actionDescription}>{description}</Text>
</View>
<Text style={styles.actionArrow}></Text>
</AnimatedTouchable>
</Animated.View>
);
}
function StatCard({
value,
label,
index,
}: {
value: number;
label: string;
index: number;
}) {
return (
<Animated.View
style={styles.statCard}
entering={FadeInDown.delay(400 + index * 100).duration(400)}
>
<Text style={styles.statValue}>{value}</Text>
<Text style={styles.statLabel}>{label}</Text>
</Animated.View>
);
}
function HomeSkeleton() {
return (
<View style={styles.content}>
{/* Header Skeleton */}
<View style={styles.header}>
<View>
<SkeletonText width={180} height={28} />
<SkeletonText width={120} height={16} style={{ marginTop: 8 }} />
</View>
</View>
{/* Credits Card Skeleton */}
<Skeleton width="100%" height={150} borderRadius={16} style={{ marginBottom: 20 }} />
{/* Actions Skeleton */}
<SkeletonText width={140} height={18} style={{ marginBottom: 12 }} />
<Skeleton width="100%" height={80} borderRadius={12} style={{ marginBottom: 12 }} />
<Skeleton width="100%" height={80} borderRadius={12} style={{ marginBottom: 12 }} />
<Skeleton width="100%" height={80} borderRadius={12} style={{ marginBottom: 20 }} />
{/* Stats Skeleton */}
<SkeletonText width={100} height={18} style={{ marginBottom: 12 }} />
<View style={styles.statsGrid}>
<SkeletonStat />
<SkeletonStat />
<SkeletonStat />
</View>
</View>
);
}
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 (
<SafeAreaView style={styles.container}>
<ScrollView style={styles.scroll}>
<HomeSkeleton />
</ScrollView>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container}>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
{/* Header */}
<Animated.View
style={styles.header}
entering={FadeIn.duration(400)}
>
<View style={styles.headerTop}>
<View>
<Text style={styles.greeting}>Hola, {user?.name || 'Usuario'}</Text>
<Text style={styles.subtitle}>
{currentStore ? currentStore.name : 'Selecciona una tienda'}
</Text>
</View>
<TouchableOpacity
style={styles.notificationButton}
onPress={() => router.push('/notifications')}
>
<Text style={styles.notificationIcon}>🔔</Text>
{unreadCount > 0 && (
<View style={styles.notificationBadge}>
<Text style={styles.notificationBadgeText}>
{unreadCount > 9 ? '9+' : unreadCount}
</Text>
</View>
)}
</TouchableOpacity>
</View>
</Animated.View>
{/* Credits Card */}
<Animated.View
style={styles.creditsCard}
entering={FadeInDown.delay(100).duration(400)}
>
<View style={styles.creditsHeader}>
<Text style={styles.creditsLabel}>Creditos disponibles</Text>
<TouchableOpacity onPress={() => router.push('/credits/history')}>
<Text style={styles.creditsHistoryLink}>Ver historial</Text>
</TouchableOpacity>
</View>
<Text style={styles.creditsAmount}>{balance?.balance ?? 0}</Text>
<TouchableOpacity
style={styles.buyButton}
onPress={() => router.push('/credits/buy')}
>
<Text style={styles.buyButtonText}>Comprar Creditos</Text>
</TouchableOpacity>
</Animated.View>
{/* Store Selector */}
{stores.length > 1 && (
<Animated.View
style={styles.storeSelector}
entering={FadeInDown.delay(150).duration(400)}
>
<Text style={styles.sectionTitle}>Tienda Activa</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{stores.map((store, index) => (
<Animated.View
key={store.id}
entering={FadeInRight.delay(200 + index * 50).duration(300)}
>
<TouchableOpacity
style={[
styles.storeChip,
currentStore?.id === store.id && styles.storeChipActive,
]}
onPress={() => useStoresStore.getState().selectStore(store)}
>
<Text
style={[
styles.storeChipText,
currentStore?.id === store.id && styles.storeChipTextActive,
]}
>
{store.name}
</Text>
</TouchableOpacity>
</Animated.View>
))}
</ScrollView>
</Animated.View>
)}
{/* Quick Actions */}
<View style={styles.actionsSection}>
<Animated.Text
style={styles.sectionTitle}
entering={FadeIn.delay(200).duration(300)}
>
Acciones Rapidas
</Animated.Text>
<ActionCard
icon="📷"
title="Escanear Anaquel"
description="Graba un video para actualizar tu inventario"
onPress={() => router.push('/(tabs)/scan')}
index={0}
/>
<ActionCard
icon="📦"
title="Ver Inventario"
description="Consulta y edita tu inventario actual"
onPress={() => router.push('/(tabs)/inventory')}
index={1}
/>
<ActionCard
icon="🎁"
title="Invitar Amigos"
description="Gana creditos por cada referido"
onPress={() => router.push('/referrals')}
index={2}
/>
</View>
{/* Stats */}
<View style={styles.statsSection}>
<Animated.Text
style={styles.sectionTitle}
entering={FadeIn.delay(350).duration(300)}
>
Resumen
</Animated.Text>
<View style={styles.statsGrid}>
<StatCard value={stores.length} label="Tiendas" index={0} />
<StatCard value={items.length} label="Productos" index={1} />
<StatCard value={balance?.totalConsumed ?? 0} label="Escaneos" index={2} />
</View>
</View>
{/* Low Stock Alert */}
{items.filter(i => i.quantity < 5).length > 0 && (
<Animated.View
style={styles.alertCard}
entering={FadeInDown.delay(600).duration(400)}
>
<Text style={styles.alertIcon}></Text>
<View style={styles.alertContent}>
<Text style={styles.alertTitle}>Stock Bajo</Text>
<Text style={styles.alertDescription}>
{items.filter(i => i.quantity < 5).length} productos con menos de 5 unidades
</Text>
</View>
<TouchableOpacity onPress={() => router.push('/(tabs)/inventory?filter=low-stock')}>
<Text style={styles.alertAction}>Ver</Text>
</TouchableOpacity>
</Animated.View>
)}
</ScrollView>
</SafeAreaView>
);
}
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,
},
});

View File

@ -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 (
<Animated.View
entering={FadeInRight.delay(Math.min(index * 50, 300)).duration(300)}
exiting={FadeOut.duration(200)}
layout={Layout.springify()}
>
<AnimatedTouchable
style={[styles.itemCard, animatedStyle]}
onPress={() => router.push(`/inventory/${item.id}`)}
onPressIn={onPressIn}
onPressOut={onPressOut}
activeOpacity={1}
>
<View style={styles.itemInfo}>
<View style={styles.itemHeader}>
<Text style={styles.itemName} numberOfLines={1}>
{item.name}
</Text>
{item.isManuallyEdited && (
<View style={styles.editedBadge}>
<Text style={styles.editedBadgeText}>Editado</Text>
</View>
)}
</View>
<Text style={styles.itemCategory}>{item.category || 'Sin categoria'}</Text>
{item.barcode && (
<Text style={styles.itemBarcode}>Codigo: {item.barcode}</Text>
)}
{item.detectionConfidence && (
<View style={styles.confidenceContainer}>
<View
style={[
styles.confidenceBar,
{ width: `${item.detectionConfidence * 100}%` },
]}
/>
</View>
)}
</View>
<View
style={[
styles.itemQuantity,
item.quantity < 5 && styles.itemQuantityLow,
]}
>
<Text
style={[
styles.quantityValue,
item.quantity < 5 && styles.quantityValueLow,
]}
>
{item.quantity}
</Text>
<Text
style={[
styles.quantityLabel,
item.quantity < 5 && styles.quantityLabelLow,
]}
>
unidades
</Text>
</View>
</AnimatedTouchable>
</Animated.View>
);
}
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 }) => (
<InventoryItemCard item={item} index={index} />
),
[]
);
const EmptyState = () => (
<Animated.View
style={styles.emptyState}
entering={FadeIn.delay(200).duration(400)}
>
<Text style={styles.emptyIcon}>📦</Text>
<Text style={styles.emptyTitle}>
{searchQuery ? 'Sin resultados' : 'Sin inventario'}
</Text>
<Text style={styles.emptyDescription}>
{searchQuery
? `No se encontraron productos que coincidan con "${searchQuery}"`
: 'Escanea tu primer anaquel para comenzar a registrar tu inventario'}
</Text>
{!searchQuery && (
<TouchableOpacity
style={styles.emptyButton}
onPress={() => router.push('/(tabs)/scan')}
>
<Text style={styles.emptyButtonText}>Escanear Anaquel</Text>
</TouchableOpacity>
)}
</Animated.View>
);
if (!currentStore) {
return (
<SafeAreaView style={styles.container}>
<Animated.View
style={styles.emptyState}
entering={FadeIn.duration(400)}
>
<Text style={styles.emptyIcon}>🏪</Text>
<Text style={styles.emptyTitle}>Sin tienda seleccionada</Text>
<Text style={styles.emptyDescription}>
Crea o selecciona una tienda para ver su inventario
</Text>
<TouchableOpacity
style={styles.emptyButton}
onPress={() => router.push('/stores/new')}
>
<Text style={styles.emptyButtonText}>Crear Tienda</Text>
</TouchableOpacity>
</Animated.View>
</SafeAreaView>
);
}
const showSkeleton = isLoading && initialLoad && items.length === 0;
return (
<SafeAreaView style={styles.container}>
{/* Header */}
<Animated.View
style={styles.header}
entering={FadeIn.duration(300)}
>
<View style={styles.headerTop}>
<View>
<Text style={styles.title}>Inventario</Text>
<Text style={styles.subtitle}>
{currentStore.name} - {total} productos
</Text>
</View>
</View>
{/* Search */}
<Animated.View
style={styles.searchContainer}
entering={FadeInDown.delay(100).duration(300)}
>
<TextInput
style={styles.searchInput}
placeholder="Buscar producto..."
placeholderTextColor="#999"
value={searchQuery}
onChangeText={setSearchQuery}
/>
{searchQuery && (
<TouchableOpacity
style={styles.clearSearch}
onPress={() => setSearchQuery('')}
>
<Text style={styles.clearSearchText}></Text>
</TouchableOpacity>
)}
</Animated.View>
{/* Filters */}
<Animated.View
style={styles.filtersContainer}
entering={FadeInDown.delay(150).duration(300)}
>
<TouchableOpacity
style={[styles.filterChip, filter === 'all' && styles.filterChipActive]}
onPress={() => setFilter('all')}
>
<Text
style={[
styles.filterChipText,
filter === 'all' && styles.filterChipTextActive,
]}
>
Todos
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.filterChip,
filter === 'low-stock' && styles.filterChipActive,
]}
onPress={() => setFilter('low-stock')}
>
<Text
style={[
styles.filterChipText,
filter === 'low-stock' && styles.filterChipTextActive,
]}
>
Stock bajo
</Text>
</TouchableOpacity>
</Animated.View>
</Animated.View>
{/* List */}
{showSkeleton ? (
<View style={styles.list}>
<InventoryListSkeleton count={8} />
</View>
) : filteredItems.length === 0 ? (
<EmptyState />
) : (
<FlatList
data={filteredItems}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#2563eb"
colors={['#2563eb']}
/>
}
/>
)}
{/* Error */}
{error && (
<Animated.View
style={styles.errorBanner}
entering={FadeInDown.duration(300)}
>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity onPress={onRefresh}>
<Text style={styles.errorRetry}>Reintentar</Text>
</TouchableOpacity>
</Animated.View>
)}
</SafeAreaView>
);
}
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',
},
});

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

@ -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;
}) => (
<TouchableOpacity style={styles.menuItem} onPress={onPress}>
<Text style={styles.menuIcon}>{icon}</Text>
<Text style={[styles.menuLabel, destructive && styles.menuLabelDestructive]}>
{label}
</Text>
{value && <Text style={styles.menuValue}>{value}</Text>}
<Text style={styles.menuArrow}></Text>
</TouchableOpacity>
);
return (
<SafeAreaView style={styles.container}>
<ScrollView
style={styles.scroll}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
{/* Header */}
<View style={styles.header}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>
{user?.name?.charAt(0).toUpperCase() || 'U'}
</Text>
</View>
<Text style={styles.name}>{user?.name || 'Usuario'}</Text>
<Text style={styles.phone}>{user?.phone || ''}</Text>
</View>
{/* Credits Card */}
<View style={styles.creditsCard}>
<View style={styles.creditsMain}>
<Text style={styles.creditsLabel}>Tu Balance</Text>
<Text style={styles.creditsAmount}>{balance?.balance ?? 0}</Text>
<Text style={styles.creditsUnit}>creditos</Text>
</View>
<View style={styles.creditsStats}>
<View style={styles.creditsStat}>
<Text style={styles.creditsStatValue}>{balance?.totalPurchased ?? 0}</Text>
<Text style={styles.creditsStatLabel}>Comprados</Text>
</View>
<View style={styles.creditsDivider} />
<View style={styles.creditsStat}>
<Text style={styles.creditsStatValue}>{balance?.totalFromReferrals ?? 0}</Text>
<Text style={styles.creditsStatLabel}>Por referidos</Text>
</View>
<View style={styles.creditsDivider} />
<View style={styles.creditsStat}>
<Text style={styles.creditsStatValue}>{balance?.totalConsumed ?? 0}</Text>
<Text style={styles.creditsStatLabel}>Usados</Text>
</View>
</View>
<TouchableOpacity
style={styles.buyCreditsButton}
onPress={() => router.push('/credits/buy')}
>
<Text style={styles.buyCreditsButtonText}>Comprar Creditos</Text>
</TouchableOpacity>
</View>
{/* Referral Card */}
<View style={styles.referralCard}>
<Text style={styles.referralTitle}>Invita y Gana</Text>
<Text style={styles.referralDescription}>
Comparte tu codigo y gana 5 creditos por cada amigo que se registre
</Text>
<View style={styles.referralCodeContainer}>
<Text style={styles.referralCodeLabel}>Tu codigo:</Text>
<View style={styles.referralCodeBox}>
<Text style={styles.referralCode}>{stats?.referralCode || '---'}</Text>
</View>
</View>
<View style={styles.referralActions}>
<TouchableOpacity
style={styles.referralActionButton}
onPress={copyReferralCode}
>
<Text style={styles.referralActionIcon}>{copied ? '✓' : '📋'}</Text>
<Text style={styles.referralActionText}>
{copied ? 'Copiado!' : 'Copiar'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.referralActionButton, styles.referralActionButtonPrimary]}
onPress={shareReferralCode}
>
<Text style={styles.referralActionIcon}>📤</Text>
<Text style={[styles.referralActionText, styles.referralActionTextPrimary]}>
Compartir
</Text>
</TouchableOpacity>
</View>
<View style={styles.referralStats}>
<View style={styles.referralStat}>
<Text style={styles.referralStatValue}>{stats?.totalReferrals ?? 0}</Text>
<Text style={styles.referralStatLabel}>Invitados</Text>
</View>
<View style={styles.referralStat}>
<Text style={styles.referralStatValue}>{stats?.completedReferrals ?? 0}</Text>
<Text style={styles.referralStatLabel}>Completados</Text>
</View>
<View style={styles.referralStat}>
<Text style={styles.referralStatValue}>{stats?.totalCreditsEarned ?? 0}</Text>
<Text style={styles.referralStatLabel}>Creditos ganados</Text>
</View>
</View>
</View>
{/* Menu Sections */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Cuenta</Text>
<View style={styles.menuGroup}>
<MenuItem
icon="👤"
label="Editar Perfil"
onPress={() => router.push('/profile/edit')}
/>
<MenuItem
icon="🏪"
label="Mis Tiendas"
onPress={() => router.push('/stores')}
/>
<MenuItem
icon="💳"
label="Metodos de Pago"
onPress={() => router.push('/payments/methods')}
/>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Creditos</Text>
<View style={styles.menuGroup}>
<MenuItem
icon="💰"
label="Comprar Creditos"
onPress={() => router.push('/credits/buy')}
/>
<MenuItem
icon="📊"
label="Historial de Transacciones"
onPress={() => router.push('/credits/history')}
/>
<MenuItem
icon="🎁"
label="Mis Referidos"
value={`${stats?.completedReferrals ?? 0} completados`}
onPress={() => router.push('/referrals')}
/>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Soporte</Text>
<View style={styles.menuGroup}>
<MenuItem
icon="❓"
label="Centro de Ayuda"
onPress={() => router.push('/help')}
/>
<MenuItem
icon="📧"
label="Contactar Soporte"
onPress={() => router.push('/support')}
/>
<MenuItem
icon="📝"
label="Terminos y Condiciones"
onPress={() => router.push('/legal/terms')}
/>
<MenuItem
icon="🔒"
label="Politica de Privacidad"
onPress={() => router.push('/legal/privacy')}
/>
</View>
</View>
<View style={styles.section}>
<View style={styles.menuGroup}>
<MenuItem
icon="🚪"
label="Cerrar Sesion"
onPress={handleLogout}
destructive
/>
</View>
</View>
<Text style={styles.version}>MiInventario v1.0.0</Text>
</ScrollView>
</SafeAreaView>
);
}
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,
},
});

467
src/app/(tabs)/scan.tsx Normal file
View File

@ -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<ProcessingStatus>('idle');
const [progress, setProgress] = useState(0);
const [recordingDuration, setRecordingDuration] = useState(0);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const cameraRef = useRef<Camera>(null);
const recordingTimer = useRef<NodeJS.Timeout | null>(null);
const pollingTimer = useRef<NodeJS.Timeout | null>(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 (
<SafeAreaView style={styles.container}>
<View style={styles.centered}>
<ActivityIndicator size="large" color="#2563eb" />
<Text style={styles.loadingText}>Cargando permisos...</Text>
</View>
</SafeAreaView>
);
}
if (!permission.granted || !audioPermission.granted) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.centered}>
<Text style={styles.permissionText}>
Necesitamos acceso a la camara y microfono para escanear tu inventario
</Text>
<TouchableOpacity
style={styles.permissionButton}
onPress={async () => {
await requestPermission();
await requestAudioPermission();
}}
>
<Text style={styles.permissionButtonText}>Dar Permisos</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
if (!currentStore) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.centered}>
<Text style={styles.permissionText}>
Primero debes crear o seleccionar una tienda
</Text>
<TouchableOpacity
style={styles.permissionButton}
onPress={() => router.push('/stores/new')}
>
<Text style={styles.permissionButtonText}>Crear Tienda</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
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<void>((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 (
<View style={styles.container}>
<Camera
ref={cameraRef}
style={styles.camera}
type={CameraType.back}
>
<SafeAreaView style={styles.overlay}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.storeName}>{currentStore.name}</Text>
<Text style={styles.headerText}>
{isRecording ? `Grabando ${formatDuration(recordingDuration)}` :
isProcessing ? 'Procesando...' :
status === 'completed' ? 'Completado' :
status === 'failed' ? 'Error' :
'Escanear Anaquel'}
</Text>
<Text style={styles.headerSubtext}>
{isRecording ? 'Toca para detener' :
isProcessing ? `${progress}% completado` :
'Mueve la camara lentamente por el anaquel'}
</Text>
</View>
{/* Progress bar for processing */}
{isProcessing && (
<View style={styles.progressContainer}>
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${progress}%` }]} />
</View>
<Text style={styles.progressText}>
{status === 'uploading' ? 'Subiendo video...' : 'Detectando productos...'}
</Text>
</View>
)}
{/* Error message */}
{status === 'failed' && errorMessage && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{errorMessage}</Text>
<TouchableOpacity style={styles.retryButton} onPress={resetState}>
<Text style={styles.retryButtonText}>Intentar de nuevo</Text>
</TouchableOpacity>
</View>
)}
{/* Controls */}
<View style={styles.controls}>
{status === 'idle' && (
<>
<TouchableOpacity
style={styles.recordButton}
onPress={startRecording}
>
<View style={styles.recordInner} />
</TouchableOpacity>
<Text style={styles.recordText}>Iniciar Grabacion</Text>
</>
)}
{isRecording && (
<>
<TouchableOpacity
style={[styles.recordButton, styles.recordButtonActive]}
onPress={stopRecording}
>
<View style={[styles.recordInner, styles.recordInnerActive]} />
</TouchableOpacity>
<Text style={styles.recordText}>Detener (max 30s)</Text>
</>
)}
{isProcessing && (
<ActivityIndicator size="large" color="#fff" />
)}
</View>
</SafeAreaView>
</Camera>
</View>
);
}
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,
},
});

50
src/app/_layout.tsx Normal file
View File

@ -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 (
<GestureHandlerRootView style={styles.container}>
<ThemeProvider>
<SafeAreaProvider>
<QueryClientProvider client={queryClient}>
<View style={styles.container}>
<OfflineBanner />
<StatusBar style="auto" />
<Stack
screenOptions={{
headerShown: false,
animation: 'slide_from_right',
animationDuration: 250,
}}
>
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
</View>
</QueryClientProvider>
</SafeAreaProvider>
</ThemeProvider>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});

View File

@ -0,0 +1,23 @@
import { Stack } from 'expo-router';
export default function CreditsLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: '#fff' },
headerTintColor: '#1a1a1a',
headerTitleStyle: { fontWeight: '600' },
headerShadowVisible: false,
}}
>
<Stack.Screen
name="buy"
options={{ title: 'Comprar Creditos' }}
/>
<Stack.Screen
name="history"
options={{ title: 'Historial' }}
/>
</Stack>
);
}

434
src/app/credits/buy.tsx Normal file
View File

@ -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<CreditPackage | null>(null);
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod>('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;
}) => (
<TouchableOpacity
style={[
styles.methodButton,
selectedMethod === method && styles.methodButtonSelected,
]}
onPress={() => setSelectedMethod(method)}
>
<Text style={styles.methodIcon}>{icon}</Text>
<View style={styles.methodInfo}>
<Text
style={[
styles.methodLabel,
selectedMethod === method && styles.methodLabelSelected,
]}
>
{label}
</Text>
<Text style={styles.methodDescription}>{description}</Text>
</View>
<View
style={[
styles.methodRadio,
selectedMethod === method && styles.methodRadioSelected,
]}
>
{selectedMethod === method && <View style={styles.methodRadioInner} />}
</View>
</TouchableOpacity>
);
if (isLoading && packages.length === 0) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#2563eb" />
<Text style={styles.loadingText}>Cargando paquetes...</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
{/* Packages */}
<Text style={styles.sectionTitle}>Selecciona un paquete</Text>
<View style={styles.packagesGrid}>
{packages.map((pkg) => (
<TouchableOpacity
key={pkg.id}
style={[
styles.packageCard,
selectedPackage?.id === pkg.id && styles.packageCardSelected,
pkg.popular && styles.packageCardPopular,
]}
onPress={() => setSelectedPackage(pkg)}
>
{pkg.popular && (
<View style={styles.popularBadge}>
<Text style={styles.popularBadgeText}>Popular</Text>
</View>
)}
<Text style={styles.packageCredits}>{pkg.credits}</Text>
<Text style={styles.packageCreditsLabel}>creditos</Text>
<Text style={styles.packagePrice}>{formatPrice(pkg.priceMXN)}</Text>
<Text style={styles.packagePerCredit}>
${(pkg.priceMXN / pkg.credits).toFixed(2)}/credito
</Text>
</TouchableOpacity>
))}
</View>
{/* Payment Methods */}
<Text style={styles.sectionTitle}>Metodo de pago</Text>
<View style={styles.methodsContainer}>
<PaymentMethodButton
method="oxxo"
label="OXXO"
icon="🏪"
description="Paga en efectivo en cualquier OXXO"
/>
<PaymentMethodButton
method="7eleven"
label="7-Eleven"
icon="🏬"
description="Paga en efectivo en cualquier 7-Eleven"
/>
<PaymentMethodButton
method="card"
label="Tarjeta"
icon="💳"
description="Debito o credito (Visa, Mastercard)"
/>
</View>
{/* Info */}
<View style={styles.infoCard}>
<Text style={styles.infoIcon}></Text>
<Text style={styles.infoText}>
{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.'}
</Text>
</View>
{/* Error */}
{error && (
<View style={styles.errorCard}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</ScrollView>
{/* Footer */}
<View style={styles.footer}>
{selectedPackage && (
<View style={styles.footerSummary}>
<Text style={styles.footerLabel}>Total a pagar:</Text>
<Text style={styles.footerPrice}>
{formatPrice(selectedPackage.priceMXN)}
</Text>
</View>
)}
<TouchableOpacity
style={[
styles.purchaseButton,
(!selectedPackage || isProcessing) && styles.purchaseButtonDisabled,
]}
onPress={handlePurchase}
disabled={!selectedPackage || isProcessing}
>
{isProcessing ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.purchaseButtonText}>
{selectedMethod === 'card' ? 'Pagar Ahora' : 'Generar Ficha de Pago'}
</Text>
)}
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
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',
},
});

221
src/app/credits/history.tsx Normal file
View File

@ -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 }) => (
<View style={styles.transactionCard}>
<View style={styles.transactionIcon}>
<Text style={styles.transactionIconText}>
{getTransactionIcon(item.type)}
</Text>
</View>
<View style={styles.transactionInfo}>
<Text style={styles.transactionDescription}>{item.description}</Text>
<Text style={styles.transactionDate}>{formatDate(item.createdAt)}</Text>
</View>
<Text
style={[
styles.transactionAmount,
{ color: getTransactionColor(item.type, item.amount) },
]}
>
{item.amount > 0 ? '+' : ''}{item.amount}
</Text>
</View>
);
const EmptyState = () => (
<View style={styles.emptyState}>
<Text style={styles.emptyIcon}>📋</Text>
<Text style={styles.emptyTitle}>Sin transacciones</Text>
<Text style={styles.emptyDescription}>
Aqui veras tu historial de creditos comprados y utilizados
</Text>
</View>
);
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
{isLoading && transactions.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#2563eb" />
<Text style={styles.loadingText}>Cargando historial...</Text>
</View>
) : transactions.length === 0 ? (
<EmptyState />
) : (
<FlatList
data={transactions}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={
isLoading ? (
<ActivityIndicator style={styles.footerLoader} color="#2563eb" />
) : null
}
ItemSeparatorComponent={() => <View style={styles.separator} />}
/>
)}
</SafeAreaView>
);
}
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,
},
});

292
src/app/help/index.tsx Normal file
View File

@ -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<string | null>(null);
const filteredFaqs =
selectedCategory === 'todos'
? faqs
: faqs.filter((faq) => faq.category === selectedCategory);
const toggleExpand = (id: string) => {
setExpandedId(expandedId === id ? null : id);
};
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<Stack.Screen
options={{
title: 'Centro de Ayuda',
headerShown: true,
}}
/>
<ScrollView style={styles.scroll}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Como podemos ayudarte?</Text>
<Text style={styles.headerSubtitle}>
Encuentra respuestas a las preguntas mas frecuentes
</Text>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.categoriesContainer}
contentContainerStyle={styles.categoriesContent}
>
{categories.map((category) => (
<TouchableOpacity
key={category.id}
style={[
styles.categoryChip,
selectedCategory === category.id && styles.categoryChipActive,
]}
onPress={() => setSelectedCategory(category.id)}
>
<Text
style={[
styles.categoryChipText,
selectedCategory === category.id && styles.categoryChipTextActive,
]}
>
{category.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
<View style={styles.faqsList}>
{filteredFaqs.map((faq) => (
<TouchableOpacity
key={faq.id}
style={styles.faqItem}
onPress={() => toggleExpand(faq.id)}
activeOpacity={0.7}
>
<View style={styles.faqHeader}>
<Text style={styles.faqQuestion}>{faq.question}</Text>
<Text style={styles.faqArrow}>
{expandedId === faq.id ? '' : '+'}
</Text>
</View>
{expandedId === faq.id && (
<Text style={styles.faqAnswer}>{faq.answer}</Text>
)}
</TouchableOpacity>
))}
</View>
<View style={styles.supportSection}>
<Text style={styles.supportTitle}>No encontraste lo que buscabas?</Text>
<TouchableOpacity
style={styles.supportButton}
onPress={() => router.push('/support')}
>
<Text style={styles.supportButtonText}>Contactar Soporte</Text>
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
);
}
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',
},
});

12
src/app/index.tsx Normal file
View File

@ -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 <Redirect href="/(tabs)" />;
}
return <Redirect href="/(auth)/login" />;
}

603
src/app/inventory/[id].tsx Normal file
View File

@ -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<InventoryItem | null>(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 (
<SafeAreaView style={styles.container}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#2563eb" />
</View>
</SafeAreaView>
);
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-MX', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<>
<Stack.Screen
options={{
title: isEditing ? 'Editar Producto' : 'Detalle del Producto',
headerRight: () =>
!isEditing ? (
<TouchableOpacity
style={styles.editHeaderButton}
onPress={() => setIsEditing(true)}
>
<Text style={styles.editHeaderButtonText}>Editar</Text>
</TouchableOpacity>
) : null,
}}
/>
<SafeAreaView style={styles.container} edges={['bottom']}>
<KeyboardAvoidingView
style={styles.keyboardAvoid}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
{/* Quantity Card */}
<View style={styles.quantityCard}>
<Text style={styles.quantityLabel}>Cantidad en inventario</Text>
{isEditing ? (
<View style={styles.quantityEditor}>
<TouchableOpacity
style={styles.quantityButton}
onPress={() => adjustQuantity(-1)}
>
<Text style={styles.quantityButtonText}>-</Text>
</TouchableOpacity>
<TextInput
style={styles.quantityInput}
value={quantity}
onChangeText={setQuantity}
keyboardType="number-pad"
textAlign="center"
/>
<TouchableOpacity
style={styles.quantityButton}
onPress={() => adjustQuantity(1)}
>
<Text style={styles.quantityButtonText}>+</Text>
</TouchableOpacity>
</View>
) : (
<Text
style={[
styles.quantityValue,
item.quantity < 5 && styles.quantityValueLow,
]}
>
{item.quantity}
</Text>
)}
{item.quantity < 5 && !isEditing && (
<View style={styles.lowStockBadge}>
<Text style={styles.lowStockBadgeText}>Stock bajo</Text>
</View>
)}
</View>
{/* Details */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Informacion del producto</Text>
<View style={styles.field}>
<Text style={styles.fieldLabel}>Nombre</Text>
{isEditing ? (
<TextInput
style={styles.fieldInput}
value={name}
onChangeText={setName}
placeholder="Nombre del producto"
/>
) : (
<Text style={styles.fieldValue}>{item.name}</Text>
)}
</View>
<View style={styles.field}>
<Text style={styles.fieldLabel}>Categoria</Text>
{isEditing ? (
<TextInput
style={styles.fieldInput}
value={category}
onChangeText={setCategory}
placeholder="Ej: Abarrotes, Bebidas..."
/>
) : (
<Text style={styles.fieldValue}>
{item.category || 'Sin categoria'}
</Text>
)}
</View>
<View style={styles.field}>
<Text style={styles.fieldLabel}>Codigo de barras</Text>
{isEditing ? (
<TextInput
style={styles.fieldInput}
value={barcode}
onChangeText={setBarcode}
placeholder="Codigo de barras"
keyboardType="number-pad"
/>
) : (
<Text style={styles.fieldValue}>
{item.barcode || 'Sin codigo'}
</Text>
)}
</View>
<View style={styles.field}>
<Text style={styles.fieldLabel}>Precio</Text>
{isEditing ? (
<TextInput
style={styles.fieldInput}
value={price}
onChangeText={setPrice}
placeholder="0.00"
keyboardType="decimal-pad"
/>
) : (
<Text style={styles.fieldValue}>
{item.price ? `$${item.price.toFixed(2)}` : 'Sin precio'}
</Text>
)}
</View>
</View>
{/* Detection Info */}
{item.detectionConfidence && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Deteccion automatica</Text>
<View style={styles.detectionCard}>
<View style={styles.detectionRow}>
<Text style={styles.detectionLabel}>Confianza</Text>
<Text style={styles.detectionValue}>
{(item.detectionConfidence * 100).toFixed(0)}%
</Text>
</View>
<View style={styles.confidenceBar}>
<View
style={[
styles.confidenceBarFill,
{ width: `${item.detectionConfidence * 100}%` },
]}
/>
</View>
{item.isManuallyEdited && (
<View style={styles.editedBadge}>
<Text style={styles.editedBadgeText}>
Editado manualmente
</Text>
</View>
)}
</View>
</View>
)}
{/* Metadata */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Historial</Text>
<View style={styles.metaCard}>
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>Creado</Text>
<Text style={styles.metaValue}>
{formatDate(item.createdAt)}
</Text>
</View>
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>Actualizado</Text>
<Text style={styles.metaValue}>
{formatDate(item.updatedAt)}
</Text>
</View>
{item.lastDetectedAt && (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>Ultima deteccion</Text>
<Text style={styles.metaValue}>
{formatDate(item.lastDetectedAt)}
</Text>
</View>
)}
</View>
</View>
{/* Error */}
{error && (
<View style={styles.errorCard}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
{/* Delete Button */}
{isEditing && (
<TouchableOpacity
style={styles.deleteButton}
onPress={handleDelete}
>
<Text style={styles.deleteButtonText}>Eliminar Producto</Text>
</TouchableOpacity>
)}
</ScrollView>
{/* Footer */}
{isEditing && (
<View style={styles.footer}>
<TouchableOpacity
style={styles.cancelButton}
onPress={() => {
setName(item.name);
setQuantity(item.quantity.toString());
setCategory(item.category || '');
setBarcode(item.barcode || '');
setPrice(item.price?.toString() || '');
setIsEditing(false);
}}
>
<Text style={styles.cancelButtonText}>Cancelar</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.saveButton,
(!name.trim() || isLoading) && styles.saveButtonDisabled,
]}
onPress={handleSave}
disabled={!name.trim() || isLoading}
>
{isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.saveButtonText}>Guardar</Text>
)}
</TouchableOpacity>
</View>
)}
</KeyboardAvoidingView>
</SafeAreaView>
</>
);
}
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',
},
});

View File

@ -0,0 +1,23 @@
import { Stack } from 'expo-router';
export default function InventoryLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: '#fff' },
headerTintColor: '#1a1a1a',
headerTitleStyle: { fontWeight: '600' },
headerShadowVisible: false,
}}
>
<Stack.Screen
name="[id]"
options={{ title: 'Detalle del Producto' }}
/>
<Stack.Screen
name="export"
options={{ title: 'Exportar Inventario' }}
/>
</Stack>
);
}

View File

@ -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<ExportFormat>('CSV');
const [lowStockOnly, setLowStockOnly] = useState(false);
const [step, setStep] = useState<ExportStep>('select');
const [progress, setProgress] = useState<ExportStatusResponse | null>(null);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [filename, setFilename] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string | null>(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) => (
<TouchableOpacity
key={value}
style={[styles.optionCard, format === value && styles.optionCardSelected]}
onPress={() => setFormat(value)}
>
<View style={styles.optionHeader}>
<View style={[styles.radio, format === value && styles.radioSelected]}>
{format === value && <View style={styles.radioInner} />}
</View>
<Text style={styles.optionLabel}>{label}</Text>
</View>
<Text style={styles.optionDescription}>{description}</Text>
</TouchableOpacity>
);
if (step === 'processing') {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<View style={styles.centerContent}>
<ActivityIndicator size="large" color="#2563eb" />
<Text style={styles.processingTitle}>Generando exportacion...</Text>
{progress && (
<Text style={styles.processingStatus}>
Estado: {progress.status}
{progress.totalRows !== undefined && ` (${progress.totalRows} productos)`}
</Text>
)}
</View>
</SafeAreaView>
);
}
if (step === 'complete') {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<View style={styles.centerContent}>
<View style={styles.successIcon}>
<Text style={styles.successIconText}></Text>
</View>
<Text style={styles.successTitle}>Exportacion lista</Text>
<Text style={styles.successFilename}>{filename}</Text>
<View style={styles.actionButtons}>
<TouchableOpacity style={styles.primaryButton} onPress={handleDownload}>
<Text style={styles.primaryButtonText}>Descargar</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.secondaryButton} onPress={handleShare}>
<Text style={styles.secondaryButtonText}>Compartir</Text>
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.linkButton} onPress={handleReset}>
<Text style={styles.linkButtonText}>Nueva exportacion</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
if (step === 'error') {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<View style={styles.centerContent}>
<View style={styles.errorIcon}>
<Text style={styles.errorIconText}>!</Text>
</View>
<Text style={styles.errorTitle}>Error al exportar</Text>
<Text style={styles.errorMessage}>{errorMessage}</Text>
<TouchableOpacity style={styles.primaryButton} onPress={handleReset}>
<Text style={styles.primaryButtonText}>Intentar de nuevo</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
<Text style={styles.sectionTitle}>Formato de exportacion</Text>
{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.',
)}
<Text style={[styles.sectionTitle, styles.sectionTitleMargin]}>Filtros</Text>
<TouchableOpacity
style={styles.checkboxRow}
onPress={() => setLowStockOnly(!lowStockOnly)}
>
<View style={[styles.checkbox, lowStockOnly && styles.checkboxChecked]}>
{lowStockOnly && <Text style={styles.checkboxCheck}></Text>}
</View>
<View style={styles.checkboxContent}>
<Text style={styles.checkboxLabel}>Solo productos con stock bajo</Text>
<Text style={styles.checkboxDescription}>
Incluir unicamente productos que necesitan reabastecimiento
</Text>
</View>
</TouchableOpacity>
<View style={styles.infoCard}>
<Text style={styles.infoTitle}>Que incluye el archivo?</Text>
<Text style={styles.infoText}>
Nombre del producto{'\n'}
Cantidad en inventario{'\n'}
Categoria{'\n'}
Codigo de barras{'\n'}
Precio y costo{'\n'}
Fecha de ultima actualizacion
</Text>
</View>
</ScrollView>
<View style={styles.footer}>
<TouchableOpacity
style={[styles.exportButton, !currentStore && styles.exportButtonDisabled]}
onPress={handleExport}
disabled={!currentStore}
>
<Text style={styles.exportButtonText}>Exportar Inventario</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
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,
},
});

197
src/app/legal/privacy.tsx Normal file
View File

@ -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 (
<SafeAreaView style={styles.container} edges={['bottom']}>
<Stack.Screen
options={{
title: 'Politica de Privacidad',
headerShown: true,
}}
/>
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
<Text style={styles.lastUpdated}>Ultima actualizacion: Enero 2025</Text>
<View style={styles.section}>
<Text style={styles.sectionTitle}>1. Informacion que Recopilamos</Text>
<Text style={styles.paragraph}>
Recopilamos la siguiente informacion:{'\n\n'}
<Text style={styles.bold}>Informacion de cuenta:</Text>{'\n'}
- Numero de telefono{'\n'}
- Nombre{'\n'}
- Correo electronico (opcional){'\n\n'}
<Text style={styles.bold}>Informacion de negocio:</Text>{'\n'}
- Nombre de la tienda{'\n'}
- Ubicacion{'\n'}
- Giro del negocio{'\n\n'}
<Text style={styles.bold}>Videos e imagenes:</Text>{'\n'}
- Videos grabados para escaneo de inventario{'\n'}
- Imagenes extraidas para procesamiento de IA
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>2. Como Usamos tu Informacion</Text>
<Text style={styles.paragraph}>
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
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>3. Almacenamiento de Videos</Text>
<Text style={styles.paragraph}>
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.
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>4. Compartir Informacion</Text>
<Text style={styles.paragraph}>
No vendemos tu informacion personal. Compartimos datos solo con:{'\n\n'}
- <Text style={styles.bold}>Stripe:</Text> Para procesar pagos de forma segura{'\n'}
- <Text style={styles.bold}>Proveedores de IA:</Text> OpenAI/Anthropic para detectar productos{'\n'}
- <Text style={styles.bold}>Firebase:</Text> Para enviar notificaciones push{'\n'}
- <Text style={styles.bold}>AWS/MinIO:</Text> Para almacenar videos temporalmente
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>5. Seguridad</Text>
<Text style={styles.paragraph}>
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
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>6. Tus Derechos</Text>
<Text style={styles.paragraph}>
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
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>7. Retencion de Datos</Text>
<Text style={styles.paragraph}>
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.
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>8. Cookies y Tecnologias</Text>
<Text style={styles.paragraph}>
Usamos tecnologias como almacenamiento local para mantener tu sesion activa y
recordar tus preferencias. No usamos cookies de terceros para publicidad.
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>9. Menores de Edad</Text>
<Text style={styles.paragraph}>
MiInventario no esta dirigido a menores de 18 anos. No recopilamos intencionalmente
informacion de menores de edad.
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>10. Cambios a esta Politica</Text>
<Text style={styles.paragraph}>
Podemos actualizar esta politica periodicamente. Te notificaremos sobre cambios
significativos a traves de la aplicacion o por correo electronico.
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>11. Contacto</Text>
<Text style={styles.paragraph}>
Para preguntas sobre privacidad, contactanos en:{'\n'}
Email: privacidad@miinventario.com{'\n'}
Telefono: +52 55 1234 5678
</Text>
</View>
<View style={styles.footer}>
<Text style={styles.footerText}>
Al usar MiInventario, aceptas esta Politica de Privacidad y el procesamiento de
tu informacion como se describe aqui.
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}
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',
},
});

164
src/app/legal/terms.tsx Normal file
View File

@ -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 (
<SafeAreaView style={styles.container} edges={['bottom']}>
<Stack.Screen
options={{
title: 'Terminos y Condiciones',
headerShown: true,
}}
/>
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
<Text style={styles.lastUpdated}>Ultima actualizacion: Enero 2025</Text>
<View style={styles.section}>
<Text style={styles.sectionTitle}>1. Aceptacion de Terminos</Text>
<Text style={styles.paragraph}>
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.
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>2. Descripcion del Servicio</Text>
<Text style={styles.paragraph}>
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.
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>3. Registro y Cuenta</Text>
<Text style={styles.paragraph}>
Para usar MiInventario debes crear una cuenta proporcionando informacion veraz y actualizada.
Eres responsable de mantener la confidencialidad de tu cuenta y contrasena.
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>4. Sistema de Creditos</Text>
<Text style={styles.paragraph}>
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
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>5. Pagos</Text>
<Text style={styles.paragraph}>
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.
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>6. Uso Aceptable</Text>
<Text style={styles.paragraph}>
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
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>7. Propiedad Intelectual</Text>
<Text style={styles.paragraph}>
MiInventario y todo su contenido, caracteristicas y funcionalidad son propiedad de
MiInventario y estan protegidos por leyes de propiedad intelectual.
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>8. Limitacion de Responsabilidad</Text>
<Text style={styles.paragraph}>
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.
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>9. Modificaciones</Text>
<Text style={styles.paragraph}>
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.
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>10. Contacto</Text>
<Text style={styles.paragraph}>
Para preguntas sobre estos terminos, contactanos en:{'\n'}
Email: legal@miinventario.com{'\n'}
Telefono: +52 55 1234 5678
</Text>
</View>
<View style={styles.footer}>
<Text style={styles.footerText}>
Al usar MiInventario, confirmas que has leido y aceptado estos Terminos y Condiciones.
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}
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',
},
});

View File

@ -0,0 +1,19 @@
import { Stack } from 'expo-router';
export default function NotificationsLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: '#fff' },
headerTintColor: '#1a1a1a',
headerTitleStyle: { fontWeight: '600' },
headerShadowVisible: false,
}}
>
<Stack.Screen
name="index"
options={{ title: 'Notificaciones' }}
/>
</Stack>
);
}

View File

@ -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<string, string> | 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 }) => (
<TouchableOpacity
style={[
styles.notificationCard,
!item.isRead && styles.notificationCardUnread,
]}
onPress={() => handleNotificationPress(item)}
>
<View style={styles.notificationIcon}>
<Text style={styles.notificationIconText}>
{getNotificationIcon(item.type)}
</Text>
</View>
<View style={styles.notificationContent}>
<View style={styles.notificationHeader}>
<Text
style={[
styles.notificationTitle,
!item.isRead && styles.notificationTitleUnread,
]}
numberOfLines={1}
>
{item.title}
</Text>
{!item.isRead && <View style={styles.unreadDot} />}
</View>
<Text style={styles.notificationBody} numberOfLines={2}>
{item.body}
</Text>
<Text style={styles.notificationTime}>{formatDate(item.createdAt)}</Text>
</View>
</TouchableOpacity>
);
const EmptyState = () => (
<View style={styles.emptyState}>
<Text style={styles.emptyIcon}>🔔</Text>
<Text style={styles.emptyTitle}>Sin notificaciones</Text>
<Text style={styles.emptyDescription}>
Aqui veras las notificaciones sobre tus escaneos, pagos y referidos
</Text>
</View>
);
return (
<>
<Stack.Screen
options={{
headerRight: () =>
unreadCount > 0 ? (
<TouchableOpacity
style={styles.markAllButton}
onPress={markAllAsRead}
>
<Text style={styles.markAllButtonText}>Marcar leidas</Text>
</TouchableOpacity>
) : null,
}}
/>
<SafeAreaView style={styles.container} edges={['bottom']}>
{isLoading && notifications.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#2563eb" />
<Text style={styles.loadingText}>Cargando notificaciones...</Text>
</View>
) : notifications.length === 0 ? (
<EmptyState />
) : (
<FlatList
data={notifications}
renderItem={renderNotification}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={
isLoading ? (
<ActivityIndicator style={styles.footerLoader} color="#2563eb" />
) : null
}
ItemSeparatorComponent={() => <View style={styles.separator} />}
/>
)}
</SafeAreaView>
</>
);
}
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,
},
});

View File

@ -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 (
<SafeAreaView style={styles.container} edges={['bottom']}>
<Stack.Screen
options={{
title: 'Metodos de Pago',
headerShown: true,
}}
/>
<ScrollView style={styles.scroll}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Metodos Disponibles</Text>
<Text style={styles.sectionDescription}>
Selecciona tu metodo de pago preferido al comprar creditos
</Text>
</View>
<View style={styles.methodsList}>
{paymentMethods.map((method) => (
<View
key={method.id}
style={[
styles.methodCard,
!method.available && styles.methodCardDisabled,
]}
>
<View style={styles.methodIcon}>
<Text style={styles.methodIconText}>{method.icon}</Text>
</View>
<View style={styles.methodInfo}>
<Text
style={[
styles.methodName,
!method.available && styles.methodNameDisabled,
]}
>
{method.name}
</Text>
<Text style={styles.methodDescription}>{method.description}</Text>
</View>
{method.available ? (
<View style={styles.checkmark}>
<Text style={styles.checkmarkText}></Text>
</View>
) : (
<View style={styles.comingSoon}>
<Text style={styles.comingSoonText}>Pronto</Text>
</View>
)}
</View>
))}
</View>
<View style={styles.infoSection}>
<Text style={styles.infoTitle}>Sobre los pagos</Text>
<View style={styles.infoItem}>
<Text style={styles.infoIcon}>🔒</Text>
<View style={styles.infoContent}>
<Text style={styles.infoLabel}>Pagos Seguros</Text>
<Text style={styles.infoText}>
Todos los pagos son procesados de forma segura a traves de Stripe
</Text>
</View>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoIcon}></Text>
<View style={styles.infoContent}>
<Text style={styles.infoLabel}>Creditos Instantaneos</Text>
<Text style={styles.infoText}>
Los creditos se acreditan inmediatamente al pagar con tarjeta
</Text>
</View>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoIcon}>🏪</Text>
<View style={styles.infoContent}>
<Text style={styles.infoLabel}>Pago en Efectivo</Text>
<Text style={styles.infoText}>
Recibe un voucher para pagar en OXXO. Los creditos se acreditan en 24-48 horas
</Text>
</View>
</View>
</View>
<TouchableOpacity
style={styles.buyButton}
onPress={() => router.push('/credits/buy')}
>
<Text style={styles.buyButtonText}>Comprar Creditos</Text>
</TouchableOpacity>
</ScrollView>
</SafeAreaView>
);
}
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',
},
});

316
src/app/profile/edit.tsx Normal file
View File

@ -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<string | null>(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 (
<SafeAreaView style={styles.container}>
<Stack.Screen
options={{
title: 'Editar Perfil',
headerShown: true,
}}
/>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#2563eb" />
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<Stack.Screen
options={{
title: 'Editar Perfil',
headerShown: true,
}}
/>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
<View style={styles.avatarContainer}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>
{name?.charAt(0).toUpperCase() || 'U'}
</Text>
</View>
</View>
<View style={styles.form}>
<View style={styles.inputGroup}>
<Text style={styles.label}>Nombre</Text>
<TextInput
style={styles.input}
value={name}
onChangeText={setName}
placeholder="Tu nombre"
autoCapitalize="words"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Telefono</Text>
<View style={styles.disabledInput}>
<Text style={styles.disabledInputText}>{user?.phone}</Text>
</View>
<Text style={styles.helperText}>
El numero de telefono no puede ser modificado
</Text>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Email (opcional)</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
placeholder="tu@email.com"
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</View>
</ScrollView>
<View style={styles.footer}>
<TouchableOpacity
style={styles.cancelButton}
onPress={() => router.back()}
disabled={isLoading}
>
<Text style={styles.cancelButtonText}>Cancelar</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.saveButton, isLoading && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.saveButtonText}>Guardar</Text>
)}
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
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',
},
});

View File

@ -0,0 +1,19 @@
import { Stack } from 'expo-router';
export default function ReferralsLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: '#fff' },
headerTintColor: '#1a1a1a',
headerTitleStyle: { fontWeight: '600' },
headerShadowVisible: false,
}}
>
<Stack.Screen
name="index"
options={{ title: 'Mis Referidos' }}
/>
</Stack>
);
}

460
src/app/referrals/index.tsx Normal file
View File

@ -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 }) => (
<View style={styles.referralCard}>
<View style={styles.referralAvatar}>
<Text style={styles.referralAvatarText}>
{item.referred?.name?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
<View style={styles.referralInfo}>
<Text style={styles.referralName}>
{item.referred?.name || 'Usuario'}
</Text>
<Text style={styles.referralDate}>
Registrado: {formatDate(item.registeredAt || item.createdAt)}
</Text>
</View>
<View style={styles.referralStatus}>
<View
style={[
styles.statusBadge,
{ backgroundColor: getStatusColor(item.status) + '20' },
]}
>
<Text
style={[
styles.statusBadgeText,
{ color: getStatusColor(item.status) },
]}
>
{getStatusLabel(item.status)}
</Text>
</View>
{item.status === 'REWARDED' && (
<Text style={styles.bonusText}>+{item.referrerBonusCredits}</Text>
)}
</View>
</View>
);
const ListHeader = () => (
<View style={styles.header}>
{/* Share Card */}
<View style={styles.shareCard}>
<Text style={styles.shareTitle}>Tu codigo de referido</Text>
<View style={styles.codeContainer}>
<Text style={styles.codeText}>{stats?.referralCode || '---'}</Text>
</View>
<View style={styles.shareActions}>
<TouchableOpacity style={styles.shareButton} onPress={copyCode}>
<Text style={styles.shareButtonIcon}>{copied ? '✓' : '📋'}</Text>
<Text style={styles.shareButtonText}>
{copied ? 'Copiado!' : 'Copiar'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.shareButton, styles.shareButtonPrimary]}
onPress={shareCode}
>
<Text style={styles.shareButtonIcon}>📤</Text>
<Text style={[styles.shareButtonText, styles.shareButtonTextPrimary]}>
Compartir
</Text>
</TouchableOpacity>
</View>
</View>
{/* Stats */}
<View style={styles.statsContainer}>
<View style={styles.statCard}>
<Text style={styles.statValue}>{stats?.totalReferrals ?? 0}</Text>
<Text style={styles.statLabel}>Invitados</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statValue}>{stats?.completedReferrals ?? 0}</Text>
<Text style={styles.statLabel}>Completados</Text>
</View>
<View style={styles.statCard}>
<Text style={[styles.statValue, styles.statValueHighlight]}>
{stats?.totalCreditsEarned ?? 0}
</Text>
<Text style={styles.statLabel}>Creditos</Text>
</View>
</View>
{/* How it works */}
<View style={styles.howItWorks}>
<Text style={styles.howItWorksTitle}>Como funciona</Text>
<View style={styles.step}>
<View style={styles.stepNumber}>
<Text style={styles.stepNumberText}>1</Text>
</View>
<Text style={styles.stepText}>Comparte tu codigo con amigos</Text>
</View>
<View style={styles.step}>
<View style={styles.stepNumber}>
<Text style={styles.stepNumberText}>2</Text>
</View>
<Text style={styles.stepText}>Tu amigo se registra con el codigo</Text>
</View>
<View style={styles.step}>
<View style={styles.stepNumber}>
<Text style={styles.stepNumberText}>3</Text>
</View>
<Text style={styles.stepText}>
Ambos reciben 5 creditos cuando tu amigo hace su primer escaneo
</Text>
</View>
</View>
{referrals.length > 0 && (
<Text style={styles.listTitle}>Tus referidos</Text>
)}
</View>
);
const EmptyReferrals = () => (
<View style={styles.emptyReferrals}>
<Text style={styles.emptyIcon}>👥</Text>
<Text style={styles.emptyTitle}>Sin referidos aun</Text>
<Text style={styles.emptyDescription}>
Comparte tu codigo y empieza a ganar creditos
</Text>
</View>
);
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<FlatList
data={referrals}
renderItem={renderReferral}
keyExtractor={(item) => item.id}
ListHeaderComponent={ListHeader}
ListEmptyComponent={
isLoading ? null : <EmptyReferrals />
}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={
isLoading && referrals.length > 0 ? (
<ActivityIndicator style={styles.footerLoader} color="#2563eb" />
) : null
}
ItemSeparatorComponent={() => <View style={styles.separator} />}
/>
</SafeAreaView>
);
}
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,
},
});

View File

@ -0,0 +1,31 @@
import { Stack } from 'expo-router';
export default function ReportsLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: '#fff' },
headerTintColor: '#1a1a1a',
headerTitleStyle: { fontWeight: '600' },
headerShadowVisible: false,
}}
>
<Stack.Screen
name="index"
options={{ title: 'Reportes' }}
/>
<Stack.Screen
name="valuation"
options={{ title: 'Valorizacion' }}
/>
<Stack.Screen
name="movements"
options={{ title: 'Movimientos' }}
/>
<Stack.Screen
name="categories"
options={{ title: 'Categorias' }}
/>
</Stack>
);
}

View File

@ -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<CategoriesReport | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedCategory, setExpandedCategory] = useState<string | null>(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 (
<View style={styles.barContainer}>
{categories.map((cat, index) => (
<View
key={cat.name}
style={[
styles.barSegment,
{
flex: cat.percentOfTotal,
backgroundColor: CATEGORY_COLORS[index % CATEGORY_COLORS.length],
},
]}
/>
))}
</View>
);
};
const renderCategoryCard = (category: CategoryDetail, index: number) => {
const isExpanded = expandedCategory === category.name;
const color = CATEGORY_COLORS[index % CATEGORY_COLORS.length];
return (
<TouchableOpacity
key={category.name}
style={styles.categoryCard}
onPress={() => toggleCategory(category.name)}
activeOpacity={0.7}
>
<View style={styles.categoryHeader}>
<View style={styles.categoryLeft}>
<View style={[styles.categoryDot, { backgroundColor: color }]} />
<View style={styles.categoryInfo}>
<Text style={styles.categoryName}>{category.name || 'Sin categoria'}</Text>
<Text style={styles.categoryCount}>{category.itemCount} productos</Text>
</View>
</View>
<View style={styles.categoryRight}>
<Text style={styles.categoryPercent}>{formatPercent(category.percentOfTotal)}</Text>
<Text style={styles.expandIcon}>{isExpanded ? '▲' : '▼'}</Text>
</View>
</View>
{isExpanded && (
<View style={styles.categoryExpanded}>
<View style={styles.statRow}>
<View style={styles.stat}>
<Text style={styles.statLabel}>Valor total</Text>
<Text style={styles.statValue}>{formatCurrency(category.totalValue)}</Text>
</View>
<View style={styles.stat}>
<Text style={styles.statLabel}>Precio promedio</Text>
<Text style={styles.statValue}>{formatCurrency(category.averagePrice)}</Text>
</View>
</View>
{category.lowStockCount > 0 && (
<View style={styles.alertRow}>
<View style={styles.alertBadge}>
<Text style={styles.alertBadgeText}>
{category.lowStockCount} productos con stock bajo
</Text>
</View>
</View>
)}
{category.topItems.length > 0 && (
<View style={styles.topItems}>
<Text style={styles.topItemsTitle}>Productos principales:</Text>
{category.topItems.map((item, i) => (
<View key={i} style={styles.topItem}>
<Text style={styles.topItemName} numberOfLines={1}>{item.name}</Text>
<Text style={styles.topItemQuantity}>x{item.quantity}</Text>
</View>
))}
</View>
)}
</View>
)}
</TouchableOpacity>
);
};
if (isLoading) {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#2563eb" />
</View>
</SafeAreaView>
);
}
if (error) {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={() => fetchReport()}>
<Text style={styles.retryButtonText}>Reintentar</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
if (!report) {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No hay datos disponibles</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={() => fetchReport(true)} />
}
>
{/* Summary Card */}
<View style={styles.summaryCard}>
<View style={styles.summaryStats}>
<View style={styles.summaryStat}>
<Text style={styles.summaryStatValue}>{report.summary.totalCategories}</Text>
<Text style={styles.summaryStatLabel}>Categorias</Text>
</View>
<View style={styles.summaryDivider} />
<View style={styles.summaryStat}>
<Text style={styles.summaryStatValue}>{report.summary.totalItems}</Text>
<Text style={styles.summaryStatLabel}>Productos</Text>
</View>
<View style={styles.summaryDivider} />
<View style={styles.summaryStat}>
<Text style={styles.summaryStatValue}>{formatCurrency(report.summary.totalValue)}</Text>
<Text style={styles.summaryStatLabel}>Valor Total</Text>
</View>
</View>
</View>
{/* Distribution Bar */}
<Text style={styles.sectionTitle}>Distribucion</Text>
{renderCategoryBar(report.categories)}
{/* Legend */}
<View style={styles.legend}>
{report.categories.slice(0, 4).map((cat, index) => (
<View key={cat.name} style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: CATEGORY_COLORS[index] }]} />
<Text style={styles.legendText} numberOfLines={1}>{cat.name || 'Sin cat.'}</Text>
</View>
))}
{report.categories.length > 4 && (
<Text style={styles.legendMore}>+{report.categories.length - 4} mas</Text>
)}
</View>
{/* Category Cards */}
<Text style={[styles.sectionTitle, styles.sectionTitleMargin]}>Desglose por categoria</Text>
{report.categories.map((category, index) => renderCategoryCard(category, index))}
</ScrollView>
</SafeAreaView>
);
}
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',
},
});

150
src/app/reports/index.tsx Normal file
View File

@ -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) => (
<TouchableOpacity
style={styles.card}
onPress={() => router.push(route as any)}
activeOpacity={0.7}
>
<View style={[styles.iconContainer, { backgroundColor: color }]}>
<Text style={styles.icon}>{icon}</Text>
</View>
<View style={styles.cardContent}>
<Text style={styles.cardTitle}>{title}</Text>
<Text style={styles.cardDescription}>{description}</Text>
</View>
<Text style={styles.chevron}></Text>
</TouchableOpacity>
);
export default function ReportsIndexScreen() {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
<Text style={styles.sectionTitle}>Reportes disponibles</Text>
<ReportCard
title="Valorizacion del Inventario"
description="Valor total, costos y margenes potenciales de tu inventario"
icon="$"
route="/reports/valuation"
color="#dcfce7"
/>
<ReportCard
title="Historial de Movimientos"
description="Entradas, salidas y ajustes de stock"
icon="↕"
route="/reports/movements"
color="#dbeafe"
/>
<ReportCard
title="Analisis por Categorias"
description="Distribucion de productos y valor por categoria"
icon="◫"
route="/reports/categories"
color="#fef3c7"
/>
<View style={styles.infoCard}>
<Text style={styles.infoTitle}>Exportar reportes</Text>
<Text style={styles.infoText}>
Todos los reportes pueden exportarse en formato CSV o Excel desde la
pantalla de cada reporte.
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}
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,
},
});

View File

@ -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<string, { label: string; color: string; bgColor: string }> = {
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<MovementsReport | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(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 (
<View style={styles.movementCard}>
<View style={styles.movementHeader}>
<View style={[styles.typeBadge, { backgroundColor: typeConfig.bgColor }]}>
<Text style={[styles.typeBadgeText, { color: typeConfig.color }]}>
{typeConfig.label}
</Text>
</View>
<Text style={styles.movementDate}>{formatDate(item.date)}</Text>
</View>
<Text style={styles.movementItem} numberOfLines={1}>{item.itemName}</Text>
<View style={styles.movementDetails}>
<View style={styles.quantityChange}>
<Text style={styles.quantityLabel}>
{item.quantityBefore} {item.quantityAfter}
</Text>
</View>
<Text style={[
styles.changeValue,
isPositive ? styles.changePositive : styles.changeNegative,
]}>
{isPositive ? '+' : ''}{item.change}
</Text>
</View>
{item.reason && (
<Text style={styles.reasonText}>{item.reason}</Text>
)}
</View>
);
};
const renderHeader = () => {
if (!report) return null;
return (
<View style={styles.headerSection}>
{/* Summary Card */}
<View style={styles.summaryCard}>
<View style={styles.summaryRow}>
<View style={styles.summaryItem}>
<Text style={styles.summaryValue}>{report.summary.totalMovements}</Text>
<Text style={styles.summaryLabel}>Movimientos</Text>
</View>
<View style={styles.summaryDivider} />
<View style={styles.summaryItem}>
<Text style={[
styles.summaryValue,
report.summary.netChange >= 0 ? styles.changePositive : styles.changeNegative,
]}>
{report.summary.netChange >= 0 ? '+' : ''}{report.summary.netChange}
</Text>
<Text style={styles.summaryLabel}>Cambio neto</Text>
</View>
</View>
<View style={styles.summaryRow}>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, styles.changePositive]}>
+{report.summary.itemsIncreased}
</Text>
<Text style={styles.summaryLabel}>Aumentos</Text>
</View>
<View style={styles.summaryDivider} />
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, styles.changeNegative]}>
-{report.summary.itemsDecreased}
</Text>
<Text style={styles.summaryLabel}>Disminuciones</Text>
</View>
</View>
</View>
<Text style={styles.sectionTitle}>Historial de movimientos</Text>
</View>
);
};
const renderFooter = () => {
if (!isLoadingMore) return null;
return (
<View style={styles.loadingMore}>
<ActivityIndicator size="small" color="#2563eb" />
</View>
);
};
if (isLoading) {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#2563eb" />
</View>
</SafeAreaView>
);
}
if (error) {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={() => fetchReport()}>
<Text style={styles.retryButtonText}>Reintentar</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<FlatList
data={report?.movements || []}
renderItem={renderMovementItem}
keyExtractor={(item) => item.id}
ListHeaderComponent={renderHeader}
ListFooterComponent={renderFooter}
contentContainerStyle={styles.content}
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={() => fetchReport(1, true)} />
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.3}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No hay movimientos registrados</Text>
</View>
}
/>
</SafeAreaView>
);
}
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',
},
});

View File

@ -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<ValuationReport | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(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 (
<SafeAreaView style={styles.container} edges={['bottom']}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#2563eb" />
</View>
</SafeAreaView>
);
}
if (error) {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={() => fetchReport()}>
<Text style={styles.retryButtonText}>Reintentar</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
if (!report) {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No hay datos disponibles</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={() => fetchReport(true)} />
}
>
{/* Summary Card */}
<View style={styles.summaryCard}>
<Text style={styles.summaryTitle}>Valor Total del Inventario</Text>
<Text style={styles.summaryValue}>{formatCurrency(report.summary.totalPrice)}</Text>
<View style={styles.summaryRow}>
<View style={styles.summaryItem}>
<Text style={styles.summaryItemLabel}>Costo</Text>
<Text style={styles.summaryItemValue}>{formatCurrency(report.summary.totalCost)}</Text>
</View>
<View style={styles.summaryDivider} />
<View style={styles.summaryItem}>
<Text style={styles.summaryItemLabel}>Margen</Text>
<Text style={[styles.summaryItemValue, styles.marginValue]}>
{formatPercent(report.summary.potentialMarginPercent)}
</Text>
</View>
</View>
<Text style={styles.totalItems}>{report.summary.totalItems} productos</Text>
</View>
{/* By Category */}
<Text style={styles.sectionTitle}>Por Categoria</Text>
{report.byCategory.map((cat, index) => (
<View key={index} style={styles.categoryCard}>
<View style={styles.categoryHeader}>
<Text style={styles.categoryName}>{cat.category || 'Sin categoria'}</Text>
<Text style={styles.categoryCount}>{cat.itemCount} productos</Text>
</View>
<View style={styles.categoryStats}>
<View style={styles.categoryStat}>
<Text style={styles.categoryStatLabel}>Valor</Text>
<Text style={styles.categoryStatValue}>{formatCurrency(cat.totalPrice)}</Text>
</View>
<View style={styles.categoryStat}>
<Text style={styles.categoryStatLabel}>Costo</Text>
<Text style={styles.categoryStatValue}>{formatCurrency(cat.totalCost)}</Text>
</View>
<View style={styles.categoryStat}>
<Text style={styles.categoryStatLabel}>Margen</Text>
<Text style={[styles.categoryStatValue, styles.marginValue]}>
{formatCurrency(cat.margin)}
</Text>
</View>
</View>
</View>
))}
{/* Top Items */}
<Text style={styles.sectionTitle}>Top Productos por Valor</Text>
{report.items.slice(0, 10).map((item, index) => (
<View key={item.id} style={styles.itemRow}>
<View style={styles.itemRank}>
<Text style={styles.itemRankText}>{index + 1}</Text>
</View>
<View style={styles.itemInfo}>
<Text style={styles.itemName} numberOfLines={1}>{item.name}</Text>
<Text style={styles.itemCategory}>{item.category || 'Sin categoria'}</Text>
</View>
<View style={styles.itemValue}>
<Text style={styles.itemValueText}>{formatCurrency(item.totalPrice)}</Text>
<Text style={styles.itemQuantity}>x{item.quantity}</Text>
</View>
</View>
))}
</ScrollView>
<View style={styles.footer}>
<TouchableOpacity style={styles.exportButton} onPress={handleExport}>
<Text style={styles.exportButtonText}>Exportar Reporte</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
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',
},
});

264
src/app/stores/[id].tsx Normal file
View File

@ -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 (
<SafeAreaView style={styles.container}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#2563eb" />
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<KeyboardAvoidingView
style={styles.keyboardAvoid}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
<View style={styles.formGroup}>
<Text style={styles.label}>Nombre de la tienda *</Text>
<TextInput
style={styles.input}
placeholder="Ej: Mi Tiendita Centro"
placeholderTextColor="#999"
value={name}
onChangeText={setName}
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Direccion</Text>
<TextInput
style={styles.input}
placeholder="Ej: Av. Juarez 123, Col. Centro"
placeholderTextColor="#999"
value={address}
onChangeText={setAddress}
multiline
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Ciudad</Text>
<TextInput
style={styles.input}
placeholder="Ej: Ciudad de Mexico"
placeholderTextColor="#999"
value={city}
onChangeText={setCity}
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Giro</Text>
<TextInput
style={styles.input}
placeholder="Ej: Abarrotes, Miscelanea..."
placeholderTextColor="#999"
value={giro}
onChangeText={setGiro}
/>
</View>
{error && (
<View style={styles.errorCard}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<TouchableOpacity
style={styles.deleteButton}
onPress={handleDelete}
>
<Text style={styles.deleteButtonText}>Eliminar Tienda</Text>
</TouchableOpacity>
</ScrollView>
<View style={styles.footer}>
<TouchableOpacity
style={[
styles.saveButton,
(!name.trim() || isLoading) && styles.saveButtonDisabled,
]}
onPress={handleUpdate}
disabled={!name.trim() || isLoading}
>
{isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.saveButtonText}>Guardar Cambios</Text>
)}
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
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',
},
});

View File

@ -0,0 +1,27 @@
import { Stack } from 'expo-router';
export default function StoresLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: '#fff' },
headerTintColor: '#1a1a1a',
headerTitleStyle: { fontWeight: '600' },
headerShadowVisible: false,
}}
>
<Stack.Screen
name="index"
options={{ title: 'Mis Tiendas' }}
/>
<Stack.Screen
name="new"
options={{ title: 'Nueva Tienda' }}
/>
<Stack.Screen
name="[id]"
options={{ title: 'Editar Tienda' }}
/>
</Stack>
);
}

301
src/app/stores/index.tsx Normal file
View File

@ -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 }) => (
<TouchableOpacity
style={[
styles.storeCard,
currentStore?.id === item.id && styles.storeCardActive,
]}
onPress={() => handleSelectStore(item)}
onLongPress={() => handleDeleteStore(item)}
>
<View style={styles.storeIcon}>
<Text style={styles.storeIconText}>🏪</Text>
</View>
<View style={styles.storeInfo}>
<Text style={styles.storeName}>{item.name}</Text>
{item.address && (
<Text style={styles.storeAddress} numberOfLines={1}>
{item.address}
</Text>
)}
</View>
{currentStore?.id === item.id && (
<View style={styles.activeBadge}>
<Text style={styles.activeBadgeText}>Activa</Text>
</View>
)}
<TouchableOpacity
style={styles.editButton}
onPress={() => router.push(`/stores/${item.id}`)}
>
<Text style={styles.editButtonText}></Text>
</TouchableOpacity>
</TouchableOpacity>
);
const EmptyState = () => (
<View style={styles.emptyState}>
<Text style={styles.emptyIcon}>🏪</Text>
<Text style={styles.emptyTitle}>Sin tiendas</Text>
<Text style={styles.emptyDescription}>
Crea tu primera tienda para comenzar a usar MiInventario
</Text>
<TouchableOpacity
style={styles.emptyButton}
onPress={() => router.push('/stores/new')}
>
<Text style={styles.emptyButtonText}>Crear Tienda</Text>
</TouchableOpacity>
</View>
);
return (
<>
<Stack.Screen
options={{
headerRight: () => (
<TouchableOpacity
style={styles.addButton}
onPress={() => router.push('/stores/new')}
>
<Text style={styles.addButtonText}>+ Nueva</Text>
</TouchableOpacity>
),
}}
/>
<SafeAreaView style={styles.container} edges={['bottom']}>
{isLoading && stores.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#2563eb" />
<Text style={styles.loadingText}>Cargando tiendas...</Text>
</View>
) : stores.length === 0 ? (
<EmptyState />
) : (
<>
<Text style={styles.hint}>
Toca para seleccionar, manten presionado para eliminar
</Text>
<FlatList
data={stores}
renderItem={renderStore}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={
isLoading ? (
<ActivityIndicator style={styles.footerLoader} color="#2563eb" />
) : null
}
ItemSeparatorComponent={() => <View style={styles.separator} />}
/>
</>
)}
</SafeAreaView>
</>
);
}
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,
},
});

221
src/app/stores/new.tsx Normal file
View File

@ -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 (
<SafeAreaView style={styles.container} edges={['bottom']}>
<KeyboardAvoidingView
style={styles.keyboardAvoid}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
<View style={styles.formGroup}>
<Text style={styles.label}>Nombre de la tienda *</Text>
<TextInput
style={styles.input}
placeholder="Ej: Mi Tiendita Centro"
placeholderTextColor="#999"
value={name}
onChangeText={setName}
autoFocus
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Direccion</Text>
<TextInput
style={styles.input}
placeholder="Ej: Av. Juarez 123, Col. Centro"
placeholderTextColor="#999"
value={address}
onChangeText={setAddress}
multiline
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Ciudad</Text>
<TextInput
style={styles.input}
placeholder="Ej: Ciudad de Mexico"
placeholderTextColor="#999"
value={city}
onChangeText={setCity}
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.label}>Giro</Text>
<TextInput
style={styles.input}
placeholder="Ej: Abarrotes, Miscelanea..."
placeholderTextColor="#999"
value={giro}
onChangeText={setGiro}
/>
</View>
{error && (
<View style={styles.errorCard}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<View style={styles.infoCard}>
<Text style={styles.infoIcon}>💡</Text>
<Text style={styles.infoText}>
Puedes agregar mas tiendas despues desde tu perfil. Cada tienda
tiene su propio inventario independiente.
</Text>
</View>
</ScrollView>
<View style={styles.footer}>
<TouchableOpacity
style={[
styles.createButton,
(!name.trim() || isLoading) && styles.createButtonDisabled,
]}
onPress={handleCreate}
disabled={!name.trim() || isLoading}
>
{isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.createButtonText}>Crear Tienda</Text>
)}
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
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',
},
});

317
src/app/support/index.tsx Normal file
View File

@ -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;
}) => (
<TouchableOpacity style={styles.contactCard} onPress={onPress}>
<View style={styles.contactIcon}>
<Text style={styles.contactIconText}>{icon}</Text>
</View>
<View style={styles.contactInfo}>
<Text style={styles.contactTitle}>{title}</Text>
<Text style={styles.contactDescription}>{description}</Text>
</View>
<Text style={styles.contactArrow}></Text>
</TouchableOpacity>
);
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<Stack.Screen
options={{
title: 'Soporte',
headerShown: true,
}}
/>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView style={styles.scroll}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Necesitas ayuda?</Text>
<Text style={styles.headerSubtitle}>
Estamos aqui para ayudarte. Elige como prefieres contactarnos.
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Contacto Rapido</Text>
<View style={styles.contactCards}>
<ContactCard
icon="💬"
title="WhatsApp"
description="Respuesta inmediata"
onPress={handleWhatsApp}
/>
<ContactCard
icon="📧"
title="Email"
description="soporte@miinventario.com"
onPress={handleEmail}
/>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Enviar Mensaje</Text>
<View style={styles.form}>
<View style={styles.inputGroup}>
<Text style={styles.label}>Asunto</Text>
<TextInput
style={styles.input}
value={subject}
onChangeText={setSubject}
placeholder="Describe brevemente tu problema"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Mensaje</Text>
<TextInput
style={[styles.input, styles.textArea]}
value={message}
onChangeText={setMessage}
placeholder="Cuentanos con detalle como podemos ayudarte..."
multiline
numberOfLines={5}
textAlignVertical="top"
/>
</View>
<TouchableOpacity
style={[styles.submitButton, isSending && styles.submitButtonDisabled]}
onPress={handleSubmit}
disabled={isSending}
>
{isSending ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.submitButtonText}>Enviar Mensaje</Text>
)}
</TouchableOpacity>
</View>
</View>
<View style={styles.infoSection}>
<Text style={styles.infoIcon}></Text>
<Text style={styles.infoTitle}>Horario de Atencion</Text>
<Text style={styles.infoText}>
Lunes a Viernes: 9:00 AM - 6:00 PM{'\n'}
Sabado: 9:00 AM - 2:00 PM
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
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,
},
});

View File

@ -0,0 +1,27 @@
import { Stack } from 'expo-router';
export default function ValidationLayout() {
return (
<Stack
screenOptions={{
headerShown: true,
headerBackTitle: 'Atras',
}}
>
<Stack.Screen
name="items"
options={{
title: 'Validar Productos',
headerBackVisible: false,
}}
/>
<Stack.Screen
name="complete"
options={{
title: 'Validacion Completada',
headerBackVisible: false,
}}
/>
</Stack>
);
}

View File

@ -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 (
<View style={styles.container}>
<View style={styles.content}>
<View style={styles.iconContainer}>
<Ionicons name="checkmark-circle" size={80} color="#28a745" />
</View>
<Text style={styles.title}>Gracias!</Text>
<Text style={styles.subtitle}>Tu validacion nos ayuda a mejorar</Text>
{creditsRewarded !== null && creditsRewarded > 0 && (
<View style={styles.rewardCard}>
<Ionicons name="gift" size={32} color="#f0ad4e" />
<View style={styles.rewardInfo}>
<Text style={styles.rewardLabel}>Recompensa</Text>
<Text style={styles.rewardValue}>+{creditsRewarded} credito</Text>
</View>
</View>
)}
<View style={styles.benefits}>
<Text style={styles.benefitsTitle}>Con tu ayuda:</Text>
<View style={styles.benefitItem}>
<Ionicons name="checkmark" size={20} color="#28a745" />
<Text style={styles.benefitText}>
Mejoramos la deteccion de productos
</Text>
</View>
<View style={styles.benefitItem}>
<Ionicons name="checkmark" size={20} color="#28a745" />
<Text style={styles.benefitText}>
Entrenamos mejor nuestros modelos
</Text>
</View>
<View style={styles.benefitItem}>
<Ionicons name="checkmark" size={20} color="#28a745" />
<Text style={styles.benefitText}>
Tu inventario sera mas preciso
</Text>
</View>
</View>
</View>
<View style={styles.footer}>
<TouchableOpacity style={styles.button} onPress={handleContinue}>
<Text style={styles.buttonText}>Continuar</Text>
<Ionicons name="arrow-forward" size={20} color="#fff" />
</TouchableOpacity>
</View>
</View>
);
}
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',
},
});

View File

@ -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<ValidationItemResponse, 'inventoryItemId'>) => {
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 (
<View style={styles.loading}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
const currentItem = items[currentItemIndex];
const currentResponse = responses.find(
(r) => r.inventoryItemId === currentItem?.id,
);
return (
<View style={styles.container}>
<ValidationProgressBar
current={currentItemIndex}
total={items.length}
validated={responses.length}
/>
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
{currentItem && (
<ValidationItemCard
item={currentItem}
onResponse={handleResponse}
existingResponse={currentResponse}
/>
)}
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</ScrollView>
<View style={styles.footer}>
<View style={styles.navigation}>
<TouchableOpacity
style={[styles.navButton, currentItemIndex === 0 && styles.navButtonDisabled]}
onPress={previousItem}
disabled={currentItemIndex === 0}
>
<Ionicons
name="chevron-back"
size={24}
color={currentItemIndex === 0 ? '#ccc' : '#007AFF'}
/>
<Text
style={[
styles.navText,
currentItemIndex === 0 && styles.navTextDisabled,
]}
>
Anterior
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.navButton,
currentItemIndex === items.length - 1 && styles.navButtonDisabled,
]}
onPress={nextItem}
disabled={currentItemIndex === items.length - 1}
>
<Text
style={[
styles.navText,
currentItemIndex === items.length - 1 && styles.navTextDisabled,
]}
>
Siguiente
</Text>
<Ionicons
name="chevron-forward"
size={24}
color={currentItemIndex === items.length - 1 ? '#ccc' : '#007AFF'}
/>
</TouchableOpacity>
</View>
<View style={styles.actions}>
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}>
<Text style={styles.skipText}>Omitir</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.submitButton,
responses.length < items.length && styles.submitButtonDisabled,
]}
onPress={handleSubmit}
disabled={isLoading || responses.length < items.length}
>
{isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<>
<Text style={styles.submitText}>Enviar</Text>
<View style={styles.badge}>
<Text style={styles.badgeText}>
{responses.length}/{items.length}
</Text>
</View>
</>
)}
</TouchableOpacity>
</View>
</View>
</View>
);
}
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',
},
});

View File

@ -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 (
<TouchableOpacity
style={styles.button}
onPress={handleConfirm}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#28a745" size="small" />
) : (
<>
<Ionicons name="checkmark-circle" size={20} color="#28a745" />
<Text style={styles.text}>Confirmar</Text>
</>
)}
</TouchableOpacity>
);
}
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,
},
});

View File

@ -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 (
<Modal visible={visible} transparent animationType="slide">
<View style={styles.overlay}>
<View style={styles.container}>
<Text style={styles.title}>Corregir Cantidad</Text>
<Text style={styles.subtitle}>{itemName}</Text>
<View style={styles.currentValue}>
<Text style={styles.label}>Cantidad actual:</Text>
<Text style={styles.value}>{currentQuantity}</Text>
</View>
<Text style={styles.inputLabel}>Nueva cantidad:</Text>
<TextInput
style={styles.input}
value={quantity}
onChangeText={setQuantity}
keyboardType="numeric"
placeholder="0"
/>
<Text style={styles.inputLabel}>Razon (opcional):</Text>
<TextInput
style={[styles.input, styles.textArea]}
value={reason}
onChangeText={setReason}
placeholder="Por que haces esta correccion?"
multiline
numberOfLines={2}
/>
{error && <Text style={styles.error}>{error}</Text>}
<View style={styles.buttons}>
<TouchableOpacity
style={[styles.button, styles.cancelButton]}
onPress={handleClose}
disabled={isLoading}
>
<Text style={styles.cancelText}>Cancelar</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.submitButton]}
onPress={handleSubmit}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.submitText}>Guardar</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
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',
},
});

View File

@ -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 (
<Modal visible={visible} transparent animationType="slide">
<View style={styles.overlay}>
<View style={styles.container}>
<Text style={styles.title}>Corregir Producto</Text>
<View style={styles.currentValue}>
<Text style={styles.label}>Nombre actual:</Text>
<Text style={styles.value}>{currentName}</Text>
</View>
<Text style={styles.inputLabel}>Nombre correcto:</Text>
<TextInput
style={styles.input}
value={name}
onChangeText={setName}
placeholder="Nombre del producto"
/>
<Text style={styles.inputLabel}>Categoria (opcional):</Text>
<TextInput
style={styles.input}
value={category}
onChangeText={setCategory}
placeholder="Categoria"
/>
<Text style={styles.inputLabel}>Codigo de barras (opcional):</Text>
<TextInput
style={styles.input}
value={barcode}
onChangeText={setBarcode}
placeholder="Codigo de barras"
keyboardType="numeric"
/>
<Text style={styles.inputLabel}>Razon (opcional):</Text>
<TextInput
style={[styles.input, styles.textArea]}
value={reason}
onChangeText={setReason}
placeholder="Por que haces esta correccion?"
multiline
numberOfLines={2}
/>
{error && <Text style={styles.error}>{error}</Text>}
<View style={styles.buttons}>
<TouchableOpacity
style={[styles.button, styles.cancelButton]}
onPress={handleClose}
disabled={isLoading}
>
<Text style={styles.cancelText}>Cancelar</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.submitButton]}
onPress={handleSubmit}
disabled={isLoading || !name.trim()}
>
{isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.submitText}>Guardar</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
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',
},
});

View File

@ -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<string, keyof typeof Ionicons.glyphMap> = {
QUANTITY: 'calculator',
SKU: 'pricetag',
CONFIRMATION: 'checkmark-circle',
};
const typeLabels: Record<string, string> = {
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 <Text style={styles.change}>Item confirmado como correcto</Text>;
}
if (correction.type === 'QUANTITY') {
return (
<Text style={styles.change}>
{correction.previousValue.quantity} {correction.newValue.quantity}
</Text>
);
}
if (correction.type === 'SKU') {
return (
<View>
<Text style={styles.change}>
"{correction.previousValue.name}" "{correction.newValue.name}"
</Text>
{correction.newValue.category !== correction.previousValue.category && (
<Text style={styles.subChange}>
Categoria: {correction.newValue.category}
</Text>
)}
</View>
);
}
return null;
};
return (
<View style={styles.container}>
<View style={styles.iconContainer}>
<Ionicons name={icon} size={20} color="#007AFF" />
</View>
<View style={styles.content}>
<View style={styles.header}>
<Text style={styles.label}>{label}</Text>
<Text style={styles.date}>
{date.toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</View>
{renderChange()}
{correction.reason && (
<Text style={styles.reason}>"{correction.reason}"</Text>
)}
{correction.user && (
<Text style={styles.user}>Por: {correction.user.name}</Text>
)}
</View>
</View>
);
}
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,
},
});

View File

@ -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 (
<View style={[styles.balanceCard, { backgroundColor: colors.primary }]}>
<SkeletonText width={100} height={14} style={{ opacity: 0.5 }} />
<Skeleton width={120} height={40} borderRadius={8} style={{ marginTop: 8 }} />
<View style={styles.balanceStats}>
<View style={styles.balanceStat}>
<SkeletonText width={50} height={12} style={{ opacity: 0.5 }} />
<SkeletonText width={40} height={16} style={{ marginTop: 4, opacity: 0.5 }} />
</View>
<View style={styles.balanceStat}>
<SkeletonText width={50} height={12} style={{ opacity: 0.5 }} />
<SkeletonText width={40} height={16} style={{ marginTop: 4, opacity: 0.5 }} />
</View>
</View>
</View>
);
}
/**
* Skeleton para transacción
*/
export function TransactionSkeleton() {
const { colors } = useTheme();
return (
<View style={[styles.transaction, { borderBottomColor: colors.border }]}>
<View style={styles.transactionIcon}>
<Skeleton width={40} height={40} borderRadius={20} />
</View>
<View style={styles.transactionContent}>
<SkeletonText width="60%" height={14} />
<SkeletonText width="40%" height={12} style={{ marginTop: 4 }} />
</View>
<SkeletonText width={50} height={16} />
</View>
);
}
/**
* Lista de skeletons de transacciones
*/
export function TransactionListSkeleton({ count = 5 }: { count?: number }) {
return (
<View>
{Array.from({ length: count }).map((_, index) => (
<TransactionSkeleton key={index} />
))}
</View>
);
}
/**
* Skeleton para paquete de créditos
*/
export function CreditPackageSkeleton() {
const { colors } = useTheme();
return (
<View style={[styles.package, { backgroundColor: colors.card }]}>
<Skeleton width={48} height={48} borderRadius={24} />
<View style={styles.packageContent}>
<SkeletonText width={80} height={18} />
<SkeletonText width={60} height={12} style={{ marginTop: 4 }} />
</View>
<SkeletonText width={70} height={24} />
</View>
);
}
/**
* Lista de skeletons de paquetes
*/
export function CreditPackageListSkeleton({ count = 4 }: { count?: number }) {
return (
<View style={styles.packageList}>
{Array.from({ length: count }).map((_, index) => (
<CreditPackageSkeleton key={index} />
))}
</View>
);
}
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,
},
});

View File

@ -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 (
<View style={[styles.container, { borderBottomColor: colors.border }]}>
<SkeletonImage width={64} height={64} borderRadius={8} />
<View style={styles.content}>
<SkeletonText width="70%" height={16} />
<SkeletonText width="40%" height={12} style={{ marginTop: 6 }} />
<View style={styles.row}>
<SkeletonText width={60} height={14} style={{ marginTop: 8 }} />
<SkeletonText width={40} height={20} style={{ marginTop: 8 }} />
</View>
</View>
</View>
);
}
/**
* Lista de skeletons de inventario
*/
export function InventoryListSkeleton({ count = 8 }: { count?: number }) {
return (
<View>
{Array.from({ length: count }).map((_, index) => (
<InventoryItemSkeleton key={index} />
))}
</View>
);
}
/**
* Skeleton para las estadísticas de inventario
*/
export function InventoryStatsSkeleton() {
const { colors } = useTheme();
return (
<View style={styles.statsContainer}>
{Array.from({ length: 4 }).map((_, index) => (
<View key={index} style={[styles.statCard, { backgroundColor: colors.card }]}>
<Skeleton width={48} height={32} borderRadius={8} />
<SkeletonText width={60} height={12} style={{ marginTop: 8 }} />
</View>
))}
</View>
);
}
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',
},
});

View File

@ -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 (
<View style={[styles.container, { borderBottomColor: colors.border }]}>
<SkeletonCircle size={44} />
<View style={styles.content}>
<SkeletonText width="80%" height={14} />
<SkeletonText width="100%" height={12} style={{ marginTop: 6 }} />
<SkeletonText width="30%" height={10} style={{ marginTop: 8 }} />
</View>
<View style={styles.indicator}>
<Skeleton width={8} height={8} borderRadius={4} />
</View>
</View>
);
}
/**
* Lista de skeletons de notificaciones
*/
export function NotificationListSkeleton({ count = 6 }: { count?: number }) {
return (
<View>
{Array.from({ length: count }).map((_, index) => (
<NotificationItemSkeleton key={index} />
))}
</View>
);
}
/**
* Skeleton para el header de notificaciones
*/
export function NotificationHeaderSkeleton() {
const { colors } = useTheme();
return (
<View style={[styles.header, { backgroundColor: colors.card }]}>
<SkeletonText width={120} height={20} />
<SkeletonText width={80} height={14} />
</View>
);
}
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,
},
});

View File

@ -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 (
<View style={[styles.container, { backgroundColor: colors.card }]}>
<View style={styles.header}>
<SkeletonCircle size={56} />
<View style={styles.headerContent}>
<SkeletonText width="60%" height={18} />
<SkeletonText width="80%" height={12} style={{ marginTop: 6 }} />
</View>
</View>
<View style={styles.stats}>
<View style={styles.stat}>
<SkeletonText width={40} height={20} />
<SkeletonText width={60} height={10} style={{ marginTop: 4 }} />
</View>
<View style={styles.stat}>
<SkeletonText width={40} height={20} />
<SkeletonText width={60} height={10} style={{ marginTop: 4 }} />
</View>
<View style={styles.stat}>
<SkeletonText width={40} height={20} />
<SkeletonText width={60} height={10} style={{ marginTop: 4 }} />
</View>
</View>
</View>
);
}
/**
* Lista de skeletons de tiendas
*/
export function StoreListSkeleton({ count = 3 }: { count?: number }) {
return (
<View style={styles.list}>
{Array.from({ length: count }).map((_, index) => (
<StoreCardSkeleton key={index} />
))}
</View>
);
}
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,
},
});

View File

@ -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<T> extends Omit<FlatListProps<T>, '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> | 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 (
<Animated.View entering={entering} layout={Layout.springify()}>
{children}
</Animated.View>
);
}
/**
* FlatList con animaciones de entrada staggered
*/
export function AnimatedList<T>({
data,
renderItem,
staggerDelay = 50,
animationType = 'fade',
animateOnRefresh = true,
onRefresh,
isRefreshing = false,
...props
}: AnimatedListProps<T>) {
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 }) => (
<AnimatedItem
index={index}
staggerDelay={staggerDelay}
animationType={animationType}
>
{renderItem({ item, index })}
</AnimatedItem>
),
[renderItem, staggerDelay, animationType]
);
return (
<FlatList
key={key}
data={data}
renderItem={animatedRenderItem}
refreshControl={
onRefresh ? (
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor={colors.primary}
colors={[colors.primary]}
/>
) : 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 (
<Animated.View entering={entering} style={style}>
{children}
</Animated.View>
);
}

View File

@ -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 (
<Animated.View
style={[
styles.container,
{ paddingTop: insets.top + 8 },
animatedStyle,
]}
>
<View style={styles.content}>
{showIcon && (
<Ionicons
name="cloud-offline-outline"
size={18}
color="#FFFFFF"
style={styles.icon}
/>
)}
<Text style={styles.text}>{message}</Text>
</View>
</Animated.View>
);
}
/**
* Componente wrapper que incluye el banner offline
* Útil para envolver contenido principal de la app
*/
export function WithOfflineBanner({ children }: { children: React.ReactNode }) {
return (
<>
<OfflineBanner />
{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',
},
});

View File

@ -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 (
<Animated.View
style={[
{
width: width as any,
height,
borderRadius,
backgroundColor: colors.border,
},
animatedStyle,
style,
]}
/>
);
}
/**
* Skeleton para texto - línea simple
*/
export function SkeletonText({
width = '80%',
height = 14,
style
}: SkeletonProps) {
return (
<Skeleton
width={width}
height={height}
borderRadius={4}
style={style}
/>
);
}
/**
* Skeleton circular - para avatares
*/
export function SkeletonCircle({
size = 40,
style
}: { size?: number; style?: ViewStyle }) {
return (
<Skeleton
width={size}
height={size}
borderRadius={size / 2}
style={style}
/>
);
}
/**
* Skeleton para imagen cuadrada
*/
export function SkeletonImage({
width = 80,
height = 80,
borderRadius = 8,
style
}: SkeletonProps) {
return (
<Skeleton
width={width}
height={height}
borderRadius={borderRadius}
style={style}
/>
);
}
/**
* Skeleton para tarjeta completa
*/
export function SkeletonCard({ style }: { style?: ViewStyle }) {
const { colors } = useTheme();
return (
<View style={[styles.card, { backgroundColor: colors.card }, style]}>
<View style={styles.cardHeader}>
<SkeletonCircle size={48} />
<View style={styles.cardHeaderText}>
<SkeletonText width="60%" height={16} />
<SkeletonText width="40%" height={12} style={{ marginTop: 8 }} />
</View>
</View>
<SkeletonText width="100%" height={14} style={{ marginTop: 16 }} />
<SkeletonText width="90%" height={14} style={{ marginTop: 8 }} />
<SkeletonText width="70%" height={14} style={{ marginTop: 8 }} />
</View>
);
}
/**
* Skeleton para item de lista
*/
export function SkeletonListItem({ style }: { style?: ViewStyle }) {
const { colors } = useTheme();
return (
<View style={[styles.listItem, { borderBottomColor: colors.border }, style]}>
<SkeletonImage width={56} height={56} borderRadius={8} />
<View style={styles.listItemContent}>
<SkeletonText width="70%" height={16} />
<SkeletonText width="50%" height={12} style={{ marginTop: 6 }} />
<SkeletonText width="30%" height={12} style={{ marginTop: 6 }} />
</View>
</View>
);
}
/**
* Skeleton para estadística/métrica
*/
export function SkeletonStat({ style }: { style?: ViewStyle }) {
const { colors } = useTheme();
return (
<View style={[styles.stat, { backgroundColor: colors.card }, style]}>
<SkeletonText width={60} height={28} />
<SkeletonText width={80} height={12} style={{ marginTop: 8 }} />
</View>
);
}
/**
* Grupo de skeletons de lista
*/
export function SkeletonList({
count = 5,
style
}: { count?: number; style?: ViewStyle }) {
return (
<View style={style}>
{Array.from({ length: count }).map((_, index) => (
<SkeletonListItem key={index} />
))}
</View>
);
}
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,
},
});

View File

@ -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<ValidationItemResponse, 'inventoryItemId'>) => 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 (
<View style={styles.container}>
{item.imageUrl && (
<Image source={{ uri: item.imageUrl }} style={styles.image} />
)}
<View style={styles.content}>
<Text style={styles.name}>{item.name}</Text>
<View style={styles.details}>
<Text style={styles.quantity}>Cantidad: {item.quantity}</Text>
{item.category && (
<Text style={styles.category}>{item.category}</Text>
)}
</View>
{confidence !== null && (
<View style={styles.confidenceContainer}>
<Text style={styles.confidenceLabel}>Confianza:</Text>
<View
style={[
styles.confidenceBadge,
confidence >= 80
? styles.highConfidence
: confidence >= 60
? styles.mediumConfidence
: styles.lowConfidence,
]}
>
<Text style={styles.confidenceText}>{confidence}%</Text>
</View>
</View>
)}
</View>
{showCorrection ? (
<View style={styles.correctionForm}>
<Text style={styles.correctionLabel}>Nombre correcto:</Text>
<TextInput
style={styles.input}
value={correctedName}
onChangeText={setCorrectedName}
placeholder="Nombre del producto"
/>
<Text style={styles.correctionLabel}>Cantidad correcta:</Text>
<TextInput
style={styles.input}
value={correctedQuantity}
onChangeText={setCorrectedQuantity}
keyboardType="numeric"
placeholder="0"
/>
<View style={styles.correctionButtons}>
<TouchableOpacity
style={styles.cancelCorrectionButton}
onPress={() => setShowCorrection(false)}
>
<Text style={styles.cancelCorrectionText}>Cancelar</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.saveCorrectionButton}
onPress={handleSubmitCorrection}
>
<Text style={styles.saveCorrectionText}>Guardar</Text>
</TouchableOpacity>
</View>
</View>
) : (
<View style={styles.actions}>
<TouchableOpacity
style={[styles.actionButton, styles.incorrectButton]}
onPress={handleCorrect}
>
<Ionicons name="create-outline" size={20} color="#dc3545" />
<Text style={styles.incorrectText}>Corregir</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.correctButton]}
onPress={handleMarkCorrect}
>
<Ionicons name="checkmark" size={20} color="#28a745" />
<Text style={styles.correctText}>Correcto</Text>
</TouchableOpacity>
</View>
)}
{existingResponse && !showCorrection && (
<View style={styles.responseIndicator}>
<Ionicons
name={existingResponse.isCorrect ? 'checkmark-circle' : 'pencil'}
size={16}
color={existingResponse.isCorrect ? '#28a745' : '#f0ad4e'}
/>
<Text
style={[
styles.responseText,
{ color: existingResponse.isCorrect ? '#28a745' : '#f0ad4e' },
]}
>
{existingResponse.isCorrect ? 'Marcado correcto' : 'Corregido'}
</Text>
</View>
)}
</View>
);
}
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',
},
});

View File

@ -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 (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.label}>
Producto {current + 1} de {total}
</Text>
<Text style={styles.validated}>
{validated} validados
</Text>
</View>
<View style={styles.track}>
<View style={[styles.fill, { width: `${progress}%` }]} />
</View>
</View>
);
}
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,
},
});

View File

@ -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 (
<Modal visible={visible} transparent animationType="fade">
<View style={styles.overlay}>
<View style={styles.container}>
<View style={styles.iconContainer}>
<Ionicons name="checkmark-done-circle" size={48} color="#007AFF" />
</View>
<Text style={styles.title}>Ayudanos a mejorar</Text>
<Text style={styles.description}>
Validando algunos productos nos ayudas a detectar mejor tu inventario
en el futuro.
</Text>
<View style={styles.stats}>
<View style={styles.statItem}>
<Ionicons name="cube-outline" size={24} color="#666" />
<Text style={styles.statValue}>{itemsCount}</Text>
<Text style={styles.statLabel}>productos</Text>
</View>
<View style={styles.statItem}>
<Ionicons name="gift-outline" size={24} color="#28a745" />
<Text style={[styles.statValue, styles.rewardValue]}>
+{creditsReward}
</Text>
<Text style={styles.statLabel}>credito</Text>
</View>
</View>
<Text style={styles.time}>Toma menos de 1 minuto</Text>
<View style={styles.buttons}>
<TouchableOpacity
style={[styles.button, styles.skipButton]}
onPress={handleSkip}
>
<Text style={styles.skipText}>Ahora no</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.acceptButton]}
onPress={handleAccept}
>
<Text style={styles.acceptText}>Validar</Text>
<Ionicons name="arrow-forward" size={18} color="#fff" />
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
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',
},
});

186
src/hooks/useAnimations.ts Normal file
View File

@ -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 };

View File

@ -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<NetworkStatus>({
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();
}

View File

@ -0,0 +1,112 @@
import { authService } from '../auth.service';
import apiClient from '../client';
jest.mock('../client');
const mockApiClient = apiClient as jest.Mocked<typeof apiClient>;
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',
});
});
});
});

View File

@ -0,0 +1,119 @@
import { inventoryService } from '../inventory.service';
import apiClient from '../client';
jest.mock('../client');
const mockApiClient = apiClient as jest.Mocked<typeof apiClient>;
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);
});
});
});

View File

@ -0,0 +1,175 @@
import { reportsService } from '../reports.service';
import apiClient from '../client';
jest.mock('../client');
const mockApiClient = apiClient as jest.Mocked<typeof apiClient>;
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);
});
});
});

View File

@ -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<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/auth/login', data);
return response.data;
},
initiateRegistration: async (data: RegisterRequest): Promise<void> => {
await apiClient.post('/auth/register', data);
},
verifyOtp: async (data: VerifyOtpRequest): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/auth/verify-otp', data);
return response.data;
},
refreshTokens: async (refreshToken: string): Promise<TokenResponse> => {
const response = await apiClient.post<TokenResponse>('/auth/refresh', {
refreshToken,
});
return response.data;
},
logout: async (refreshToken: string): Promise<void> => {
await apiClient.post('/auth/logout', { refreshToken });
},
};

View File

@ -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;

View File

@ -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<BalanceResponse> => {
const response = await apiClient.get<BalanceResponse>('/credits/balance');
return response.data;
},
getTransactions: async (
page = 1,
limit = 20
): Promise<TransactionsResponse> => {
const response = await apiClient.get<TransactionsResponse>(
'/credits/transactions',
{ params: { page, limit } }
);
return response.data;
},
purchase: async (data: PurchaseRequest): Promise<PurchaseResponse> => {
const response = await apiClient.post<PurchaseResponse>(
'/credits/purchase',
data
);
return response.data;
},
};

View File

@ -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<ExportJobResponse> => {
const response = await apiClient.post<ExportJobResponse>(
`/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<ExportJobResponse> => {
const response = await apiClient.post<ExportJobResponse>(
`/stores/${storeId}/exports/report`,
{ type, format, ...filters },
);
return response.data;
},
/**
* Get export status
*/
getExportStatus: async (
storeId: string,
jobId: string,
): Promise<ExportStatusResponse> => {
const response = await apiClient.get<ExportStatusResponse>(
`/stores/${storeId}/exports/${jobId}`,
);
return response.data;
},
/**
* Get download URL for completed export
*/
getDownloadUrl: async (
storeId: string,
jobId: string,
): Promise<ExportDownloadResponse> => {
const response = await apiClient.get<ExportDownloadResponse>(
`/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<ExportStatusResponse> => {
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();
});
},
};

View File

@ -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<string, any>;
newValue: Record<string, any>;
createdAt: string;
}
export interface CorrectionHistoryItem {
id: string;
type: 'QUANTITY' | 'SKU' | 'CONFIRMATION';
previousValue: Record<string, any>;
newValue: Record<string, any>;
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<string, any>;
}
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;

View File

@ -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<InventoryResponse> => {
const response = await apiClient.get<InventoryResponse>(
`/stores/${storeId}/inventory`,
{ params: { page, limit } }
);
return response.data;
},
getItem: async (storeId: string, itemId: string): Promise<InventoryItem> => {
const response = await apiClient.get<InventoryItem>(
`/stores/${storeId}/inventory/${itemId}`
);
return response.data;
},
updateItem: async (
storeId: string,
itemId: string,
data: Partial<InventoryItem>
): Promise<InventoryItem> => {
const response = await apiClient.patch<InventoryItem>(
`/stores/${storeId}/inventory/${itemId}`,
data
);
return response.data;
},
deleteItem: async (storeId: string, itemId: string): Promise<void> => {
await apiClient.delete(`/stores/${storeId}/inventory/${itemId}`);
},
};

View File

@ -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<string, unknown>;
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<NotificationsListResponse> => {
const response = await apiClient.get<NotificationsListResponse>('/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;
},
};

View File

@ -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<CreditPackage[]> => {
const response = await apiClient.get<CreditPackage[]>('/credits/packages');
return response.data;
},
createPayment: async (data: CreatePaymentRequest): Promise<PaymentResponse> => {
const response = await apiClient.post<PaymentResponse>('/payments', data);
return response.data;
},
getPaymentHistory: async (page = 1, limit = 20): Promise<PaymentsListResponse> => {
const response = await apiClient.get<PaymentsListResponse>('/payments', {
params: { page, limit },
});
return response.data;
},
getPaymentById: async (paymentId: string): Promise<Payment> => {
const response = await apiClient.get<Payment>(`/payments/${paymentId}`);
return response.data;
},
};

View File

@ -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<ReferralStats> => {
const response = await apiClient.get<ReferralStats>('/referrals/stats');
return response.data;
},
getReferrals: async (page = 1, limit = 20): Promise<ReferralsListResponse> => {
const response = await apiClient.get<ReferralsListResponse>('/referrals', {
params: { page, limit },
});
return response.data;
},
validateCode: async (code: string): Promise<ValidateCodeResponse> => {
const response = await apiClient.get<ValidateCodeResponse>('/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;
},
};

View File

@ -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<ValuationReport> => {
const response = await apiClient.get<ValuationReport>(
`/stores/${storeId}/reports/valuation`,
);
return response.data;
},
/**
* Get movements report
*/
getMovementsReport: async (
storeId: string,
params?: MovementsQueryParams,
): Promise<MovementsReport> => {
const response = await apiClient.get<MovementsReport>(
`/stores/${storeId}/reports/movements`,
{ params },
);
return response.data;
},
/**
* Get categories report
*/
getCategoriesReport: async (storeId: string): Promise<CategoriesReport> => {
const response = await apiClient.get<CategoriesReport>(
`/stores/${storeId}/reports/categories`,
);
return response.data;
},
/**
* Get low stock report
*/
getLowStockReport: async (storeId: string): Promise<LowStockReport> => {
const response = await apiClient.get<LowStockReport>(
`/stores/${storeId}/reports/low-stock`,
);
return response.data;
},
};

View File

@ -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<StoresListResponse> => {
const response = await apiClient.get<StoresListResponse>('/stores', {
params: { page, limit },
});
return response.data;
},
getStoreById: async (storeId: string): Promise<Store> => {
const response = await apiClient.get<Store>(`/stores/${storeId}`);
return response.data;
},
createStore: async (data: CreateStoreRequest): Promise<Store> => {
const response = await apiClient.post<Store>('/stores', data);
return response.data;
},
updateStore: async (storeId: string, data: UpdateStoreRequest): Promise<Store> => {
const response = await apiClient.patch<Store>(`/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;
},
};

View File

@ -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<UserProfile> => {
const response = await apiClient.get<UserProfile>('/users/me');
return response.data;
},
updateProfile: async (data: UpdateProfileRequest): Promise<UserProfile> => {
const response = await apiClient.patch<UserProfile>('/users/me', data);
return response.data;
},
updateFcmToken: async (fcmToken: string): Promise<void> => {
await apiClient.patch('/users/me/fcm-token', { fcmToken });
},
};

View File

@ -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<SubmitValidationResponse> {
const response = await apiClient.post(
`/validations/${requestId}/submit`,
data,
);
return response.data;
},
async skip(requestId: string): Promise<void> {
await apiClient.post(`/validations/${requestId}/skip`);
},
};
export default validationsService;

View File

@ -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<UploadResponse> => {
const response = await apiClient.post<UploadResponse>(
`/stores/${storeId}/videos/initiate`,
{ fileName, fileSize }
);
return response.data;
},
uploadVideo: async (
uploadUrl: string,
localUri: string,
onProgress?: (progress: number) => void
): Promise<void> => {
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<void> => {
await apiClient.post(`/stores/${storeId}/videos/${videoId}/confirm`);
},
getStatus: async (storeId: string, videoId: string): Promise<VideoStatus> => {
const response = await apiClient.get<VideoStatus>(
`/stores/${storeId}/videos/${videoId}/status`
);
return response.data;
},
getResult: async (
storeId: string,
videoId: string
): Promise<ProcessingResult> => {
const response = await apiClient.get<ProcessingResult>(
`/stores/${storeId}/videos/${videoId}/result`
);
return response.data;
},
};

View File

@ -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<typeof authService>;
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);
});
});
});

View File

@ -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<typeof creditsService>;
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);
});
});
});

View File

@ -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<typeof feedbackService>;
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();
});
});
});

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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<typeof paymentsService>;
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();
});
});
});

View File

@ -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<typeof referralsService>;
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');
});
});
});

View File

@ -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<typeof storesService>;
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);
});
});
});

View File

@ -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<typeof validationsService>;
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);
});
});
});

137
src/stores/auth.store.ts Normal file
View File

@ -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<void>;
initiateRegistration: (phone: string, name: string) => Promise<void>;
verifyOtp: (phone: string, otp: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshTokens: () => Promise<void>;
setUser: (user: User) => void;
}
const secureStorage = {
getItem: async (name: string): Promise<string | null> => {
return await SecureStore.getItemAsync(name);
},
setItem: async (name: string, value: string): Promise<void> => {
await SecureStore.setItemAsync(name, value);
},
removeItem: async (name: string): Promise<void> => {
await SecureStore.deleteItemAsync(name);
},
};
export const useAuthStore = create<AuthState>()(
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,
}),
}
)
);

138
src/stores/credits.store.ts Normal file
View File

@ -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<void>;
fetchTransactions: (refresh?: boolean) => Promise<void>;
deductCredits: (amount: number) => void;
addCredits: (amount: number) => void;
clearError: () => void;
}
const MAX_CACHED_TRANSACTIONS = 50;
export const useCreditsStore = create<CreditsState>()(
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,
}),
}
)
);

View File

@ -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<void>;
correctSku: (
storeId: string,
itemId: string,
data: CorrectSkuRequest,
) => Promise<void>;
confirmItem: (storeId: string, itemId: string) => Promise<void>;
fetchCorrectionHistory: (storeId: string, itemId: string) => Promise<void>;
submitProduct: (data: SubmitProductRequest) => Promise<void>;
searchProducts: (query: string) => Promise<void>;
clearError: () => void;
reset: () => void;
}
export const useFeedbackStore = create<FeedbackState>((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,
}),
}));

View File

@ -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<void>;
fetchInventory: (storeId: string) => Promise<void>;
updateItem: (itemId: string, data: Partial<InventoryItem>) => Promise<void>;
deleteItem: (itemId: string) => Promise<void>;
setSelectedStore: (storeId: string) => void;
setSearchQuery: (query: string) => void;
setSelectedCategory: (category: string | null) => void;
clearError: () => void;
}
const MAX_CACHED_ITEMS = 100;
export const useInventoryStore = create<InventoryState>()(
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<InventoryItem>) => {
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,
}),
}
)
);

View File

@ -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<void>;
fetchUnreadCount: () => Promise<void>;
markAsRead: (notificationId: string) => Promise<void>;
markAllAsRead: () => Promise<void>;
registerFcmToken: (token: string) => Promise<void>;
clearError: () => void;
}
const MAX_CACHED_NOTIFICATIONS = 50;
export const useNotificationsStore = create<NotificationsState>()(
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,
}),
}
)
);

View File

@ -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<void>;
fetchPayments: (refresh?: boolean) => Promise<void>;
createPayment: (data: CreatePaymentRequest) => Promise<PaymentResponse | null>;
getPaymentById: (paymentId: string) => Promise<Payment | null>;
clearCurrentPayment: () => void;
clearError: () => void;
}
export const usePaymentsStore = create<PaymentsState>((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 }),
}));

View File

@ -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<void>;
fetchReferrals: (refresh?: boolean) => Promise<void>;
validateCode: (code: string) => Promise<{ valid: boolean; referrerName?: string }>;
applyCode: (code: string) => Promise<{ success: boolean; message: string }>;
clearError: () => void;
}
export const useReferralsStore = create<ReferralsState>((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 }),
}));

164
src/stores/stores.store.ts Normal file
View File

@ -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<void>;
selectStore: (store: Store) => void;
getStoreById: (storeId: string) => Promise<Store | null>;
createStore: (data: CreateStoreRequest) => Promise<Store | null>;
updateStore: (storeId: string, data: UpdateStoreRequest) => Promise<Store | null>;
deleteStore: (storeId: string) => Promise<boolean>;
clearError: () => void;
}
export const useStoresStore = create<StoresState>()(
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,
}),
}
)
);

View File

@ -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<boolean>;
fetchValidationItems: (requestId: string) => Promise<void>;
addResponse: (response: ValidationItemResponse) => void;
nextItem: () => void;
previousItem: () => void;
submitValidation: () => Promise<void>;
skipValidation: () => Promise<void>;
clearError: () => void;
reset: () => void;
}
export const useValidationsStore = create<ValidationsState>((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,
}),
}));

Some files were not shown because too many files have changed in this diff Show More