From 2b2361d87cba9dfc7cbd4a5edc9f73ad35b44009 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 19:26:16 -0600 Subject: [PATCH] feat(ux-ui): add hooks, providers, i18n, and update shared exports - usePermissions, useFilteredNavigation, useTheme, useTenantTheme - ThemeProvider, TenantThemeProvider - i18n config with ES/EN locales - Updated EmptyState with SVG illustrations Co-Authored-By: Claude Opus 4.5 --- .../templates/EmptyState/EmptyState.tsx | 197 +++++++++-- src/shared/hooks/index.ts | 3 + src/shared/hooks/useFilteredNavigation.ts | 174 ++++++++++ src/shared/hooks/usePermissions.tsx | 16 +- src/shared/hooks/useTenantTheme.ts | 15 + src/shared/hooks/useTheme.ts | 12 + src/shared/i18n/index.ts | 167 +++++++++ src/shared/i18n/locales/en.json | 137 ++++++++ src/shared/i18n/locales/es.json | 137 ++++++++ src/shared/i18n/useTranslation.ts | 122 +++++++ src/shared/index.ts | 2 + src/shared/providers/TenantThemeProvider.tsx | 319 ++++++++++++++++++ src/shared/providers/ThemeProvider.tsx | 91 +++++ src/shared/providers/index.ts | 2 + src/shared/utils/formatters.ts | 11 + 15 files changed, 1371 insertions(+), 34 deletions(-) create mode 100644 src/shared/hooks/useFilteredNavigation.ts create mode 100644 src/shared/hooks/useTenantTheme.ts create mode 100644 src/shared/hooks/useTheme.ts create mode 100644 src/shared/i18n/index.ts create mode 100644 src/shared/i18n/locales/en.json create mode 100644 src/shared/i18n/locales/es.json create mode 100644 src/shared/i18n/useTranslation.ts create mode 100644 src/shared/providers/TenantThemeProvider.tsx create mode 100644 src/shared/providers/ThemeProvider.tsx create mode 100644 src/shared/providers/index.ts diff --git a/src/shared/components/templates/EmptyState/EmptyState.tsx b/src/shared/components/templates/EmptyState/EmptyState.tsx index c4c3109..0426f93 100644 --- a/src/shared/components/templates/EmptyState/EmptyState.tsx +++ b/src/shared/components/templates/EmptyState/EmptyState.tsx @@ -1,13 +1,6 @@ import { type ReactNode } from 'react'; import { motion } from 'framer-motion'; -import { - FileSearch, - Inbox, - AlertCircle, - Search, - Plus, - type LucideIcon -} from 'lucide-react'; +import { Plus } from 'lucide-react'; import { Button } from '@components/atoms/Button'; import { cn } from '@utils/cn'; @@ -32,11 +25,92 @@ export interface EmptyStateProps { animated?: boolean; } -const variantIcons: Record, LucideIcon> = { - default: Inbox, - search: Search, - error: AlertCircle, - 'no-data': FileSearch, +// Inline SVG illustrations - simple line art style +const InboxIllustration = ({ className }: { className?: string }) => ( + + + + + + + +); + +const SearchIllustration = ({ className }: { className?: string }) => ( + + + + + + + + + +); + +const ErrorIllustration = ({ className }: { className?: string }) => ( + + + + + + + + + +); + +const NoDataIllustration = ({ className }: { className?: string }) => ( + + + + + + + + + + + +); + +const variantIllustrations: Record, React.FC<{ className?: string }>> = { + default: InboxIllustration, + search: SearchIllustration, + error: ErrorIllustration, + 'no-data': NoDataIllustration, }; const variantColors: Record, string> = { @@ -57,7 +131,7 @@ export function EmptyState({ compact = false, animated = true, }: EmptyStateProps) { - const IconComponent = variant !== 'custom' ? variantIcons[variant] : null; + const IllustrationComponent = variant !== 'custom' ? variantIllustrations[variant] : null; const iconColor = variant !== 'custom' ? variantColors[variant] : 'text-gray-400'; const content = ( @@ -68,15 +142,20 @@ export function EmptyState({ className )} > - {/* Icon */} + {/* Illustration */}
- {icon || (IconComponent && ( - + {icon || (IllustrationComponent && ( + ))}
@@ -161,16 +240,16 @@ export function NoResultsEmptyState({ return ( , } @@ -219,8 +298,8 @@ export interface ErrorEmptyStateProps { } export function ErrorEmptyState({ - title = 'Ocurrió un error', - description = 'No pudimos cargar la información. Por favor intenta de nuevo.', + title = 'Algo salio mal', + description = 'Hubo un problema al cargar los datos. Esto puede ser temporal, intenta de nuevo en unos momentos.', onRetry, className, }: ErrorEmptyStateProps) { @@ -232,7 +311,7 @@ export function ErrorEmptyState({ primaryAction={ onRetry ? { - label: 'Reintentar', + label: 'Volver a intentar', onClick: onRetry, variant: 'primary', } @@ -257,9 +336,65 @@ export function ComingSoonEmptyState({ return ( ); } + +// New SearchEmptyState preset +export interface SearchEmptyStateProps { + query?: string; + suggestions?: string[]; + onSuggestionClick?: (suggestion: string) => void; + onClearFilters?: () => void; + className?: string; +} + +export function SearchEmptyState({ + query, + suggestions, + onSuggestionClick, + onClearFilters, + className, +}: SearchEmptyStateProps) { + return ( +
+ + {suggestions && suggestions.length > 0 && onSuggestionClick && ( +
+

Sugerencias:

+
+ {suggestions.map((suggestion) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 22d3190..e1867c7 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -1,4 +1,7 @@ export * from './useDebounce'; +export * from './useFilteredNavigation'; export * from './useLocalStorage'; export * from './useMediaQuery'; export * from './usePermissions'; +export * from './useTenantTheme'; +export * from './useTheme'; diff --git a/src/shared/hooks/useFilteredNavigation.ts b/src/shared/hooks/useFilteredNavigation.ts new file mode 100644 index 0000000..82a29ea --- /dev/null +++ b/src/shared/hooks/useFilteredNavigation.ts @@ -0,0 +1,174 @@ +import { useMemo } from 'react'; +import { usePermissions } from './usePermissions'; + +/** + * Navigation item with optional permission requirements + */ +export interface NavigationItem { + /** Display name */ + name: string; + /** Route path */ + href: string; + /** Icon component */ + icon?: React.ComponentType<{ className?: string }>; + /** Required permission code (e.g., 'users:read') */ + permission?: string; + /** Required module (e.g., 'users') - requires any action on this module */ + module?: string; + /** Child items (for submenus) */ + children?: NavigationItem[]; + /** Badge to display */ + badge?: string | number; + /** Whether the item is disabled */ + disabled?: boolean; + /** Whether link is external */ + external?: boolean; + /** Roles that can access this item */ + roles?: string[]; +} + +/** + * Hook options + */ +export interface UseFilteredNavigationOptions { + /** Keep items that have accessible children even if parent has no permission */ + keepParentIfChildrenAccessible?: boolean; +} + +/** + * Return type for the hook + */ +export interface UseFilteredNavigationReturn { + /** Filtered navigation items based on user permissions */ + items: NavigationItem[]; + /** Whether permissions are still loading */ + isLoading: boolean; + /** Whether user has any accessible navigation items */ + hasAccessibleItems: boolean; +} + +/** + * Hook to filter navigation items based on user permissions + * + * @example + * ```tsx + * const navigation = [ + * { name: 'Dashboard', href: '/dashboard', icon: Home }, + * { name: 'Users', href: '/users', icon: Users, permission: 'users:read' }, + * { name: 'Settings', href: '/settings', icon: Settings, module: 'settings' }, + * ]; + * + * function Sidebar() { + * const { items, isLoading } = useFilteredNavigation(navigation); + * + * if (isLoading) return ; + * + * return ( + * + * ); + * } + * ``` + */ +export function useFilteredNavigation( + navigationItems: NavigationItem[], + options: UseFilteredNavigationOptions = {} +): UseFilteredNavigationReturn { + const { keepParentIfChildrenAccessible = true } = options; + const { canAny, hasAnyRole, isLoading, roles } = usePermissions(); + + const isSuperAdmin = roles.includes('super_admin'); + + const items = useMemo(() => { + /** + * Check if user has access to a navigation item + */ + const hasAccess = (item: NavigationItem): boolean => { + // Super admin has access to everything + if (isSuperAdmin) { + return true; + } + + // Check role requirements + if (item.roles && item.roles.length > 0) { + if (!hasAnyRole(...item.roles)) { + return false; + } + } + + // Check specific permission requirement + if (item.permission) { + return canAny(item.permission); + } + + // Check module requirement (any action on the module) + if (item.module) { + const modulePermissions = [ + `${item.module}:read`, + `${item.module}:write`, + `${item.module}:create`, + `${item.module}:update`, + `${item.module}:delete`, + `${item.module}:admin`, + ]; + return canAny(...modulePermissions); + } + + // No permission requirement - item is accessible + return true; + }; + + /** + * Recursively filter navigation items + */ + const filterItems = (navItems: NavigationItem[]): NavigationItem[] => { + return navItems.reduce((filtered, item) => { + // Check if this item has children + if (item.children && item.children.length > 0) { + const filteredChildren = filterItems(item.children); + + // If we should keep parent when children are accessible + if (keepParentIfChildrenAccessible && filteredChildren.length > 0) { + filtered.push({ + ...item, + children: filteredChildren, + }); + return filtered; + } + + // Otherwise, check parent permission too + if (hasAccess(item) && filteredChildren.length > 0) { + filtered.push({ + ...item, + children: filteredChildren, + }); + } + + return filtered; + } + + // Leaf item - check permission + if (hasAccess(item)) { + filtered.push(item); + } + + return filtered; + }, []); + }; + + return filterItems(navigationItems); + }, [navigationItems, canAny, hasAnyRole, isSuperAdmin, keepParentIfChildrenAccessible]); + + return { + items, + isLoading, + hasAccessibleItems: items.length > 0, + }; +} + +export default useFilteredNavigation; diff --git a/src/shared/hooks/usePermissions.tsx b/src/shared/hooks/usePermissions.tsx index 5aea6ea..47b288d 100644 --- a/src/shared/hooks/usePermissions.tsx +++ b/src/shared/hooks/usePermissions.tsx @@ -87,8 +87,13 @@ export function usePermissions() { /** * Check if user is super_admin (can do anything) + * @deprecated Use isSuperAdmin boolean property instead */ - const isSuperAdmin = useCallback(() => roles.includes('super_admin'), [roles]); + const isSuperAdminFn = useCallback(() => roles.includes('super_admin'), [roles]); + + // Computed role checks as boolean properties + const isSuperAdmin = roles.includes('super_admin'); + const isAdmin = roles.includes('admin') || isSuperAdmin; /** * Force refresh permissions from server @@ -114,10 +119,15 @@ export function usePermissions() { hasAnyPermission, hasAllPermissions, - // Role checks + // Role checks (boolean properties) + isAdmin, + isSuperAdmin, + + // Role checks (functions) hasRole, hasAnyRole, - isSuperAdmin, + /** @deprecated Use isSuperAdmin boolean instead */ + isSuperAdminFn, // Actions refresh, diff --git a/src/shared/hooks/useTenantTheme.ts b/src/shared/hooks/useTenantTheme.ts new file mode 100644 index 0000000..a4bad00 --- /dev/null +++ b/src/shared/hooks/useTenantTheme.ts @@ -0,0 +1,15 @@ +import { useContext } from 'react'; +import { + TenantThemeContext, + type TenantThemeContextValue, +} from '@shared/providers/TenantThemeProvider'; + +export function useTenantTheme(): TenantThemeContextValue { + const context = useContext(TenantThemeContext); + + if (context === undefined) { + throw new Error('useTenantTheme must be used within a TenantThemeProvider'); + } + + return context; +} diff --git a/src/shared/hooks/useTheme.ts b/src/shared/hooks/useTheme.ts new file mode 100644 index 0000000..c1135bb --- /dev/null +++ b/src/shared/hooks/useTheme.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react'; +import { ThemeContext, type ThemeContextValue } from '@shared/providers/ThemeProvider'; + +export function useTheme(): ThemeContextValue { + const context = useContext(ThemeContext); + + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + + return context; +} diff --git a/src/shared/i18n/index.ts b/src/shared/i18n/index.ts new file mode 100644 index 0000000..6216398 --- /dev/null +++ b/src/shared/i18n/index.ts @@ -0,0 +1,167 @@ +/** + * i18n Configuration + * + * IMPORTANT: This module requires the following packages to be installed: + * - i18next + * - react-i18next + * + * Install with: npm install i18next react-i18next + * + * Until packages are installed, this exports a placeholder that won't break the app. + */ + +import es from './locales/es.json'; +import en from './locales/en.json'; + +// Export resources for external use +export const resources = { + es: { translation: es }, + en: { translation: en }, +} as const; + +export type Language = 'es' | 'en'; + +export const defaultLanguage: Language = 'es'; +export const fallbackLanguage: Language = 'es'; + +export const supportedLanguages: Language[] = ['es', 'en']; + +export const languageNames: Record = { + es: 'Espanol', + en: 'English', +}; + +// i18n instance type (generic to avoid import errors when packages not installed) +interface I18nInstance { + use: (plugin: unknown) => I18nInstance; + init: (options: unknown) => Promise; + changeLanguage: (lang: string) => void; + language: string; + t: (key: string, options?: Record) => string; +} + +// Try to import i18next, fallback to placeholder if not installed +let i18n: I18nInstance | null = null; +let isI18nInitialized = false; + +/** + * Initialize i18n + * Call this function in your app's entry point (main.tsx) + */ +export async function initI18n(): Promise { + try { + // Dynamic imports - will fail gracefully if packages not installed + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const i18next = await import(/* webpackIgnore: true */ 'i18next' as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const reactI18next = await import(/* webpackIgnore: true */ 'react-i18next' as any); + const { initReactI18next } = reactI18next; + + i18n = i18next.default as I18nInstance; + + await i18n! + .use(initReactI18next) + .init({ + resources, + lng: getStoredLanguage(), + fallbackLng: fallbackLanguage, + interpolation: { + escapeValue: false, + }, + react: { + useSuspense: false, + }, + }); + + isI18nInitialized = true; + console.log('[i18n] Initialized successfully'); + } catch (error) { + console.warn('[i18n] i18next not installed. Install with: npm install i18next react-i18next'); + isI18nInitialized = false; + } +} + +/** + * Get stored language from localStorage + */ +export function getStoredLanguage(): Language { + if (typeof window === 'undefined') return defaultLanguage; + + const stored = localStorage.getItem('app-language'); + if (stored && supportedLanguages.includes(stored as Language)) { + return stored as Language; + } + return defaultLanguage; +} + +/** + * Set language and persist to localStorage + */ +export function setLanguage(lang: Language): void { + if (!supportedLanguages.includes(lang)) { + console.warn(`[i18n] Unsupported language: ${lang}`); + return; + } + + localStorage.setItem('app-language', lang); + + if (i18n && isI18nInitialized) { + i18n.changeLanguage(lang); + } +} + +/** + * Get current language + */ +export function getCurrentLanguage(): Language { + if (i18n && isI18nInitialized) { + return (i18n.language || defaultLanguage) as Language; + } + return getStoredLanguage(); +} + +/** + * Check if i18n is available + */ +export function isI18nAvailable(): boolean { + return isI18nInitialized; +} + +/** + * Placeholder translation function when i18next is not installed + * This allows the app to work without i18next, returning translation keys + */ +export function t(key: string, options?: Record): string { + if (i18n && isI18nInitialized) { + return i18n.t(key, options) as string; + } + + // Fallback: Try to get value from loaded JSON + const lang = getStoredLanguage(); + const translations = lang === 'es' ? es : en; + + const keys = key.split('.'); + let value: unknown = translations; + + for (const k of keys) { + if (value && typeof value === 'object' && k in value) { + value = (value as Record)[k]; + } else { + return key; // Return key if not found + } + } + + if (typeof value === 'string') { + // Handle interpolation + if (options) { + return value.replace(/\{\{(\w+)\}\}/g, (_, match) => { + return String(options[match] ?? `{{${match}}}`); + }); + } + return value; + } + + return key; +} + +export default { initI18n, t, setLanguage, getCurrentLanguage, isI18nAvailable }; diff --git a/src/shared/i18n/locales/en.json b/src/shared/i18n/locales/en.json new file mode 100644 index 0000000..e564e9e --- /dev/null +++ b/src/shared/i18n/locales/en.json @@ -0,0 +1,137 @@ +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "create": "Create", + "search": "Search", + "loading": "Loading...", + "noData": "No data", + "error": "Error", + "success": "Success", + "confirm": "Confirm", + "close": "Close", + "back": "Back", + "next": "Next", + "previous": "Previous", + "yes": "Yes", + "no": "No", + "actions": "Actions", + "filter": "Filter", + "reset": "Reset", + "apply": "Apply", + "select": "Select", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "copy": "Copy", + "paste": "Paste", + "download": "Download", + "upload": "Upload", + "export": "Export", + "import": "Import", + "print": "Print", + "refresh": "Refresh", + "view": "View", + "details": "Details", + "add": "Add", + "remove": "Remove", + "new": "New", + "active": "Active", + "inactive": "Inactive", + "enabled": "Enabled", + "disabled": "Disabled", + "pending": "Pending", + "completed": "Completed", + "inProgress": "In progress", + "total": "Total", + "of": "of" + }, + "nav": { + "dashboard": "Dashboard", + "users": "Users", + "companies": "Companies", + "partners": "Partners", + "settings": "Settings", + "profile": "Profile", + "notifications": "Notifications", + "reports": "Reports", + "analytics": "Analytics", + "configuration": "Configuration", + "administration": "Administration", + "help": "Help", + "support": "Support" + }, + "auth": { + "login": "Login", + "logout": "Logout", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm password", + "forgotPassword": "Forgot your password?", + "resetPassword": "Reset password", + "changePassword": "Change password", + "register": "Register", + "rememberMe": "Remember me", + "signIn": "Sign in", + "signOut": "Sign out", + "signUp": "Sign up", + "welcome": "Welcome", + "welcomeBack": "Welcome back" + }, + "validation": { + "required": "This field is required", + "email": "Invalid email", + "minLength": "Minimum {{min}} characters", + "maxLength": "Maximum {{max}} characters", + "min": "Minimum value is {{min}}", + "max": "Maximum value is {{max}}", + "pattern": "Invalid format", + "passwordMismatch": "Passwords do not match", + "invalidFormat": "Invalid format", + "numberOnly": "Numbers only", + "lettersOnly": "Letters only", + "alphanumeric": "Letters and numbers only", + "phone": "Invalid phone number", + "url": "Invalid URL", + "date": "Invalid date", + "dateRange": "Invalid date range", + "unique": "This value already exists" + }, + "errors": { + "general": "An error occurred", + "network": "Connection error", + "unauthorized": "Unauthorized", + "forbidden": "Access denied", + "notFound": "Not found", + "serverError": "Server error", + "timeout": "Request timeout", + "tryAgain": "Please try again" + }, + "messages": { + "saved": "Saved successfully", + "updated": "Updated successfully", + "deleted": "Deleted successfully", + "created": "Created successfully", + "confirmDelete": "Are you sure you want to delete this item?", + "confirmAction": "Are you sure you want to perform this action?", + "noResults": "No results found", + "loading": "Loading data...", + "processing": "Processing..." + }, + "table": { + "rowsPerPage": "Rows per page", + "showing": "Showing", + "of": "of", + "entries": "entries", + "noData": "No data to display", + "loading": "Loading data...", + "sortAsc": "Sort ascending", + "sortDesc": "Sort descending" + }, + "language": { + "select": "Select language", + "es": "Spanish", + "en": "English" + } +} diff --git a/src/shared/i18n/locales/es.json b/src/shared/i18n/locales/es.json new file mode 100644 index 0000000..afda8a7 --- /dev/null +++ b/src/shared/i18n/locales/es.json @@ -0,0 +1,137 @@ +{ + "common": { + "save": "Guardar", + "cancel": "Cancelar", + "delete": "Eliminar", + "edit": "Editar", + "create": "Crear", + "search": "Buscar", + "loading": "Cargando...", + "noData": "No hay datos", + "error": "Error", + "success": "Exito", + "confirm": "Confirmar", + "close": "Cerrar", + "back": "Volver", + "next": "Siguiente", + "previous": "Anterior", + "yes": "Si", + "no": "No", + "actions": "Acciones", + "filter": "Filtrar", + "reset": "Restablecer", + "apply": "Aplicar", + "select": "Seleccionar", + "selectAll": "Seleccionar todo", + "deselectAll": "Deseleccionar todo", + "copy": "Copiar", + "paste": "Pegar", + "download": "Descargar", + "upload": "Subir", + "export": "Exportar", + "import": "Importar", + "print": "Imprimir", + "refresh": "Actualizar", + "view": "Ver", + "details": "Detalles", + "add": "Agregar", + "remove": "Quitar", + "new": "Nuevo", + "active": "Activo", + "inactive": "Inactivo", + "enabled": "Habilitado", + "disabled": "Deshabilitado", + "pending": "Pendiente", + "completed": "Completado", + "inProgress": "En progreso", + "total": "Total", + "of": "de" + }, + "nav": { + "dashboard": "Dashboard", + "users": "Usuarios", + "companies": "Empresas", + "partners": "Partners", + "settings": "Configuracion", + "profile": "Perfil", + "notifications": "Notificaciones", + "reports": "Reportes", + "analytics": "Analiticas", + "configuration": "Configuracion", + "administration": "Administracion", + "help": "Ayuda", + "support": "Soporte" + }, + "auth": { + "login": "Iniciar sesion", + "logout": "Cerrar sesion", + "email": "Correo electronico", + "password": "Contrasena", + "confirmPassword": "Confirmar contrasena", + "forgotPassword": "¿Olvidaste tu contrasena?", + "resetPassword": "Restablecer contrasena", + "changePassword": "Cambiar contrasena", + "register": "Registrarse", + "rememberMe": "Recordarme", + "signIn": "Entrar", + "signOut": "Salir", + "signUp": "Crear cuenta", + "welcome": "Bienvenido", + "welcomeBack": "Bienvenido de nuevo" + }, + "validation": { + "required": "Este campo es requerido", + "email": "Correo electronico invalido", + "minLength": "Minimo {{min}} caracteres", + "maxLength": "Maximo {{max}} caracteres", + "min": "El valor minimo es {{min}}", + "max": "El valor maximo es {{max}}", + "pattern": "Formato invalido", + "passwordMismatch": "Las contrasenas no coinciden", + "invalidFormat": "Formato invalido", + "numberOnly": "Solo numeros", + "lettersOnly": "Solo letras", + "alphanumeric": "Solo letras y numeros", + "phone": "Numero de telefono invalido", + "url": "URL invalida", + "date": "Fecha invalida", + "dateRange": "Rango de fechas invalido", + "unique": "Este valor ya existe" + }, + "errors": { + "general": "Ha ocurrido un error", + "network": "Error de conexion", + "unauthorized": "No autorizado", + "forbidden": "Acceso denegado", + "notFound": "No encontrado", + "serverError": "Error del servidor", + "timeout": "Tiempo de espera agotado", + "tryAgain": "Por favor, intenta de nuevo" + }, + "messages": { + "saved": "Guardado exitosamente", + "updated": "Actualizado exitosamente", + "deleted": "Eliminado exitosamente", + "created": "Creado exitosamente", + "confirmDelete": "¿Estas seguro de que deseas eliminar este elemento?", + "confirmAction": "¿Estas seguro de que deseas realizar esta accion?", + "noResults": "No se encontraron resultados", + "loading": "Cargando datos...", + "processing": "Procesando..." + }, + "table": { + "rowsPerPage": "Filas por pagina", + "showing": "Mostrando", + "of": "de", + "entries": "registros", + "noData": "No hay datos para mostrar", + "loading": "Cargando datos...", + "sortAsc": "Ordenar ascendente", + "sortDesc": "Ordenar descendente" + }, + "language": { + "select": "Seleccionar idioma", + "es": "Espanol", + "en": "Ingles" + } +} diff --git a/src/shared/i18n/useTranslation.ts b/src/shared/i18n/useTranslation.ts new file mode 100644 index 0000000..ce1fef2 --- /dev/null +++ b/src/shared/i18n/useTranslation.ts @@ -0,0 +1,122 @@ +/** + * Type-safe translation hook wrapper + * + * This hook provides a consistent API whether i18next is installed or not. + * When i18next is available, it uses react-i18next's useTranslation. + * When not available, it falls back to a simple implementation using the JSON files. + */ + +import { useState, useEffect, useCallback } from 'react'; +import type { Language } from './index'; +import { + t as translateFn, + getCurrentLanguage, + setLanguage, + isI18nAvailable, + supportedLanguages, + languageNames, +} from './index'; + +export interface UseTranslationReturn { + /** Translate a key with optional interpolation */ + t: (key: string, options?: Record) => string; + /** Current language */ + language: Language; + /** Change the current language */ + changeLanguage: (lang: Language) => void; + /** Whether i18next is available and initialized */ + ready: boolean; + /** List of supported languages */ + languages: readonly Language[]; + /** Language display names */ + languageNames: Record; +} + +/** + * Hook to use translations in components + * + * @example + * ```tsx + * function MyComponent() { + * const { t, language, changeLanguage } = useTranslation(); + * + * return ( + *
+ *

{t('common.save')}

+ *

{t('validation.minLength', { min: 3 })}

+ * + *
+ * ); + * } + * ``` + */ +export function useTranslation(): UseTranslationReturn { + const [language, setLanguageState] = useState(getCurrentLanguage); + const [ready, setReady] = useState(isI18nAvailable); + + // Subscribe to language changes when i18next is available + useEffect(() => { + const checkI18n = async () => { + if (isI18nAvailable()) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const i18next = await import(/* webpackIgnore: true */ 'i18next' as any); + const i18n = i18next.default; + const handleLanguageChange = (lang: string) => { + setLanguageState(lang as Language); + }; + + i18n.on('languageChanged', handleLanguageChange); + setLanguageState(i18n.language as Language); + setReady(true); + + return () => { + i18n.off('languageChanged', handleLanguageChange); + }; + } catch { + // i18next not available + } + } + }; + + checkI18n(); + }, []); + + // Listen for storage changes (for cross-tab sync) + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === 'app-language' && e.newValue) { + const newLang = e.newValue as Language; + if (supportedLanguages.includes(newLang)) { + setLanguageState(newLang); + } + } + }; + + window.addEventListener('storage', handleStorageChange); + return () => window.removeEventListener('storage', handleStorageChange); + }, []); + + const changeLanguage = useCallback((lang: Language) => { + setLanguage(lang); + setLanguageState(lang); + }, []); + + const t = useCallback( + (key: string, options?: Record) => translateFn(key, options), + [] + ); + + return { + t, + language, + changeLanguage, + ready, + languages: supportedLanguages, + languageNames, + }; +} + +export default useTranslation; diff --git a/src/shared/index.ts b/src/shared/index.ts index 4feb97e..c5f7e48 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -4,3 +4,5 @@ export * from './stores'; export * from './utils'; export * from './types'; export * from './constants'; +export * from './i18n'; +export { useTranslation } from './i18n/useTranslation'; diff --git a/src/shared/providers/TenantThemeProvider.tsx b/src/shared/providers/TenantThemeProvider.tsx new file mode 100644 index 0000000..0276ba7 --- /dev/null +++ b/src/shared/providers/TenantThemeProvider.tsx @@ -0,0 +1,319 @@ +import { + createContext, + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; + +// ============================================================================= +// TYPES +// ============================================================================= + +export interface TenantTheme { + primaryColor?: string; // HEX color e.g., "#FF5500" + secondaryColor?: string; + logo?: string; // URL to logo + companyName?: string; + favicon?: string; +} + +export interface TenantThemeContextValue { + theme: TenantTheme | null; + setTheme: (theme: TenantTheme) => void; + resetTheme: () => void; + isCustomized: boolean; +} + +// ============================================================================= +// CONTEXT +// ============================================================================= + +export const TenantThemeContext = createContext( + undefined +); + +// ============================================================================= +// COLOR UTILITIES +// ============================================================================= + +/** + * Converts HEX color to RGB space-separated format for CSS variables + * @param hex - HEX color string (e.g., "#FF5500" or "#F50") + * @returns RGB string in "R G B" format (e.g., "255 85 0") + */ +function hexToRgb(hex: string): string { + // Remove # if present + let cleanHex = hex.replace('#', ''); + + // Expand shorthand (e.g., "F50" -> "FF5500") + if (cleanHex.length === 3) { + cleanHex = cleanHex + .split('') + .map((char) => char + char) + .join(''); + } + + const r = parseInt(cleanHex.substring(0, 2), 16); + const g = parseInt(cleanHex.substring(2, 4), 16); + const b = parseInt(cleanHex.substring(4, 6), 16); + + return `${r} ${g} ${b}`; +} + +/** + * Mixes two RGB colors + * @param rgb1 - First RGB values [r, g, b] + * @param rgb2 - Second RGB values [r, g, b] + * @param ratio - Mix ratio (0 = all rgb1, 1 = all rgb2) + * @returns Mixed RGB string in "R G B" format + */ +function mixRgb( + rgb1: [number, number, number], + rgb2: [number, number, number], + ratio: number +): string { + const r = Math.round(rgb1[0] + (rgb2[0] - rgb1[0]) * ratio); + const g = Math.round(rgb1[1] + (rgb2[1] - rgb1[1]) * ratio); + const b = Math.round(rgb1[2] + (rgb2[2] - rgb1[2]) * ratio); + return `${r} ${g} ${b}`; +} + +/** + * Parses RGB string to tuple + * @param rgbString - RGB string in "R G B" format + * @returns Tuple [r, g, b] + */ +function parseRgb(rgbString: string): [number, number, number] { + const parts = rgbString.split(' ').map(Number); + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; +} + +/** + * Generates a full color scale (50-900) from a base color + * @param baseHex - Base HEX color (used as 500) + * @returns Object with shade numbers as keys and RGB strings as values + */ +function generateColorScale(baseHex: string): Record { + const baseRgb = parseRgb(hexToRgb(baseHex)); + const white: [number, number, number] = [255, 255, 255]; + const black: [number, number, number] = [0, 0, 0]; + + return { + '50': mixRgb(baseRgb, white, 0.9), + '100': mixRgb(baseRgb, white, 0.8), + '200': mixRgb(baseRgb, white, 0.6), + '300': mixRgb(baseRgb, white, 0.4), + '400': mixRgb(baseRgb, white, 0.2), + '500': `${baseRgb[0]} ${baseRgb[1]} ${baseRgb[2]}`, + '600': mixRgb(baseRgb, black, 0.2), + '700': mixRgb(baseRgb, black, 0.4), + '800': mixRgb(baseRgb, black, 0.6), + '900': mixRgb(baseRgb, black, 0.8), + }; +} + +/** + * Converts HEX to CSS hex format (ensures # prefix) + */ +function toHexCss(hex: string): string { + return hex.startsWith('#') ? hex : `#${hex}`; +} + +// ============================================================================= +// DEFAULT VALUES (from index.css) +// ============================================================================= + +const DEFAULT_PRIMARY_SCALE: Record = { + '50': '230 243 255', + '100': '204 231 255', + '200': '153 207 255', + '300': '102 183 255', + '400': '51 159 255', + '500': '0 97 168', + '600': '0 77 134', + '700': '0 58 101', + '800': '0 38 67', + '900': '0 19 34', +}; + +const DEFAULT_SECONDARY_SCALE: Record = { + '50': '230 255 245', + '100': '204 255 235', + '200': '153 255 214', + '300': '102 255 194', + '400': '51 255 173', + '500': '0 168 104', + '600': '0 134 83', + '700': '0 101 63', + '800': '0 67 42', + '900': '0 34 21', +}; + +const DEFAULT_BRAND_PRIMARY = '#0061A8'; +const DEFAULT_BRAND_SECONDARY = '#00A868'; + +// ============================================================================= +// THEME APPLICATION +// ============================================================================= + +/** + * Applies a color scale to CSS variables + * @param prefix - CSS variable prefix (e.g., "primary" or "secondary") + * @param scale - Color scale object + */ +function applyColorScale(prefix: string, scale: Record): void { + const root = document.documentElement; + Object.entries(scale).forEach(([shade, rgb]) => { + root.style.setProperty(`--color-${prefix}-${shade}`, rgb); + }); +} + +/** + * Removes inline styles for a color scale + * @param prefix - CSS variable prefix (e.g., "primary" or "secondary") + */ +function removeColorScale(prefix: string): void { + const root = document.documentElement; + const shades = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900']; + shades.forEach((shade) => { + root.style.removeProperty(`--color-${prefix}-${shade}`); + }); +} + +/** + * Applies the full tenant theme to CSS variables + * @param theme - Tenant theme configuration + */ +function applyTheme(theme: TenantTheme): void { + const root = document.documentElement; + + if (theme.primaryColor) { + const primaryScale = generateColorScale(theme.primaryColor); + applyColorScale('primary', primaryScale); + root.style.setProperty('--color-brand-primary', toHexCss(theme.primaryColor)); + root.style.setProperty('--color-primary-hex', toHexCss(theme.primaryColor)); + } + + if (theme.secondaryColor) { + const secondaryScale = generateColorScale(theme.secondaryColor); + applyColorScale('secondary', secondaryScale); + root.style.setProperty('--color-brand-secondary', toHexCss(theme.secondaryColor)); + root.style.setProperty('--color-secondary-hex', toHexCss(theme.secondaryColor)); + } + + if (theme.favicon) { + updateFavicon(theme.favicon); + } +} + +/** + * Resets all theme customizations to defaults + */ +function resetThemeStyles(): void { + const root = document.documentElement; + + // Reset primary colors + removeColorScale('primary'); + Object.entries(DEFAULT_PRIMARY_SCALE).forEach(([shade, rgb]) => { + root.style.setProperty(`--color-primary-${shade}`, rgb); + }); + root.style.setProperty('--color-brand-primary', DEFAULT_BRAND_PRIMARY); + root.style.setProperty('--color-primary-hex', DEFAULT_BRAND_PRIMARY); + + // Reset secondary colors + removeColorScale('secondary'); + Object.entries(DEFAULT_SECONDARY_SCALE).forEach(([shade, rgb]) => { + root.style.setProperty(`--color-secondary-${shade}`, rgb); + }); + root.style.setProperty('--color-brand-secondary', DEFAULT_BRAND_SECONDARY); + root.style.setProperty('--color-secondary-hex', DEFAULT_BRAND_SECONDARY); + + // Reset favicon to default + resetFavicon(); +} + +/** + * Updates the page favicon + * @param url - URL to the new favicon + */ +function updateFavicon(url: string): void { + let link: HTMLLinkElement | null = document.querySelector("link[rel~='icon']"); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + document.head.appendChild(link); + } + link.href = url; +} + +/** + * Resets favicon to default + */ +function resetFavicon(): void { + const link: HTMLLinkElement | null = document.querySelector("link[rel~='icon']"); + if (link) { + link.href = '/favicon.ico'; + } +} + +// ============================================================================= +// PROVIDER COMPONENT +// ============================================================================= + +interface TenantThemeProviderProps { + children: ReactNode; + initialTheme?: TenantTheme; +} + +export function TenantThemeProvider({ + children, + initialTheme, +}: TenantThemeProviderProps) { + const [theme, setThemeState] = useState(initialTheme ?? null); + + const isCustomized = useMemo(() => { + if (!theme) return false; + return !!(theme.primaryColor || theme.secondaryColor || theme.logo || theme.favicon); + }, [theme]); + + const setTheme = useCallback((newTheme: TenantTheme) => { + setThemeState(newTheme); + }, []); + + const resetTheme = useCallback(() => { + setThemeState(null); + resetThemeStyles(); + }, []); + + // Apply theme when it changes + useEffect(() => { + if (theme) { + applyTheme(theme); + } + }, [theme]); + + // Apply initial theme on mount + useEffect(() => { + if (initialTheme) { + applyTheme(initialTheme); + } + }, [initialTheme]); + + const value = useMemo( + () => ({ + theme, + setTheme, + resetTheme, + isCustomized, + }), + [theme, setTheme, resetTheme, isCustomized] + ); + + return ( + + {children} + + ); +} diff --git a/src/shared/providers/ThemeProvider.tsx b/src/shared/providers/ThemeProvider.tsx new file mode 100644 index 0000000..a88129c --- /dev/null +++ b/src/shared/providers/ThemeProvider.tsx @@ -0,0 +1,91 @@ +import { + createContext, + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; + +export type Theme = 'light' | 'dark' | 'system'; +export type ResolvedTheme = 'light' | 'dark'; + +export interface ThemeContextValue { + theme: Theme; + resolvedTheme: ResolvedTheme; + setTheme: (theme: Theme) => void; +} + +export const ThemeContext = createContext(undefined); + +const STORAGE_KEY = 'theme'; + +function getSystemTheme(): ResolvedTheme { + if (typeof window === 'undefined') return 'light'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function getStoredTheme(): Theme { + if (typeof window === 'undefined') return 'system'; + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'light' || stored === 'dark' || stored === 'system') { + return stored; + } + return 'system'; +} + +interface ThemeProviderProps { + children: ReactNode; + defaultTheme?: Theme; +} + +export function ThemeProvider({ children, defaultTheme }: ThemeProviderProps) { + const [theme, setThemeState] = useState(() => { + return defaultTheme ?? getStoredTheme(); + }); + const [systemTheme, setSystemTheme] = useState(getSystemTheme); + + const resolvedTheme: ResolvedTheme = theme === 'system' ? systemTheme : theme; + + const setTheme = useCallback((newTheme: Theme) => { + setThemeState(newTheme); + localStorage.setItem(STORAGE_KEY, newTheme); + }, []); + + // Apply dark class to document + useEffect(() => { + const root = document.documentElement; + if (resolvedTheme === 'dark') { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + }, [resolvedTheme]); + + // Listen for system preference changes + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const handleChange = (e: MediaQueryListEvent) => { + setSystemTheme(e.matches ? 'dark' : 'light'); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + const value = useMemo( + () => ({ + theme, + resolvedTheme, + setTheme, + }), + [theme, resolvedTheme, setTheme] + ); + + return ( + + {children} + + ); +} diff --git a/src/shared/providers/index.ts b/src/shared/providers/index.ts new file mode 100644 index 0000000..63bae78 --- /dev/null +++ b/src/shared/providers/index.ts @@ -0,0 +1,2 @@ +export * from './TenantThemeProvider'; +export * from './ThemeProvider'; diff --git a/src/shared/utils/formatters.ts b/src/shared/utils/formatters.ts index 27b801e..5f3a3e5 100644 --- a/src/shared/utils/formatters.ts +++ b/src/shared/utils/formatters.ts @@ -44,3 +44,14 @@ export function truncate(str: string, length: number): string { if (str.length <= length) return str; return `${str.slice(0, length)}...`; } + +/** + * Format distance to now (e.g., "hace 5 minutos", "en 2 días") + */ +export function formatDistanceToNowLocale(date: Date | string): string { + const d = typeof date === 'string' ? parseISO(date) : date; + return formatDistanceToNow(d, { locale: es }); +} + +// Re-export for widget compatibility +export { formatDistanceToNowLocale as formatDistanceToNow };