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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 19:26:16 -06:00
parent 357c3cf1f9
commit 2b2361d87c
15 changed files with 1371 additions and 34 deletions

View File

@ -1,13 +1,6 @@
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { import { Plus } from 'lucide-react';
FileSearch,
Inbox,
AlertCircle,
Search,
Plus,
type LucideIcon
} from 'lucide-react';
import { Button } from '@components/atoms/Button'; import { Button } from '@components/atoms/Button';
import { cn } from '@utils/cn'; import { cn } from '@utils/cn';
@ -32,11 +25,92 @@ export interface EmptyStateProps {
animated?: boolean; animated?: boolean;
} }
const variantIcons: Record<Exclude<EmptyStateVariant, 'custom'>, LucideIcon> = { // Inline SVG illustrations - simple line art style
default: Inbox, const InboxIllustration = ({ className }: { className?: string }) => (
search: Search, <svg
error: AlertCircle, viewBox="0 0 120 120"
'no-data': FileSearch, fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect x="20" y="30" width="80" height="60" rx="4" />
<path d="M20 50 L60 75 L100 50" />
<path d="M35 45 L60 60 L85 45" strokeDasharray="4 2" opacity="0.5" />
<circle cx="95" cy="35" r="12" fill="currentColor" opacity="0.1" />
<path d="M92 35 L95 38 L101 32" strokeWidth="2.5" />
</svg>
);
const SearchIllustration = ({ className }: { className?: string }) => (
<svg
viewBox="0 0 120 120"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="52" cy="52" r="28" />
<path d="M72 72 L95 95" strokeWidth="3" />
<path d="M40 45 L64 45" opacity="0.4" />
<path d="M40 55 L58 55" opacity="0.4" />
<path d="M30 80 L25 95" strokeDasharray="3 3" opacity="0.3" />
<path d="M74 80 L79 95" strokeDasharray="3 3" opacity="0.3" />
<circle cx="95" cy="95" r="6" fill="currentColor" opacity="0.15" />
</svg>
);
const ErrorIllustration = ({ className }: { className?: string }) => (
<svg
viewBox="0 0 120 120"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="60" cy="60" r="35" />
<path d="M60 40 L60 65" strokeWidth="3" />
<circle cx="60" cy="78" r="3" fill="currentColor" />
<path d="M25 25 L35 35" opacity="0.4" />
<path d="M95 25 L85 35" opacity="0.4" />
<path d="M20 60 L30 60" opacity="0.3" />
<path d="M90 60 L100 60" opacity="0.3" />
</svg>
);
const NoDataIllustration = ({ className }: { className?: string }) => (
<svg
viewBox="0 0 120 120"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect x="25" y="20" width="50" height="65" rx="3" />
<path d="M35 35 L65 35" opacity="0.5" />
<path d="M35 45 L60 45" opacity="0.5" />
<path d="M35 55 L55 55" opacity="0.5" />
<path d="M35 65 L50 65" opacity="0.5" />
<circle cx="80" cy="75" r="22" />
<path d="M95 90 L105 100" strokeWidth="3" />
<path d="M73 75 L87 75" />
<path d="M80 68 L80 82" />
</svg>
);
const variantIllustrations: Record<Exclude<EmptyStateVariant, 'custom'>, React.FC<{ className?: string }>> = {
default: InboxIllustration,
search: SearchIllustration,
error: ErrorIllustration,
'no-data': NoDataIllustration,
}; };
const variantColors: Record<Exclude<EmptyStateVariant, 'custom'>, string> = { const variantColors: Record<Exclude<EmptyStateVariant, 'custom'>, string> = {
@ -57,7 +131,7 @@ export function EmptyState({
compact = false, compact = false,
animated = true, animated = true,
}: EmptyStateProps) { }: 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 iconColor = variant !== 'custom' ? variantColors[variant] : 'text-gray-400';
const content = ( const content = (
@ -68,15 +142,20 @@ export function EmptyState({
className className
)} )}
> >
{/* Icon */} {/* Illustration */}
<div <div
className={cn( className={cn(
'flex items-center justify-center rounded-full bg-gray-100', 'flex items-center justify-center',
compact ? 'h-12 w-12 mb-3' : 'h-16 w-16 mb-4' compact ? 'mb-3' : 'mb-6'
)} )}
> >
{icon || (IconComponent && ( {icon || (IllustrationComponent && (
<IconComponent className={cn(compact ? 'h-6 w-6' : 'h-8 w-8', iconColor)} /> <IllustrationComponent
className={cn(
compact ? 'w-16 h-16' : 'w-24 h-24',
iconColor
)}
/>
))} ))}
</div> </div>
@ -161,16 +240,16 @@ export function NoResultsEmptyState({
return ( return (
<EmptyState <EmptyState
variant="search" variant="search"
title="No se encontraron resultados" title="Sin resultados de busqueda"
description={ description={
searchTerm searchTerm
? `No hay resultados para "${searchTerm}". Intenta con otros términos de búsqueda.` ? `No encontramos coincidencias para "${searchTerm}". Prueba con terminos diferentes o revisa la ortografia.`
: 'No se encontraron elementos que coincidan con tu búsqueda.' : 'Tu busqueda no produjo resultados. Intenta ajustar los filtros o usar palabras clave distintas.'
} }
primaryAction={ primaryAction={
onClearSearch onClearSearch
? { ? {
label: 'Limpiar búsqueda', label: 'Limpiar busqueda',
onClick: onClearSearch, onClick: onClearSearch,
variant: 'outline', variant: 'outline',
} }
@ -195,12 +274,12 @@ export function NoDataEmptyState({
return ( return (
<EmptyState <EmptyState
variant="no-data" variant="no-data"
title={`No hay ${entityName}`} title={`Aun no hay ${entityName}`}
description={`Aún no se han creado ${entityName}. Comienza agregando el primero.`} description={`Este espacio esta esperando tus primeros ${entityName}. Crea uno para comenzar a organizar tu informacion.`}
primaryAction={ primaryAction={
onCreateNew onCreateNew
? { ? {
label: `Crear ${entityName.slice(0, -1)}`, label: `Agregar ${entityName.slice(0, -1)}`,
onClick: onCreateNew, onClick: onCreateNew,
icon: <Plus className="h-4 w-4 mr-1" />, icon: <Plus className="h-4 w-4 mr-1" />,
} }
@ -219,8 +298,8 @@ export interface ErrorEmptyStateProps {
} }
export function ErrorEmptyState({ export function ErrorEmptyState({
title = 'Ocurrió un error', title = 'Algo salio mal',
description = 'No pudimos cargar la información. Por favor intenta de nuevo.', description = 'Hubo un problema al cargar los datos. Esto puede ser temporal, intenta de nuevo en unos momentos.',
onRetry, onRetry,
className, className,
}: ErrorEmptyStateProps) { }: ErrorEmptyStateProps) {
@ -232,7 +311,7 @@ export function ErrorEmptyState({
primaryAction={ primaryAction={
onRetry onRetry
? { ? {
label: 'Reintentar', label: 'Volver a intentar',
onClick: onRetry, onClick: onRetry,
variant: 'primary', variant: 'primary',
} }
@ -257,9 +336,65 @@ export function ComingSoonEmptyState({
return ( return (
<EmptyState <EmptyState
variant="default" variant="default"
title={`${featureName} próximamente`} title={`${featureName} en camino`}
description={description || 'Esta funcionalidad estará disponible en una próxima actualización.'} description={description || 'Estamos trabajando en esta funcionalidad. Pronto estara disponible para ti.'}
className={className} className={className}
/> />
); );
} }
// 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 (
<div className={className}>
<EmptyState
variant="search"
title="No encontramos lo que buscas"
description={
query
? `No hay resultados para "${query}". Verifica que este bien escrito o prueba con sinonimos.`
: 'Parece que no hay coincidencias con los filtros actuales.'
}
primaryAction={
onClearFilters
? {
label: 'Limpiar filtros',
onClick: onClearFilters,
variant: 'outline',
}
: undefined
}
/>
{suggestions && suggestions.length > 0 && onSuggestionClick && (
<div className="flex flex-col items-center mt-4">
<p className="text-sm text-gray-500 mb-2">Sugerencias:</p>
<div className="flex flex-wrap justify-center gap-2">
{suggestions.map((suggestion) => (
<button
key={suggestion}
onClick={() => onSuggestionClick(suggestion)}
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-full transition-colors"
>
{suggestion}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -1,4 +1,7 @@
export * from './useDebounce'; export * from './useDebounce';
export * from './useFilteredNavigation';
export * from './useLocalStorage'; export * from './useLocalStorage';
export * from './useMediaQuery'; export * from './useMediaQuery';
export * from './usePermissions'; export * from './usePermissions';
export * from './useTenantTheme';
export * from './useTheme';

View File

@ -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 <Spinner />;
*
* return (
* <nav>
* {items.map(item => (
* <Link key={item.href} to={item.href}>
* {item.name}
* </Link>
* ))}
* </nav>
* );
* }
* ```
*/
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<NavigationItem[]>((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;

View File

@ -87,8 +87,13 @@ export function usePermissions() {
/** /**
* Check if user is super_admin (can do anything) * 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 * Force refresh permissions from server
@ -114,10 +119,15 @@ export function usePermissions() {
hasAnyPermission, hasAnyPermission,
hasAllPermissions, hasAllPermissions,
// Role checks // Role checks (boolean properties)
isAdmin,
isSuperAdmin,
// Role checks (functions)
hasRole, hasRole,
hasAnyRole, hasAnyRole,
isSuperAdmin, /** @deprecated Use isSuperAdmin boolean instead */
isSuperAdminFn,
// Actions // Actions
refresh, refresh,

View File

@ -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;
}

View File

@ -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;
}

167
src/shared/i18n/index.ts Normal file
View File

@ -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<Language, string> = {
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<unknown>;
changeLanguage: (lang: string) => void;
language: string;
t: (key: string, options?: Record<string, unknown>) => 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<void> {
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, unknown>): 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<string, unknown>)[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 };

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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, unknown>) => 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<Language, string>;
}
/**
* Hook to use translations in components
*
* @example
* ```tsx
* function MyComponent() {
* const { t, language, changeLanguage } = useTranslation();
*
* return (
* <div>
* <p>{t('common.save')}</p>
* <p>{t('validation.minLength', { min: 3 })}</p>
* <button onClick={() => changeLanguage('en')}>
* {language === 'es' ? 'English' : 'Español'}
* </button>
* </div>
* );
* }
* ```
*/
export function useTranslation(): UseTranslationReturn {
const [language, setLanguageState] = useState<Language>(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<string, unknown>) => translateFn(key, options),
[]
);
return {
t,
language,
changeLanguage,
ready,
languages: supportedLanguages,
languageNames,
};
}
export default useTranslation;

View File

@ -4,3 +4,5 @@ export * from './stores';
export * from './utils'; export * from './utils';
export * from './types'; export * from './types';
export * from './constants'; export * from './constants';
export * from './i18n';
export { useTranslation } from './i18n/useTranslation';

View File

@ -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<TenantThemeContextValue | undefined>(
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<string, string> {
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<string, string> = {
'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<string, string> = {
'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<string, string>): 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<TenantTheme | null>(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<TenantThemeContextValue>(
() => ({
theme,
setTheme,
resetTheme,
isCustomized,
}),
[theme, setTheme, resetTheme, isCustomized]
);
return (
<TenantThemeContext.Provider value={value}>
{children}
</TenantThemeContext.Provider>
);
}

View File

@ -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<ThemeContextValue | undefined>(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<Theme>(() => {
return defaultTheme ?? getStoredTheme();
});
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(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<ThemeContextValue>(
() => ({
theme,
resolvedTheme,
setTheme,
}),
[theme, resolvedTheme, setTheme]
);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}

View File

@ -0,0 +1,2 @@
export * from './TenantThemeProvider';
export * from './ThemeProvider';

View File

@ -44,3 +44,14 @@ export function truncate(str: string, length: number): string {
if (str.length <= length) return str; if (str.length <= length) return str;
return `${str.slice(0, length)}...`; 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 };