Migración desde michangarrito/frontend - Estándar multi-repo v2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 08:12:10 -06:00
parent 663614c75e
commit 67d3eef6a5
49 changed files with 10072 additions and 2 deletions

29
.dockerignore Normal file
View File

@ -0,0 +1,29 @@
# Dependencies
node_modules
npm-debug.log
# Build output
dist
# IDE
.idea
.vscode
*.swp
*.swo
# Environment
.env
.env.*
!.env.example
# Git
.git
.gitignore
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Documentation
README.md

9
.env Normal file
View File

@ -0,0 +1,9 @@
# MiChangarrito Frontend - Environment Variables
# API Configuration
VITE_API_URL=http://localhost:3141
VITE_API_PREFIX=/api/v1
# App Configuration
VITE_APP_NAME=MiChangarrito
VITE_APP_VERSION=1.0.0

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

63
Dockerfile Normal file
View File

@ -0,0 +1,63 @@
# =============================================================================
# MiChangarrito - Frontend Dockerfile
# =============================================================================
# Multi-stage build for React + Vite application
# Served by nginx
# Puerto: 80 (interno) -> 3140 (host)
# =============================================================================
# Build stage
FROM node:20-alpine AS builder
# Build argument for API URL
ARG VITE_API_URL=http://localhost:3141/api/v1
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Set environment variable for build
ENV VITE_API_URL=$VITE_API_URL
# Build application
RUN npm run build
# Production stage with nginx
FROM nginx:alpine AS production
# Labels
LABEL maintainer="ISEM"
LABEL description="MiChangarrito Frontend Web"
LABEL version="1.0.0"
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built assets from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Create non-root user
RUN addgroup -g 1001 -S nginx-user && \
adduser -S nginx-user -u 1001 -G nginx-user && \
chown -R nginx-user:nginx-user /usr/share/nginx/html && \
chown -R nginx-user:nginx-user /var/cache/nginx && \
chown -R nginx-user:nginx-user /var/log/nginx && \
touch /var/run/nginx.pid && \
chown -R nginx-user:nginx-user /var/run/nginx.pid
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80 || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@ -1,3 +1,73 @@
# michangarrito-frontend-v2
# React + TypeScript + Vite
Frontend de michangarrito - Workspace V2
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

144
e2e/auth.spec.ts Normal file
View File

@ -0,0 +1,144 @@
import { test, expect } from '@playwright/test';
/**
* E2E Tests - Autenticacion
* MiChangarrito Frontend
*/
test.describe('Autenticacion', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('debe mostrar la pagina de login', async ({ page }) => {
// Verificar titulo
await expect(page.locator('h1')).toContainText('MiChangarrito');
await expect(page.locator('h2')).toContainText('Iniciar sesion');
// Verificar campos del formulario
await expect(page.locator('input[name="phone"]')).toBeVisible();
await expect(page.locator('input[name="pin"]')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toBeVisible();
// Verificar link a registro
await expect(page.locator('a[href="/register"]')).toBeVisible();
});
test('debe validar campos requeridos', async ({ page }) => {
// Intentar enviar formulario vacio
await page.locator('button[type="submit"]').click();
// Los campos tienen required, el navegador debe validar
const phoneInput = page.locator('input[name="phone"]');
await expect(phoneInput).toHaveAttribute('required', '');
});
test('debe aceptar solo numeros en telefono', async ({ page }) => {
const phoneInput = page.locator('input[name="phone"]');
// Escribir texto con letras
await phoneInput.fill('abc123def456');
// Debe filtrar solo numeros
await expect(phoneInput).toHaveValue('123456');
});
test('debe limitar telefono a 10 digitos', async ({ page }) => {
const phoneInput = page.locator('input[name="phone"]');
await phoneInput.fill('12345678901234');
// maxLength=10
await expect(phoneInput).toHaveAttribute('maxLength', '10');
});
test('debe aceptar solo numeros en PIN', async ({ page }) => {
const pinInput = page.locator('input[name="pin"]');
await pinInput.fill('abc1234');
// Debe filtrar solo numeros
await expect(pinInput).toHaveValue('1234');
});
test('debe mostrar error con credenciales invalidas', async ({ page }) => {
// Llenar formulario con datos invalidos
await page.locator('input[name="phone"]').fill('5500000000');
await page.locator('input[name="pin"]').fill('0000');
// Enviar
await page.locator('button[type="submit"]').click();
// Esperar respuesta del servidor
await page.waitForTimeout(1000);
// Debe mostrar mensaje de error (si el backend esta corriendo)
// Si no hay backend, verificamos que el boton vuelve a estar habilitado
const submitButton = page.locator('button[type="submit"]');
await expect(submitButton).not.toBeDisabled();
});
test('debe mostrar estado de carga al enviar', async ({ page }) => {
// Llenar formulario
await page.locator('input[name="phone"]').fill('5512345678');
await page.locator('input[name="pin"]').fill('1234');
// Enviar y verificar estado de carga
const submitButton = page.locator('button[type="submit"]');
await submitButton.click();
// Verificar que muestra "Ingresando..." mientras carga
// Nota: Esto es rapido, puede no capturarse siempre
await expect(submitButton).toContainText(/Ingresar|Ingresando/);
});
test('debe navegar a registro', async ({ page }) => {
await page.locator('a[href="/register"]').click();
await expect(page).toHaveURL('/register');
});
test('debe redirigir a login si no esta autenticado', async ({ page }) => {
// Intentar acceder a ruta protegida
await page.goto('/dashboard');
// Debe redirigir a login
await expect(page).toHaveURL('/login');
});
test('debe redirigir a login desde rutas protegidas', async ({ page }) => {
const protectedRoutes = [
'/dashboard',
'/products',
'/orders',
'/customers',
'/fiado',
'/inventory',
'/settings',
];
for (const route of protectedRoutes) {
await page.goto(route);
await expect(page).toHaveURL('/login');
}
});
});
test.describe('Registro', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/register');
});
test('debe mostrar la pagina de registro', async ({ page }) => {
await expect(page.locator('h1')).toContainText('MiChangarrito');
// Verificar link a login
await expect(page.locator('a[href="/login"]')).toBeVisible();
});
test('debe navegar a login desde registro', async ({ page }) => {
await page.locator('a[href="/login"]').click();
await expect(page).toHaveURL('/login');
});
});

48
e2e/fixtures/test-data.ts Normal file
View File

@ -0,0 +1,48 @@
/**
* Test Data Fixtures - MiChangarrito E2E Tests
*/
export const TEST_USER = {
phone: '5512345678',
pin: '1234',
name: 'Usuario Test',
businessName: 'Tienda Test',
};
export const TEST_PRODUCT = {
name: 'Coca-Cola 600ml',
sku: 'CC600',
barcode: '7501055300000',
price: 18.00,
cost: 12.00,
stock: 50,
category: 'Bebidas',
};
export const TEST_CUSTOMER = {
name: 'Juan Perez',
phone: '5598765432',
email: 'juan@test.com',
creditLimit: 500,
};
export const TEST_ORDER = {
items: [
{ productId: '1', quantity: 2, price: 18.00 },
{ productId: '2', quantity: 1, price: 25.00 },
],
total: 61.00,
paymentMethod: 'cash',
};
export const ROUTES = {
login: '/login',
register: '/register',
dashboard: '/dashboard',
products: '/products',
orders: '/orders',
customers: '/customers',
fiado: '/fiado',
inventory: '/inventory',
settings: '/settings',
};

120
e2e/navigation.spec.ts Normal file
View File

@ -0,0 +1,120 @@
import { test, expect } from '@playwright/test';
/**
* E2E Tests - Navegacion
* MiChangarrito Frontend
*
* Nota: Estos tests requieren autenticacion
* Se usan con un usuario de prueba pre-configurado
*/
test.describe('Navegacion Publica', () => {
test('debe cargar la aplicacion', async ({ page }) => {
await page.goto('/');
// Al no estar autenticado, debe redirigir a login
await expect(page).toHaveURL('/login');
});
test('rutas invalidas redirigen a login', async ({ page }) => {
await page.goto('/ruta-que-no-existe');
await expect(page).toHaveURL('/login');
});
test('login page tiene titulo correcto', async ({ page }) => {
await page.goto('/login');
await expect(page).toHaveTitle(/MiChangarrito/i);
});
});
test.describe('UI Responsiva', () => {
test('login se adapta a mobile', async ({ page }) => {
// Viewport mobile
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/login');
// El formulario debe ser visible y usable
await expect(page.locator('form')).toBeVisible();
await expect(page.locator('input[name="phone"]')).toBeVisible();
await expect(page.locator('input[name="pin"]')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toBeVisible();
});
test('login se adapta a tablet', async ({ page }) => {
// Viewport tablet
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto('/login');
await expect(page.locator('form')).toBeVisible();
});
test('login se adapta a desktop', async ({ page }) => {
// Viewport desktop
await page.setViewportSize({ width: 1920, height: 1080 });
await page.goto('/login');
await expect(page.locator('form')).toBeVisible();
});
});
test.describe('Accesibilidad', () => {
test('campos tienen labels', async ({ page }) => {
await page.goto('/login');
// Verificar que los inputs tienen labels asociados
const phoneLabel = page.locator('label[for="phone"]');
const pinLabel = page.locator('label[for="pin"]');
await expect(phoneLabel).toBeVisible();
await expect(pinLabel).toBeVisible();
});
test('formulario es navegable con teclado', async ({ page }) => {
await page.goto('/login');
// Tab al primer campo
await page.keyboard.press('Tab');
const phoneInput = page.locator('input[name="phone"]');
await expect(phoneInput).toBeFocused();
// Tab al segundo campo
await page.keyboard.press('Tab');
const pinInput = page.locator('input[name="pin"]');
await expect(pinInput).toBeFocused();
// Tab al boton
await page.keyboard.press('Tab');
const submitButton = page.locator('button[type="submit"]');
await expect(submitButton).toBeFocused();
});
test('formulario puede enviarse con Enter', async ({ page }) => {
await page.goto('/login');
// Llenar campos
await page.locator('input[name="phone"]').fill('5512345678');
await page.locator('input[name="pin"]').fill('1234');
// Enviar con Enter
await page.keyboard.press('Enter');
// El formulario debe intentar enviarse (el boton estara en estado loading)
const submitButton = page.locator('button[type="submit"]');
// Puede estar disabled durante el envio o ya haber terminado
await page.waitForTimeout(500);
});
});
test.describe('Performance', () => {
test('login carga en menos de 3 segundos', async ({ page }) => {
const startTime = Date.now();
await page.goto('/login');
await expect(page.locator('h1')).toBeVisible();
const loadTime = Date.now() - startTime;
expect(loadTime).toBeLessThan(3000);
});
});

149
e2e/orders.spec.ts Normal file
View File

@ -0,0 +1,149 @@
import { test, expect } from '@playwright/test';
import { TEST_ORDER, TEST_CUSTOMER, ROUTES } from './fixtures/test-data';
/**
* E2E Tests - Pedidos y Fiado
* MiChangarrito Frontend
*
* Nota: Estos tests requieren autenticacion previa
* y un backend corriendo con datos de prueba
*/
test.describe('Pedidos - Sin Autenticacion', () => {
test('redirige a login si no esta autenticado', async ({ page }) => {
await page.goto(ROUTES.orders);
await expect(page).toHaveURL('/login');
});
test('fiado redirige a login sin autenticacion', async ({ page }) => {
await page.goto(ROUTES.fiado);
await expect(page).toHaveURL('/login');
});
test('clientes redirige a login sin autenticacion', async ({ page }) => {
await page.goto(ROUTES.customers);
await expect(page).toHaveURL('/login');
});
});
test.describe('Pedidos - Estructura de Datos', () => {
test('datos de pedido de prueba tienen estructura correcta', () => {
expect(TEST_ORDER.items).toBeInstanceOf(Array);
expect(TEST_ORDER.items.length).toBeGreaterThan(0);
expect(TEST_ORDER.total).toBeGreaterThan(0);
expect(TEST_ORDER.paymentMethod).toBeDefined();
// Verificar estructura de items
const item = TEST_ORDER.items[0];
expect(item.productId).toBeDefined();
expect(item.quantity).toBeGreaterThan(0);
expect(item.price).toBeGreaterThan(0);
});
test('datos de cliente de prueba tienen estructura correcta', () => {
expect(TEST_CUSTOMER.name).toBeDefined();
expect(TEST_CUSTOMER.phone).toBeDefined();
expect(TEST_CUSTOMER.phone.length).toBe(10);
expect(TEST_CUSTOMER.creditLimit).toBeGreaterThanOrEqual(0);
});
test('total del pedido es correcto', () => {
// Verificar calculo: 2 * 18.00 + 1 * 25.00 = 61.00
const calculatedTotal = TEST_ORDER.items.reduce(
(sum, item) => sum + item.quantity * item.price,
0
);
expect(calculatedTotal).toBe(TEST_ORDER.total);
});
});
test.describe('Sistema de Fiado', () => {
test('rutas de fiado protegidas', async ({ page }) => {
const fiadoRoutes = [
'/fiado',
'/customers',
];
for (const route of fiadoRoutes) {
await page.goto(route);
await expect(page).toHaveURL('/login');
}
});
});
test.describe('Historial de Pedidos', () => {
test('ruta de pedidos protegida', async ({ page }) => {
await page.goto('/orders');
await expect(page).toHaveURL('/login');
});
test('ruta de dashboard protegida', async ({ page }) => {
await page.goto(ROUTES.dashboard);
await expect(page).toHaveURL('/login');
});
});
test.describe('Clientes', () => {
test('ruta de clientes protegida', async ({ page }) => {
await page.goto('/customers');
await expect(page).toHaveURL('/login');
});
test('validacion de limite de credito', () => {
// El limite de credito debe ser un numero positivo o cero
expect(TEST_CUSTOMER.creditLimit).toBeGreaterThanOrEqual(0);
expect(typeof TEST_CUSTOMER.creditLimit).toBe('number');
});
});
test.describe('Metodos de Pago', () => {
test('metodo de pago valido', () => {
const validPaymentMethods = ['cash', 'card', 'transfer', 'fiado'];
expect(validPaymentMethods).toContain(TEST_ORDER.paymentMethod);
});
});
test.describe('Configuracion', () => {
test('ruta de configuracion protegida', async ({ page }) => {
await page.goto(ROUTES.settings);
await expect(page).toHaveURL('/login');
});
});
test.describe('Rutas Completas', () => {
test('todas las rutas definidas en fixtures existen', () => {
const expectedRoutes = [
'login',
'register',
'dashboard',
'products',
'orders',
'customers',
'fiado',
'inventory',
'settings',
];
for (const route of expectedRoutes) {
expect(ROUTES[route as keyof typeof ROUTES]).toBeDefined();
expect(ROUTES[route as keyof typeof ROUTES]).toMatch(/^\//);
}
});
test('todas las rutas protegidas redirigen a login', async ({ page }) => {
const protectedRoutes = [
ROUTES.dashboard,
ROUTES.products,
ROUTES.orders,
ROUTES.customers,
ROUTES.fiado,
ROUTES.inventory,
ROUTES.settings,
];
for (const route of protectedRoutes) {
await page.goto(route);
await expect(page).toHaveURL('/login');
}
});
});

103
e2e/pos.spec.ts Normal file
View File

@ -0,0 +1,103 @@
import { test, expect } from '@playwright/test';
import { TEST_PRODUCT, ROUTES } from './fixtures/test-data';
/**
* E2E Tests - Punto de Venta (POS)
* MiChangarrito Frontend
*
* Nota: Estos tests requieren autenticacion previa
* y un backend corriendo con datos de prueba
*/
test.describe('Punto de Venta - Sin Autenticacion', () => {
test('redirige a login si no esta autenticado', async ({ page }) => {
await page.goto('/pos');
await expect(page).toHaveURL('/login');
});
});
test.describe('Punto de Venta - UI Basica', () => {
// Estos tests verifican la estructura esperada del POS
// cuando el usuario esta autenticado
test.beforeEach(async ({ page }) => {
// Mock de autenticacion para tests
// En un escenario real, se usaria un fixture de login
await page.goto('/login');
});
test('pagina de productos existe', async ({ page }) => {
await page.goto(ROUTES.products);
// Sin auth, redirige a login
await expect(page).toHaveURL('/login');
});
});
test.describe('Punto de Venta - Flujo de Venta', () => {
// Tests de flujo completo de venta
// Requieren setup de autenticacion
test('estructura del formulario de busqueda', async ({ page }) => {
await page.goto('/login');
// Verificar que la pagina de login tiene estructura correcta
// antes de poder probar el POS
await expect(page.locator('form')).toBeVisible();
});
});
test.describe('Carrito de Compras', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('login tiene campos necesarios para acceder al POS', async ({ page }) => {
// Verificar campos necesarios
await expect(page.locator('input[name="phone"]')).toBeVisible();
await expect(page.locator('input[name="pin"]')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toBeVisible();
});
});
test.describe('Busqueda de Productos', () => {
test('rutas de productos protegidas', async ({ page }) => {
// Verificar que todas las rutas relacionadas al POS estan protegidas
const posRoutes = [
'/products',
'/pos',
'/inventory',
];
for (const route of posRoutes) {
await page.goto(route);
await expect(page).toHaveURL('/login');
}
});
});
test.describe('Metodos de Pago', () => {
test('verificar existencia de ruta de pedidos', async ({ page }) => {
await page.goto(ROUTES.orders);
// Sin auth, redirige
await expect(page).toHaveURL('/login');
});
});
test.describe('Recibos y Tickets', () => {
test('estructura de datos de producto de prueba', () => {
// Verificar que los datos de prueba tienen la estructura correcta
expect(TEST_PRODUCT.name).toBeDefined();
expect(TEST_PRODUCT.price).toBeGreaterThan(0);
expect(TEST_PRODUCT.cost).toBeGreaterThan(0);
expect(TEST_PRODUCT.stock).toBeGreaterThanOrEqual(0);
expect(TEST_PRODUCT.sku).toBeDefined();
expect(TEST_PRODUCT.barcode).toBeDefined();
});
});
test.describe('Inventario desde POS', () => {
test('ruta de inventario protegida', async ({ page }) => {
await page.goto(ROUTES.inventory);
await expect(page).toHaveURL('/login');
});
});

23
eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

42
nginx.conf Normal file
View File

@ -0,0 +1,42 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Deny access to hidden files
location ~ /\. {
deny all;
}
}

4317
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:report": "playwright show-report"
},
"dependencies": {
"@tanstack/react-query": "^5.90.16",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.11.0"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@eslint/js": "^9.39.1",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

53
playwright.config.ts Normal file
View File

@ -0,0 +1,53 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright E2E Test Configuration - MiChangarrito Frontend
* @see https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['list'],
],
use: {
baseURL: 'http://localhost:3140',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3140',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

95
src/App.tsx Normal file
View File

@ -0,0 +1,95 @@
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { Layout } from './components/Layout';
import { Dashboard } from './pages/Dashboard';
import { Products } from './pages/Products';
import { Orders } from './pages/Orders';
import { Customers } from './pages/Customers';
import { Fiado } from './pages/Fiado';
import { Inventory } from './pages/Inventory';
import { Settings } from './pages/Settings';
import { Referrals } from './pages/Referrals';
import { Invoices } from './pages/Invoices';
import { Marketplace } from './pages/Marketplace';
import Login from './pages/Login';
import Register from './pages/Register';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
});
// Protected route wrapper
function ProtectedRoute() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-emerald-600"></div>
</div>
);
}
return isAuthenticated ? <Outlet /> : <Navigate to="/login" replace />;
}
// Public route wrapper (redirect if authenticated)
function PublicRoute() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-emerald-600"></div>
</div>
);
}
return isAuthenticated ? <Navigate to="/dashboard" replace /> : <Outlet />;
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<BrowserRouter>
<Routes>
{/* Public routes */}
<Route element={<PublicRoute />}>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
</Route>
{/* Protected routes */}
<Route element={<ProtectedRoute />}>
<Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="products" element={<Products />} />
<Route path="orders" element={<Orders />} />
<Route path="customers" element={<Customers />} />
<Route path="fiado" element={<Fiado />} />
<Route path="inventory" element={<Inventory />} />
<Route path="referrals" element={<Referrals />} />
<Route path="invoices" element={<Invoices />} />
<Route path="marketplace" element={<Marketplace />} />
<Route path="settings" element={<Settings />} />
</Route>
</Route>
{/* Catch all */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</QueryClientProvider>
);
}
export default App;

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

173
src/components/Layout.tsx Normal file
View File

@ -0,0 +1,173 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
Package,
ShoppingCart,
Users,
CreditCard,
Boxes,
Settings,
Menu,
X,
Store,
LogOut,
Gift,
FileText,
Truck,
} from 'lucide-react';
import { useState } from 'react';
import clsx from 'clsx';
import { useAuth } from '../contexts/AuthContext';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'Productos', href: '/products', icon: Package },
{ name: 'Pedidos', href: '/orders', icon: ShoppingCart },
{ name: 'Clientes', href: '/customers', icon: Users },
{ name: 'Fiado', href: '/fiado', icon: CreditCard },
{ name: 'Inventario', href: '/inventory', icon: Boxes },
{ name: 'Facturacion', href: '/invoices', icon: FileText },
{ name: 'Proveedores', href: '/marketplace', icon: Truck },
{ name: 'Referidos', href: '/referrals', icon: Gift },
{ name: 'Ajustes', href: '/settings', icon: Settings },
];
export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { user, tenant, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login');
};
const getInitials = (name: string) => {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
return (
<div className="min-h-screen bg-gray-50">
{/* Mobile sidebar */}
<div
className={clsx(
'fixed inset-0 z-50 lg:hidden',
sidebarOpen ? 'block' : 'hidden'
)}
>
<div
className="fixed inset-0 bg-gray-900/80"
onClick={() => setSidebarOpen(false)}
/>
<div className="fixed inset-y-0 left-0 w-64 bg-white shadow-xl">
<div className="flex h-16 items-center justify-between px-4 border-b">
<div className="flex items-center gap-2">
<Store className="h-8 w-8 text-primary-500" />
<span className="font-bold text-lg">MiChangarrito</span>
</div>
<button onClick={() => setSidebarOpen(false)}>
<X className="h-6 w-6" />
</button>
</div>
<nav className="p-4 space-y-1">
{navigation.map((item) => (
<NavLink
key={item.name}
to={item.href}
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-lg transition-colors',
isActive
? 'bg-primary-50 text-primary-600'
: 'text-gray-700 hover:bg-gray-100'
)
}
>
<item.icon className="h-5 w-5" />
{item.name}
</NavLink>
))}
</nav>
</div>
</div>
{/* Desktop sidebar */}
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
<div className="flex flex-col flex-grow bg-white border-r border-gray-200">
<div className="flex h-16 items-center px-4 border-b">
<div className="flex items-center gap-2">
<Store className="h-8 w-8 text-primary-500" />
<span className="font-bold text-lg">MiChangarrito</span>
</div>
</div>
<nav className="flex-1 p-4 space-y-1">
{navigation.map((item) => (
<NavLink
key={item.name}
to={item.href}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-lg transition-colors',
isActive
? 'bg-primary-50 text-primary-600'
: 'text-gray-700 hover:bg-gray-100'
)
}
>
<item.icon className="h-5 w-5" />
{item.name}
</NavLink>
))}
</nav>
<div className="p-4 border-t">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-emerald-100 flex items-center justify-center">
<span className="text-emerald-600 font-semibold">
{tenant ? getInitials(tenant.name) : 'MC'}
</span>
</div>
<div>
<p className="text-sm font-medium">{tenant?.name || 'Mi Negocio'}</p>
<p className="text-xs text-gray-500">{user?.name}</p>
</div>
</div>
<button
onClick={handleLogout}
className="p-2 text-gray-400 hover:text-red-500 transition-colors"
title="Cerrar sesion"
>
<LogOut className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
{/* Main content */}
<div className="lg:pl-64">
{/* Mobile header */}
<div className="sticky top-0 z-40 flex h-16 items-center gap-4 bg-white border-b px-4 lg:hidden">
<button onClick={() => setSidebarOpen(true)}>
<Menu className="h-6 w-6" />
</button>
<div className="flex items-center gap-2">
<Store className="h-6 w-6 text-primary-500" />
<span className="font-bold">MiChangarrito</span>
</div>
</div>
{/* Page content */}
<main className="p-4 lg:p-8">
<Outlet />
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,112 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Copy, Check, Building } from 'lucide-react';
import { codiSpeiApi } from '../../lib/api';
interface ClabeDisplayProps {
showCreateButton?: boolean;
beneficiaryName?: string;
}
export function ClabeDisplay({ showCreateButton = true, beneficiaryName }: ClabeDisplayProps) {
const [copied, setCopied] = useState(false);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['clabe'],
queryFn: async () => {
const res = await codiSpeiApi.getClabe();
return res.data;
},
});
const createMutation = useMutation({
mutationFn: (name: string) => codiSpeiApi.createClabe(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clabe'] });
},
});
const copyClabe = async () => {
if (data?.clabe) {
await navigator.clipboard.writeText(data.clabe);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const formatClabe = (clabe: string) => {
// Format: XXX XXX XXXX XXXX XXXX
return clabe.replace(/(\d{3})(\d{3})(\d{4})(\d{4})(\d{4})/, '$1 $2 $3 $4 $5');
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
);
}
if (!data?.clabe && showCreateButton) {
return (
<div className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3 mb-3">
<Building className="h-6 w-6 text-gray-400" />
<div>
<h4 className="font-medium">CLABE Virtual</h4>
<p className="text-sm text-gray-500">Recibe transferencias SPEI</p>
</div>
</div>
<button
onClick={() => createMutation.mutate(beneficiaryName || 'Mi Negocio')}
disabled={createMutation.isPending}
className="w-full px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50"
>
{createMutation.isPending ? 'Creando...' : 'Crear CLABE Virtual'}
</button>
</div>
);
}
if (!data?.clabe) {
return null;
}
return (
<div className="p-4 bg-blue-50 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Building className="h-5 w-5 text-blue-600" />
<span className="text-sm font-medium text-blue-800">Tu CLABE para recibir SPEI</span>
</div>
<button
onClick={copyClabe}
className="p-2 rounded-lg hover:bg-blue-100 transition-colors"
title="Copiar CLABE"
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4 text-blue-600" />
)}
</button>
</div>
<div className="bg-white rounded-lg p-3">
<p className="font-mono text-lg text-center font-bold tracking-wider">
{formatClabe(data.clabe)}
</p>
{data.beneficiaryName && (
<p className="text-sm text-gray-500 text-center mt-1">
{data.beneficiaryName}
</p>
)}
</div>
<p className="text-xs text-blue-600 text-center mt-2">
Las transferencias se reflejan automaticamente
</p>
</div>
);
}

View File

@ -0,0 +1,167 @@
import { useState, useEffect } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { QrCode, Clock, Check, X, RefreshCw } from 'lucide-react';
import { codiSpeiApi } from '../../lib/api';
interface CodiQRProps {
amount: number;
description?: string;
saleId?: string;
onSuccess?: () => void;
onCancel?: () => void;
}
export function CodiQR({ amount, description, saleId, onSuccess, onCancel }: CodiQRProps) {
const [transactionId, setTransactionId] = useState<string | null>(null);
const [timeLeft, setTimeLeft] = useState<number>(300); // 5 minutes in seconds
const generateMutation = useMutation({
mutationFn: () => codiSpeiApi.generateQr({ amount, description, saleId }),
onSuccess: (res) => {
setTransactionId(res.data.id);
setTimeLeft(300);
},
});
const { data: status, refetch } = useQuery({
queryKey: ['codi-status', transactionId],
queryFn: async () => {
if (!transactionId) return null;
const res = await codiSpeiApi.getCodiStatus(transactionId);
return res.data;
},
enabled: !!transactionId,
refetchInterval: 3000, // Poll every 3 seconds
});
// Generate QR on mount
useEffect(() => {
generateMutation.mutate();
}, []);
// Countdown timer
useEffect(() => {
if (timeLeft <= 0) return;
const timer = setInterval(() => {
setTimeLeft((prev) => prev - 1);
}, 1000);
return () => clearInterval(timer);
}, [timeLeft]);
// Handle status changes
useEffect(() => {
if (status?.status === 'confirmed') {
onSuccess?.();
} else if (status?.status === 'expired' || status?.status === 'cancelled') {
// QR expired or cancelled
}
}, [status?.status]);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const handleRefresh = () => {
generateMutation.mutate();
};
if (generateMutation.isPending) {
return (
<div className="flex flex-col items-center justify-center p-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<p className="text-gray-500">Generando QR de cobro...</p>
</div>
);
}
if (status?.status === 'confirmed') {
return (
<div className="flex flex-col items-center justify-center p-8">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
<Check className="h-8 w-8 text-green-600" />
</div>
<h3 className="text-xl font-bold text-green-600 mb-2">Pago Confirmado</h3>
<p className="text-gray-500">El pago de ${amount.toFixed(2)} fue recibido</p>
</div>
);
}
if (status?.status === 'expired' || timeLeft <= 0) {
return (
<div className="flex flex-col items-center justify-center p-8">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
<X className="h-8 w-8 text-red-600" />
</div>
<h3 className="text-xl font-bold text-red-600 mb-2">QR Expirado</h3>
<p className="text-gray-500 mb-4">El codigo QR ha expirado</p>
<button
onClick={handleRefresh}
className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600"
>
<RefreshCw className="h-4 w-4" />
Generar nuevo QR
</button>
</div>
);
}
return (
<div className="flex flex-col items-center p-6">
<div className="text-center mb-4">
<h3 className="text-lg font-bold mb-1">Pagar con CoDi</h3>
<p className="text-2xl font-bold text-primary-600">${amount.toFixed(2)}</p>
{description && <p className="text-sm text-gray-500">{description}</p>}
</div>
{/* QR Code Display */}
<div className="bg-white p-4 rounded-xl shadow-lg mb-4">
<div className="w-48 h-48 bg-gray-100 rounded-lg flex items-center justify-center">
{/* In production, generate actual QR code from qrData */}
<div className="text-center">
<QrCode className="h-32 w-32 text-gray-800 mx-auto" />
<p className="text-xs text-gray-400 mt-2">Escanea con tu app bancaria</p>
</div>
</div>
</div>
{/* Timer */}
<div className="flex items-center gap-2 text-gray-600 mb-4">
<Clock className="h-4 w-4" />
<span>Expira en: {formatTime(timeLeft)}</span>
</div>
{/* Instructions */}
<div className="text-center text-sm text-gray-500 mb-4">
<p>1. Abre la app de tu banco</p>
<p>2. Escanea el codigo QR</p>
<p>3. Confirma el pago</p>
</div>
{/* Actions */}
<div className="flex gap-3 w-full">
<button
onClick={handleRefresh}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
>
<RefreshCw className="h-4 w-4" />
Regenerar
</button>
<button
onClick={onCancel}
className="flex-1 px-4 py-2 text-red-600 border border-red-300 rounded-lg hover:bg-red-50"
>
Cancelar
</button>
</div>
{/* Reference */}
{status?.reference && (
<p className="text-xs text-gray-400 mt-4">Ref: {status.reference}</p>
)}
</div>
);
}

View File

@ -0,0 +1,118 @@
import { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react';
import { authApi } from '../lib/api';
interface User {
id: string;
name: string;
role: string;
phone: string;
}
interface Tenant {
id: string;
name: string;
slug: string;
businessType: string;
subscriptionStatus: string;
}
interface AuthContextType {
user: User | null;
tenant: Tenant | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (phone: string, pin: string) => Promise<void>;
register: (data: RegisterData) => Promise<void>;
logout: () => void;
}
interface RegisterData {
name: string;
ownerName: string;
businessType: string;
phone: string;
pin: string;
email?: string;
whatsapp?: string;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [tenant, setTenant] = useState<Tenant | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check for existing session
const storedUser = localStorage.getItem('user');
const storedTenant = localStorage.getItem('tenant');
const accessToken = localStorage.getItem('accessToken');
if (storedUser && storedTenant && accessToken) {
setUser(JSON.parse(storedUser));
setTenant(JSON.parse(storedTenant));
}
setIsLoading(false);
}, []);
const login = async (phone: string, pin: string) => {
const response = await authApi.login({ phone, pin });
const { accessToken, refreshToken, user: userData, tenant: tenantData } = response.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
localStorage.setItem('user', JSON.stringify(userData));
localStorage.setItem('tenant', JSON.stringify(tenantData));
setUser(userData);
setTenant(tenantData);
};
const register = async (data: RegisterData) => {
const response = await authApi.register(data);
const { accessToken, refreshToken, user: userData, tenant: tenantData } = response.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
localStorage.setItem('user', JSON.stringify(userData));
localStorage.setItem('tenant', JSON.stringify(tenantData));
setUser(userData);
setTenant(tenantData);
};
const logout = () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
localStorage.removeItem('tenant');
setUser(null);
setTenant(null);
};
return (
<AuthContext.Provider
value={{
user,
tenant,
isAuthenticated: !!user,
isLoading,
login,
register,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

53
src/index.css Normal file
View File

@ -0,0 +1,53 @@
@import "tailwindcss";
@theme {
--color-primary-50: #fff7ed;
--color-primary-100: #ffedd5;
--color-primary-200: #fed7aa;
--color-primary-300: #fdba74;
--color-primary-400: #fb923c;
--color-primary-500: #f97316;
--color-primary-600: #ea580c;
--color-primary-700: #c2410c;
--color-primary-800: #9a3412;
--color-primary-900: #7c2d12;
--color-secondary-50: #f0fdf4;
--color-secondary-100: #dcfce7;
--color-secondary-200: #bbf7d0;
--color-secondary-300: #86efac;
--color-secondary-400: #4ade80;
--color-secondary-500: #22c55e;
--color-secondary-600: #16a34a;
--color-secondary-700: #15803d;
--color-secondary-800: #166534;
--color-secondary-900: #14532d;
}
@layer base {
body {
@apply bg-gray-50 text-gray-900;
}
}
@layer components {
.btn-primary {
@apply bg-primary-500 hover:bg-primary-600 text-white font-medium py-2 px-4 rounded-lg transition-colors;
}
.btn-secondary {
@apply bg-secondary-500 hover:bg-secondary-600 text-white font-medium py-2 px-4 rounded-lg transition-colors;
}
.btn-outline {
@apply border border-gray-300 hover:border-gray-400 text-gray-700 font-medium py-2 px-4 rounded-lg transition-colors;
}
.card {
@apply bg-white rounded-xl shadow-sm border border-gray-200 p-4;
}
.input {
@apply w-full rounded-lg border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500;
}
}

216
src/lib/api.ts Normal file
View File

@ -0,0 +1,216 @@
import axios, { AxiosError } from 'axios';
// Use proxy in development (vite will forward to backend)
// In production, use VITE_API_URL
const API_BASE = import.meta.env.VITE_API_URL || '';
const API_PREFIX = import.meta.env.VITE_API_PREFIX || '/api/v1';
export const api = axios.create({
baseURL: `${API_BASE}${API_PREFIX}`,
headers: {
'Content-Type': 'application/json',
},
});
// Add auth interceptor - attach token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor - handle 401 errors
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
if (error.response?.status === 401) {
// Try refresh token
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
try {
const response = await axios.post(`${API_BASE}${API_PREFIX}/auth/refresh`, {
refreshToken,
});
const { accessToken, refreshToken: newRefresh } = response.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', newRefresh);
// Retry original request
if (error.config) {
error.config.headers.Authorization = `Bearer ${accessToken}`;
return axios(error.config);
}
} catch {
// Refresh failed, clear tokens and redirect to login
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
localStorage.removeItem('tenant');
window.location.href = '/login';
}
} else {
// No refresh token, redirect to login
localStorage.removeItem('accessToken');
localStorage.removeItem('user');
localStorage.removeItem('tenant');
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
// Auth API
export const authApi = {
register: (data: {
name: string;
ownerName: string;
businessType: string;
phone: string;
pin: string;
email?: string;
whatsapp?: string;
}) => api.post('/auth/register', data),
login: (data: { phone: string; pin: string }) =>
api.post('/auth/login', data),
refresh: (refreshToken: string) =>
api.post('/auth/refresh', { refreshToken }),
changePin: (data: { currentPin: string; newPin: string }) =>
api.post('/auth/change-pin', data),
};
// Products API
export const productsApi = {
getAll: (params?: { category?: string; search?: string }) =>
api.get('/products', { params }),
getById: (id: string) => api.get(`/products/${id}`),
create: (data: any) => api.post('/products', data),
update: (id: string, data: any) => api.patch(`/products/${id}`, data),
delete: (id: string) => api.delete(`/products/${id}`),
};
// Orders API
export const ordersApi = {
getAll: (params?: { status?: string; date?: string }) =>
api.get('/orders', { params }),
getById: (id: string) => api.get(`/orders/${id}`),
create: (data: any) => api.post('/orders', data),
updateStatus: (id: string, status: string) =>
api.patch(`/orders/${id}/status`, { status }),
};
// Customers API
export const customersApi = {
getAll: (params?: { search?: string }) =>
api.get('/customers', { params }),
getById: (id: string) => api.get(`/customers/${id}`),
create: (data: any) => api.post('/customers', data),
update: (id: string, data: any) => api.patch(`/customers/${id}`, data),
getFiado: (id: string) => api.get(`/customers/${id}/fiado`),
};
// Inventory API
export const inventoryApi = {
getMovements: (params?: { productId?: string; type?: string }) =>
api.get('/inventory/movements', { params }),
createMovement: (data: any) => api.post('/inventory/movements', data),
getLowStock: () => api.get('/inventory/low-stock'),
getAlerts: () => api.get('/inventory/alerts'),
};
// Dashboard API
export const dashboardApi = {
getStats: () => api.get('/dashboard/stats'),
getSalesChart: (period: string) => api.get('/dashboard/sales', { params: { period } }),
getTopProducts: () => api.get('/dashboard/top-products'),
};
// Referrals API
export const referralsApi = {
getMyCode: () => api.get('/referrals/my-code'),
generateCode: () => api.post('/referrals/generate-code'),
validateCode: (code: string) => api.get(`/referrals/validate/${code}`),
applyCode: (code: string) => api.post('/referrals/apply-code', { code }),
getMyReferrals: () => api.get('/referrals/list'),
getStats: () => api.get('/referrals/stats'),
getRewards: () => api.get('/referrals/rewards'),
getAvailableMonths: () => api.get('/referrals/rewards/available-months'),
getDiscount: () => api.get('/referrals/discount'),
};
// CoDi/SPEI API
export const codiSpeiApi = {
// CoDi
generateQr: (data: { amount: number; description?: string; saleId?: string }) =>
api.post('/codi/generate-qr', data),
getCodiStatus: (id: string) => api.get(`/codi/status/${id}`),
getCodiTransactions: (limit?: number) =>
api.get('/codi/transactions', { params: { limit } }),
// SPEI
getClabe: () => api.get('/spei/clabe'),
createClabe: (beneficiaryName: string) =>
api.post('/spei/create-clabe', { beneficiaryName }),
getSpeiTransactions: (limit?: number) =>
api.get('/spei/transactions', { params: { limit } }),
// Summary
getSummary: (date?: string) =>
api.get('/payments/summary', { params: { date } }),
};
// Invoices API (SAT/CFDI)
export const invoicesApi = {
// Tax Config
getTaxConfig: () => api.get('/invoices/tax-config'),
saveTaxConfig: (data: any) => api.post('/invoices/tax-config', data),
// Invoices
getAll: (params?: { status?: string; from?: string; to?: string; limit?: number }) =>
api.get('/invoices', { params }),
getById: (id: string) => api.get(`/invoices/${id}`),
create: (data: any) => api.post('/invoices', data),
stamp: (id: string) => api.post(`/invoices/${id}/stamp`),
cancel: (id: string, reason: string, uuidReplacement?: string) =>
api.post(`/invoices/${id}/cancel`, { reason, uuidReplacement }),
send: (id: string, email?: string) =>
api.post(`/invoices/${id}/send`, { email }),
getSummary: (month?: string) =>
api.get('/invoices/summary', { params: { month } }),
};
// Marketplace API
export const marketplaceApi = {
// Suppliers
getSuppliers: (params?: { category?: string; zipCode?: string; search?: string; limit?: number }) =>
api.get('/marketplace/suppliers', { params }),
getSupplier: (id: string) => api.get(`/marketplace/suppliers/${id}`),
getSupplierProducts: (id: string, params?: { category?: string; search?: string }) =>
api.get(`/marketplace/suppliers/${id}/products`, { params }),
getSupplierReviews: (id: string, params?: { limit?: number }) =>
api.get(`/marketplace/suppliers/${id}/reviews`, { params }),
// Orders
createOrder: (data: any) => api.post('/marketplace/orders', data),
getOrders: (params?: { status?: string; supplierId?: string; limit?: number }) =>
api.get('/marketplace/orders', { params }),
getOrder: (id: string) => api.get(`/marketplace/orders/${id}`),
cancelOrder: (id: string, reason: string) =>
api.put(`/marketplace/orders/${id}/cancel`, { reason }),
// Reviews
createReview: (data: any) => api.post('/marketplace/reviews', data),
// Favorites
getFavorites: () => api.get('/marketplace/favorites'),
addFavorite: (supplierId: string) => api.post(`/marketplace/favorites/${supplierId}`),
removeFavorite: (supplierId: string) => api.delete(`/marketplace/favorites/${supplierId}`),
// Stats
getStats: () => api.get('/marketplace/stats'),
};

72
src/lib/i18n.ts Normal file
View File

@ -0,0 +1,72 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// Import translations
import esMX from '../locales/es-MX';
import esCO from '../locales/es-CO';
import esAR from '../locales/es-AR';
import ptBR from '../locales/pt-BR';
const resources = {
'es-MX': { translation: esMX },
'es-CO': { translation: esCO },
'es-AR': { translation: esAR },
'pt-BR': { translation: ptBR },
};
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'es-MX',
defaultNS: 'translation',
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
});
export default i18n;
// Currency formatting by locale
export const currencyConfig: Record<string, { currency: string; symbol: string }> = {
'es-MX': { currency: 'MXN', symbol: '$' },
'es-CO': { currency: 'COP', symbol: '$' },
'es-AR': { currency: 'ARS', symbol: '$' },
'pt-BR': { currency: 'BRL', symbol: 'R$' },
};
export function formatCurrency(amount: number, locale: string = 'es-MX'): string {
const config = currencyConfig[locale] || currencyConfig['es-MX'];
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: config.currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
export function formatDate(date: Date | string, locale: string = 'es-MX'): string {
const d = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(d);
}
export function formatDateTime(date: Date | string, locale: string = 'es-MX'): string {
const d = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(d);
}

View File

@ -0,0 +1,63 @@
import esMX from '../es-MX';
// Argentina Spanish - Override specific terms
const esAR = {
...esMX,
// Navigation overrides
nav: {
...esMX.nav,
// Same in Argentina
},
// Auth overrides
auth: {
...esMX.auth,
phone: 'Celular',
enterPhone: 'Ingresa tu celular',
},
// Dashboard overrides
dashboard: {
...esMX.dashboard,
// Same in Argentina
},
// Products overrides - Argentine terminology
products: {
...esMX.products,
subtitle: 'Administra tu stock',
stock: 'Stock',
},
// Fiado overrides - Argentine terminology
fiado: {
...esMX.fiado,
title: 'Cuenta',
subtitle: 'Control de cuentas corrientes',
registerPayment: 'Registrar pago',
},
// Payments overrides
payments: {
...esMX.payments,
fiado: 'Cuenta corriente',
transfer: 'Transferencia',
change: 'Vuelto',
},
// Business Types - Argentine terminology
businessTypes: {
tienda: 'Almacen',
papeleria: 'Libreria',
farmacia: 'Farmacia',
ferreteria: 'Ferreteria',
carniceria: 'Carniceria',
verduleria: 'Verduleria',
panaderia: 'Panaderia',
tortilleria: 'Rotiseria',
otro: 'Otro',
},
};
export default esAR;

View File

@ -0,0 +1,60 @@
import esMX from '../es-MX';
// Colombia Spanish - Override specific terms
const esCO = {
...esMX,
// Navigation overrides
nav: {
...esMX.nav,
// Same in Colombia
},
// Auth overrides
auth: {
...esMX.auth,
phone: 'Celular',
enterPhone: 'Ingresa tu celular',
},
// Dashboard overrides
dashboard: {
...esMX.dashboard,
// Same in Colombia
},
// Products overrides - Colombian terminology
products: {
...esMX.products,
subtitle: 'Administra tu inventario',
},
// Fiado overrides
fiado: {
...esMX.fiado,
title: 'Credito',
subtitle: 'Control de creditos a clientes',
},
// Payments overrides
payments: {
...esMX.payments,
fiado: 'Credito',
transfer: 'Transferencia bancaria',
},
// Business Types - Colombian terminology
businessTypes: {
tienda: 'Tienda',
papeleria: 'Papeleria',
farmacia: 'Drogueria',
ferreteria: 'Ferreteria',
carniceria: 'Carniceria',
verduleria: 'Fruteria',
panaderia: 'Panaderia',
tortilleria: 'Areperia',
otro: 'Otro',
},
};
export default esCO;

231
src/locales/es-MX/index.ts Normal file
View File

@ -0,0 +1,231 @@
export default {
// Common
common: {
save: 'Guardar',
cancel: 'Cancelar',
delete: 'Eliminar',
edit: 'Editar',
add: 'Agregar',
search: 'Buscar',
loading: 'Cargando...',
error: 'Error',
success: 'Exito',
confirm: 'Confirmar',
back: 'Volver',
next: 'Siguiente',
close: 'Cerrar',
yes: 'Si',
no: 'No',
all: 'Todos',
none: 'Ninguno',
required: 'Requerido',
optional: 'Opcional',
},
// Navigation
nav: {
dashboard: 'Dashboard',
products: 'Productos',
orders: 'Pedidos',
customers: 'Clientes',
fiado: 'Fiado',
inventory: 'Inventario',
referrals: 'Referidos',
settings: 'Ajustes',
logout: 'Cerrar sesion',
},
// Auth
auth: {
login: 'Iniciar sesion',
register: 'Registrarse',
phone: 'Telefono',
pin: 'PIN',
enterPhone: 'Ingresa tu telefono',
enterPin: 'Ingresa tu PIN',
forgotPin: 'Olvide mi PIN',
noAccount: 'No tienes cuenta?',
hasAccount: 'Ya tienes cuenta?',
createAccount: 'Crear cuenta',
businessName: 'Nombre del negocio',
ownerName: 'Tu nombre',
businessType: 'Tipo de negocio',
},
// Dashboard
dashboard: {
title: 'Dashboard',
todaySales: 'Ventas de hoy',
weekSales: 'Ventas de la semana',
monthSales: 'Ventas del mes',
transactions: 'transacciones',
lowStock: 'Stock bajo',
pendingOrders: 'Pedidos pendientes',
pendingCredits: 'Fiados pendientes',
topProducts: 'Productos mas vendidos',
},
// Products
products: {
title: 'Productos',
subtitle: 'Administra tu catalogo',
addProduct: 'Agregar producto',
editProduct: 'Editar producto',
name: 'Nombre',
price: 'Precio',
cost: 'Costo',
stock: 'Stock',
sku: 'SKU',
barcode: 'Codigo de barras',
category: 'Categoria',
description: 'Descripcion',
noProducts: 'No hay productos',
scanBarcode: 'Escanear codigo',
},
// Orders
orders: {
title: 'Pedidos',
subtitle: 'Gestiona los pedidos',
newOrder: 'Nuevo pedido',
orderNumber: 'Pedido #{{number}}',
status: 'Estado',
pending: 'Pendiente',
preparing: 'Preparando',
ready: 'Listo',
delivered: 'Entregado',
cancelled: 'Cancelado',
total: 'Total',
items: 'articulos',
noOrders: 'No hay pedidos',
},
// Customers
customers: {
title: 'Clientes',
subtitle: 'Administra tus clientes',
addCustomer: 'Agregar cliente',
name: 'Nombre',
phone: 'Telefono',
email: 'Correo',
creditLimit: 'Limite de credito',
currentBalance: 'Saldo actual',
noCustomers: 'No hay clientes',
},
// Fiado (Credit)
fiado: {
title: 'Fiado',
subtitle: 'Control de creditos',
totalOwed: 'Total adeudado',
overdueAmount: 'Vencido',
customersWithCredit: 'Clientes con fiado',
registerPayment: 'Registrar abono',
paymentHistory: 'Historial de pagos',
dueDate: 'Fecha de vencimiento',
overdue: 'Vencido',
noCredits: 'No hay fiados pendientes',
},
// Inventory
inventory: {
title: 'Inventario',
subtitle: 'Control de existencias',
movements: 'Movimientos',
addMovement: 'Agregar movimiento',
entry: 'Entrada',
exit: 'Salida',
adjustment: 'Ajuste',
lowStockAlerts: 'Alertas de stock bajo',
reorder: 'Reabastecer',
noMovements: 'No hay movimientos',
},
// Referrals
referrals: {
title: 'Programa de Referidos',
subtitle: 'Invita amigos y gana',
yourCode: 'Tu codigo de referido',
copy: 'Copiar',
share: 'Compartir',
shareWhatsApp: 'Compartir por WhatsApp',
invited: 'Invitados',
converted: 'Convertidos',
monthsEarned: 'Meses ganados',
monthsAvailable: 'Disponibles',
howItWorks: 'Como funciona',
step1: 'Comparte tu codigo',
step1Desc: 'Envia tu codigo a amigos por WhatsApp',
step2: 'Tu amigo se registra',
step2Desc: 'Obtiene 50% de descuento en su primer mes',
step3: 'Tu ganas 1 mes gratis',
step3Desc: 'Cuando tu amigo paga su primer mes',
yourReferrals: 'Tus referidos',
noReferrals: 'Aun no tienes referidos',
},
// Settings
settings: {
title: 'Ajustes',
subtitle: 'Configura tu tienda',
businessInfo: 'Informacion del negocio',
fiadoSettings: 'Configuracion de fiado',
whatsapp: 'WhatsApp Business',
notifications: 'Notificaciones',
subscription: 'Suscripcion',
language: 'Idioma',
enableFiado: 'Habilitar fiado',
defaultCreditLimit: 'Limite de credito por defecto',
gracePeriod: 'Dias de gracia',
connected: 'Conectado',
autoResponses: 'Respuestas automaticas',
lowStockAlerts: 'Alertas de stock bajo',
overdueAlerts: 'Alertas de fiados vencidos',
newOrderAlerts: 'Alertas de nuevos pedidos',
currentPlan: 'Plan actual',
upgradePlan: 'Mejorar plan',
},
// Payments
payments: {
cash: 'Efectivo',
card: 'Tarjeta',
transfer: 'Transferencia',
fiado: 'Fiado',
codi: 'CoDi',
spei: 'SPEI',
change: 'Cambio',
total: 'Total',
subtotal: 'Subtotal',
tax: 'IVA',
discount: 'Descuento',
payNow: 'Pagar ahora',
generateQR: 'Generar QR',
scanQR: 'Escanea el QR con tu app bancaria',
paymentReceived: 'Pago recibido',
paymentFailed: 'Pago fallido',
},
// Errors
errors: {
generic: 'Algo salio mal',
networkError: 'Error de conexion',
unauthorized: 'No autorizado',
notFound: 'No encontrado',
validationError: 'Error de validacion',
serverError: 'Error del servidor',
},
// Business Types
businessTypes: {
tienda: 'Tienda de abarrotes',
papeleria: 'Papeleria',
farmacia: 'Farmacia',
ferreteria: 'Ferreteria',
carniceria: 'Carniceria',
verduleria: 'Verduleria',
panaderia: 'Panaderia',
tortilleria: 'Tortilleria',
otro: 'Otro',
},
};

234
src/locales/pt-BR/index.ts Normal file
View File

@ -0,0 +1,234 @@
// Brazilian Portuguese
const ptBR = {
// Common
common: {
save: 'Salvar',
cancel: 'Cancelar',
delete: 'Excluir',
edit: 'Editar',
add: 'Adicionar',
search: 'Buscar',
loading: 'Carregando...',
error: 'Erro',
success: 'Sucesso',
confirm: 'Confirmar',
back: 'Voltar',
next: 'Proximo',
close: 'Fechar',
yes: 'Sim',
no: 'Nao',
all: 'Todos',
none: 'Nenhum',
required: 'Obrigatorio',
optional: 'Opcional',
},
// Navigation
nav: {
dashboard: 'Painel',
products: 'Produtos',
orders: 'Pedidos',
customers: 'Clientes',
fiado: 'Fiado',
inventory: 'Estoque',
referrals: 'Indicacoes',
settings: 'Configuracoes',
logout: 'Sair',
},
// Auth
auth: {
login: 'Entrar',
register: 'Cadastrar',
phone: 'Celular',
pin: 'PIN',
enterPhone: 'Digite seu celular',
enterPin: 'Digite seu PIN',
forgotPin: 'Esqueci meu PIN',
noAccount: 'Nao tem conta?',
hasAccount: 'Ja tem conta?',
createAccount: 'Criar conta',
businessName: 'Nome do negocio',
ownerName: 'Seu nome',
businessType: 'Tipo de negocio',
},
// Dashboard
dashboard: {
title: 'Painel',
todaySales: 'Vendas de hoje',
weekSales: 'Vendas da semana',
monthSales: 'Vendas do mes',
transactions: 'transacoes',
lowStock: 'Estoque baixo',
pendingOrders: 'Pedidos pendentes',
pendingCredits: 'Fiados pendentes',
topProducts: 'Produtos mais vendidos',
},
// Products
products: {
title: 'Produtos',
subtitle: 'Gerencie seu catalogo',
addProduct: 'Adicionar produto',
editProduct: 'Editar produto',
name: 'Nome',
price: 'Preco',
cost: 'Custo',
stock: 'Estoque',
sku: 'SKU',
barcode: 'Codigo de barras',
category: 'Categoria',
description: 'Descricao',
noProducts: 'Nenhum produto',
scanBarcode: 'Escanear codigo',
},
// Orders
orders: {
title: 'Pedidos',
subtitle: 'Gerencie os pedidos',
newOrder: 'Novo pedido',
orderNumber: 'Pedido #{{number}}',
status: 'Status',
pending: 'Pendente',
preparing: 'Preparando',
ready: 'Pronto',
delivered: 'Entregue',
cancelled: 'Cancelado',
total: 'Total',
items: 'itens',
noOrders: 'Nenhum pedido',
},
// Customers
customers: {
title: 'Clientes',
subtitle: 'Gerencie seus clientes',
addCustomer: 'Adicionar cliente',
name: 'Nome',
phone: 'Celular',
email: 'Email',
creditLimit: 'Limite de credito',
currentBalance: 'Saldo atual',
noCustomers: 'Nenhum cliente',
},
// Fiado (Credit)
fiado: {
title: 'Fiado',
subtitle: 'Controle de creditos',
totalOwed: 'Total devido',
overdueAmount: 'Vencido',
customersWithCredit: 'Clientes com fiado',
registerPayment: 'Registrar pagamento',
paymentHistory: 'Historico de pagamentos',
dueDate: 'Data de vencimento',
overdue: 'Vencido',
noCredits: 'Nenhum fiado pendente',
},
// Inventory
inventory: {
title: 'Estoque',
subtitle: 'Controle de estoque',
movements: 'Movimentacoes',
addMovement: 'Adicionar movimentacao',
entry: 'Entrada',
exit: 'Saida',
adjustment: 'Ajuste',
lowStockAlerts: 'Alertas de estoque baixo',
reorder: 'Repor',
noMovements: 'Nenhuma movimentacao',
},
// Referrals
referrals: {
title: 'Programa de Indicacoes',
subtitle: 'Indique amigos e ganhe',
yourCode: 'Seu codigo de indicacao',
copy: 'Copiar',
share: 'Compartilhar',
shareWhatsApp: 'Compartilhar por WhatsApp',
invited: 'Indicados',
converted: 'Convertidos',
monthsEarned: 'Meses ganhos',
monthsAvailable: 'Disponiveis',
howItWorks: 'Como funciona',
step1: 'Compartilhe seu codigo',
step1Desc: 'Envie seu codigo para amigos por WhatsApp',
step2: 'Seu amigo se cadastra',
step2Desc: 'Ganha 50% de desconto no primeiro mes',
step3: 'Voce ganha 1 mes gratis',
step3Desc: 'Quando seu amigo paga o primeiro mes',
yourReferrals: 'Suas indicacoes',
noReferrals: 'Voce ainda nao tem indicacoes',
},
// Settings
settings: {
title: 'Configuracoes',
subtitle: 'Configure sua loja',
businessInfo: 'Informacoes do negocio',
fiadoSettings: 'Configuracao de fiado',
whatsapp: 'WhatsApp Business',
notifications: 'Notificacoes',
subscription: 'Assinatura',
language: 'Idioma',
enableFiado: 'Habilitar fiado',
defaultCreditLimit: 'Limite de credito padrao',
gracePeriod: 'Dias de carencia',
connected: 'Conectado',
autoResponses: 'Respostas automaticas',
lowStockAlerts: 'Alertas de estoque baixo',
overdueAlerts: 'Alertas de fiados vencidos',
newOrderAlerts: 'Alertas de novos pedidos',
currentPlan: 'Plano atual',
upgradePlan: 'Melhorar plano',
},
// Payments
payments: {
cash: 'Dinheiro',
card: 'Cartao',
transfer: 'Transferencia',
fiado: 'Fiado',
codi: 'Pix',
spei: 'TED',
change: 'Troco',
total: 'Total',
subtotal: 'Subtotal',
tax: 'Impostos',
discount: 'Desconto',
payNow: 'Pagar agora',
generateQR: 'Gerar QR',
scanQR: 'Escaneie o QR com seu app do banco',
paymentReceived: 'Pagamento recebido',
paymentFailed: 'Pagamento falhou',
},
// Errors
errors: {
generic: 'Algo deu errado',
networkError: 'Erro de conexao',
unauthorized: 'Nao autorizado',
notFound: 'Nao encontrado',
validationError: 'Erro de validacao',
serverError: 'Erro do servidor',
},
// Business Types
businessTypes: {
tienda: 'Loja',
papeleria: 'Papelaria',
farmacia: 'Farmacia',
ferreteria: 'Ferragem',
carniceria: 'Acougue',
verduleria: 'Hortifruti',
panaderia: 'Padaria',
tortilleria: 'Tapiocaria',
otro: 'Outro',
},
};
export default ptBR;

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

134
src/pages/Customers.tsx Normal file
View File

@ -0,0 +1,134 @@
import { useState } from 'react';
import { Search, Plus, Phone, Mail, MapPin, CreditCard } from 'lucide-react';
const mockCustomers = [
{
id: '1',
name: 'Maria Lopez',
phone: '5551234567',
email: 'maria@email.com',
address: 'Calle 1 #123',
totalPurchases: 45,
totalSpent: 3450.00,
fiadoBalance: 150.00,
fiadoLimit: 500.00,
lastVisit: '2024-01-15',
},
{
id: '2',
name: 'Juan Perez',
phone: '5559876543',
email: null,
address: null,
totalPurchases: 23,
totalSpent: 1890.00,
fiadoBalance: 0,
fiadoLimit: 300.00,
lastVisit: '2024-01-14',
},
{
id: '3',
name: 'Ana Garcia',
phone: '5555555555',
email: 'ana@email.com',
address: 'Av. Principal #456',
totalPurchases: 67,
totalSpent: 5670.00,
fiadoBalance: 320.00,
fiadoLimit: 1000.00,
lastVisit: '2024-01-15',
},
];
export function Customers() {
const [search, setSearch] = useState('');
const filteredCustomers = mockCustomers.filter((customer) =>
customer.name.toLowerCase().includes(search.toLowerCase()) ||
customer.phone.includes(search)
);
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Clientes</h1>
<p className="text-gray-500">{mockCustomers.length} clientes registrados</p>
</div>
<button className="btn-primary flex items-center gap-2">
<Plus className="h-5 w-5" />
Nuevo Cliente
</button>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Buscar por nombre o telefono..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10"
/>
</div>
{/* Customers List */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredCustomers.map((customer) => (
<div key={customer.id} className="card hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-bold text-gray-900">{customer.name}</h3>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Phone className="h-4 w-4" />
{customer.phone}
</div>
{customer.email && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Mail className="h-4 w-4" />
{customer.email}
</div>
)}
{customer.address && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<MapPin className="h-4 w-4" />
{customer.address}
</div>
)}
</div>
<div className="text-right">
<p className="text-xs text-gray-500">Ultima visita</p>
<p className="text-sm font-medium">{customer.lastVisit}</p>
</div>
</div>
<div className="grid grid-cols-3 gap-4 pt-4 border-t">
<div>
<p className="text-sm text-gray-500">Compras</p>
<p className="text-lg font-bold">{customer.totalPurchases}</p>
</div>
<div>
<p className="text-sm text-gray-500">Total gastado</p>
<p className="text-lg font-bold">${customer.totalSpent.toFixed(0)}</p>
</div>
<div>
<p className="text-sm text-gray-500">Fiado</p>
<div className="flex items-center gap-1">
<CreditCard className={`h-4 w-4 ${
customer.fiadoBalance > 0 ? 'text-orange-500' : 'text-green-500'
}`} />
<p className={`text-lg font-bold ${
customer.fiadoBalance > 0 ? 'text-orange-600' : 'text-green-600'
}`}>
${customer.fiadoBalance.toFixed(0)}
</p>
</div>
</div>
</div>
</div>
))}
</div>
</div>
);
}

131
src/pages/Dashboard.tsx Normal file
View File

@ -0,0 +1,131 @@
import {
TrendingUp,
ShoppingCart,
Users,
CreditCard,
Package,
AlertCircle,
} from 'lucide-react';
const stats = [
{ name: 'Ventas Hoy', value: '$1,240', change: '+12%', icon: TrendingUp, color: 'green' },
{ name: 'Pedidos', value: '23', change: '+5', icon: ShoppingCart, color: 'blue' },
{ name: 'Clientes', value: '156', change: '+3', icon: Users, color: 'purple' },
{ name: 'Fiados Pendientes', value: '$2,100', change: '-$450', icon: CreditCard, color: 'orange' },
];
const recentOrders = [
{ id: 'MCH-001', customer: 'Maria Lopez', total: 156.00, status: 'ready', time: '10:30 AM' },
{ id: 'MCH-002', customer: 'Juan Perez', total: 89.50, status: 'preparing', time: '10:45 AM' },
{ id: 'MCH-003', customer: 'Ana Garcia', total: 234.00, status: 'pending', time: '11:00 AM' },
];
const lowStockProducts = [
{ name: 'Coca-Cola 600ml', stock: 5, minStock: 10 },
{ name: 'Pan Bimbo', stock: 2, minStock: 5 },
{ name: 'Leche Lala 1L', stock: 3, minStock: 8 },
];
const statusColors: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
preparing: 'bg-blue-100 text-blue-800',
ready: 'bg-green-100 text-green-800',
completed: 'bg-gray-100 text-gray-800',
};
export function Dashboard() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-500">Bienvenido a MiChangarrito</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<div key={stat.name} className="card">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">{stat.name}</p>
<p className="text-2xl font-bold">{stat.value}</p>
<p className={`text-sm ${
stat.change.startsWith('+') ? 'text-green-600' : 'text-red-600'
}`}>
{stat.change}
</p>
</div>
<div className={`p-3 rounded-full bg-${stat.color}-100`}>
<stat.icon className={`h-6 w-6 text-${stat.color}-600`} />
</div>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Recent Orders */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<ShoppingCart className="h-5 w-5" />
Pedidos Recientes
</h2>
<a href="/orders" className="text-sm text-primary-600 hover:underline">
Ver todos
</a>
</div>
<div className="space-y-3">
{recentOrders.map((order) => (
<div
key={order.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div>
<p className="font-medium">{order.id}</p>
<p className="text-sm text-gray-500">{order.customer}</p>
</div>
<div className="text-right">
<p className="font-medium">${order.total.toFixed(2)}</p>
<span className={`inline-block px-2 py-1 text-xs rounded-full ${statusColors[order.status]}`}>
{order.status}
</span>
</div>
</div>
))}
</div>
</div>
{/* Low Stock Alert */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-orange-500" />
Stock Bajo
</h2>
<a href="/inventory" className="text-sm text-primary-600 hover:underline">
Ver inventario
</a>
</div>
<div className="space-y-3">
{lowStockProducts.map((product) => (
<div
key={product.name}
className="flex items-center justify-between p-3 bg-orange-50 rounded-lg"
>
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-orange-500" />
<p className="font-medium">{product.name}</p>
</div>
<div className="text-right">
<p className="font-bold text-orange-600">{product.stock} unidades</p>
<p className="text-xs text-gray-500">Min: {product.minStock}</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

180
src/pages/Fiado.tsx Normal file
View File

@ -0,0 +1,180 @@
import { useState } from 'react';
import { CreditCard, AlertTriangle, CheckCircle, Clock, Plus } from 'lucide-react';
import clsx from 'clsx';
const mockFiados = [
{
id: '1',
customer: 'Maria Lopez',
phone: '5551234567',
amount: 150.00,
description: 'Compra del 15/01',
status: 'pending',
dueDate: '2024-01-30',
createdAt: '2024-01-15',
},
{
id: '2',
customer: 'Ana Garcia',
phone: '5555555555',
amount: 320.00,
description: 'Productos varios',
status: 'overdue',
dueDate: '2024-01-10',
createdAt: '2024-01-01',
},
{
id: '3',
customer: 'Pedro Martinez',
phone: '5553334444',
amount: 89.50,
description: 'Bebidas y botanas',
status: 'pending',
dueDate: '2024-02-01',
createdAt: '2024-01-14',
},
];
const recentPayments = [
{ customer: 'Juan Perez', amount: 200.00, date: '2024-01-15' },
{ customer: 'Laura Sanchez', amount: 150.00, date: '2024-01-14' },
{ customer: 'Carlos Ruiz', amount: 75.00, date: '2024-01-13' },
];
export function Fiado() {
const [filter, setFilter] = useState<'all' | 'pending' | 'overdue'>('all');
const totalPending = mockFiados.reduce((sum, f) => sum + f.amount, 0);
const overdueCount = mockFiados.filter(f => f.status === 'overdue').length;
const filteredFiados = filter === 'all'
? mockFiados
: mockFiados.filter(f => f.status === filter);
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Fiado</h1>
<p className="text-gray-500">Gestiona las cuentas de credito de tus clientes</p>
</div>
<button className="btn-primary flex items-center gap-2">
<Plus className="h-5 w-5" />
Nuevo Fiado
</button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="card bg-orange-50 border-orange-200">
<div className="flex items-center gap-3">
<CreditCard className="h-8 w-8 text-orange-600" />
<div>
<p className="text-sm text-orange-700">Total Pendiente</p>
<p className="text-2xl font-bold text-orange-800">${totalPending.toFixed(2)}</p>
</div>
</div>
</div>
<div className="card bg-red-50 border-red-200">
<div className="flex items-center gap-3">
<AlertTriangle className="h-8 w-8 text-red-600" />
<div>
<p className="text-sm text-red-700">Vencidos</p>
<p className="text-2xl font-bold text-red-800">{overdueCount} cuentas</p>
</div>
</div>
</div>
<div className="card bg-green-50 border-green-200">
<div className="flex items-center gap-3">
<CheckCircle className="h-8 w-8 text-green-600" />
<div>
<p className="text-sm text-green-700">Cobrado este mes</p>
<p className="text-2xl font-bold text-green-800">$425.00</p>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Fiados List */}
<div className="lg:col-span-2 space-y-4">
<div className="flex gap-2">
{(['all', 'pending', 'overdue'] as const).map((status) => (
<button
key={status}
onClick={() => setFilter(status)}
className={clsx(
'px-4 py-2 rounded-lg font-medium transition-colors',
filter === status
? 'bg-primary-100 text-primary-700'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
)}
>
{status === 'all' && 'Todos'}
{status === 'pending' && 'Pendientes'}
{status === 'overdue' && 'Vencidos'}
</button>
))}
</div>
{filteredFiados.map((fiado) => (
<div key={fiado.id} className="card">
<div className="flex items-start justify-between">
<div>
<h3 className="font-bold text-lg">{fiado.customer}</h3>
<p className="text-sm text-gray-500">{fiado.phone}</p>
<p className="text-gray-600 mt-1">{fiado.description}</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-orange-600">
${fiado.amount.toFixed(2)}
</p>
<div className={clsx(
'inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium mt-1',
fiado.status === 'overdue'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'
)}>
{fiado.status === 'overdue' ? (
<AlertTriangle className="h-3 w-3" />
) : (
<Clock className="h-3 w-3" />
)}
{fiado.status === 'overdue' ? 'Vencido' : 'Pendiente'}
</div>
</div>
</div>
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<div className="text-sm text-gray-500">
<span>Creado: {fiado.createdAt}</span>
<span className="mx-2">|</span>
<span>Vence: {fiado.dueDate}</span>
</div>
<div className="flex gap-2">
<button className="btn-outline text-sm py-1">Enviar recordatorio</button>
<button className="btn-primary text-sm py-1">Registrar pago</button>
</div>
</div>
</div>
))}
</div>
{/* Recent Payments */}
<div className="card h-fit">
<h2 className="font-bold text-lg mb-4">Pagos Recientes</h2>
<div className="space-y-3">
{recentPayments.map((payment, i) => (
<div key={i} className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<div>
<p className="font-medium">{payment.customer}</p>
<p className="text-sm text-gray-500">{payment.date}</p>
</div>
<p className="font-bold text-green-600">+${payment.amount.toFixed(2)}</p>
</div>
))}
</div>
</div>
</div>
</div>
);
}

175
src/pages/Inventory.tsx Normal file
View File

@ -0,0 +1,175 @@
import { useState } from 'react';
import { Package, TrendingUp, TrendingDown, AlertTriangle, Plus, Minus } from 'lucide-react';
import clsx from 'clsx';
const mockInventory = [
{ id: '1', name: 'Coca-Cola 600ml', category: 'bebidas', stock: 24, minStock: 10, maxStock: 50, cost: 12.00, price: 18.00 },
{ id: '2', name: 'Sabritas Original', category: 'botanas', stock: 12, minStock: 5, maxStock: 30, cost: 10.00, price: 15.00 },
{ id: '3', name: 'Leche Lala 1L', category: 'lacteos', stock: 3, minStock: 8, maxStock: 20, cost: 22.00, price: 28.00 },
{ id: '4', name: 'Pan Bimbo Grande', category: 'panaderia', stock: 2, minStock: 5, maxStock: 15, cost: 35.00, price: 45.00 },
{ id: '5', name: 'Fabuloso 1L', category: 'limpieza', stock: 6, minStock: 5, maxStock: 20, cost: 25.00, price: 32.00 },
];
const recentMovements = [
{ product: 'Coca-Cola 600ml', type: 'sale', quantity: -2, date: '10:30 AM' },
{ product: 'Sabritas', type: 'sale', quantity: -1, date: '10:15 AM' },
{ product: 'Leche Lala 1L', type: 'purchase', quantity: +12, date: '09:00 AM' },
{ product: 'Pan Bimbo', type: 'sale', quantity: -3, date: '08:45 AM' },
];
export function Inventory() {
const [showLowStock, setShowLowStock] = useState(false);
const lowStockItems = mockInventory.filter(item => item.stock <= item.minStock);
const totalValue = mockInventory.reduce((sum, item) => sum + (item.stock * item.cost), 0);
const totalItems = mockInventory.reduce((sum, item) => sum + item.stock, 0);
const displayItems = showLowStock
? lowStockItems
: mockInventory;
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Inventario</h1>
<p className="text-gray-500">Control de existencias</p>
</div>
<div className="flex gap-2">
<button className="btn-outline flex items-center gap-2">
<Plus className="h-5 w-5" />
Entrada
</button>
<button className="btn-outline flex items-center gap-2">
<Minus className="h-5 w-5" />
Salida
</button>
</div>
</div>
{/* Summary */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="card">
<div className="flex items-center gap-3">
<Package className="h-8 w-8 text-blue-600" />
<div>
<p className="text-sm text-gray-500">Total Unidades</p>
<p className="text-2xl font-bold">{totalItems}</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center gap-3">
<TrendingUp className="h-8 w-8 text-green-600" />
<div>
<p className="text-sm text-gray-500">Valor del Inventario</p>
<p className="text-2xl font-bold">${totalValue.toFixed(2)}</p>
</div>
</div>
</div>
<div
className={clsx(
'card cursor-pointer transition-colors',
showLowStock ? 'bg-orange-50 border-orange-200' : ''
)}
onClick={() => setShowLowStock(!showLowStock)}
>
<div className="flex items-center gap-3">
<AlertTriangle className="h-8 w-8 text-orange-600" />
<div>
<p className="text-sm text-gray-500">Stock Bajo</p>
<p className="text-2xl font-bold text-orange-600">{lowStockItems.length} productos</p>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Inventory Table */}
<div className="lg:col-span-2">
<div className="card overflow-hidden p-0">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="text-left p-4 font-medium text-gray-600">Producto</th>
<th className="text-center p-4 font-medium text-gray-600">Stock</th>
<th className="text-center p-4 font-medium text-gray-600">Min/Max</th>
<th className="text-right p-4 font-medium text-gray-600">Valor</th>
</tr>
</thead>
<tbody className="divide-y">
{displayItems.map((item) => {
const isLow = item.stock <= item.minStock;
const percentage = (item.stock / item.maxStock) * 100;
return (
<tr key={item.id} className={clsx(isLow && 'bg-orange-50')}>
<td className="p-4">
<p className="font-medium">{item.name}</p>
<p className="text-sm text-gray-500 capitalize">{item.category}</p>
</td>
<td className="p-4 text-center">
<div className="flex flex-col items-center">
<span className={clsx(
'text-lg font-bold',
isLow ? 'text-orange-600' : 'text-gray-900'
)}>
{item.stock}
</span>
<div className="w-20 h-2 bg-gray-200 rounded-full mt-1">
<div
className={clsx(
'h-full rounded-full',
percentage < 30 ? 'bg-red-500' :
percentage < 50 ? 'bg-orange-500' : 'bg-green-500'
)}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
</div>
</td>
<td className="p-4 text-center text-sm text-gray-500">
{item.minStock} / {item.maxStock}
</td>
<td className="p-4 text-right font-medium">
${(item.stock * item.cost).toFixed(2)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Recent Movements */}
<div className="card h-fit">
<h2 className="font-bold text-lg mb-4">Movimientos Recientes</h2>
<div className="space-y-3">
{recentMovements.map((mov, i) => (
<div key={i} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
{mov.type === 'sale' ? (
<TrendingDown className="h-5 w-5 text-red-500" />
) : (
<TrendingUp className="h-5 w-5 text-green-500" />
)}
<div>
<p className="font-medium text-sm">{mov.product}</p>
<p className="text-xs text-gray-500">{mov.date}</p>
</div>
</div>
<span className={clsx(
'font-bold',
mov.quantity > 0 ? 'text-green-600' : 'text-red-600'
)}>
{mov.quantity > 0 ? '+' : ''}{mov.quantity}
</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}

668
src/pages/Invoices.tsx Normal file
View File

@ -0,0 +1,668 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
FileText,
Send,
XCircle,
CheckCircle,
Clock,
Download,
Search,
Plus,
Settings,
AlertCircle,
} from 'lucide-react';
import clsx from 'clsx';
import { invoicesApi } from '../lib/api';
const statusConfig = {
draft: { label: 'Borrador', color: 'gray', icon: Clock },
pending: { label: 'Pendiente', color: 'yellow', icon: Clock },
stamped: { label: 'Timbrada', color: 'green', icon: CheckCircle },
sent: { label: 'Enviada', color: 'blue', icon: Send },
cancelled: { label: 'Cancelada', color: 'red', icon: XCircle },
};
export function Invoices() {
const [filter, setFilter] = useState('all');
const [showConfig, setShowConfig] = useState(false);
const [showNewInvoice, setShowNewInvoice] = useState(false);
const queryClient = useQueryClient();
const { data: invoices = [], isLoading } = useQuery({
queryKey: ['invoices', filter],
queryFn: async () => {
const params = filter !== 'all' ? { status: filter } : {};
const response = await invoicesApi.getAll(params);
return response.data;
},
});
const { data: summary } = useQuery({
queryKey: ['invoices-summary'],
queryFn: async () => {
const response = await invoicesApi.getSummary();
return response.data;
},
});
const { data: taxConfig } = useQuery({
queryKey: ['tax-config'],
queryFn: async () => {
const response = await invoicesApi.getTaxConfig();
return response.data;
},
});
const stampMutation = useMutation({
mutationFn: (id: string) => invoicesApi.stamp(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices'] });
},
});
const sendMutation = useMutation({
mutationFn: (id: string) => invoicesApi.send(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices'] });
},
});
const hasActiveConfig = taxConfig?.status === 'active';
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Facturacion</h1>
<p className="text-gray-500">Emite facturas electronicas (CFDI)</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowConfig(true)}
className="btn-secondary flex items-center gap-2"
>
<Settings className="h-4 w-4" />
Configuracion
</button>
<button
onClick={() => setShowNewInvoice(true)}
disabled={!hasActiveConfig}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Nueva Factura
</button>
</div>
</div>
{/* Alert if no config */}
{!hasActiveConfig && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-yellow-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-yellow-800">Configuracion fiscal requerida</p>
<p className="text-sm text-yellow-700">
Para emitir facturas, primero configura tus datos fiscales y certificados.
</p>
</div>
</div>
)}
{/* Summary Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="card">
<p className="text-sm text-gray-500">Facturas del mes</p>
<p className="text-2xl font-bold">{summary?.total_invoices || 0}</p>
</div>
<div className="card">
<p className="text-sm text-gray-500">Monto facturado</p>
<p className="text-2xl font-bold text-green-600">
${(summary?.total_amount || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</p>
</div>
<div className="card">
<p className="text-sm text-gray-500">Canceladas</p>
<p className="text-2xl font-bold text-red-600">{summary?.total_cancelled || 0}</p>
</div>
<div className="card">
<p className="text-sm text-gray-500">RFC Emisor</p>
<p className="text-lg font-mono">{taxConfig?.rfc || 'No configurado'}</p>
</div>
</div>
{/* Status Filter */}
<div className="flex gap-2 overflow-x-auto pb-2">
<button
onClick={() => setFilter('all')}
className={clsx(
'px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors',
filter === 'all'
? 'bg-primary-100 text-primary-700'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
)}
>
Todas
</button>
{Object.entries(statusConfig).map(([status, config]) => (
<button
key={status}
onClick={() => setFilter(status)}
className={clsx(
'px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors',
filter === status
? `bg-${config.color}-100 text-${config.color}-700`
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
)}
>
{config.label}
</button>
))}
</div>
{/* Invoices List */}
{isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
<p className="text-gray-500 mt-2">Cargando facturas...</p>
</div>
) : invoices.length === 0 ? (
<div className="text-center py-12 card">
<FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900">No hay facturas</h3>
<p className="text-gray-500">Las facturas emitidas apareceran aqui</p>
</div>
) : (
<div className="space-y-4">
{invoices.map((invoice: any) => {
const status = statusConfig[invoice.status as keyof typeof statusConfig] || statusConfig.draft;
const StatusIcon = status.icon;
return (
<div key={invoice.id} className="card">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div className="flex items-start gap-4">
<div className={`p-3 rounded-full bg-${status.color}-100`}>
<StatusIcon className={`h-6 w-6 text-${status.color}-600`} />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-bold text-lg">
{invoice.serie}-{invoice.folio}
</h3>
<span className={`px-2 py-0.5 text-xs rounded-full bg-${status.color}-100 text-${status.color}-700`}>
{status.label}
</span>
</div>
<p className="text-gray-600">{invoice.receptorNombre}</p>
<p className="text-sm text-gray-500 font-mono">{invoice.receptorRfc}</p>
{invoice.uuid && (
<p className="text-xs text-gray-400 font-mono mt-1">
UUID: {invoice.uuid}
</p>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-2xl font-bold">
${Number(invoice.total).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</p>
<p className="text-sm text-gray-500">
{new Date(invoice.createdAt).toLocaleDateString('es-MX')}
</p>
</div>
<div className="flex gap-2">
{invoice.status === 'draft' && (
<button
onClick={() => stampMutation.mutate(invoice.id)}
disabled={stampMutation.isPending}
className="btn-primary"
>
Timbrar
</button>
)}
{invoice.status === 'stamped' && (
<button
onClick={() => sendMutation.mutate(invoice.id)}
disabled={sendMutation.isPending}
className="btn-secondary flex items-center gap-2"
>
<Send className="h-4 w-4" />
Enviar
</button>
)}
{(invoice.status === 'stamped' || invoice.status === 'sent') && (
<button className="btn-secondary flex items-center gap-2">
<Download className="h-4 w-4" />
PDF
</button>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
{/* Tax Config Modal */}
{showConfig && (
<TaxConfigModal
config={taxConfig}
onClose={() => setShowConfig(false)}
onSave={() => {
queryClient.invalidateQueries({ queryKey: ['tax-config'] });
setShowConfig(false);
}}
/>
)}
{/* New Invoice Modal */}
{showNewInvoice && (
<NewInvoiceModal
onClose={() => setShowNewInvoice(false)}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['invoices'] });
setShowNewInvoice(false);
}}
/>
)}
</div>
);
}
function TaxConfigModal({
config,
onClose,
onSave,
}: {
config: any;
onClose: () => void;
onSave: () => void;
}) {
const [formData, setFormData] = useState({
rfc: config?.rfc || '',
razonSocial: config?.razonSocial || '',
regimenFiscal: config?.regimenFiscal || '601',
codigoPostal: config?.codigoPostal || '',
serie: config?.serie || 'A',
pacProvider: config?.pacProvider || 'facturapi',
pacSandbox: config?.pacSandbox ?? true,
});
const mutation = useMutation({
mutationFn: (data: any) => invoicesApi.saveTaxConfig(data),
onSuccess: onSave,
});
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b">
<h2 className="text-xl font-bold">Configuracion Fiscal</h2>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
mutation.mutate(formData);
}}
className="p-6 space-y-4"
>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">RFC</label>
<input
type="text"
value={formData.rfc}
onChange={(e) => setFormData({ ...formData, rfc: e.target.value.toUpperCase() })}
className="input"
maxLength={13}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Razon Social</label>
<input
type="text"
value={formData.razonSocial}
onChange={(e) => setFormData({ ...formData, razonSocial: e.target.value })}
className="input"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Regimen Fiscal</label>
<select
value={formData.regimenFiscal}
onChange={(e) => setFormData({ ...formData, regimenFiscal: e.target.value })}
className="input"
>
<option value="601">General de Ley PM</option>
<option value="603">Personas Morales sin Fines de Lucro</option>
<option value="605">Sueldos y Salarios</option>
<option value="606">Arrendamiento</option>
<option value="612">Personas Fisicas con Actividades Empresariales</option>
<option value="621">Incorporacion Fiscal</option>
<option value="625">Regimen de Actividades Agricolas</option>
<option value="626">Regimen Simplificado de Confianza</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Codigo Postal</label>
<input
type="text"
value={formData.codigoPostal}
onChange={(e) => setFormData({ ...formData, codigoPostal: e.target.value })}
className="input"
maxLength={5}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Serie</label>
<input
type="text"
value={formData.serie}
onChange={(e) => setFormData({ ...formData, serie: e.target.value.toUpperCase() })}
className="input"
maxLength={10}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Proveedor PAC</label>
<select
value={formData.pacProvider}
onChange={(e) => setFormData({ ...formData, pacProvider: e.target.value })}
className="input"
>
<option value="facturapi">Facturapi</option>
<option value="swsapien">SW Sapien</option>
<option value="finkok">Finkok</option>
</select>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="sandbox"
checked={formData.pacSandbox}
onChange={(e) => setFormData({ ...formData, pacSandbox: e.target.checked })}
className="rounded border-gray-300"
/>
<label htmlFor="sandbox" className="text-sm text-gray-700">
Modo sandbox (pruebas)
</label>
</div>
<div className="flex gap-3 pt-4">
<button type="button" onClick={onClose} className="btn-secondary flex-1">
Cancelar
</button>
<button type="submit" disabled={mutation.isPending} className="btn-primary flex-1">
{mutation.isPending ? 'Guardando...' : 'Guardar'}
</button>
</div>
</form>
</div>
</div>
);
}
function NewInvoiceModal({
onClose,
onSuccess,
}: {
onClose: () => void;
onSuccess: () => void;
}) {
const [formData, setFormData] = useState({
receptorRfc: '',
receptorNombre: '',
receptorRegimenFiscal: '601',
receptorCodigoPostal: '',
receptorUsoCfdi: 'G03',
receptorEmail: '',
formaPago: '01',
metodoPago: 'PUE',
items: [{ descripcion: '', cantidad: 1, valorUnitario: 0, claveProdServ: '01010101', claveUnidad: 'H87' }],
});
const mutation = useMutation({
mutationFn: (data: any) => invoicesApi.create(data),
onSuccess,
});
const addItem = () => {
setFormData({
...formData,
items: [
...formData.items,
{ descripcion: '', cantidad: 1, valorUnitario: 0, claveProdServ: '01010101', claveUnidad: 'H87' },
],
});
};
const updateItem = (index: number, field: string, value: any) => {
const newItems = [...formData.items];
newItems[index] = { ...newItems[index], [field]: value };
setFormData({ ...formData, items: newItems });
};
const removeItem = (index: number) => {
if (formData.items.length > 1) {
setFormData({
...formData,
items: formData.items.filter((_, i) => i !== index),
});
}
};
const total = formData.items.reduce(
(sum, item) => sum + item.cantidad * item.valorUnitario * 1.16,
0
);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b">
<h2 className="text-xl font-bold">Nueva Factura</h2>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
mutation.mutate(formData);
}}
className="p-6 space-y-6"
>
{/* Receptor */}
<div>
<h3 className="font-medium text-gray-900 mb-3">Datos del Receptor</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">RFC</label>
<input
type="text"
value={formData.receptorRfc}
onChange={(e) => setFormData({ ...formData, receptorRfc: e.target.value.toUpperCase() })}
className="input"
maxLength={13}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre/Razon Social</label>
<input
type="text"
value={formData.receptorNombre}
onChange={(e) => setFormData({ ...formData, receptorNombre: e.target.value })}
className="input"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Codigo Postal</label>
<input
type="text"
value={formData.receptorCodigoPostal}
onChange={(e) => setFormData({ ...formData, receptorCodigoPostal: e.target.value })}
className="input"
maxLength={5}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Uso CFDI</label>
<select
value={formData.receptorUsoCfdi}
onChange={(e) => setFormData({ ...formData, receptorUsoCfdi: e.target.value })}
className="input"
>
<option value="G01">Adquisicion de mercancias</option>
<option value="G03">Gastos en general</option>
<option value="P01">Por definir</option>
</select>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Email (opcional)</label>
<input
type="email"
value={formData.receptorEmail}
onChange={(e) => setFormData({ ...formData, receptorEmail: e.target.value })}
className="input"
placeholder="cliente@email.com"
/>
</div>
</div>
</div>
{/* Pago */}
<div>
<h3 className="font-medium text-gray-900 mb-3">Metodo de Pago</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Forma de Pago</label>
<select
value={formData.formaPago}
onChange={(e) => setFormData({ ...formData, formaPago: e.target.value })}
className="input"
>
<option value="01">Efectivo</option>
<option value="03">Transferencia</option>
<option value="04">Tarjeta de Credito</option>
<option value="28">Tarjeta de Debito</option>
<option value="99">Por definir</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Metodo de Pago</label>
<select
value={formData.metodoPago}
onChange={(e) => setFormData({ ...formData, metodoPago: e.target.value })}
className="input"
>
<option value="PUE">Pago en una sola exhibicion</option>
<option value="PPD">Pago en parcialidades</option>
</select>
</div>
</div>
</div>
{/* Items */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-gray-900">Conceptos</h3>
<button type="button" onClick={addItem} className="text-primary-600 text-sm font-medium">
+ Agregar concepto
</button>
</div>
<div className="space-y-3">
{formData.items.map((item, index) => (
<div key={index} className="p-4 bg-gray-50 rounded-lg">
<div className="grid grid-cols-12 gap-3">
<div className="col-span-6">
<input
type="text"
value={item.descripcion}
onChange={(e) => updateItem(index, 'descripcion', e.target.value)}
className="input"
placeholder="Descripcion"
required
/>
</div>
<div className="col-span-2">
<input
type="number"
value={item.cantidad}
onChange={(e) => updateItem(index, 'cantidad', Number(e.target.value))}
className="input"
placeholder="Cant"
min={1}
required
/>
</div>
<div className="col-span-3">
<input
type="number"
value={item.valorUnitario}
onChange={(e) => updateItem(index, 'valorUnitario', Number(e.target.value))}
className="input"
placeholder="Precio"
min={0}
step={0.01}
required
/>
</div>
<div className="col-span-1 flex items-center justify-center">
{formData.items.length > 1 && (
<button
type="button"
onClick={() => removeItem(index)}
className="text-red-600 hover:text-red-700"
>
<XCircle className="h-5 w-5" />
</button>
)}
</div>
</div>
</div>
))}
</div>
</div>
{/* Total */}
<div className="text-right">
<p className="text-gray-500">Total (IVA incluido)</p>
<p className="text-3xl font-bold">
${total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</p>
</div>
<div className="flex gap-3 pt-4">
<button type="button" onClick={onClose} className="btn-secondary flex-1">
Cancelar
</button>
<button type="submit" disabled={mutation.isPending} className="btn-primary flex-1">
{mutation.isPending ? 'Creando...' : 'Crear Factura'}
</button>
</div>
</form>
</div>
</div>
);
}

103
src/pages/Login.tsx Normal file
View File

@ -0,0 +1,103 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export default function Login() {
const [phone, setPhone] = useState('');
const [pin, setPin] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login(phone, pin);
navigate('/');
} catch (err: any) {
setError(err.response?.data?.message || 'Error al iniciar sesion');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h1 className="text-center text-3xl font-bold text-emerald-600">
MiChangarrito
</h1>
<h2 className="mt-6 text-center text-2xl font-bold text-gray-900">
Iniciar sesion
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
O{' '}
<Link to="/register" className="font-medium text-emerald-600 hover:text-emerald-500">
registra tu negocio
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
Telefono
</label>
<input
id="phone"
name="phone"
type="tel"
required
maxLength={10}
pattern="[0-9]{10}"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
placeholder="5512345678"
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
/>
</div>
<div>
<label htmlFor="pin" className="block text-sm font-medium text-gray-700">
PIN
</label>
<input
id="pin"
name="pin"
type="password"
required
maxLength={6}
minLength={4}
pattern="[0-9]{4,6}"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
placeholder="****"
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, ''))}
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Ingresando...' : 'Ingresar'}
</button>
</form>
</div>
</div>
);
}

731
src/pages/Marketplace.tsx Normal file
View File

@ -0,0 +1,731 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Store,
Search,
Star,
Heart,
ShoppingCart,
Package,
Truck,
Clock,
CheckCircle,
XCircle,
MapPin,
Phone,
ChevronRight,
Filter,
} from 'lucide-react';
import clsx from 'clsx';
import { marketplaceApi } from '../lib/api';
const categories = [
{ id: 'bebidas', name: 'Bebidas', icon: '🥤' },
{ id: 'botanas', name: 'Botanas', icon: '🍿' },
{ id: 'lacteos', name: 'Lacteos', icon: '🥛' },
{ id: 'pan', name: 'Pan', icon: '🍞' },
{ id: 'abarrotes', name: 'Abarrotes', icon: '🛒' },
{ id: 'limpieza', name: 'Limpieza', icon: '🧹' },
];
const orderStatusConfig = {
pending: { label: 'Pendiente', color: 'yellow', icon: Clock },
confirmed: { label: 'Confirmado', color: 'blue', icon: CheckCircle },
preparing: { label: 'Preparando', color: 'indigo', icon: Package },
shipped: { label: 'En camino', color: 'purple', icon: Truck },
delivered: { label: 'Entregado', color: 'green', icon: CheckCircle },
cancelled: { label: 'Cancelado', color: 'red', icon: XCircle },
rejected: { label: 'Rechazado', color: 'red', icon: XCircle },
};
export function Marketplace() {
const [view, setView] = useState<'suppliers' | 'orders' | 'favorites'>('suppliers');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedSupplier, setSelectedSupplier] = useState<any>(null);
const [showCart, setShowCart] = useState(false);
const [cart, setCart] = useState<{ product: any; quantity: number }[]>([]);
const queryClient = useQueryClient();
const { data: suppliers = [], isLoading: loadingSuppliers } = useQuery({
queryKey: ['suppliers', selectedCategory, searchQuery],
queryFn: async () => {
const response = await marketplaceApi.getSuppliers({
category: selectedCategory || undefined,
search: searchQuery || undefined,
});
return response.data;
},
});
const { data: orders = [], isLoading: loadingOrders } = useQuery({
queryKey: ['marketplace-orders'],
queryFn: async () => {
const response = await marketplaceApi.getOrders();
return response.data;
},
enabled: view === 'orders',
});
const { data: favorites = [] } = useQuery({
queryKey: ['supplier-favorites'],
queryFn: async () => {
const response = await marketplaceApi.getFavorites();
return response.data;
},
enabled: view === 'favorites',
});
const addToCart = (product: any) => {
const existing = cart.find((item) => item.product.id === product.id);
if (existing) {
setCart(
cart.map((item) =>
item.product.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
);
} else {
setCart([...cart, { product, quantity: 1 }]);
}
};
const removeFromCart = (productId: string) => {
setCart(cart.filter((item) => item.product.id !== productId));
};
const updateCartQuantity = (productId: string, quantity: number) => {
if (quantity <= 0) {
removeFromCart(productId);
} else {
setCart(
cart.map((item) =>
item.product.id === productId ? { ...item, quantity } : item
)
);
}
};
const cartTotal = cart.reduce(
(sum, item) => sum + Number(item.product.unitPrice) * item.quantity,
0
);
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Marketplace</h1>
<p className="text-gray-500">Encuentra proveedores para tu negocio</p>
</div>
{cart.length > 0 && (
<button
onClick={() => setShowCart(true)}
className="btn-primary flex items-center gap-2"
>
<ShoppingCart className="h-4 w-4" />
Carrito ({cart.length})
<span className="ml-2 font-bold">
${cartTotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</span>
</button>
)}
</div>
{/* Tabs */}
<div className="flex gap-2 border-b">
<button
onClick={() => setView('suppliers')}
className={clsx(
'px-4 py-2 font-medium border-b-2 -mb-px transition-colors',
view === 'suppliers'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
)}
>
<Store className="h-4 w-4 inline mr-2" />
Proveedores
</button>
<button
onClick={() => setView('orders')}
className={clsx(
'px-4 py-2 font-medium border-b-2 -mb-px transition-colors',
view === 'orders'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
)}
>
<Package className="h-4 w-4 inline mr-2" />
Mis Pedidos
</button>
<button
onClick={() => setView('favorites')}
className={clsx(
'px-4 py-2 font-medium border-b-2 -mb-px transition-colors',
view === 'favorites'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
)}
>
<Heart className="h-4 w-4 inline mr-2" />
Favoritos
</button>
</div>
{view === 'suppliers' && (
<>
{/* Search & Filter */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar proveedores..."
className="input pl-10"
/>
</div>
</div>
{/* Categories */}
<div className="flex gap-2 overflow-x-auto pb-2">
<button
onClick={() => setSelectedCategory(null)}
className={clsx(
'px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors',
!selectedCategory
? 'bg-primary-100 text-primary-700'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
)}
>
Todos
</button>
{categories.map((category) => (
<button
key={category.id}
onClick={() => setSelectedCategory(category.id)}
className={clsx(
'px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors flex items-center gap-2',
selectedCategory === category.id
? 'bg-primary-100 text-primary-700'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
)}
>
<span>{category.icon}</span>
{category.name}
</button>
))}
</div>
{/* Suppliers Grid */}
{loadingSuppliers ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
<p className="text-gray-500 mt-2">Buscando proveedores...</p>
</div>
) : suppliers.length === 0 ? (
<div className="text-center py-12 card">
<Store className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900">No hay proveedores</h3>
<p className="text-gray-500">Pronto habra mas proveedores disponibles</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{suppliers.map((supplier: any) => (
<SupplierCard
key={supplier.id}
supplier={supplier}
onClick={() => setSelectedSupplier(supplier)}
/>
))}
</div>
)}
</>
)}
{view === 'orders' && (
<div className="space-y-4">
{loadingOrders ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
</div>
) : orders.length === 0 ? (
<div className="text-center py-12 card">
<Package className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900">No hay pedidos</h3>
<p className="text-gray-500">Tus pedidos a proveedores apareceran aqui</p>
</div>
) : (
orders.map((order: any) => (
<OrderCard key={order.id} order={order} />
))
)}
</div>
)}
{view === 'favorites' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{favorites.length === 0 ? (
<div className="col-span-full text-center py-12 card">
<Heart className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900">Sin favoritos</h3>
<p className="text-gray-500">Agrega proveedores a tus favoritos</p>
</div>
) : (
favorites.map((supplier: any) => (
<SupplierCard
key={supplier.id}
supplier={supplier}
onClick={() => setSelectedSupplier(supplier)}
/>
))
)}
</div>
)}
{/* Supplier Detail Modal */}
{selectedSupplier && (
<SupplierDetailModal
supplier={selectedSupplier}
onClose={() => setSelectedSupplier(null)}
onAddToCart={addToCart}
cart={cart}
/>
)}
{/* Cart Modal */}
{showCart && (
<CartModal
cart={cart}
supplier={selectedSupplier}
onClose={() => setShowCart(false)}
onUpdateQuantity={updateCartQuantity}
onRemove={removeFromCart}
onOrderSuccess={() => {
setCart([]);
setShowCart(false);
queryClient.invalidateQueries({ queryKey: ['marketplace-orders'] });
}}
/>
)}
</div>
);
}
function SupplierCard({
supplier,
onClick,
}: {
supplier: any;
onClick: () => void;
}) {
return (
<div
onClick={onClick}
className="card cursor-pointer hover:shadow-md transition-shadow"
>
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-lg bg-gray-100 flex items-center justify-center overflow-hidden">
{supplier.logoUrl ? (
<img src={supplier.logoUrl} alt={supplier.name} className="w-full h-full object-cover" />
) : (
<Store className="h-8 w-8 text-gray-400" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h3 className="font-bold text-gray-900 truncate">{supplier.name}</h3>
{supplier.verified && (
<CheckCircle className="h-4 w-4 text-blue-500 flex-shrink-0" />
)}
</div>
<div className="flex items-center gap-2 mt-1">
<div className="flex items-center text-yellow-500">
<Star className="h-4 w-4 fill-current" />
<span className="ml-1 text-sm font-medium">{Number(supplier.rating).toFixed(1)}</span>
</div>
<span className="text-gray-400">·</span>
<span className="text-sm text-gray-500">{supplier.totalReviews} resenas</span>
</div>
<div className="flex flex-wrap gap-1 mt-2">
{supplier.categories?.slice(0, 3).map((cat: string) => (
<span key={cat} className="px-2 py-0.5 text-xs bg-gray-100 rounded-full text-gray-600">
{cat}
</span>
))}
</div>
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
<span>Min: ${Number(supplier.minOrderAmount).toFixed(0)}</span>
{Number(supplier.deliveryFee) > 0 ? (
<span>Envio: ${Number(supplier.deliveryFee).toFixed(0)}</span>
) : (
<span className="text-green-600">Envio gratis</span>
)}
</div>
</div>
<ChevronRight className="h-5 w-5 text-gray-400 flex-shrink-0" />
</div>
</div>
);
}
function OrderCard({ order }: { order: any }) {
const status = orderStatusConfig[order.status as keyof typeof orderStatusConfig] || orderStatusConfig.pending;
const StatusIcon = status.icon;
return (
<div className="card">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div className="flex items-start gap-4">
<div className={`p-3 rounded-full bg-${status.color}-100`}>
<StatusIcon className={`h-6 w-6 text-${status.color}-600`} />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-bold">Pedido #{order.orderNumber}</h3>
<span className={`px-2 py-0.5 text-xs rounded-full bg-${status.color}-100 text-${status.color}-700`}>
{status.label}
</span>
</div>
<p className="text-gray-600">{order.supplier?.name}</p>
<p className="text-sm text-gray-500">
{order.items?.length} productos
</p>
</div>
</div>
<div className="text-right">
<p className="text-2xl font-bold">
${Number(order.total).toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</p>
<p className="text-sm text-gray-500">
{new Date(order.createdAt).toLocaleDateString('es-MX')}
</p>
</div>
</div>
</div>
);
}
function SupplierDetailModal({
supplier,
onClose,
onAddToCart,
cart,
}: {
supplier: any;
onClose: () => void;
onAddToCart: (product: any) => void;
cart: { product: any; quantity: number }[];
}) {
const [search, setSearch] = useState('');
const queryClient = useQueryClient();
const { data: products = [], isLoading } = useQuery({
queryKey: ['supplier-products', supplier.id, search],
queryFn: async () => {
const response = await marketplaceApi.getSupplierProducts(supplier.id, {
search: search || undefined,
});
return response.data;
},
});
const favoriteMutation = useMutation({
mutationFn: () => marketplaceApi.addFavorite(supplier.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['supplier-favorites'] });
},
});
const getCartQuantity = (productId: string) => {
const item = cart.find((i) => i.product.id === productId);
return item?.quantity || 0;
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-xl max-w-3xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="p-6 border-b">
<div className="flex items-start gap-4">
<div className="w-20 h-20 rounded-lg bg-gray-100 flex items-center justify-center overflow-hidden">
{supplier.logoUrl ? (
<img src={supplier.logoUrl} alt={supplier.name} className="w-full h-full object-cover" />
) : (
<Store className="h-10 w-10 text-gray-400" />
)}
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">{supplier.name}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<XCircle className="h-6 w-6" />
</button>
</div>
<div className="flex items-center gap-2 mt-1">
<Star className="h-4 w-4 text-yellow-500 fill-current" />
<span className="font-medium">{Number(supplier.rating).toFixed(1)}</span>
<span className="text-gray-400">·</span>
<span className="text-gray-500">{supplier.totalReviews} resenas</span>
</div>
{supplier.address && (
<p className="text-sm text-gray-500 mt-1 flex items-center gap-1">
<MapPin className="h-4 w-4" />
{supplier.city}, {supplier.state}
</p>
)}
</div>
</div>
<div className="flex gap-2 mt-4">
<button
onClick={() => favoriteMutation.mutate()}
className="btn-secondary flex items-center gap-2"
>
<Heart className="h-4 w-4" />
Agregar a favoritos
</button>
{supplier.contactPhone && (
<a href={`tel:${supplier.contactPhone}`} className="btn-secondary flex items-center gap-2">
<Phone className="h-4 w-4" />
Llamar
</a>
)}
</div>
</div>
{/* Search */}
<div className="p-4 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar productos..."
className="input pl-10"
/>
</div>
</div>
{/* Products */}
<div className="flex-1 overflow-y-auto p-4">
{isLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
</div>
) : products.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No hay productos disponibles
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{products.map((product: any) => {
const inCart = getCartQuantity(product.id);
return (
<div key={product.id} className="flex gap-3 p-3 bg-gray-50 rounded-lg">
<div className="w-16 h-16 rounded bg-white flex items-center justify-center overflow-hidden">
{product.imageUrl ? (
<img src={product.imageUrl} alt={product.name} className="w-full h-full object-cover" />
) : (
<Package className="h-6 w-6 text-gray-300" />
)}
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 truncate">{product.name}</h4>
<p className="text-sm text-gray-500">
Min: {product.minQuantity} {product.unitType}
</p>
<div className="flex items-center justify-between mt-2">
<span className="font-bold text-primary-600">
${Number(product.unitPrice).toFixed(2)}
</span>
{inCart > 0 ? (
<span className="text-sm text-green-600">
{inCart} en carrito
</span>
) : (
<button
onClick={() => onAddToCart(product)}
className="text-sm text-primary-600 font-medium"
>
+ Agregar
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
function CartModal({
cart,
supplier,
onClose,
onUpdateQuantity,
onRemove,
onOrderSuccess,
}: {
cart: { product: any; quantity: number }[];
supplier: any;
onClose: () => void;
onUpdateQuantity: (productId: string, quantity: number) => void;
onRemove: (productId: string) => void;
onOrderSuccess: () => void;
}) {
const [deliveryAddress, setDeliveryAddress] = useState('');
const [deliveryPhone, setDeliveryPhone] = useState('');
const [notes, setNotes] = useState('');
const subtotal = cart.reduce(
(sum, item) => sum + Number(item.product.unitPrice) * item.quantity,
0
);
const orderMutation = useMutation({
mutationFn: (data: any) => marketplaceApi.createOrder(data),
onSuccess: onOrderSuccess,
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const supplierId = cart[0]?.product?.supplierId;
if (!supplierId) return;
orderMutation.mutate({
supplierId,
items: cart.map((item) => ({
productId: item.product.id,
quantity: item.quantity,
})),
deliveryAddress,
deliveryPhone,
notes,
});
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b flex items-center justify-between">
<h2 className="text-xl font-bold">Tu Carrito</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<XCircle className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{/* Items */}
<div className="space-y-3">
{cart.map((item) => (
<div key={item.product.id} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<div className="flex-1">
<p className="font-medium">{item.product.name}</p>
<p className="text-sm text-gray-500">
${Number(item.product.unitPrice).toFixed(2)} x {item.quantity}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onUpdateQuantity(item.product.id, item.quantity - 1)}
className="w-8 h-8 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center"
>
-
</button>
<span className="w-8 text-center font-medium">{item.quantity}</span>
<button
type="button"
onClick={() => onUpdateQuantity(item.product.id, item.quantity + 1)}
className="w-8 h-8 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center"
>
+
</button>
</div>
<p className="font-bold w-20 text-right">
${(Number(item.product.unitPrice) * item.quantity).toFixed(2)}
</p>
</div>
))}
</div>
{/* Delivery Info */}
<div className="pt-4 border-t space-y-4">
<h3 className="font-medium">Datos de entrega</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Direccion de entrega
</label>
<textarea
value={deliveryAddress}
onChange={(e) => setDeliveryAddress(e.target.value)}
className="input"
rows={2}
required
placeholder="Calle, numero, colonia, CP"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Telefono de contacto
</label>
<input
type="tel"
value={deliveryPhone}
onChange={(e) => setDeliveryPhone(e.target.value)}
className="input"
required
placeholder="5512345678"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notas (opcional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="input"
rows={2}
placeholder="Instrucciones especiales..."
/>
</div>
</div>
{/* Total */}
<div className="pt-4 border-t">
<div className="flex justify-between text-lg font-bold">
<span>Total</span>
<span>${subtotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</span>
</div>
</div>
<div className="flex gap-3 pt-4">
<button type="button" onClick={onClose} className="btn-secondary flex-1">
Cancelar
</button>
<button
type="submit"
disabled={orderMutation.isPending || cart.length === 0}
className="btn-primary flex-1"
>
{orderMutation.isPending ? 'Enviando...' : 'Enviar Pedido'}
</button>
</div>
</form>
</div>
</div>
);
}

181
src/pages/Orders.tsx Normal file
View File

@ -0,0 +1,181 @@
import { useState } from 'react';
import { Clock, CheckCircle, XCircle, ChefHat, Package } from 'lucide-react';
import clsx from 'clsx';
const mockOrders = [
{
id: 'MCH-001',
customer: 'Maria Lopez',
phone: '5551234567',
items: [
{ name: 'Coca-Cola 600ml', qty: 2, price: 18.00 },
{ name: 'Sabritas', qty: 3, price: 15.00 },
],
total: 81.00,
status: 'pending',
source: 'whatsapp',
createdAt: '2024-01-15T10:30:00',
},
{
id: 'MCH-002',
customer: 'Juan Perez',
phone: '5559876543',
items: [
{ name: 'Leche Lala 1L', qty: 2, price: 28.00 },
{ name: 'Pan Bimbo', qty: 1, price: 45.00 },
],
total: 101.00,
status: 'preparing',
source: 'pos',
createdAt: '2024-01-15T10:45:00',
},
{
id: 'MCH-003',
customer: 'Ana Garcia',
phone: '5555555555',
items: [
{ name: 'Fabuloso 1L', qty: 1, price: 32.00 },
],
total: 32.00,
status: 'ready',
source: 'whatsapp',
createdAt: '2024-01-15T11:00:00',
},
];
const statusConfig = {
pending: { label: 'Pendiente', color: 'yellow', icon: Clock },
confirmed: { label: 'Confirmado', color: 'blue', icon: CheckCircle },
preparing: { label: 'Preparando', color: 'indigo', icon: ChefHat },
ready: { label: 'Listo', color: 'green', icon: Package },
completed: { label: 'Completado', color: 'gray', icon: CheckCircle },
cancelled: { label: 'Cancelado', color: 'red', icon: XCircle },
};
const statusFlow = ['pending', 'confirmed', 'preparing', 'ready', 'completed'];
export function Orders() {
const [filter, setFilter] = useState('all');
const filteredOrders = filter === 'all'
? mockOrders
: mockOrders.filter(o => o.status === filter);
const updateStatus = (orderId: string, currentStatus: string) => {
const currentIndex = statusFlow.indexOf(currentStatus);
if (currentIndex < statusFlow.length - 1) {
const nextStatus = statusFlow[currentIndex + 1];
console.log(`Updating ${orderId} to ${nextStatus}`);
// API call would go here
}
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Pedidos</h1>
<p className="text-gray-500">Gestiona los pedidos de tu tienda</p>
</div>
{/* Status Tabs */}
<div className="flex gap-2 overflow-x-auto pb-2">
<button
onClick={() => setFilter('all')}
className={clsx(
'px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors',
filter === 'all'
? 'bg-primary-100 text-primary-700'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
)}
>
Todos ({mockOrders.length})
</button>
{Object.entries(statusConfig).slice(0, 4).map(([status, config]) => {
const count = mockOrders.filter(o => o.status === status).length;
return (
<button
key={status}
onClick={() => setFilter(status)}
className={clsx(
'px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors',
filter === status
? `bg-${config.color}-100 text-${config.color}-700`
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
)}
>
{config.label} ({count})
</button>
);
})}
</div>
{/* Orders List */}
<div className="space-y-4">
{filteredOrders.map((order) => {
const status = statusConfig[order.status as keyof typeof statusConfig];
const StatusIcon = status.icon;
return (
<div key={order.id} className="card">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div className="flex items-start gap-4">
<div className={`p-3 rounded-full bg-${status.color}-100`}>
<StatusIcon className={`h-6 w-6 text-${status.color}-600`} />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-bold text-lg">{order.id}</h3>
<span className={`px-2 py-0.5 text-xs rounded-full bg-${status.color}-100 text-${status.color}-700`}>
{status.label}
</span>
<span className="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600">
{order.source === 'whatsapp' ? 'WhatsApp' : 'POS'}
</span>
</div>
<p className="text-gray-600">{order.customer}</p>
<p className="text-sm text-gray-500">{order.phone}</p>
</div>
</div>
<div className="flex-1 lg:px-8">
<div className="text-sm text-gray-600">
{order.items.map((item, i) => (
<span key={i}>
{item.qty}x {item.name}
{i < order.items.length - 1 ? ', ' : ''}
</span>
))}
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-2xl font-bold">${order.total.toFixed(2)}</p>
<p className="text-sm text-gray-500">
{new Date(order.createdAt).toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
{order.status !== 'completed' && order.status !== 'cancelled' && (
<button
onClick={() => updateStatus(order.id, order.status)}
className="btn-primary whitespace-nowrap"
>
{order.status === 'pending' && 'Confirmar'}
{order.status === 'confirmed' && 'Preparar'}
{order.status === 'preparing' && 'Listo'}
{order.status === 'ready' && 'Entregar'}
</button>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

103
src/pages/Products.tsx Normal file
View File

@ -0,0 +1,103 @@
import { useState } from 'react';
import { Search, Plus, Edit, Trash2, Package } from 'lucide-react';
const mockProducts = [
{ id: '1', name: 'Coca-Cola 600ml', category: 'bebidas', price: 18.00, stock: 24, barcode: '7501055300051' },
{ id: '2', name: 'Sabritas Original', category: 'botanas', price: 15.00, stock: 12, barcode: '7501000111111' },
{ id: '3', name: 'Leche Lala 1L', category: 'lacteos', price: 28.00, stock: 8, barcode: '7501020500001' },
{ id: '4', name: 'Pan Bimbo Grande', category: 'panaderia', price: 45.00, stock: 5, barcode: '7501030400001' },
{ id: '5', name: 'Fabuloso 1L', category: 'limpieza', price: 32.00, stock: 6, barcode: '7501040300001' },
{ id: '6', name: 'Pepsi 600ml', category: 'bebidas', price: 17.00, stock: 20, barcode: '7501055300052' },
];
const categories = [
{ id: 'all', name: 'Todos' },
{ id: 'bebidas', name: 'Bebidas' },
{ id: 'botanas', name: 'Botanas' },
{ id: 'lacteos', name: 'Lacteos' },
{ id: 'panaderia', name: 'Panaderia' },
{ id: 'limpieza', name: 'Limpieza' },
];
export function Products() {
const [search, setSearch] = useState('');
const [category, setCategory] = useState('all');
const filteredProducts = mockProducts.filter((product) => {
const matchesSearch = product.name.toLowerCase().includes(search.toLowerCase());
const matchesCategory = category === 'all' || product.category === category;
return matchesSearch && matchesCategory;
});
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Productos</h1>
<p className="text-gray-500">{mockProducts.length} productos en catalogo</p>
</div>
<button className="btn-primary flex items-center gap-2">
<Plus className="h-5 w-5" />
Agregar Producto
</button>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Buscar productos..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10"
/>
</div>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="input sm:w-48"
>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
{/* Products Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredProducts.map((product) => (
<div key={product.id} className="card hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="p-2 bg-gray-100 rounded-lg">
<Package className="h-8 w-8 text-gray-600" />
</div>
<div className="flex gap-1">
<button className="p-1 hover:bg-gray-100 rounded">
<Edit className="h-4 w-4 text-gray-500" />
</button>
<button className="p-1 hover:bg-red-100 rounded">
<Trash2 className="h-4 w-4 text-red-500" />
</button>
</div>
</div>
<h3 className="font-semibold text-gray-900">{product.name}</h3>
<p className="text-sm text-gray-500 capitalize">{product.category}</p>
<div className="mt-3 flex items-center justify-between">
<span className="text-xl font-bold text-primary-600">
${product.price.toFixed(2)}
</span>
<span className={`text-sm font-medium ${
product.stock > 10 ? 'text-green-600' : product.stock > 5 ? 'text-yellow-600' : 'text-red-600'
}`}>
{product.stock} en stock
</span>
</div>
<p className="text-xs text-gray-400 mt-2">{product.barcode}</p>
</div>
))}
</div>
</div>
);
}

276
src/pages/Referrals.tsx Normal file
View File

@ -0,0 +1,276 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Gift, Users, Copy, Share2, Check, Clock, X } from 'lucide-react';
import { referralsApi } from '../lib/api';
interface ReferralStats {
code: string;
totalInvited: number;
totalConverted: number;
totalPending: number;
totalExpired: number;
monthsEarned: number;
monthsAvailable: number;
}
interface Referral {
id: string;
referredTenantId: string;
codeUsed: string;
status: 'pending' | 'converted' | 'rewarded' | 'expired';
createdAt: string;
convertedAt?: string;
}
export function Referrals() {
const [copied, setCopied] = useState(false);
const queryClient = useQueryClient();
const { data: stats, isLoading } = useQuery<ReferralStats>({
queryKey: ['referral-stats'],
queryFn: async () => {
const res = await referralsApi.getStats();
return res.data;
},
});
const { data: referrals } = useQuery<Referral[]>({
queryKey: ['referrals'],
queryFn: async () => {
const res = await referralsApi.getMyReferrals();
return res.data;
},
});
const generateCodeMutation = useMutation({
mutationFn: () => referralsApi.generateCode(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['referral-stats'] });
},
});
const copyCode = async () => {
if (stats?.code) {
await navigator.clipboard.writeText(stats.code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const shareWhatsApp = () => {
if (stats?.code) {
const text = `Usa mi codigo ${stats.code} para registrarte en MiChangarrito y obtener 50% de descuento en tu primer mes! https://michangarrito.com/r/${stats.code}`;
window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, '_blank');
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'pending':
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<Clock className="h-3 w-3" />
Pendiente
</span>
);
case 'converted':
case 'rewarded':
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
<Check className="h-3 w-3" />
Convertido
</span>
);
case 'expired':
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">
<X className="h-3 w-3" />
Expirado
</span>
);
default:
return null;
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Programa de Referidos</h1>
<p className="text-gray-500">Invita amigos y gana meses gratis</p>
</div>
{/* Share Card */}
<div className="card bg-gradient-to-r from-primary-500 to-primary-600 text-white">
<div className="flex items-center gap-3 mb-4">
<Gift className="h-8 w-8" />
<div>
<h2 className="text-xl font-bold">Invita amigos y gana</h2>
<p className="text-primary-100">Por cada amigo que se suscriba, ganas 1 mes gratis</p>
</div>
</div>
<div className="bg-white/10 rounded-lg p-4 mb-4">
<p className="text-sm text-primary-100 mb-2">Tu codigo de referido:</p>
<div className="flex items-center gap-3">
<span className="text-2xl font-bold tracking-wider">{stats?.code || 'Generando...'}</span>
<button
onClick={copyCode}
className="p-2 rounded-lg bg-white/20 hover:bg-white/30 transition-colors"
title="Copiar codigo"
>
{copied ? <Check className="h-5 w-5" /> : <Copy className="h-5 w-5" />}
</button>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={copyCode}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-white text-primary-600 rounded-lg font-medium hover:bg-primary-50 transition-colors"
>
<Copy className="h-5 w-5" />
{copied ? 'Copiado!' : 'Copiar codigo'}
</button>
<button
onClick={shareWhatsApp}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-green-500 text-white rounded-lg font-medium hover:bg-green-600 transition-colors"
>
<Share2 className="h-5 w-5" />
Compartir por WhatsApp
</button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="card">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Users className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-2xl font-bold">{stats?.totalInvited || 0}</p>
<p className="text-sm text-gray-500">Invitados</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-lg">
<Check className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-2xl font-bold">{stats?.totalConverted || 0}</p>
<p className="text-sm text-gray-500">Convertidos</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 rounded-lg">
<Gift className="h-5 w-5 text-purple-600" />
</div>
<div>
<p className="text-2xl font-bold">{stats?.monthsEarned || 0}</p>
<p className="text-sm text-gray-500">Meses ganados</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary-100 rounded-lg">
<Gift className="h-5 w-5 text-primary-600" />
</div>
<div>
<p className="text-2xl font-bold">{stats?.monthsAvailable || 0}</p>
<p className="text-sm text-gray-500">Disponibles</p>
</div>
</div>
</div>
</div>
{/* How it works */}
<div className="card">
<h3 className="text-lg font-bold mb-4">Como funciona</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<div className="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-3">
<span className="text-xl font-bold text-primary-600">1</span>
</div>
<h4 className="font-medium mb-1">Comparte tu codigo</h4>
<p className="text-sm text-gray-500">Envia tu codigo a amigos por WhatsApp</p>
</div>
<div className="text-center">
<div className="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-3">
<span className="text-xl font-bold text-primary-600">2</span>
</div>
<h4 className="font-medium mb-1">Tu amigo se registra</h4>
<p className="text-sm text-gray-500">Obtiene 50% de descuento en su primer mes</p>
</div>
<div className="text-center">
<div className="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-3">
<span className="text-xl font-bold text-primary-600">3</span>
</div>
<h4 className="font-medium mb-1">Tu ganas 1 mes gratis</h4>
<p className="text-sm text-gray-500">Cuando tu amigo paga su primer mes</p>
</div>
</div>
</div>
{/* Referrals List */}
<div className="card">
<h3 className="text-lg font-bold mb-4">Tus referidos</h3>
{referrals && referrals.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-sm text-gray-500 border-b">
<th className="pb-3 font-medium">Fecha</th>
<th className="pb-3 font-medium">Estado</th>
<th className="pb-3 font-medium">Conversion</th>
</tr>
</thead>
<tbody className="divide-y">
{referrals.map((referral) => (
<tr key={referral.id}>
<td className="py-3">
{new Date(referral.createdAt).toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</td>
<td className="py-3">{getStatusBadge(referral.status)}</td>
<td className="py-3 text-sm text-gray-500">
{referral.convertedAt
? new Date(referral.convertedAt).toLocaleDateString('es-MX')
: '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<Users className="h-12 w-12 mx-auto mb-3 text-gray-300" />
<p>Aun no tienes referidos</p>
<p className="text-sm">Comparte tu codigo para empezar a ganar</p>
</div>
)}
</div>
</div>
);
}

241
src/pages/Register.tsx Normal file
View File

@ -0,0 +1,241 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
const BUSINESS_TYPES = [
{ value: 'tiendita', label: 'Tiendita / Abarrotes' },
{ value: 'fonda', label: 'Fonda / Comida' },
{ value: 'taqueria', label: 'Taqueria' },
{ value: 'tortilleria', label: 'Tortilleria' },
{ value: 'verduleria', label: 'Verduleria' },
{ value: 'otro', label: 'Otro' },
];
export default function Register() {
const [formData, setFormData] = useState({
name: '',
ownerName: '',
businessType: 'tiendita',
phone: '',
pin: '',
confirmPin: '',
email: '',
});
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { register } = useAuth();
const navigate = useNavigate();
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
if (name === 'phone' || name === 'pin' || name === 'confirmPin') {
setFormData({ ...formData, [name]: value.replace(/\D/g, '') });
} else {
setFormData({ ...formData, [name]: value });
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (formData.pin !== formData.confirmPin) {
setError('Los PINs no coinciden');
return;
}
if (formData.pin.length < 4) {
setError('El PIN debe tener al menos 4 digitos');
return;
}
setIsLoading(true);
try {
await register({
name: formData.name,
ownerName: formData.ownerName,
businessType: formData.businessType,
phone: formData.phone,
pin: formData.pin,
email: formData.email || undefined,
});
navigate('/');
} catch (err: any) {
setError(err.response?.data?.message || 'Error al registrar');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h1 className="text-center text-3xl font-bold text-emerald-600">
MiChangarrito
</h1>
<h2 className="mt-6 text-center text-2xl font-bold text-gray-900">
Registra tu negocio
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Ya tienes cuenta?{' '}
<Link to="/login" className="font-medium text-emerald-600 hover:text-emerald-500">
Inicia sesion
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Nombre del negocio *
</label>
<input
id="name"
name="name"
type="text"
required
maxLength={100}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
placeholder="Ej: Abarrotes Don Pepe"
value={formData.name}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="ownerName" className="block text-sm font-medium text-gray-700">
Tu nombre *
</label>
<input
id="ownerName"
name="ownerName"
type="text"
required
maxLength={100}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
placeholder="Ej: Jose Perez"
value={formData.ownerName}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="businessType" className="block text-sm font-medium text-gray-700">
Tipo de negocio *
</label>
<select
id="businessType"
name="businessType"
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
value={formData.businessType}
onChange={handleChange}
>
{BUSINESS_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
Telefono (10 digitos) *
</label>
<input
id="phone"
name="phone"
type="tel"
required
maxLength={10}
pattern="[0-9]{10}"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
placeholder="5512345678"
value={formData.phone}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email (opcional)
</label>
<input
id="email"
name="email"
type="email"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
placeholder="tucorreo@ejemplo.com"
value={formData.email}
onChange={handleChange}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="pin" className="block text-sm font-medium text-gray-700">
PIN (4-6 digitos) *
</label>
<input
id="pin"
name="pin"
type="password"
required
maxLength={6}
minLength={4}
pattern="[0-9]{4,6}"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
placeholder="****"
value={formData.pin}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="confirmPin" className="block text-sm font-medium text-gray-700">
Confirmar PIN *
</label>
<input
id="confirmPin"
name="confirmPin"
type="password"
required
maxLength={6}
minLength={4}
pattern="[0-9]{4,6}"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500"
placeholder="****"
value={formData.confirmPin}
onChange={handleChange}
/>
</div>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Registrando...' : 'Registrar mi negocio'}
</button>
<p className="text-xs text-center text-gray-500 mt-4">
Al registrarte aceptas los terminos y condiciones del servicio.
Prueba gratis por 14 dias.
</p>
</form>
</div>
</div>
);
}

165
src/pages/Settings.tsx Normal file
View File

@ -0,0 +1,165 @@
import { Store, CreditCard, Bell, MessageSquare, Shield } from 'lucide-react';
export function Settings() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Ajustes</h1>
<p className="text-gray-500">Configura tu tienda</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Business Info */}
<div className="card">
<div className="flex items-center gap-3 mb-4">
<Store className="h-6 w-6 text-primary-600" />
<h2 className="text-lg font-bold">Informacion del Negocio</h2>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nombre de la tienda
</label>
<input type="text" className="input" defaultValue="Mi Tiendita" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Telefono
</label>
<input type="tel" className="input" defaultValue="555-123-4567" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Direccion
</label>
<input type="text" className="input" defaultValue="Calle Principal #123" />
</div>
<button className="btn-primary">Guardar cambios</button>
</div>
</div>
{/* Fiado Settings */}
<div className="card">
<div className="flex items-center gap-3 mb-4">
<CreditCard className="h-6 w-6 text-primary-600" />
<h2 className="text-lg font-bold">Configuracion de Fiado</h2>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Habilitar fiado</p>
<p className="text-sm text-gray-500">Permite credito a clientes</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" defaultChecked />
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500"></div>
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Limite de credito por defecto
</label>
<input type="number" className="input" defaultValue="500" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Dias de gracia
</label>
<input type="number" className="input" defaultValue="15" />
</div>
</div>
</div>
{/* WhatsApp Settings */}
<div className="card">
<div className="flex items-center gap-3 mb-4">
<MessageSquare className="h-6 w-6 text-green-600" />
<h2 className="text-lg font-bold">WhatsApp Business</h2>
</div>
<div className="space-y-4">
<div className="p-4 bg-green-50 rounded-lg">
<p className="text-green-800 font-medium">Conectado</p>
<p className="text-sm text-green-600">+52 555 123 4567</p>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Respuestas automaticas</p>
<p className="text-sm text-gray-500">Usa IA para responder</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" defaultChecked />
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500"></div>
</label>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Notificar pedidos</p>
<p className="text-sm text-gray-500">Avisa cuando hay pedidos nuevos</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" defaultChecked />
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500"></div>
</label>
</div>
</div>
</div>
{/* Notifications */}
<div className="card">
<div className="flex items-center gap-3 mb-4">
<Bell className="h-6 w-6 text-primary-600" />
<h2 className="text-lg font-bold">Notificaciones</h2>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Stock bajo</p>
<p className="text-sm text-gray-500">Alerta cuando hay poco inventario</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" defaultChecked />
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500"></div>
</label>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Fiados vencidos</p>
<p className="text-sm text-gray-500">Recordatorio de cobros pendientes</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" defaultChecked />
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500"></div>
</label>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Nuevos pedidos</p>
<p className="text-sm text-gray-500">Sonido al recibir pedidos</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" defaultChecked />
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500"></div>
</label>
</div>
</div>
</div>
</div>
{/* Subscription */}
<div className="card">
<div className="flex items-center gap-3 mb-4">
<Shield className="h-6 w-6 text-primary-600" />
<h2 className="text-lg font-bold">Plan de Suscripcion</h2>
</div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<p className="font-bold text-xl">Plan Basico</p>
<p className="text-gray-500">$299/mes - 1,000 mensajes IA incluidos</p>
<p className="text-sm text-gray-400">Renueva: 15 de febrero, 2024</p>
</div>
<button className="btn-primary">Mejorar plan</button>
</div>
</div>
</div>
);
}

40
tailwind.config.js Normal file
View File

@ -0,0 +1,40 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#fff7ed',
100: '#ffedd5',
200: '#fed7aa',
300: '#fdba74',
400: '#fb923c',
500: '#f97316',
600: '#ea580c',
700: '#c2410c',
800: '#9a3412',
900: '#7c2d12',
},
secondary: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
},
},
},
plugins: [
require('@tailwindcss/forms'),
],
}

28
tsconfig.app.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

17
vite.config.ts Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3140,
proxy: {
'/api': {
target: 'http://localhost:3141',
changeOrigin: true,
secure: false,
},
},
},
})