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:
parent
357c3cf1f9
commit
2b2361d87c
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
174
src/shared/hooks/useFilteredNavigation.ts
Normal file
174
src/shared/hooks/useFilteredNavigation.ts
Normal 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;
|
||||||
@ -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,
|
||||||
|
|||||||
15
src/shared/hooks/useTenantTheme.ts
Normal file
15
src/shared/hooks/useTenantTheme.ts
Normal 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;
|
||||||
|
}
|
||||||
12
src/shared/hooks/useTheme.ts
Normal file
12
src/shared/hooks/useTheme.ts
Normal 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
167
src/shared/i18n/index.ts
Normal 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 };
|
||||||
137
src/shared/i18n/locales/en.json
Normal file
137
src/shared/i18n/locales/en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/shared/i18n/locales/es.json
Normal file
137
src/shared/i18n/locales/es.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/shared/i18n/useTranslation.ts
Normal file
122
src/shared/i18n/useTranslation.ts
Normal 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;
|
||||||
@ -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';
|
||||||
|
|||||||
319
src/shared/providers/TenantThemeProvider.tsx
Normal file
319
src/shared/providers/TenantThemeProvider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/shared/providers/ThemeProvider.tsx
Normal file
91
src/shared/providers/ThemeProvider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/shared/providers/index.ts
Normal file
2
src/shared/providers/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './TenantThemeProvider';
|
||||||
|
export * from './ThemeProvider';
|
||||||
@ -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 };
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user