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:
parent
67fa906b6f
commit
eb718a95aa
5
.env.example
Normal file
5
.env.example
Normal 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
47
.eslintrc.js
Normal 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
11
.prettierrc
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": true,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"jsxSingleQuote": false
|
||||||
|
}
|
||||||
55
app.json
Normal file
55
app.json
Normal 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
25
babel.config.js
Normal 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
37
jest.config.js
Normal 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
71
jest.setup.js
Normal 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
63
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/__mocks__/apiClient.mock.ts
Normal file
49
src/__mocks__/apiClient.mock.ts
Normal 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;
|
||||||
16
src/app/(auth)/_layout.tsx
Normal file
16
src/app/(auth)/_layout.tsx
Normal 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
134
src/app/(auth)/login.tsx
Normal 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
136
src/app/(auth)/register.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
129
src/app/(auth)/verify-otp.tsx
Normal file
129
src/app/(auth)/verify-otp.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
48
src/app/(tabs)/_layout.tsx
Normal file
48
src/app/(tabs)/_layout.tsx
Normal 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
542
src/app/(tabs)/index.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
554
src/app/(tabs)/inventory.tsx
Normal file
554
src/app/(tabs)/inventory.tsx
Normal 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
531
src/app/(tabs)/profile.tsx
Normal 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
467
src/app/(tabs)/scan.tsx
Normal 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
50
src/app/_layout.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
23
src/app/credits/_layout.tsx
Normal file
23
src/app/credits/_layout.tsx
Normal 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
434
src/app/credits/buy.tsx
Normal 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
221
src/app/credits/history.tsx
Normal 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
292
src/app/help/index.tsx
Normal 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
12
src/app/index.tsx
Normal 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
603
src/app/inventory/[id].tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
23
src/app/inventory/_layout.tsx
Normal file
23
src/app/inventory/_layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
492
src/app/inventory/export.tsx
Normal file
492
src/app/inventory/export.tsx
Normal 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
197
src/app/legal/privacy.tsx
Normal 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
164
src/app/legal/terms.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
19
src/app/notifications/_layout.tsx
Normal file
19
src/app/notifications/_layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
312
src/app/notifications/index.tsx
Normal file
312
src/app/notifications/index.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
288
src/app/payments/methods.tsx
Normal file
288
src/app/payments/methods.tsx
Normal 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
316
src/app/profile/edit.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
19
src/app/referrals/_layout.tsx
Normal file
19
src/app/referrals/_layout.tsx
Normal 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
460
src/app/referrals/index.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
31
src/app/reports/_layout.tsx
Normal file
31
src/app/reports/_layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
479
src/app/reports/categories.tsx
Normal file
479
src/app/reports/categories.tsx
Normal 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
150
src/app/reports/index.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
371
src/app/reports/movements.tsx
Normal file
371
src/app/reports/movements.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
381
src/app/reports/valuation.tsx
Normal file
381
src/app/reports/valuation.tsx
Normal 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
264
src/app/stores/[id].tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
27
src/app/stores/_layout.tsx
Normal file
27
src/app/stores/_layout.tsx
Normal 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
301
src/app/stores/index.tsx
Normal 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
221
src/app/stores/new.tsx
Normal 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
317
src/app/support/index.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
27
src/app/validation/_layout.tsx
Normal file
27
src/app/validation/_layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
src/app/validation/complete.tsx
Normal file
165
src/app/validation/complete.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
301
src/app/validation/items.tsx
Normal file
301
src/app/validation/items.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
57
src/components/feedback/ConfirmItemButton.tsx
Normal file
57
src/components/feedback/ConfirmItemButton.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
203
src/components/feedback/CorrectQuantityModal.tsx
Normal file
203
src/components/feedback/CorrectQuantityModal.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
220
src/components/feedback/CorrectSkuModal.tsx
Normal file
220
src/components/feedback/CorrectSkuModal.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
147
src/components/feedback/CorrectionHistoryCard.tsx
Normal file
147
src/components/feedback/CorrectionHistoryCard.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
137
src/components/skeletons/CreditCardSkeleton.tsx
Normal file
137
src/components/skeletons/CreditCardSkeleton.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
86
src/components/skeletons/InventoryItemSkeleton.tsx
Normal file
86
src/components/skeletons/InventoryItemSkeleton.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
75
src/components/skeletons/NotificationSkeleton.tsx
Normal file
75
src/components/skeletons/NotificationSkeleton.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
80
src/components/skeletons/StoreCardSkeleton.tsx
Normal file
80
src/components/skeletons/StoreCardSkeleton.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
154
src/components/ui/AnimatedList.tsx
Normal file
154
src/components/ui/AnimatedList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/components/ui/OfflineBanner.tsx
Normal file
114
src/components/ui/OfflineBanner.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
215
src/components/ui/Skeleton.tsx
Normal file
215
src/components/ui/Skeleton.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
317
src/components/validation/ValidationItemCard.tsx
Normal file
317
src/components/validation/ValidationItemCard.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
63
src/components/validation/ValidationProgressBar.tsx
Normal file
63
src/components/validation/ValidationProgressBar.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
184
src/components/validation/ValidationPromptModal.tsx
Normal file
184
src/components/validation/ValidationPromptModal.tsx
Normal 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
186
src/hooks/useAnimations.ts
Normal 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 };
|
||||||
73
src/hooks/useNetworkStatus.ts
Normal file
73
src/hooks/useNetworkStatus.ts
Normal 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();
|
||||||
|
}
|
||||||
112
src/services/api/__tests__/auth.service.spec.ts
Normal file
112
src/services/api/__tests__/auth.service.spec.ts
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
119
src/services/api/__tests__/inventory.service.spec.ts
Normal file
119
src/services/api/__tests__/inventory.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
175
src/services/api/__tests__/reports.service.spec.ts
Normal file
175
src/services/api/__tests__/reports.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
62
src/services/api/auth.service.ts
Normal file
62
src/services/api/auth.service.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
58
src/services/api/client.ts
Normal file
58
src/services/api/client.ts
Normal 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;
|
||||||
61
src/services/api/credits.service.ts
Normal file
61
src/services/api/credits.service.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
143
src/services/api/exports.service.ts
Normal file
143
src/services/api/exports.service.ts
Normal 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();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
115
src/services/api/feedback.service.ts
Normal file
115
src/services/api/feedback.service.ts
Normal 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;
|
||||||
61
src/services/api/inventory.service.ts
Normal file
61
src/services/api/inventory.service.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
66
src/services/api/notifications.service.ts
Normal file
66
src/services/api/notifications.service.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
76
src/services/api/payments.service.ts
Normal file
76
src/services/api/payments.service.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
75
src/services/api/referrals.service.ts
Normal file
75
src/services/api/referrals.service.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
171
src/services/api/reports.service.ts
Normal file
171
src/services/api/reports.service.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
70
src/services/api/stores.service.ts
Normal file
70
src/services/api/stores.service.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
35
src/services/api/users.service.ts
Normal file
35
src/services/api/users.service.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
70
src/services/api/validations.service.ts
Normal file
70
src/services/api/validations.service.ts
Normal 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;
|
||||||
87
src/services/api/videos.service.ts
Normal file
87
src/services/api/videos.service.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
198
src/stores/__tests__/auth.store.spec.ts
Normal file
198
src/stores/__tests__/auth.store.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
98
src/stores/__tests__/credits.store.spec.ts
Normal file
98
src/stores/__tests__/credits.store.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
162
src/stores/__tests__/feedback.store.spec.ts
Normal file
162
src/stores/__tests__/feedback.store.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
200
src/stores/__tests__/inventory.store.spec.ts
Normal file
200
src/stores/__tests__/inventory.store.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
100
src/stores/__tests__/notifications.store.spec.ts
Normal file
100
src/stores/__tests__/notifications.store.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
152
src/stores/__tests__/payments.store.spec.ts
Normal file
152
src/stores/__tests__/payments.store.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
95
src/stores/__tests__/referrals.store.spec.ts
Normal file
95
src/stores/__tests__/referrals.store.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
149
src/stores/__tests__/stores.store.spec.ts
Normal file
149
src/stores/__tests__/stores.store.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
146
src/stores/__tests__/validations.store.spec.ts
Normal file
146
src/stores/__tests__/validations.store.spec.ts
Normal 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
137
src/stores/auth.store.ts
Normal 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
138
src/stores/credits.store.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
129
src/stores/feedback.store.ts
Normal file
129
src/stores/feedback.store.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
141
src/stores/inventory.store.ts
Normal file
141
src/stores/inventory.store.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
129
src/stores/notifications.store.ts
Normal file
129
src/stores/notifications.store.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
105
src/stores/payments.store.ts
Normal file
105
src/stores/payments.store.ts
Normal 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 }),
|
||||||
|
}));
|
||||||
101
src/stores/referrals.store.ts
Normal file
101
src/stores/referrals.store.ts
Normal 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
164
src/stores/stores.store.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
158
src/stores/validations.store.ts
Normal file
158
src/stores/validations.store.ts
Normal 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
Loading…
Reference in New Issue
Block a user