[GAPS] feat(components): Implement remaining frontend gaps G-003 to G-013

Components added:
- G-003: ErrorBoundary with withErrorBoundary HOC and PageErrorFallback
- G-010: Pagination with SimplePagination variant
- G-011: Dropdown system (Dropdown, DropdownItem, Menu, etc.)
- G-012: FileUpload with drag & drop and preview
- G-013: DatePicker with DateRangePicker

Hooks added:
- G-006: useDebounce, useDebouncedCallback, useDebounceWithImmediate
- G-007: useLocalStorage, useSessionStorage

Services added:
- G-004: API Client with request/response interceptors, token refresh

Tests: 49 passing (14 new tests for utility hooks)
TypeScript: All types validated

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-04 01:34:21 -06:00
parent b3dd4b859e
commit 816d591115
13 changed files with 2408 additions and 1 deletions

View File

@ -0,0 +1,432 @@
/**
* DatePicker - Date selection component
* G-013: Date picker with range support and localization
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import clsx from 'clsx';
export interface DatePickerProps {
/** Selected date */
value?: Date | null;
/** Callback when date changes */
onChange: (date: Date | null) => void;
/** Minimum selectable date */
minDate?: Date;
/** Maximum selectable date */
maxDate?: Date;
/** Placeholder text */
placeholder?: string;
/** Date format for display */
format?: string;
/** Disabled state */
disabled?: boolean;
/** Error message */
error?: string;
/** Label text */
label?: string;
/** Helper text */
helperText?: string;
/** Allow clearing the date */
clearable?: boolean;
/** Additional CSS classes */
className?: string;
}
// Spanish month and day names
const MONTHS_ES = [
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
];
const DAYS_ES = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'];
export function DatePicker({
value,
onChange,
minDate,
maxDate,
placeholder = 'Seleccionar fecha',
disabled = false,
error,
label,
helperText,
clearable = true,
className,
}: DatePickerProps) {
const [isOpen, setIsOpen] = useState(false);
const [viewDate, setViewDate] = useState(value || new Date());
const containerRef = useRef<HTMLDivElement>(null);
// Format date for display
const formatDate = (date: Date): string => {
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
return `${day}/${month}/${year}`;
};
// Get calendar days for current month view
const getCalendarDays = useCallback(() => {
const year = viewDate.getFullYear();
const month = viewDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startOffset = firstDay.getDay(); // Day of week (0-6)
const totalDays = lastDay.getDate();
const days: Array<{ date: Date; isCurrentMonth: boolean }> = [];
// Previous month days
const prevMonthLastDay = new Date(year, month, 0).getDate();
for (let i = startOffset - 1; i >= 0; i--) {
days.push({
date: new Date(year, month - 1, prevMonthLastDay - i),
isCurrentMonth: false,
});
}
// Current month days
for (let i = 1; i <= totalDays; i++) {
days.push({
date: new Date(year, month, i),
isCurrentMonth: true,
});
}
// Next month days to fill the grid
const remaining = 42 - days.length; // 6 rows * 7 days
for (let i = 1; i <= remaining; i++) {
days.push({
date: new Date(year, month + 1, i),
isCurrentMonth: false,
});
}
return days;
}, [viewDate]);
// Check if a date is selectable
const isDateSelectable = (date: Date): boolean => {
if (minDate && date < minDate) return false;
if (maxDate && date > maxDate) return false;
return true;
};
// Check if a date is selected
const isDateSelected = (date: Date): boolean => {
if (!value) return false;
return (
date.getDate() === value.getDate() &&
date.getMonth() === value.getMonth() &&
date.getFullYear() === value.getFullYear()
);
};
// Check if a date is today
const isToday = (date: Date): boolean => {
const today = new Date();
return (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
);
};
// Navigate months
const goToPrevMonth = () => {
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1));
};
const goToNextMonth = () => {
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1));
};
// Handle date selection
const handleDateClick = (date: Date) => {
if (!isDateSelectable(date)) return;
onChange(date);
setIsOpen(false);
};
// Handle clear
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onChange(null);
};
// Close on click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Update view date when value changes
useEffect(() => {
if (value) {
setViewDate(value);
}
}, [value]);
const days = getCalendarDays();
return (
<div ref={containerRef} className={clsx('relative', className)}>
{/* Label */}
{label && (
<label className="block text-sm font-medium text-foreground dark:text-foreground mb-1">
{label}
</label>
)}
{/* Input trigger */}
<div
onClick={() => !disabled && setIsOpen(!isOpen)}
className={clsx(
'flex items-center gap-2 px-3 py-2 rounded-md border transition-colors cursor-pointer',
disabled
? 'bg-background-muted border-border cursor-not-allowed opacity-60'
: isOpen
? 'border-primary ring-2 ring-primary/20'
: error
? 'border-danger'
: 'border-border hover:border-foreground-muted dark:border-border dark:hover:border-foreground-muted',
'bg-background dark:bg-background'
)}
>
<CalendarIcon className="w-5 h-5 text-foreground-muted flex-shrink-0" />
<span
className={clsx(
'flex-1 text-sm',
value
? 'text-foreground dark:text-foreground'
: 'text-foreground-muted dark:text-foreground-muted'
)}
>
{value ? formatDate(value) : placeholder}
</span>
{clearable && value && !disabled && (
<button
type="button"
onClick={handleClear}
className="p-0.5 hover:bg-background-muted dark:hover:bg-background-muted rounded"
>
<XIcon className="w-4 h-4 text-foreground-muted hover:text-foreground" />
</button>
)}
</div>
{/* Helper text */}
{helperText && !error && (
<p className="mt-1 text-sm text-foreground-muted dark:text-foreground-muted">
{helperText}
</p>
)}
{/* Error message */}
{error && (
<p className="mt-1 text-sm text-danger">{error}</p>
)}
{/* Calendar dropdown */}
{isOpen && (
<div className="absolute z-dropdown mt-1 p-3 bg-surface-popover dark:bg-surface-popover border border-border dark:border-border rounded-lg shadow-lg min-w-[280px]">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<button
type="button"
onClick={goToPrevMonth}
className="p-1 hover:bg-background-muted dark:hover:bg-background-muted rounded"
>
<ChevronLeftIcon className="w-5 h-5" />
</button>
<span className="text-sm font-semibold text-foreground dark:text-foreground">
{MONTHS_ES[viewDate.getMonth()]} {viewDate.getFullYear()}
</span>
<button
type="button"
onClick={goToNextMonth}
className="p-1 hover:bg-background-muted dark:hover:bg-background-muted rounded"
>
<ChevronRightIcon className="w-5 h-5" />
</button>
</div>
{/* Day names */}
<div className="grid grid-cols-7 gap-1 mb-1">
{DAYS_ES.map((day) => (
<div
key={day}
className="text-xs font-medium text-foreground-muted dark:text-foreground-muted text-center py-1"
>
{day}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-1">
{days.map(({ date, isCurrentMonth }, index) => {
const selectable = isDateSelectable(date);
const selected = isDateSelected(date);
const today = isToday(date);
return (
<button
key={index}
type="button"
onClick={() => handleDateClick(date)}
disabled={!selectable}
className={clsx(
'w-8 h-8 text-sm rounded-md transition-colors',
!isCurrentMonth && 'text-foreground-subtle',
isCurrentMonth && !selected && 'text-foreground dark:text-foreground',
selectable && !selected && 'hover:bg-background-muted dark:hover:bg-background-muted',
selected && 'bg-primary text-white',
today && !selected && 'ring-1 ring-primary',
!selectable && 'opacity-40 cursor-not-allowed'
)}
>
{date.getDate()}
</button>
);
})}
</div>
{/* Today button */}
<div className="mt-3 pt-3 border-t border-border dark:border-border">
<button
type="button"
onClick={() => handleDateClick(new Date())}
className="w-full text-sm text-primary hover:text-primary-600 font-medium"
>
Hoy
</button>
</div>
</div>
)}
</div>
);
}
/**
* DateRangePicker - Select a date range
*/
export interface DateRangePickerProps {
startDate: Date | null;
endDate: Date | null;
onRangeChange: (start: Date | null, end: Date | null) => void;
minDate?: Date;
maxDate?: Date;
disabled?: boolean;
error?: string;
label?: string;
className?: string;
}
export function DateRangePicker({
startDate,
endDate,
onRangeChange,
minDate,
maxDate,
disabled = false,
error,
label,
className,
}: DateRangePickerProps) {
return (
<div className={clsx('space-y-2', className)}>
{label && (
<label className="block text-sm font-medium text-foreground dark:text-foreground">
{label}
</label>
)}
<div className="flex items-center gap-2">
<DatePicker
value={startDate}
onChange={(date) => onRangeChange(date, endDate)}
maxDate={endDate || maxDate}
minDate={minDate}
placeholder="Fecha inicio"
disabled={disabled}
className="flex-1"
/>
<span className="text-foreground-muted"></span>
<DatePicker
value={endDate}
onChange={(date) => onRangeChange(startDate, date)}
minDate={startDate || minDate}
maxDate={maxDate}
placeholder="Fecha fin"
disabled={disabled}
className="flex-1"
/>
</div>
{error && (
<p className="text-sm text-danger">{error}</p>
)}
</div>
);
}
// Icons
function CalendarIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
);
}
function ChevronLeftIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
);
}
function ChevronRightIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
);
}
function XIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
);
}
export default DatePicker;

View File

@ -0,0 +1,292 @@
/**
* Dropdown - Dropdown menu component with positioning
* G-011: Accessible dropdown menus and popovers
*/
import { useState, useRef, useEffect, ReactNode, createContext, useContext } from 'react';
import clsx from 'clsx';
// Context for dropdown state
interface DropdownContextValue {
isOpen: boolean;
close: () => void;
}
const DropdownContext = createContext<DropdownContextValue | null>(null);
function useDropdownContext() {
const context = useContext(DropdownContext);
if (!context) {
throw new Error('Dropdown components must be used within a Dropdown');
}
return context;
}
// Types
type DropdownPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
interface DropdownProps {
children: ReactNode;
className?: string;
}
interface DropdownTriggerProps {
children: ReactNode;
asChild?: boolean;
className?: string;
}
interface DropdownContentProps {
children: ReactNode;
placement?: DropdownPlacement;
align?: 'start' | 'end';
className?: string;
minWidth?: number;
}
interface DropdownItemProps {
children: ReactNode;
onClick?: () => void;
disabled?: boolean;
danger?: boolean;
icon?: ReactNode;
className?: string;
}
/**
* Dropdown container component
*/
export function Dropdown({ children, className }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const close = () => setIsOpen(false);
const toggle = () => setIsOpen((prev) => !prev);
// Close on click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
close();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Close on escape key
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
return (
<DropdownContext.Provider value={{ isOpen, close }}>
<div ref={dropdownRef} className={clsx('relative inline-block', className)}>
{/* Inject toggle function to trigger */}
{Array.isArray(children)
? children.map((child, index) => {
if (index === 0 && typeof child === 'object' && child !== null) {
return (
<div key={index} onClick={toggle}>
{child}
</div>
);
}
return child;
})
: children}
</div>
</DropdownContext.Provider>
);
}
/**
* Dropdown trigger button
*/
export function DropdownTrigger({ children, className }: DropdownTriggerProps) {
return (
<div className={clsx('cursor-pointer', className)} role="button" tabIndex={0}>
{children}
</div>
);
}
/**
* Dropdown content container
*/
export function DropdownContent({
children,
placement = 'bottom-start',
className,
minWidth = 180,
}: DropdownContentProps) {
const { isOpen } = useDropdownContext();
if (!isOpen) return null;
const placementClasses: Record<DropdownPlacement, string> = {
'bottom-start': 'top-full left-0 mt-1',
'bottom-end': 'top-full right-0 mt-1',
'top-start': 'bottom-full left-0 mb-1',
'top-end': 'bottom-full right-0 mb-1',
};
return (
<div
className={clsx(
'absolute z-dropdown',
placementClasses[placement],
'bg-surface-popover dark:bg-surface-popover',
'border border-border dark:border-border',
'rounded-lg shadow-lg',
'py-1',
'animate-in fade-in-0 zoom-in-95 duration-150',
className
)}
style={{ minWidth }}
role="menu"
aria-orientation="vertical"
>
{children}
</div>
);
}
/**
* Dropdown menu item
*/
export function DropdownItem({
children,
onClick,
disabled = false,
danger = false,
icon,
className,
}: DropdownItemProps) {
const { close } = useDropdownContext();
const handleClick = () => {
if (disabled) return;
onClick?.();
close();
};
return (
<button
type="button"
onClick={handleClick}
disabled={disabled}
role="menuitem"
className={clsx(
'w-full flex items-center gap-2 px-3 py-2 text-sm text-left',
'transition-colors duration-150',
disabled
? 'text-foreground-subtle cursor-not-allowed opacity-50'
: danger
? 'text-danger hover:bg-danger/10 dark:hover:bg-danger/20'
: 'text-foreground dark:text-foreground hover:bg-background-muted dark:hover:bg-background-muted',
className
)}
>
{icon && <span className="w-4 h-4 flex-shrink-0">{icon}</span>}
{children}
</button>
);
}
/**
* Dropdown separator
*/
export function DropdownSeparator({ className }: { className?: string }) {
return (
<div
className={clsx('my-1 border-t border-border dark:border-border', className)}
role="separator"
/>
);
}
/**
* Dropdown label (non-interactive)
*/
export function DropdownLabel({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={clsx(
'px-3 py-1.5 text-xs font-semibold uppercase tracking-wider',
'text-foreground-muted dark:text-foreground-muted',
className
)}
>
{children}
</div>
);
}
/**
* Simple Menu component (pre-composed dropdown)
*/
interface MenuProps {
trigger: ReactNode;
items: Array<{
label: string;
onClick: () => void;
icon?: ReactNode;
disabled?: boolean;
danger?: boolean;
separator?: boolean;
}>;
placement?: DropdownPlacement;
className?: string;
}
export function Menu({ trigger, items, placement = 'bottom-end', className }: MenuProps) {
return (
<Dropdown className={className}>
<DropdownTrigger>{trigger}</DropdownTrigger>
<DropdownContent placement={placement}>
{items.map((item, index) => (
<div key={index}>
{item.separator && <DropdownSeparator />}
{!item.separator && (
<DropdownItem
onClick={item.onClick}
icon={item.icon}
disabled={item.disabled}
danger={item.danger}
>
{item.label}
</DropdownItem>
)}
</div>
))}
</DropdownContent>
</Dropdown>
);
}
export default Dropdown;

View File

@ -0,0 +1,224 @@
/**
* ErrorBoundary - React error boundary component
* G-003: Catches JavaScript errors in child components
*/
import React, { Component, ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
/** Custom fallback UI */
fallback?: ReactNode;
/** Custom fallback render function with error info */
fallbackRender?: (props: { error: Error; resetError: () => void }) => ReactNode;
/** Callback when error is caught */
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
/** Callback when error is reset */
onReset?: () => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
console.error('ErrorBoundary caught an error:', error, errorInfo);
this.props.onError?.(error, errorInfo);
}
resetError = (): void => {
this.props.onReset?.();
this.setState({ hasError: false, error: null });
};
render(): ReactNode {
if (this.state.hasError && this.state.error) {
// Custom fallback render function
if (this.props.fallbackRender) {
return this.props.fallbackRender({
error: this.state.error,
resetError: this.resetError,
});
}
// Custom fallback component
if (this.props.fallback) {
return this.props.fallback;
}
// Default fallback UI
return (
<DefaultErrorFallback
error={this.state.error}
resetError={this.resetError}
/>
);
}
return this.props.children;
}
}
/**
* Default error fallback UI
*/
interface DefaultErrorFallbackProps {
error: Error;
resetError: () => void;
}
function DefaultErrorFallback({ error, resetError }: DefaultErrorFallbackProps) {
return (
<div
role="alert"
className="min-h-[200px] flex items-center justify-center p-6"
>
<div className="max-w-md w-full bg-surface-card dark:bg-surface-card rounded-lg border border-danger/20 p-6 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-danger/10 flex items-center justify-center">
<svg
className="w-6 h-6 text-danger"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h2 className="text-lg font-semibold text-foreground dark:text-foreground mb-2">
Algo salió mal
</h2>
<p className="text-sm text-foreground-muted dark:text-foreground-muted mb-4">
Ha ocurrido un error inesperado. Por favor, intenta de nuevo.
</p>
{import.meta.env.DEV && (
<details className="mb-4 text-left">
<summary className="cursor-pointer text-sm text-foreground-subtle hover:text-foreground">
Detalles del error
</summary>
<pre className="mt-2 p-3 bg-background-muted dark:bg-background-muted rounded text-xs overflow-auto max-h-32 text-danger">
{error.message}
{error.stack && `\n\n${error.stack}`}
</pre>
</details>
)}
<button
onClick={resetError}
className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-primary hover:bg-primary-600 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
>
Intentar de nuevo
</button>
</div>
</div>
);
}
/**
* Higher-order component to wrap a component with ErrorBoundary
*/
export function withErrorBoundary<P extends object>(
WrappedComponent: React.ComponentType<P>,
errorBoundaryProps?: Omit<ErrorBoundaryProps, 'children'>
): React.FC<P> {
const displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
const ComponentWithErrorBoundary: React.FC<P> = (props) => (
<ErrorBoundary {...errorBoundaryProps}>
<WrappedComponent {...props} />
</ErrorBoundary>
);
ComponentWithErrorBoundary.displayName = `withErrorBoundary(${displayName})`;
return ComponentWithErrorBoundary;
}
/**
* Error fallback component for page-level errors
*/
export function PageErrorFallback({
error,
resetError,
}: {
error: Error;
resetError: () => void;
}) {
return (
<div className="min-h-screen flex items-center justify-center bg-background dark:bg-background p-4">
<div className="max-w-lg w-full bg-surface-card dark:bg-surface-card rounded-xl shadow-lg p-8 text-center">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-danger/10 flex items-center justify-center">
<svg
className="w-8 h-8 text-danger"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-foreground dark:text-foreground mb-3">
Error de aplicación
</h1>
<p className="text-foreground-muted dark:text-foreground-muted mb-6">
Lo sentimos, ha ocurrido un error inesperado. Nuestro equipo ha sido
notificado y estamos trabajando para solucionarlo.
</p>
{import.meta.env.DEV && (
<details className="mb-6 text-left bg-background-muted dark:bg-background-muted rounded-lg p-4">
<summary className="cursor-pointer text-sm font-medium text-foreground">
Información técnica
</summary>
<pre className="mt-3 text-xs overflow-auto max-h-48 text-danger whitespace-pre-wrap">
{error.message}
{error.stack && `\n\n${error.stack}`}
</pre>
</details>
)}
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<button
onClick={resetError}
className="inline-flex items-center justify-center px-6 py-2.5 text-sm font-medium text-white bg-primary hover:bg-primary-600 rounded-lg transition-colors"
>
Intentar de nuevo
</button>
<button
onClick={() => window.location.href = '/'}
className="inline-flex items-center justify-center px-6 py-2.5 text-sm font-medium text-foreground bg-background-muted hover:bg-background-emphasis rounded-lg transition-colors"
>
Ir al inicio
</button>
</div>
</div>
</div>
);
}
export default ErrorBoundary;

View File

@ -0,0 +1,366 @@
/**
* FileUpload - File upload component with drag & drop
* G-012: File upload with preview, validation, and progress
*/
import { useState, useRef, useCallback, ChangeEvent, DragEvent } from 'react';
import clsx from 'clsx';
export interface FileUploadProps {
/** Accepted file types (e.g., "image/*", ".pdf,.doc") */
accept?: string;
/** Allow multiple files */
multiple?: boolean;
/** Max file size in bytes */
maxSize?: number;
/** Max number of files */
maxFiles?: number;
/** Callback when files are selected */
onFilesSelected: (files: File[]) => void;
/** Callback when files are rejected */
onFilesRejected?: (errors: FileError[]) => void;
/** Callback for upload progress */
onUploadProgress?: (progress: number) => void;
/** Show file preview */
showPreview?: boolean;
/** Custom label */
label?: string;
/** Helper text */
helperText?: string;
/** Disabled state */
disabled?: boolean;
/** Error message */
error?: string;
/** Additional CSS classes */
className?: string;
}
export interface FileError {
file: File;
type: 'size' | 'type' | 'count';
message: string;
}
interface FilePreview {
file: File;
preview: string | null;
}
export function FileUpload({
accept,
multiple = false,
maxSize = 10 * 1024 * 1024, // 10MB default
maxFiles = 10,
onFilesSelected,
onFilesRejected,
showPreview = true,
label = 'Arrastra archivos aquí o haz clic para seleccionar',
helperText,
disabled = false,
error,
className,
}: FileUploadProps) {
const [isDragging, setIsDragging] = useState(false);
const [files, setFiles] = useState<FilePreview[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const validateFiles = useCallback(
(fileList: FileList | File[]): { valid: File[]; errors: FileError[] } => {
const valid: File[] = [];
const errors: FileError[] = [];
const fileArray = Array.from(fileList);
// Check max files
if (fileArray.length > maxFiles) {
fileArray.slice(maxFiles).forEach((file) => {
errors.push({
file,
type: 'count',
message: `Máximo ${maxFiles} archivos permitidos`,
});
});
}
fileArray.slice(0, maxFiles).forEach((file) => {
// Check file size
if (file.size > maxSize) {
errors.push({
file,
type: 'size',
message: `El archivo excede el tamaño máximo de ${formatFileSize(maxSize)}`,
});
return;
}
// Check file type if accept is specified
if (accept) {
const acceptedTypes = accept.split(',').map((t) => t.trim());
const fileType = file.type;
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
const isAccepted = acceptedTypes.some((type) => {
if (type.startsWith('.')) {
return fileExtension === type.toLowerCase();
}
if (type.endsWith('/*')) {
return fileType.startsWith(type.replace('/*', '/'));
}
return fileType === type;
});
if (!isAccepted) {
errors.push({
file,
type: 'type',
message: `Tipo de archivo no permitido: ${file.type || fileExtension}`,
});
return;
}
}
valid.push(file);
});
return { valid, errors };
},
[accept, maxSize, maxFiles]
);
const createPreviews = (fileList: File[]): FilePreview[] => {
return fileList.map((file) => {
const preview = file.type.startsWith('image/')
? URL.createObjectURL(file)
: null;
return { file, preview };
});
};
const handleFiles = useCallback(
(fileList: FileList | File[]) => {
const { valid, errors } = validateFiles(fileList);
if (errors.length > 0 && onFilesRejected) {
onFilesRejected(errors);
}
if (valid.length > 0) {
const previews = createPreviews(valid);
setFiles((prev) => (multiple ? [...prev, ...previews] : previews));
onFilesSelected(valid);
}
},
[validateFiles, onFilesSelected, onFilesRejected, multiple]
);
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (!disabled) {
setIsDragging(true);
}
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
if (disabled) return;
const { files: droppedFiles } = e.dataTransfer;
if (droppedFiles.length > 0) {
handleFiles(droppedFiles);
}
};
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const { files: selectedFiles } = e.target;
if (selectedFiles && selectedFiles.length > 0) {
handleFiles(selectedFiles);
}
// Reset input
if (inputRef.current) {
inputRef.current.value = '';
}
};
const handleClick = () => {
if (!disabled && inputRef.current) {
inputRef.current.click();
}
};
const removeFile = (index: number) => {
setFiles((prev) => {
const newFiles = [...prev];
const removed = newFiles.splice(index, 1);
// Revoke preview URL to prevent memory leaks
if (removed[0]?.preview) {
URL.revokeObjectURL(removed[0].preview);
}
return newFiles;
});
};
return (
<div className={clsx('w-full', className)}>
{/* Drop zone */}
<div
onClick={handleClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={clsx(
'relative border-2 border-dashed rounded-lg p-6 transition-colors cursor-pointer',
'text-center',
disabled
? 'border-border bg-background-muted cursor-not-allowed opacity-60'
: isDragging
? 'border-primary bg-primary/5 dark:bg-primary/10'
: error
? 'border-danger bg-danger/5 dark:bg-danger/10'
: 'border-border dark:border-border hover:border-primary hover:bg-background-muted dark:hover:bg-background-muted'
)}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleInputChange}
disabled={disabled}
className="hidden"
/>
<div className="flex flex-col items-center gap-2">
<div
className={clsx(
'w-12 h-12 rounded-full flex items-center justify-center',
isDragging
? 'bg-primary/10 text-primary'
: 'bg-background-muted dark:bg-background-muted text-foreground-muted'
)}
>
<UploadIcon className="w-6 h-6" />
</div>
<div className="text-sm text-foreground dark:text-foreground font-medium">
{label}
</div>
{helperText && (
<div className="text-xs text-foreground-muted dark:text-foreground-muted">
{helperText}
</div>
)}
<div className="text-xs text-foreground-subtle dark:text-foreground-subtle">
Máximo {formatFileSize(maxSize)} por archivo
{multiple && ` • Hasta ${maxFiles} archivos`}
</div>
</div>
</div>
{/* Error message */}
{error && (
<p className="mt-2 text-sm text-danger">{error}</p>
)}
{/* File previews */}
{showPreview && files.length > 0 && (
<div className="mt-4 space-y-2">
{files.map((item, index) => (
<div
key={`${item.file.name}-${index}`}
className="flex items-center gap-3 p-3 bg-background-muted dark:bg-background-muted rounded-lg"
>
{/* Preview thumbnail */}
{item.preview ? (
<img
src={item.preview}
alt={item.file.name}
className="w-10 h-10 object-cover rounded"
/>
) : (
<div className="w-10 h-10 bg-background-emphasis dark:bg-background-emphasis rounded flex items-center justify-center">
<FileIcon className="w-5 h-5 text-foreground-muted" />
</div>
)}
{/* File info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground dark:text-foreground truncate">
{item.file.name}
</p>
<p className="text-xs text-foreground-muted dark:text-foreground-muted">
{formatFileSize(item.file.size)}
</p>
</div>
{/* Remove button */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeFile(index);
}}
className="p-1 hover:bg-background-emphasis dark:hover:bg-background-emphasis rounded transition-colors"
aria-label="Eliminar archivo"
>
<XIcon className="w-4 h-4 text-foreground-muted hover:text-danger" />
</button>
</div>
))}
</div>
)}
</div>
);
}
// Icons
function UploadIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
);
}
function FileIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
);
}
function XIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
);
}
export default FileUpload;

View File

@ -0,0 +1,289 @@
/**
* Pagination - Component for paginated data navigation
* G-010: Reusable pagination with multiple display modes
*/
import clsx from 'clsx';
export interface PaginationProps {
/** Current page (1-indexed) */
currentPage: number;
/** Total number of pages */
totalPages: number;
/** Total number of items */
totalItems?: number;
/** Items per page */
pageSize?: number;
/** Callback when page changes */
onPageChange: (page: number) => void;
/** Callback when page size changes */
onPageSizeChange?: (size: number) => void;
/** Available page sizes */
pageSizeOptions?: number[];
/** Show page size selector */
showPageSize?: boolean;
/** Show total items info */
showTotal?: boolean;
/** Number of page buttons to show */
siblingCount?: number;
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Additional CSS classes */
className?: string;
/** Disabled state */
disabled?: boolean;
}
const sizeClasses = {
sm: 'h-7 min-w-[28px] text-xs',
md: 'h-9 min-w-[36px] text-sm',
lg: 'h-11 min-w-[44px] text-base',
};
const iconSizes = {
sm: 'w-3 h-3',
md: 'w-4 h-4',
lg: 'w-5 h-5',
};
export function Pagination({
currentPage,
totalPages,
totalItems,
pageSize = 10,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 20, 50, 100],
showPageSize = false,
showTotal = true,
siblingCount = 1,
size = 'md',
className,
disabled = false,
}: PaginationProps) {
// Generate page numbers to display
const getPageNumbers = (): (number | 'ellipsis')[] => {
const pages: (number | 'ellipsis')[] = [];
// Always show first page
pages.push(1);
// Calculate range around current page
const leftSibling = Math.max(2, currentPage - siblingCount);
const rightSibling = Math.min(totalPages - 1, currentPage + siblingCount);
// Add ellipsis if gap after first page
if (leftSibling > 2) {
pages.push('ellipsis');
}
// Add pages around current
for (let i = leftSibling; i <= rightSibling; i++) {
if (i !== 1 && i !== totalPages) {
pages.push(i);
}
}
// Add ellipsis if gap before last page
if (rightSibling < totalPages - 1) {
pages.push('ellipsis');
}
// Always show last page if more than 1 page
if (totalPages > 1) {
pages.push(totalPages);
}
return pages;
};
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages && page !== currentPage && !disabled) {
onPageChange(page);
}
};
const pageNumbers = getPageNumbers();
// Calculate showing range
const startItem = totalItems ? (currentPage - 1) * pageSize + 1 : 0;
const endItem = totalItems ? Math.min(currentPage * pageSize, totalItems) : 0;
return (
<div
className={clsx(
'flex flex-col sm:flex-row items-center justify-between gap-4',
className
)}
>
{/* Total info */}
{showTotal && totalItems !== undefined && (
<div className="text-sm text-foreground-muted dark:text-foreground-muted">
Mostrando{' '}
<span className="font-medium text-foreground dark:text-foreground">
{startItem}-{endItem}
</span>{' '}
de{' '}
<span className="font-medium text-foreground dark:text-foreground">
{totalItems}
</span>{' '}
resultados
</div>
)}
<div className="flex items-center gap-2">
{/* Page size selector */}
{showPageSize && onPageSizeChange && (
<div className="flex items-center gap-2">
<span className="text-sm text-foreground-muted dark:text-foreground-muted">
Mostrar
</span>
<select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
disabled={disabled}
className={clsx(
'border border-border dark:border-border rounded-md bg-background dark:bg-background text-foreground dark:text-foreground',
'focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent',
'disabled:opacity-50 disabled:cursor-not-allowed',
sizeClasses[size],
'px-2'
)}
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
)}
{/* Page navigation */}
<nav className="flex items-center gap-1" aria-label="Paginación">
{/* Previous button */}
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || disabled}
aria-label="Página anterior"
className={clsx(
'inline-flex items-center justify-center rounded-md transition-colors',
'border border-border dark:border-border',
'hover:bg-background-muted dark:hover:bg-background-muted',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent',
sizeClasses[size]
)}
>
<ChevronLeftIcon className={iconSizes[size]} />
</button>
{/* Page numbers */}
{pageNumbers.map((page, index) =>
page === 'ellipsis' ? (
<span
key={`ellipsis-${index}`}
className={clsx(
'inline-flex items-center justify-center text-foreground-muted',
sizeClasses[size]
)}
>
...
</span>
) : (
<button
key={page}
onClick={() => handlePageChange(page)}
disabled={disabled}
aria-label={`Página ${page}`}
aria-current={currentPage === page ? 'page' : undefined}
className={clsx(
'inline-flex items-center justify-center rounded-md transition-colors font-medium',
sizeClasses[size],
'px-1',
currentPage === page
? 'bg-primary text-white'
: 'border border-border dark:border-border hover:bg-background-muted dark:hover:bg-background-muted text-foreground dark:text-foreground',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{page}
</button>
)
)}
{/* Next button */}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages || disabled}
aria-label="Página siguiente"
className={clsx(
'inline-flex items-center justify-center rounded-md transition-colors',
'border border-border dark:border-border',
'hover:bg-background-muted dark:hover:bg-background-muted',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent',
sizeClasses[size]
)}
>
<ChevronRightIcon className={iconSizes[size]} />
</button>
</nav>
</div>
</div>
);
}
/**
* Simple pagination (just prev/next)
*/
export function SimplePagination({
currentPage,
totalPages,
onPageChange,
disabled = false,
className,
}: Pick<PaginationProps, 'currentPage' | 'totalPages' | 'onPageChange' | 'disabled' | 'className'>) {
return (
<div className={clsx('flex items-center justify-between', className)}>
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1 || disabled}
className="inline-flex items-center gap-1 px-3 py-2 text-sm font-medium text-foreground dark:text-foreground hover:bg-background-muted dark:hover:bg-background-muted rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeftIcon className="w-4 h-4" />
Anterior
</button>
<span className="text-sm text-foreground-muted dark:text-foreground-muted">
Página {currentPage} de {totalPages}
</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages || disabled}
className="inline-flex items-center gap-1 px-3 py-2 text-sm font-medium text-foreground dark:text-foreground hover:bg-background-muted dark:hover:bg-background-muted rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Siguiente
<ChevronRightIcon className="w-4 h-4" />
</button>
</div>
);
}
// Icons
function ChevronLeftIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
);
}
function ChevronRightIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
);
}
export default Pagination;

View File

@ -3,7 +3,7 @@
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { render } from '@testing-library/react';
import {
Skeleton,
SkeletonText,

View File

@ -45,3 +45,29 @@ export type { DataTableColumn, DataTablePagination } from './DataTable';
// Toast Notifications
export { ToastContainer, ToastItem } from './Toast';
// Error Handling
export { ErrorBoundary, withErrorBoundary, PageErrorFallback } from './ErrorBoundary';
// Navigation & Pagination
export { Pagination, SimplePagination } from './Pagination';
export type { PaginationProps } from './Pagination';
// Dropdown & Menus
export {
Dropdown,
DropdownTrigger,
DropdownContent,
DropdownItem,
DropdownSeparator,
DropdownLabel,
Menu,
} from './Dropdown';
// File Upload
export { FileUpload } from './FileUpload';
export type { FileUploadProps, FileError } from './FileUpload';
// Date Picker
export { DatePicker, DateRangePicker } from './DatePicker';
export type { DatePickerProps, DateRangePickerProps } from './DatePicker';

View File

@ -7,3 +7,7 @@ export * from './useHSE';
export * from './useProgress';
export * from './useFinance';
export * from './useToast';
// Utility hooks
export { useDebounce, useDebouncedCallback, useDebounceWithImmediate } from './useDebounce';
export { useLocalStorage, useSessionStorage } from './useLocalStorage';

View File

@ -0,0 +1,143 @@
/**
* Tests for useDebounce hook
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDebounce, useDebouncedCallback } from './useDebounce';
describe('useDebounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('useDebounce value', () => {
it('should return initial value immediately', () => {
const { result } = renderHook(() => useDebounce('test', 300));
expect(result.current).toBe('test');
});
it('should debounce value changes', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: 'initial' } }
);
expect(result.current).toBe('initial');
// Change value
rerender({ value: 'changed' });
// Value should not change immediately
expect(result.current).toBe('initial');
// Advance timers
act(() => {
vi.advanceTimersByTime(300);
});
// Now value should be updated
expect(result.current).toBe('changed');
});
it('should reset timer on rapid changes', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: 'a' } }
);
// Rapid changes
rerender({ value: 'b' });
act(() => {
vi.advanceTimersByTime(100);
});
rerender({ value: 'c' });
act(() => {
vi.advanceTimersByTime(100);
});
rerender({ value: 'd' });
act(() => {
vi.advanceTimersByTime(100);
});
// Still showing initial value
expect(result.current).toBe('a');
// Advance full delay
act(() => {
vi.advanceTimersByTime(300);
});
// Should show last value
expect(result.current).toBe('d');
});
it('should use custom delay', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: 'initial' } }
);
rerender({ value: 'changed' });
// Advance less than delay
act(() => {
vi.advanceTimersByTime(400);
});
expect(result.current).toBe('initial');
// Advance past delay
act(() => {
vi.advanceTimersByTime(100);
});
expect(result.current).toBe('changed');
});
});
describe('useDebouncedCallback', () => {
it('should debounce callback execution', () => {
const callback = vi.fn();
const { result } = renderHook(() => useDebouncedCallback(callback, 300));
// Call multiple times
act(() => {
result.current('a');
result.current('b');
result.current('c');
});
// Callback should not be called yet
expect(callback).not.toHaveBeenCalled();
// Advance timer
act(() => {
vi.advanceTimersByTime(300);
});
// Callback should be called once with last args
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith('c');
});
it('should maintain stable reference', () => {
const callback = vi.fn();
const { result, rerender } = renderHook(
({ cb }) => useDebouncedCallback(cb, 300),
{ initialProps: { cb: callback } }
);
const firstRef = result.current;
rerender({ cb: callback });
const secondRef = result.current;
expect(firstRef).toBe(secondRef);
});
});
});

View File

@ -0,0 +1,104 @@
/**
* useDebounce - Hook for debouncing values
* G-006: Utility hook for search inputs, form validation, etc.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
/**
* Debounces a value by delaying updates until after a specified delay
* @param value - The value to debounce
* @param delay - Delay in milliseconds (default: 300ms)
* @returns The debounced value
*/
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
/**
* Returns a debounced callback function
* @param callback - The function to debounce
* @param delay - Delay in milliseconds (default: 300ms)
* @returns Debounced callback function
*/
export function useDebouncedCallback<T extends (...args: unknown[]) => unknown>(
callback: T,
delay: number = 300
): (...args: Parameters<T>) => void {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const callbackRef = useRef(callback);
// Keep callback ref updated
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return useCallback(
(...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
},
[delay]
);
}
/**
* Debounces a value with immediate option
* @param value - The value to debounce
* @param delay - Delay in milliseconds
* @param immediate - If true, trigger on the leading edge instead of trailing
*/
export function useDebounceWithImmediate<T>(
value: T,
delay: number = 300,
immediate: boolean = false
): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
const isFirstRender = useRef(true);
useEffect(() => {
if (immediate && isFirstRender.current) {
setDebouncedValue(value);
isFirstRender.current = false;
return;
}
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay, immediate]);
return debouncedValue;
}
export default useDebounce;

View File

@ -0,0 +1,135 @@
/**
* Tests for useLocalStorage hook
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage, useSessionStorage } from './useLocalStorage';
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete store[key];
}),
clear: vi.fn(() => {
store = {};
}),
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
describe('useLocalStorage', () => {
beforeEach(() => {
localStorageMock.clear();
vi.clearAllMocks();
});
it('should return initial value when localStorage is empty', () => {
const { result } = renderHook(() => useLocalStorage('key', 'initial'));
expect(result.current[0]).toBe('initial');
});
it('should return stored value from localStorage', () => {
localStorageMock.setItem('key', JSON.stringify('stored'));
const { result } = renderHook(() => useLocalStorage('key', 'initial'));
expect(result.current[0]).toBe('stored');
});
it('should update localStorage when value changes', () => {
const { result } = renderHook(() => useLocalStorage('key', 'initial'));
act(() => {
result.current[1]('updated');
});
expect(result.current[0]).toBe('updated');
expect(localStorageMock.setItem).toHaveBeenCalledWith('key', JSON.stringify('updated'));
});
it('should support function updater', () => {
const { result } = renderHook(() => useLocalStorage('counter', 0));
act(() => {
result.current[1]((prev) => prev + 1);
});
expect(result.current[0]).toBe(1);
act(() => {
result.current[1]((prev) => prev + 1);
});
expect(result.current[0]).toBe(2);
});
it('should remove value from localStorage', () => {
const { result } = renderHook(() => useLocalStorage('key', 'initial'));
act(() => {
result.current[1]('stored');
});
act(() => {
result.current[2](); // removeValue
});
expect(result.current[0]).toBe('initial');
expect(localStorageMock.removeItem).toHaveBeenCalledWith('key');
});
it('should handle objects', () => {
const initialValue = { name: 'John', age: 30 };
const { result } = renderHook(() => useLocalStorage('user', initialValue));
expect(result.current[0]).toEqual(initialValue);
act(() => {
result.current[1]({ name: 'Jane', age: 25 });
});
expect(result.current[0]).toEqual({ name: 'Jane', age: 25 });
});
it('should handle arrays', () => {
const { result } = renderHook(() => useLocalStorage<string[]>('items', []));
act(() => {
result.current[1](['a', 'b', 'c']);
});
expect(result.current[0]).toEqual(['a', 'b', 'c']);
});
});
describe('useSessionStorage', () => {
// Similar tests for sessionStorage
it('should work with sessionStorage', () => {
const sessionStorageMock = {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
};
Object.defineProperty(window, 'sessionStorage', {
value: sessionStorageMock,
writable: true,
});
const { result } = renderHook(() => useSessionStorage('key', 'initial'));
expect(result.current[0]).toBe('initial');
});
});

View File

@ -0,0 +1,169 @@
/**
* useLocalStorage - Hook for persisting state in localStorage
* G-007: Utility hook for user preferences, form drafts, etc.
*/
import { useState, useEffect, useCallback } from 'react';
type SetValue<T> = T | ((prevValue: T) => T);
/**
* Syncs state with localStorage
* @param key - localStorage key
* @param initialValue - Initial/default value
* @returns [storedValue, setValue, removeValue]
*/
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: SetValue<T>) => void, () => void] {
// Get from localStorage or use initial value
const readValue = useCallback((): T => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
}, [key, initialValue]);
const [storedValue, setStoredValue] = useState<T>(readValue);
// Return a wrapped version of useState's setter function
const setValue = useCallback(
(value: SetValue<T>) => {
if (typeof window === 'undefined') {
console.warn(
`Tried setting localStorage key "${key}" even though environment is not a browser`
);
return;
}
try {
// Allow value to be a function
const newValue = value instanceof Function ? value(storedValue) : value;
// Save to localStorage
window.localStorage.setItem(key, JSON.stringify(newValue));
// Save state
setStoredValue(newValue);
// Dispatch storage event for other tabs/windows
window.dispatchEvent(
new StorageEvent('storage', {
key,
newValue: JSON.stringify(newValue),
})
);
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
},
[key, storedValue]
);
// Remove value from localStorage
const removeValue = useCallback(() => {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.removeItem(key);
setStoredValue(initialValue);
window.dispatchEvent(
new StorageEvent('storage', {
key,
newValue: null,
})
);
} catch (error) {
console.warn(`Error removing localStorage key "${key}":`, error);
}
}, [key, initialValue]);
// Listen for changes in other tabs/windows
useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.key === key && event.newValue !== null) {
try {
setStoredValue(JSON.parse(event.newValue) as T);
} catch {
setStoredValue(initialValue);
}
} else if (event.key === key && event.newValue === null) {
setStoredValue(initialValue);
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key, initialValue]);
return [storedValue, setValue, removeValue];
}
/**
* useSessionStorage - Same as useLocalStorage but with sessionStorage
*/
export function useSessionStorage<T>(
key: string,
initialValue: T
): [T, (value: SetValue<T>) => void, () => void] {
const readValue = useCallback((): T => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.sessionStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch (error) {
console.warn(`Error reading sessionStorage key "${key}":`, error);
return initialValue;
}
}, [key, initialValue]);
const [storedValue, setStoredValue] = useState<T>(readValue);
const setValue = useCallback(
(value: SetValue<T>) => {
if (typeof window === 'undefined') {
return;
}
try {
const newValue = value instanceof Function ? value(storedValue) : value;
window.sessionStorage.setItem(key, JSON.stringify(newValue));
setStoredValue(newValue);
} catch (error) {
console.warn(`Error setting sessionStorage key "${key}":`, error);
}
},
[key, storedValue]
);
const removeValue = useCallback(() => {
if (typeof window === 'undefined') {
return;
}
try {
window.sessionStorage.removeItem(key);
setStoredValue(initialValue);
} catch (error) {
console.warn(`Error removing sessionStorage key "${key}":`, error);
}
}, [key, initialValue]);
return [storedValue, setValue, removeValue];
}
export default useLocalStorage;

View File

@ -0,0 +1,223 @@
/**
* API Client - Centralized HTTP client with interceptors
* G-004: Request/response interceptors, auth handling, error transformation
*/
import axios, {
AxiosInstance,
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
// API Configuration
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3021/api';
const API_TIMEOUT = 30000; // 30 seconds
// Token storage keys
const ACCESS_TOKEN_KEY = 'access_token';
const REFRESH_TOKEN_KEY = 'refresh_token';
// API Error interface
export interface ApiError {
message: string;
statusCode: number;
error?: string;
details?: Record<string, unknown>;
}
// Pagination response interface
export interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
/**
* Create axios instance with base configuration
*/
const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: API_TIMEOUT,
headers: {
'Content-Type': 'application/json',
},
});
/**
* Request interceptor - Add auth token to requests
*/
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
// Log requests in development
if (import.meta.env.DEV) {
console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`, config.data || '');
}
return config;
},
(error: AxiosError) => {
return Promise.reject(error);
}
);
/**
* Response interceptor - Handle responses and errors
*/
apiClient.interceptors.response.use(
(response: AxiosResponse) => {
// Log responses in development
if (import.meta.env.DEV) {
console.log(`[API] Response ${response.status}:`, response.data);
}
return response;
},
async (error: AxiosError<ApiError>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Handle 401 Unauthorized - Attempt token refresh
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
if (refreshToken) {
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
refreshToken,
});
const { accessToken, refreshToken: newRefreshToken } = response.data;
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
if (newRefreshToken) {
localStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
}
// Retry original request with new token
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
}
return apiClient(originalRequest);
}
} catch (refreshError) {
// Refresh failed - clear tokens and redirect to login
clearAuthTokens();
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
// Transform error for consistent handling
const apiError = transformError(error);
return Promise.reject(apiError);
}
);
/**
* Transform axios error to ApiError
*/
function transformError(error: AxiosError<ApiError>): ApiError {
if (error.response) {
// Server responded with error
return {
message: error.response.data?.message || 'Error del servidor',
statusCode: error.response.status,
error: error.response.data?.error,
details: error.response.data?.details,
};
}
if (error.request) {
// Request made but no response
return {
message: 'No se pudo conectar con el servidor',
statusCode: 0,
error: 'NETWORK_ERROR',
};
}
// Request setup error
return {
message: error.message || 'Error desconocido',
statusCode: 0,
error: 'REQUEST_ERROR',
};
}
/**
* Token management utilities
*/
export function setAuthTokens(accessToken: string, refreshToken?: string): void {
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
if (refreshToken) {
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
}
}
export function getAccessToken(): string | null {
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function clearAuthTokens(): void {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
export function isAuthenticated(): boolean {
return !!getAccessToken();
}
/**
* API helper methods
*/
export const api = {
get: <T>(url: string, config?: object) =>
apiClient.get<T>(url, config).then((res) => res.data),
post: <T>(url: string, data?: unknown, config?: object) =>
apiClient.post<T>(url, data, config).then((res) => res.data),
put: <T>(url: string, data?: unknown, config?: object) =>
apiClient.put<T>(url, data, config).then((res) => res.data),
patch: <T>(url: string, data?: unknown, config?: object) =>
apiClient.patch<T>(url, data, config).then((res) => res.data),
delete: <T>(url: string, config?: object) =>
apiClient.delete<T>(url, config).then((res) => res.data),
};
/**
* Paginated request helper
*/
export async function getPaginated<T>(
url: string,
params?: {
page?: number;
limit?: number;
sort?: string;
order?: 'asc' | 'desc';
search?: string;
[key: string]: unknown;
}
): Promise<PaginatedResponse<T>> {
const response = await apiClient.get<PaginatedResponse<T>>(url, { params });
return response.data;
}
export default apiClient;