workspace/projects/gamilit/docs/95-guias-desarrollo/frontend/ESTRUCTURA-SHARED.md
rckrdmrd ea1879f4ad feat: Initial workspace structure with multi-level Git configuration
- 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>
2025-12-08 10:44:23 -06:00

12 KiB

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

// 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/

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

// 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/

// 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

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

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

// 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)

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

// 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

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