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 { motion } from 'framer-motion';
|
||||
import {
|
||||
FileSearch,
|
||||
Inbox,
|
||||
AlertCircle,
|
||||
Search,
|
||||
Plus,
|
||||
type LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { cn } from '@utils/cn';
|
||||
|
||||
@ -32,11 +25,92 @@ export interface EmptyStateProps {
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
const variantIcons: Record<Exclude<EmptyStateVariant, 'custom'>, LucideIcon> = {
|
||||
default: Inbox,
|
||||
search: Search,
|
||||
error: AlertCircle,
|
||||
'no-data': FileSearch,
|
||||
// Inline SVG illustrations - simple line art style
|
||||
const InboxIllustration = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
viewBox="0 0 120 120"
|
||||
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> = {
|
||||
@ -57,7 +131,7 @@ export function EmptyState({
|
||||
compact = false,
|
||||
animated = true,
|
||||
}: EmptyStateProps) {
|
||||
const IconComponent = variant !== 'custom' ? variantIcons[variant] : null;
|
||||
const IllustrationComponent = variant !== 'custom' ? variantIllustrations[variant] : null;
|
||||
const iconColor = variant !== 'custom' ? variantColors[variant] : 'text-gray-400';
|
||||
|
||||
const content = (
|
||||
@ -68,15 +142,20 @@ export function EmptyState({
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
{/* Illustration */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-full bg-gray-100',
|
||||
compact ? 'h-12 w-12 mb-3' : 'h-16 w-16 mb-4'
|
||||
'flex items-center justify-center',
|
||||
compact ? 'mb-3' : 'mb-6'
|
||||
)}
|
||||
>
|
||||
{icon || (IconComponent && (
|
||||
<IconComponent className={cn(compact ? 'h-6 w-6' : 'h-8 w-8', iconColor)} />
|
||||
{icon || (IllustrationComponent && (
|
||||
<IllustrationComponent
|
||||
className={cn(
|
||||
compact ? 'w-16 h-16' : 'w-24 h-24',
|
||||
iconColor
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -161,16 +240,16 @@ export function NoResultsEmptyState({
|
||||
return (
|
||||
<EmptyState
|
||||
variant="search"
|
||||
title="No se encontraron resultados"
|
||||
title="Sin resultados de busqueda"
|
||||
description={
|
||||
searchTerm
|
||||
? `No hay resultados para "${searchTerm}". Intenta con otros términos de búsqueda.`
|
||||
: 'No se encontraron elementos que coincidan con tu búsqueda.'
|
||||
? `No encontramos coincidencias para "${searchTerm}". Prueba con terminos diferentes o revisa la ortografia.`
|
||||
: 'Tu busqueda no produjo resultados. Intenta ajustar los filtros o usar palabras clave distintas.'
|
||||
}
|
||||
primaryAction={
|
||||
onClearSearch
|
||||
? {
|
||||
label: 'Limpiar búsqueda',
|
||||
label: 'Limpiar busqueda',
|
||||
onClick: onClearSearch,
|
||||
variant: 'outline',
|
||||
}
|
||||
@ -195,12 +274,12 @@ export function NoDataEmptyState({
|
||||
return (
|
||||
<EmptyState
|
||||
variant="no-data"
|
||||
title={`No hay ${entityName}`}
|
||||
description={`Aún no se han creado ${entityName}. Comienza agregando el primero.`}
|
||||
title={`Aun no hay ${entityName}`}
|
||||
description={`Este espacio esta esperando tus primeros ${entityName}. Crea uno para comenzar a organizar tu informacion.`}
|
||||
primaryAction={
|
||||
onCreateNew
|
||||
? {
|
||||
label: `Crear ${entityName.slice(0, -1)}`,
|
||||
label: `Agregar ${entityName.slice(0, -1)}`,
|
||||
onClick: onCreateNew,
|
||||
icon: <Plus className="h-4 w-4 mr-1" />,
|
||||
}
|
||||
@ -219,8 +298,8 @@ export interface ErrorEmptyStateProps {
|
||||
}
|
||||
|
||||
export function ErrorEmptyState({
|
||||
title = 'Ocurrió un error',
|
||||
description = 'No pudimos cargar la información. Por favor intenta de nuevo.',
|
||||
title = 'Algo salio mal',
|
||||
description = 'Hubo un problema al cargar los datos. Esto puede ser temporal, intenta de nuevo en unos momentos.',
|
||||
onRetry,
|
||||
className,
|
||||
}: ErrorEmptyStateProps) {
|
||||
@ -232,7 +311,7 @@ export function ErrorEmptyState({
|
||||
primaryAction={
|
||||
onRetry
|
||||
? {
|
||||
label: 'Reintentar',
|
||||
label: 'Volver a intentar',
|
||||
onClick: onRetry,
|
||||
variant: 'primary',
|
||||
}
|
||||
@ -257,9 +336,65 @@ export function ComingSoonEmptyState({
|
||||
return (
|
||||
<EmptyState
|
||||
variant="default"
|
||||
title={`${featureName} próximamente`}
|
||||
description={description || 'Esta funcionalidad estará disponible en una próxima actualización.'}
|
||||
title={`${featureName} en camino`}
|
||||
description={description || 'Estamos trabajando en esta funcionalidad. Pronto estara disponible para ti.'}
|
||||
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 './useFilteredNavigation';
|
||||
export * from './useLocalStorage';
|
||||
export * from './useMediaQuery';
|
||||
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)
|
||||
* @deprecated Use isSuperAdmin boolean property instead
|
||||
*/
|
||||
const isSuperAdmin = useCallback(() => roles.includes('super_admin'), [roles]);
|
||||
const isSuperAdminFn = useCallback(() => roles.includes('super_admin'), [roles]);
|
||||
|
||||
// Computed role checks as boolean properties
|
||||
const isSuperAdmin = roles.includes('super_admin');
|
||||
const isAdmin = roles.includes('admin') || isSuperAdmin;
|
||||
|
||||
/**
|
||||
* Force refresh permissions from server
|
||||
@ -114,10 +119,15 @@ export function usePermissions() {
|
||||
hasAnyPermission,
|
||||
hasAllPermissions,
|
||||
|
||||
// Role checks
|
||||
// Role checks (boolean properties)
|
||||
isAdmin,
|
||||
isSuperAdmin,
|
||||
|
||||
// Role checks (functions)
|
||||
hasRole,
|
||||
hasAnyRole,
|
||||
isSuperAdmin,
|
||||
/** @deprecated Use isSuperAdmin boolean instead */
|
||||
isSuperAdminFn,
|
||||
|
||||
// Actions
|
||||
refresh,
|
||||
|
||||
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 './types';
|
||||
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;
|
||||
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