diff options
Diffstat (limited to 'web/src/context')
| -rw-r--r-- | web/src/context/AuthContext.tsx | 79 | ||||
| -rw-r--r-- | web/src/context/Toast.module.css | 75 | ||||
| -rw-r--r-- | web/src/context/ToastContext.tsx | 71 |
3 files changed, 225 insertions, 0 deletions
diff --git a/web/src/context/AuthContext.tsx b/web/src/context/AuthContext.tsx new file mode 100644 index 0000000..f86a940 --- /dev/null +++ b/web/src/context/AuthContext.tsx @@ -0,0 +1,79 @@ +/** + * AuthContext.tsx + * + * Contexto global de autenticação. + * Persiste o JWT no localStorage e expõe o estado do usuário logado. + */ + +import { + createContext, + useContext, + useState, + useCallback, + type ReactNode, +} from 'react'; +import type { UserResponse } from '@/lib/api'; + +// ─── Tipos ──────────────────────────────────────────────────────────────────── + +interface AuthState { + user: UserResponse | null; + token: string | null; + isAuthenticated: boolean; + login: (token: string, user: UserResponse) => void; + logout: () => void; +} + +// ─── Chave no localStorage ──────────────────────────────────────────────────── + +const TOKEN_KEY = 'helpdesk:token'; +const USER_KEY = 'helpdesk:user'; + +// ─── Context ────────────────────────────────────────────────────────────────── + +const AuthContext = createContext<AuthState | null>(null); + +// ─── Provider ───────────────────────────────────────────────────────────────── + +export function AuthProvider({ children }: { children: ReactNode }) { + // Inicializa com dados persistidos (restaura sessão ao recarregar) + const [token, setToken] = useState<string | null>( + () => localStorage.getItem(TOKEN_KEY), + ); + const [user, setUser] = useState<UserResponse | null>(() => { + const stored = localStorage.getItem(USER_KEY); + return stored ? (JSON.parse(stored) as UserResponse) : null; + }); + + const login = useCallback((newToken: string, newUser: UserResponse) => { + localStorage.setItem(TOKEN_KEY, newToken); + localStorage.setItem(USER_KEY, JSON.stringify(newUser)); + setToken(newToken); + setUser(newUser); + }, []); + + const logout = useCallback(() => { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); + setToken(null); + setUser(null); + }, []); + + return ( + <AuthContext.Provider + value={{ user, token, isAuthenticated: Boolean(token), login, logout }} + > + {children} + </AuthContext.Provider> + ); +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +export function useAuth(): AuthState { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error('useAuth deve ser usado dentro de <AuthProvider>'); + } + return ctx; +} diff --git a/web/src/context/Toast.module.css b/web/src/context/Toast.module.css new file mode 100644 index 0000000..bb56336 --- /dev/null +++ b/web/src/context/Toast.module.css @@ -0,0 +1,75 @@ +.toastContainer { + position: fixed; + bottom: var(--space-6); + right: var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-2); + z-index: 9999; + pointer-events: none; /* Deixa cliques passarem pelo container vazio */ +} + +.toast { + pointer-events: auto; /* Reativa cliques dentro do toast */ + min-width: 300px; + background: var(--color-surface); + color: var(--color-text-primary); + border: 1px solid var(--color-border); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + font-size: var(--text-sm); + font-weight: 500; + backdrop-filter: blur(8px); + + animation: slideIn var(--transition-normal) ease forwards; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(100%) scale(0.95); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +.info { + border-left: 4px solid var(--color-accent); +} + +.success { + border-left: 4px solid var(--color-priority-low); +} + +.error { + border-left: 4px solid var(--color-priority-critical); +} + +.closeBtn { + background: none; + border: none; + color: var(--color-text-muted); + font-size: var(--text-lg); + cursor: pointer; + padding: 0; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: var(--radius-sm); + transition: background var(--transition-fast), color var(--transition-fast); +} + +.closeBtn:hover { + background: var(--color-bg); + color: var(--color-text-primary); +} diff --git a/web/src/context/ToastContext.tsx b/web/src/context/ToastContext.tsx new file mode 100644 index 0000000..87b3894 --- /dev/null +++ b/web/src/context/ToastContext.tsx @@ -0,0 +1,71 @@ +/** + * ToastContext.tsx + * Sistema minimalista de Toast Notifications. + */ + +import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import styles from './Toast.module.css'; + +type ToastType = 'info' | 'success' | 'error'; + +interface ToastData { + id: string; + message: string; + type: ToastType; +} + +interface ToastContextValue { + addToast: (message: string, type?: ToastType) => void; +} + +const ToastContext = createContext<ToastContextValue | null>(null); + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState<ToastData[]>([]); + + const addToast = useCallback((message: string, type: ToastType = 'info') => { + const id = Math.random().toString(36).substring(2, 9); + setToasts((prev) => [...prev, { id, message, type }]); + + // Remove automaticamente após 4 segundos + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, 4000); + }, []); + + const removeToast = (id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }; + + return ( + <ToastContext.Provider value={{ addToast }}> + {children} + + {/* Portal renderiza os toasts no final do <body> */} + {createPortal( + <div className={styles.toastContainer}> + {toasts.map((toast) => ( + <div key={toast.id} className={`${styles.toast} ${styles[toast.type]}`}> + <span>{toast.message}</span> + <button + className={styles.closeBtn} + onClick={() => removeToast(toast.id)} + aria-label="Fechar" + > + × + </button> + </div> + ))} + </div>, + document.body + )} + </ToastContext.Provider> + ); +} + +export function useToast() { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast deve ser usado dentro do ToastProvider'); + return ctx; +} |