- Configure workspace Git repository with comprehensive .gitignore - Add Odoo as submodule for ERP reference code - Include documentation: SETUP.md, GIT-STRUCTURE.md - Add gitignore templates for projects (backend, frontend, database) - Structure supports independent repos per project/subproject level Workspace includes: - core/ - Reusable patterns, modules, orchestration system - projects/ - Active projects (erp-suite, gamilit, trading-platform, etc.) - knowledge-base/ - Reference code and patterns (includes Odoo submodule) - devtools/ - Development tools and templates - customers/ - Client implementations template 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
504 lines
12 KiB
Markdown
504 lines
12 KiB
Markdown
# Estructura de Código Compartido Frontend
|
|
|
|
**Versión:** 1.0.0
|
|
**Última Actualización:** 2025-11-28
|
|
**Aplica a:** apps/frontend/src/shared/
|
|
|
|
---
|
|
|
|
## Resumen
|
|
|
|
La carpeta `shared/` contiene código reutilizable que no pertenece a ninguna feature específica. Incluye componentes UI base, hooks genéricos, utilidades, tipos comunes y configuración.
|
|
|
|
---
|
|
|
|
## Estructura de Carpetas
|
|
|
|
```
|
|
shared/
|
|
├── components/ # Componentes UI reutilizables
|
|
│ ├── ui/ # Componentes base (Button, Input, Modal...)
|
|
│ ├── layout/ # Layout components (Header, Sidebar, Footer)
|
|
│ ├── feedback/ # Loading, Error, Empty states
|
|
│ └── index.ts
|
|
├── hooks/ # Custom hooks genéricos
|
|
│ ├── useLocalStorage.ts
|
|
│ ├── useMediaQuery.ts
|
|
│ ├── useDebounce.ts
|
|
│ └── index.ts
|
|
├── lib/ # Configuración de librerías
|
|
│ ├── axios.ts # Instancia configurada de axios
|
|
│ ├── queryClient.ts # React Query client
|
|
│ └── i18n.ts # Internacionalización
|
|
├── types/ # Tipos TypeScript compartidos
|
|
│ ├── api.types.ts # Tipos de respuestas API
|
|
│ ├── common.types.ts # Tipos genéricos
|
|
│ └── index.ts
|
|
├── utils/ # Funciones utilitarias
|
|
│ ├── format.ts # Formateo de fechas, números
|
|
│ ├── validation.ts # Validadores
|
|
│ ├── storage.ts # LocalStorage helpers
|
|
│ └── index.ts
|
|
├── constants/ # Constantes globales
|
|
│ ├── routes.ts # Rutas de la aplicación
|
|
│ ├── api.ts # Endpoints base
|
|
│ └── index.ts
|
|
└── styles/ # Estilos globales
|
|
├── globals.css
|
|
└── variables.css
|
|
```
|
|
|
|
---
|
|
|
|
## Componentes UI Base
|
|
|
|
### components/ui/
|
|
|
|
```
|
|
components/ui/
|
|
├── Button/
|
|
│ ├── Button.tsx
|
|
│ ├── Button.test.tsx
|
|
│ └── index.ts
|
|
├── Input/
|
|
│ ├── Input.tsx
|
|
│ ├── Input.test.tsx
|
|
│ └── index.ts
|
|
├── Modal/
|
|
│ ├── Modal.tsx
|
|
│ ├── ModalHeader.tsx
|
|
│ ├── ModalBody.tsx
|
|
│ ├── ModalFooter.tsx
|
|
│ └── index.ts
|
|
├── Card/
|
|
├── Badge/
|
|
├── Avatar/
|
|
├── Tooltip/
|
|
├── Dropdown/
|
|
├── Tabs/
|
|
├── Table/
|
|
├── Pagination/
|
|
└── index.ts
|
|
```
|
|
|
|
### Ejemplo: Button
|
|
|
|
```typescript
|
|
// shared/components/ui/Button/Button.tsx
|
|
import { cva, type VariantProps } from 'class-variance-authority';
|
|
import { cn } from '@/shared/utils/cn';
|
|
|
|
const buttonVariants = cva(
|
|
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2',
|
|
{
|
|
variants: {
|
|
variant: {
|
|
primary: 'bg-primary text-white hover:bg-primary-dark',
|
|
secondary: 'bg-secondary text-white hover:bg-secondary-dark',
|
|
outline: 'border border-gray-300 bg-transparent hover:bg-gray-50',
|
|
ghost: 'hover:bg-gray-100',
|
|
danger: 'bg-red-500 text-white hover:bg-red-600',
|
|
},
|
|
size: {
|
|
sm: 'h-8 px-3 text-sm',
|
|
md: 'h-10 px-4 text-base',
|
|
lg: 'h-12 px-6 text-lg',
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
variant: 'primary',
|
|
size: 'md',
|
|
},
|
|
}
|
|
);
|
|
|
|
interface ButtonProps
|
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
VariantProps<typeof buttonVariants> {
|
|
isLoading?: boolean;
|
|
}
|
|
|
|
export const Button: React.FC<ButtonProps> = ({
|
|
className,
|
|
variant,
|
|
size,
|
|
isLoading,
|
|
children,
|
|
disabled,
|
|
...props
|
|
}) => {
|
|
return (
|
|
<button
|
|
className={cn(buttonVariants({ variant, size, className }))}
|
|
disabled={disabled || isLoading}
|
|
{...props}
|
|
>
|
|
{isLoading ? <Spinner className="mr-2" /> : null}
|
|
{children}
|
|
</button>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Componentes de Layout
|
|
|
|
### components/layout/
|
|
|
|
```typescript
|
|
// shared/components/layout/MainLayout.tsx
|
|
interface MainLayoutProps {
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<Header />
|
|
<div className="flex">
|
|
<Sidebar />
|
|
<main className="flex-1 p-6">{children}</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
```typescript
|
|
// shared/components/layout/Header.tsx
|
|
import { useAuth } from '@/features/auth';
|
|
import { MlCoinsDisplay, RankBadge } from '@/features/gamification';
|
|
|
|
export const Header: React.FC = () => {
|
|
const { user } = useAuth();
|
|
|
|
return (
|
|
<header className="h-16 bg-white border-b flex items-center px-6 justify-between">
|
|
<Logo />
|
|
<div className="flex items-center gap-4">
|
|
<MlCoinsDisplay />
|
|
<RankBadge />
|
|
<UserMenu user={user} />
|
|
</div>
|
|
</header>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Componentes de Feedback
|
|
|
|
### components/feedback/
|
|
|
|
```typescript
|
|
// shared/components/feedback/LoadingSpinner.tsx
|
|
interface LoadingSpinnerProps {
|
|
size?: 'sm' | 'md' | 'lg';
|
|
className?: string;
|
|
}
|
|
|
|
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
|
size = 'md',
|
|
className,
|
|
}) => {
|
|
const sizeClasses = {
|
|
sm: 'w-4 h-4',
|
|
md: 'w-8 h-8',
|
|
lg: 'w-12 h-12',
|
|
};
|
|
|
|
return (
|
|
<div className={cn('animate-spin', sizeClasses[size], className)}>
|
|
<SpinnerIcon />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// shared/components/feedback/ErrorMessage.tsx
|
|
interface ErrorMessageProps {
|
|
error: Error | string;
|
|
onRetry?: () => void;
|
|
}
|
|
|
|
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ error, onRetry }) => {
|
|
const message = typeof error === 'string' ? error : error.message;
|
|
|
|
return (
|
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
|
<p className="text-red-700">{message}</p>
|
|
{onRetry && (
|
|
<Button variant="outline" size="sm" onClick={onRetry} className="mt-2">
|
|
Reintentar
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// shared/components/feedback/EmptyState.tsx
|
|
interface EmptyStateProps {
|
|
title: string;
|
|
description?: string;
|
|
action?: React.ReactNode;
|
|
icon?: React.ReactNode;
|
|
}
|
|
|
|
export const EmptyState: React.FC<EmptyStateProps> = ({
|
|
title,
|
|
description,
|
|
action,
|
|
icon,
|
|
}) => {
|
|
return (
|
|
<div className="text-center py-12">
|
|
{icon && <div className="mb-4">{icon}</div>}
|
|
<h3 className="text-lg font-medium text-gray-900">{title}</h3>
|
|
{description && <p className="mt-1 text-gray-500">{description}</p>}
|
|
{action && <div className="mt-4">{action}</div>}
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Hooks Compartidos
|
|
|
|
### hooks/
|
|
|
|
```typescript
|
|
// shared/hooks/useLocalStorage.ts
|
|
export function useLocalStorage<T>(
|
|
key: string,
|
|
initialValue: T
|
|
): [T, (value: T) => void] {
|
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
|
try {
|
|
const item = window.localStorage.getItem(key);
|
|
return item ? JSON.parse(item) : initialValue;
|
|
} catch {
|
|
return initialValue;
|
|
}
|
|
});
|
|
|
|
const setValue = (value: T) => {
|
|
setStoredValue(value);
|
|
window.localStorage.setItem(key, JSON.stringify(value));
|
|
};
|
|
|
|
return [storedValue, setValue];
|
|
}
|
|
|
|
// shared/hooks/useDebounce.ts
|
|
export function useDebounce<T>(value: T, delay: number): T {
|
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
|
|
useEffect(() => {
|
|
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
|
return () => clearTimeout(handler);
|
|
}, [value, delay]);
|
|
|
|
return debouncedValue;
|
|
}
|
|
|
|
// shared/hooks/useMediaQuery.ts
|
|
export function useMediaQuery(query: string): boolean {
|
|
const [matches, setMatches] = useState(
|
|
() => window.matchMedia(query).matches
|
|
);
|
|
|
|
useEffect(() => {
|
|
const mediaQuery = window.matchMedia(query);
|
|
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
|
|
|
mediaQuery.addEventListener('change', handler);
|
|
return () => mediaQuery.removeEventListener('change', handler);
|
|
}, [query]);
|
|
|
|
return matches;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Configuración de Librerías
|
|
|
|
### lib/axios.ts
|
|
|
|
```typescript
|
|
import axios from 'axios';
|
|
import { getAuthToken, clearAuthToken } from '@/features/auth';
|
|
|
|
export const api = axios.create({
|
|
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
// Request interceptor - añadir token
|
|
api.interceptors.request.use((config) => {
|
|
const token = getAuthToken();
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
// Response interceptor - manejar errores
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
(error) => {
|
|
if (error.response?.status === 401) {
|
|
clearAuthToken();
|
|
window.location.href = '/login';
|
|
}
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
```
|
|
|
|
### lib/queryClient.ts
|
|
|
|
```typescript
|
|
import { QueryClient } from '@tanstack/react-query';
|
|
|
|
export const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
staleTime: 5 * 60 * 1000, // 5 minutos
|
|
gcTime: 10 * 60 * 1000, // 10 minutos
|
|
retry: 1,
|
|
refetchOnWindowFocus: false,
|
|
},
|
|
mutations: {
|
|
retry: 0,
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Utilidades
|
|
|
|
### utils/format.ts
|
|
|
|
```typescript
|
|
// Formatear fecha
|
|
export const formatDate = (date: Date | string, format = 'short'): string => {
|
|
const d = new Date(date);
|
|
const options: Intl.DateTimeFormatOptions =
|
|
format === 'short'
|
|
? { day: '2-digit', month: 'short', year: 'numeric' }
|
|
: { day: '2-digit', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' };
|
|
|
|
return d.toLocaleDateString('es-MX', options);
|
|
};
|
|
|
|
// Formatear número con separadores
|
|
export const formatNumber = (num: number): string => {
|
|
return num.toLocaleString('es-MX');
|
|
};
|
|
|
|
// Formatear XP
|
|
export const formatXp = (xp: number): string => {
|
|
if (xp >= 1000) {
|
|
return `${(xp / 1000).toFixed(1)}K XP`;
|
|
}
|
|
return `${xp} XP`;
|
|
};
|
|
```
|
|
|
|
### utils/cn.ts (classNames helper)
|
|
|
|
```typescript
|
|
import { clsx, type ClassValue } from 'clsx';
|
|
import { twMerge } from 'tailwind-merge';
|
|
|
|
export function cn(...inputs: ClassValue[]) {
|
|
return twMerge(clsx(inputs));
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Tipos Compartidos
|
|
|
|
### types/api.types.ts
|
|
|
|
```typescript
|
|
// Respuesta paginada genérica
|
|
export interface PaginatedResponse<T> {
|
|
data: T[];
|
|
meta: {
|
|
total: number;
|
|
page: number;
|
|
limit: number;
|
|
totalPages: number;
|
|
};
|
|
}
|
|
|
|
// Respuesta de error
|
|
export interface ApiError {
|
|
statusCode: number;
|
|
code: string;
|
|
message: string;
|
|
errors?: Array<{
|
|
field: string;
|
|
messages: string[];
|
|
}>;
|
|
}
|
|
|
|
// Query params de paginación
|
|
export interface PaginationParams {
|
|
page?: number;
|
|
limit?: number;
|
|
sortBy?: string;
|
|
sortOrder?: 'asc' | 'desc';
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Constantes
|
|
|
|
### constants/routes.ts
|
|
|
|
```typescript
|
|
export const ROUTES = {
|
|
HOME: '/',
|
|
LOGIN: '/login',
|
|
REGISTER: '/register',
|
|
DASHBOARD: '/dashboard',
|
|
EXERCISES: '/exercises',
|
|
EXERCISE: (id: string) => `/exercises/${id}`,
|
|
ACHIEVEMENTS: '/achievements',
|
|
LEADERBOARD: '/leaderboard',
|
|
PROFILE: '/profile',
|
|
ADMIN: '/admin',
|
|
TEACHER: '/teacher',
|
|
} as const;
|
|
```
|
|
|
|
---
|
|
|
|
## Buenas Prácticas
|
|
|
|
1. **Componentes genéricos**: Sin lógica de negocio
|
|
2. **Props tipadas**: Interfaces claras para cada componente
|
|
3. **Variantes con CVA**: Para componentes con múltiples estilos
|
|
4. **Hooks puros**: Sin efectos secundarios de negocio
|
|
5. **Utils sin estado**: Funciones puras
|
|
6. **Barrel exports**: index.ts en cada carpeta
|
|
|
|
---
|
|
|
|
## Ver También
|
|
|
|
- [ESTRUCTURA-FEATURES.md](./ESTRUCTURA-FEATURES.md) - Estructura de features
|
|
- [COMPONENTES-UI.md](./COMPONENTES-UI.md) - Guía de componentes UI
|
|
- [STATE-MANAGEMENT.md](./STATE-MANAGEMENT.md) - Gestión de estado
|