chore(release): snapshot 1.4.2

This commit is contained in:
Erik Silva
2025-12-17 13:36:23 -03:00
parent 2a112f169d
commit 99d828869a
95 changed files with 9933 additions and 1601 deletions

View File

@@ -0,0 +1,123 @@
import { Fragment } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { ExclamationTriangleIcon, XMarkIcon } from '@heroicons/react/24/outline';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'info';
}
export default function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirmar',
cancelText = 'Cancelar',
variant = 'danger'
}: ConfirmDialogProps) {
const handleConfirm = () => {
onConfirm();
onClose();
};
const variantStyles = {
danger: {
icon: 'bg-red-100 dark:bg-red-900/20',
iconColor: 'text-red-600 dark:text-red-400',
button: 'bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800'
},
warning: {
icon: 'bg-yellow-100 dark:bg-yellow-900/20',
iconColor: 'text-yellow-600 dark:text-yellow-400',
button: 'bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-700 dark:hover:bg-yellow-800'
},
info: {
icon: 'bg-blue-100 dark:bg-blue-900/20',
iconColor: 'text-blue-600 dark:text-blue-400',
button: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800'
}
};
const style = variantStyles[variant];
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-zinc-900/40 backdrop-blur-sm transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-2xl bg-white dark:bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg border border-zinc-200 dark:border-zinc-800">
<div className="p-6">
<div className="flex items-start gap-4">
<div className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl ${style.icon}`}>
<ExclamationTriangleIcon className={`h-6 w-6 ${style.iconColor}`} />
</div>
<div className="flex-1">
<Dialog.Title className="text-lg font-semibold text-zinc-900 dark:text-white">
{title}
</Dialog.Title>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
{message}
</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="mt-6 flex gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2.5 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 font-medium rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
{cancelText}
</button>
<button
type="button"
onClick={handleConfirm}
className={`flex-1 px-4 py-2.5 text-white font-medium rounded-lg transition-colors ${style.button}`}
>
{confirmText}
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

View File

@@ -0,0 +1,33 @@
import { ReactNode } from 'react';
interface EmptyStateProps {
icon: ReactNode;
title: string;
description: string;
actionLabel?: string;
onAction?: () => void;
}
export default function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 text-center p-8">
<div className="w-16 h-16 bg-zinc-50 dark:bg-zinc-800 rounded-full flex items-center justify-center mb-4">
{icon}
</div>
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
{title}
</h3>
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
{description}
</p>
{actionLabel && onAction && (
<button
onClick={onAction}
className="mt-4 text-sm text-[var(--brand-color)] hover:underline font-medium"
>
{actionLabel}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,7 @@
export default function LoadingState() {
return (
<div className="flex items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--brand-color)]"></div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
interface PageHeaderProps {
title: string;
description: string;
actionLabel: string;
onAction: () => void;
}
export default function PageHeader({ title, description, actionLabel, onAction }: PageHeaderProps) {
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">
{title}
</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
{description}
</p>
</div>
<button
onClick={onAction}
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-white rounded-lg hover:opacity-90 transition-opacity"
style={{ background: 'var(--gradient)' }}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{actionLabel}
</button>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
interface PaginationProps {
currentPage: number;
totalPages: number;
totalItems: number;
itemsPerPage: number;
onPageChange: (page: number) => void;
}
export default function Pagination({
currentPage,
totalPages,
totalItems,
itemsPerPage,
onPageChange
}: PaginationProps) {
const startItem = (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
const pages = [];
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
if (endPage - startPage < maxVisiblePages - 1) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return (
<div className="px-6 py-4 border-t border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-800/50 flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Mostrando <span className="font-medium">{startItem}</span> a{' '}
<span className="font-medium">{endItem}</span> de{' '}
<span className="font-medium">{totalItems}</span> resultados
</p>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700"
>
<ChevronLeftIcon className="w-4 h-4" />
Anterior
</button>
<div className="hidden sm:flex items-center gap-1">
{startPage > 1 && (
<>
<button
onClick={() => onPageChange(1)}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
>
1
</button>
{startPage > 2 && (
<span className="px-2 text-zinc-400">...</span>
)}
</>
)}
{pages.map(page => (
<button
key={page}
onClick={() => onPageChange(page)}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${page === currentPage
? 'text-white shadow-sm'
: 'bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
}`}
style={page === currentPage ? { background: 'var(--gradient)' } : {}}
>
{page}
</button>
))}
{endPage < totalPages && (
<>
{endPage < totalPages - 1 && (
<span className="px-2 text-zinc-400">...</span>
)}
<button
onClick={() => onPageChange(totalPages)}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
>
{totalPages}
</button>
</>
)}
</div>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700"
>
Próxima
<ChevronRightIcon className="w-4 h-4" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,294 @@
# Componentes de Layout Padrão
Este diretório contém componentes reutilizáveis para manter um design system consistente em todas as páginas de listagem do dashboard.
## Componentes Disponíveis
### 1. **PageHeader**
Header padrão com título, descrição e botão de ação.
```tsx
import PageHeader from '@/components/layout/PageHeader';
<PageHeader
title="Agências"
description="Gerencie seus parceiros e acompanhe o desempenho."
actionLabel="Nova Agência"
onAction={() => setModalOpen(true)}
/>
```
### 2. **SearchBar**
Barra de busca padrão com ícone de lupa.
```tsx
import SearchBar from '@/components/layout/SearchBar';
<SearchBar
value={searchTerm}
onChange={setSearchTerm}
placeholder="Buscar por nome, email..."
/>
```
### 3. **StatusFilter**
Dropdown de filtro de status com Headless UI.
```tsx
import StatusFilter from '@/components/layout/StatusFilter';
const STATUS_OPTIONS = [
{ id: 'all', name: 'Todos os Status' },
{ id: 'active', name: 'Ativos' },
{ id: 'inactive', name: 'Inativos' },
];
<StatusFilter
options={STATUS_OPTIONS}
selected={selectedStatus}
onChange={setSelectedStatus}
/>
```
### 4. **EmptyState**
Estado vazio padrão com ícone, título e descrição.
```tsx
import EmptyState from '@/components/layout/EmptyState';
import { BuildingOfficeIcon } from '@heroicons/react/24/outline';
<EmptyState
icon={<BuildingOfficeIcon className="w-8 h-8 text-zinc-400" />}
title="Nenhuma agência encontrada"
description="Não encontramos resultados para os filtros selecionados."
actionLabel="Limpar todos os filtros"
onAction={clearFilters}
/>
```
### 5. **LoadingState**
Estado de carregamento com spinner.
```tsx
import LoadingState from '@/components/layout/LoadingState';
{loading && <LoadingState />}
```
### 6. **StatusBadge**
Badge de status ativo/inativo com toggle opcional.
```tsx
import StatusBadge from '@/components/layout/StatusBadge';
<StatusBadge
active={item.is_active}
onClick={() => toggleStatus(item.id, item.is_active)}
activeLabel="Ativo"
inactiveLabel="Inativo"
/>
```
### 7. **Pagination** ⭐ NEW
Paginação funcional com navegação e indicador de páginas.
```tsx
import Pagination from '@/components/layout/Pagination';
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const totalItems = filteredItems.length;
const totalPages = Math.ceil(totalItems / itemsPerPage);
const paginatedItems = filteredItems.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
onPageChange={setCurrentPage}
/>
```
### 8. **ConfirmDialog** ⭐ NEW
Modal de confirmação profissional (substitui `confirm()`).
```tsx
import { useState } from 'react';
import ConfirmDialog from '@/components/layout/ConfirmDialog';
const [confirmOpen, setConfirmOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<string | null>(null);
const handleDeleteClick = (id: string) => {
setItemToDelete(id);
setConfirmOpen(true);
};
const handleConfirmDelete = () => {
if (itemToDelete) {
// Executar exclusão
deleteItem(itemToDelete);
}
};
<ConfirmDialog
isOpen={confirmOpen}
onClose={() => setConfirmOpen(false)}
onConfirm={handleConfirmDelete}
title="Excluir Item"
message="Tem certeza que deseja excluir este item? Esta ação não pode ser desfeita."
confirmText="Excluir"
cancelText="Cancelar"
variant="danger"
/>
```
### 9. **ToastContext & useToast** ⭐ NEW
Sistema de notificações toast (substitui `alert()`).
**Setup no layout:**
```tsx
import { ToastProvider } from '@/components/layout/ToastContext';
<ToastProvider>
{children}
</ToastProvider>
```
**Uso nas páginas:**
```tsx
import { useToast } from '@/components/layout/ToastContext';
const toast = useToast();
// Sucesso
toast.success('Item criado!', 'O item foi criado com sucesso.');
// Erro
toast.error('Erro ao excluir', 'Não foi possível excluir o item.');
// Info
toast.info('Informação', 'Ação concluída.');
```
## Exemplo de Uso Completo
```tsx
"use client";
import { useState, useEffect } from 'react';
import PageHeader from '@/components/layout/PageHeader';
import SearchBar from '@/components/layout/SearchBar';
import StatusFilter from '@/components/layout/StatusFilter';
import LoadingState from '@/components/layout/LoadingState';
import EmptyState from '@/components/layout/EmptyState';
import StatusBadge from '@/components/layout/StatusBadge';
import TableFooter from '@/components/layout/TableFooter';
import { BuildingOfficeIcon } from '@heroicons/react/24/outline';
const STATUS_OPTIONS = [
{ id: 'all', name: 'Todos os Status' },
{ id: 'active', name: 'Ativos' },
{ id: 'inactive', name: 'Inativos' },
];
export default function MyListPage() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [selectedStatus, setSelectedStatus] = useState(STATUS_OPTIONS[0]);
const filteredItems = items.filter(item => {
const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus =
selectedStatus.id === 'all' ? true :
selectedStatus.id === 'active' ? item.is_active :
!item.is_active;
return matchesSearch && matchesStatus;
});
return (
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
<PageHeader
title="Minha Lista"
description="Descrição da página"
actionLabel="Novo Item"
onAction={() => {}}
/>
<div className="flex flex-col lg:flex-row gap-4 items-center justify-between">
<SearchBar
value={searchTerm}
onChange={setSearchTerm}
placeholder="Buscar..."
/>
<StatusFilter
options={STATUS_OPTIONS}
selected={selectedStatus}
onChange={setSelectedStatus}
/>
</div>
{loading ? (
<LoadingState />
) : filteredItems.length === 0 ? (
<EmptyState
icon={<BuildingOfficeIcon className="w-8 h-8 text-zinc-400" />}
title="Nenhum item encontrado"
description="Tente ajustar os filtros."
/>
) : (
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
<table className="w-full">
{/* Sua tabela aqui */}
</table>
<TableFooter count={filteredItems.length} />
</div>
)}
</div>
);
}
```
## Design System
### Cores
```css
--gradient: linear-gradient(135deg, #ff3a05, #ff0080)
--brand-color: #ff0080
```
### Classes Tailwind Padrão
**Container principal:**
```
p-6 max-w-[1600px] mx-auto space-y-6
```
**Tabela:**
```
bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800
```
**Header da tabela:**
```
bg-zinc-50/50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-800
```
**Linha hover:**
```
hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors
```
## Benefícios
**Consistência visual** - Todas as páginas seguem o mesmo padrão
**Manutenção fácil** - Altere um componente, atualiza em todas as páginas
**Desenvolvimento rápido** - Reutilize componentes prontos
**Design system** - Cores e estilos centralizados
**Acessibilidade** - Componentes já otimizados

View File

@@ -0,0 +1,24 @@
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export default function SearchBar({ value, onChange, placeholder = "Buscar..." }: SearchBarProps) {
return (
<div className="relative w-full lg:w-96">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-zinc-400" aria-hidden="true" />
</div>
<input
type="text"
className="block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg leading-5 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-1 focus:ring-[var(--brand-color)] focus:border-[var(--brand-color)] sm:text-sm transition duration-150 ease-in-out"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}

View File

@@ -0,0 +1,28 @@
interface StatusBadgeProps {
active: boolean;
onClick?: () => void;
activeLabel?: string;
inactiveLabel?: string;
}
export default function StatusBadge({
active,
onClick,
activeLabel = 'Ativo',
inactiveLabel = 'Inativo'
}: StatusBadgeProps) {
const Component = onClick ? 'button' : 'span';
return (
<Component
onClick={onClick}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border transition-all ${active
? 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-900/30'
: 'bg-zinc-100 text-zinc-600 border-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:border-zinc-700'
} ${onClick ? 'cursor-pointer hover:opacity-80' : ''}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${active ? 'bg-emerald-500' : 'bg-zinc-400'}`} />
{active ? activeLabel : inactiveLabel}
</Component>
);
}

View File

@@ -0,0 +1,61 @@
import { Fragment } from 'react';
import { Listbox, Transition } from '@headlessui/react';
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/24/outline';
interface StatusOption {
id: string;
name: string;
}
interface StatusFilterProps {
options: StatusOption[];
selected: StatusOption;
onChange: (option: StatusOption) => void;
}
export default function StatusFilter({ options, selected, onChange }: StatusFilterProps) {
return (
<Listbox value={selected} onChange={onChange}>
<div className="relative w-full sm:w-[180px]">
<Listbox.Button className="relative w-full cursor-pointer rounded-lg bg-white dark:bg-zinc-900 py-2 pl-3 pr-10 text-left text-sm border border-zinc-200 dark:border-zinc-700 focus:outline-none focus:border-[var(--brand-color)] focus:ring-1 focus:ring-[var(--brand-color)] text-zinc-700 dark:text-zinc-300">
<span className="block truncate">{selected.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-zinc-400" aria-hidden="true" />
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-zinc-800 py-1 text-base ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm border border-zinc-200 dark:border-zinc-700">
{options.map((option, idx) => (
<Listbox.Option
key={idx}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${active ? 'bg-zinc-100 dark:bg-zinc-700 text-zinc-900 dark:text-white' : 'text-zinc-900 dark:text-zinc-100'
}`
}
value={option}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{option.name}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-[var(--brand-color)]">
<CheckIcon className="h-4 w-4" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import { createContext, useContext, useState, useCallback } from 'react';
import ToastNotification, { Toast } from './ToastNotification';
interface ToastContextType {
showToast: (type: Toast['type'], title: string, message?: string) => void;
success: (title: string, message?: string) => void;
error: (title: string, message?: string) => void;
info: (title: string, message?: string) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((type: Toast['type'], title: string, message?: string) => {
const id = Date.now().toString();
setToasts(prev => [...prev, { id, type, title, message }]);
}, []);
const success = useCallback((title: string, message?: string) => {
showToast('success', title, message);
}, [showToast]);
const error = useCallback((title: string, message?: string) => {
showToast('error', title, message);
}, [showToast]);
const info = useCallback((title: string, message?: string) => {
showToast('info', title, message);
}, [showToast]);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
return (
<ToastContext.Provider value={{ showToast, success, error, info }}>
{children}
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-3 w-96">
{toasts.map(toast => (
<ToastNotification key={toast.id} toast={toast} onClose={removeToast} />
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
}

View File

@@ -0,0 +1,99 @@
import { Fragment, useEffect } from 'react';
import { Transition } from '@headlessui/react';
import {
CheckCircleIcon,
XCircleIcon,
InformationCircleIcon,
XMarkIcon
} from '@heroicons/react/24/outline';
export interface Toast {
id: string;
type: 'success' | 'error' | 'info';
title: string;
message?: string;
}
interface ToastNotificationProps {
toast: Toast;
onClose: (id: string) => void;
}
export default function ToastNotification({ toast, onClose }: ToastNotificationProps) {
useEffect(() => {
const timer = setTimeout(() => {
onClose(toast.id);
}, 5000);
return () => clearTimeout(timer);
}, [toast.id, onClose]);
const styles = {
success: {
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
border: 'border-emerald-200 dark:border-emerald-900/30',
icon: 'text-emerald-600 dark:text-emerald-400',
title: 'text-emerald-900 dark:text-emerald-300',
IconComponent: CheckCircleIcon
},
error: {
bg: 'bg-red-50 dark:bg-red-900/20',
border: 'border-red-200 dark:border-red-900/30',
icon: 'text-red-600 dark:text-red-400',
title: 'text-red-900 dark:text-red-300',
IconComponent: XCircleIcon
},
info: {
bg: 'bg-blue-50 dark:bg-blue-900/20',
border: 'border-blue-200 dark:border-blue-900/30',
icon: 'text-blue-600 dark:text-blue-400',
title: 'text-blue-900 dark:text-blue-300',
IconComponent: InformationCircleIcon
}
};
const style = styles[toast.type];
const Icon = style.IconComponent;
return (
<Transition
show={true}
as={Fragment}
enter="transform ease-out duration-300 transition"
enterFrom="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enterTo="translate-y-0 opacity-100 sm:translate-x-0"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className={`pointer-events-auto w-full rounded-lg border shadow-lg ${style.bg} ${style.border}`}>
<div className="p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<Icon className={`h-6 w-6 ${style.icon}`} />
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-semibold ${style.title}`}>
{toast.title}
</p>
{toast.message && (
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
{toast.message}
</p>
)}
</div>
<div className="flex-shrink-0">
<button
type="button"
onClick={() => onClose(toast.id)}
className="inline-flex rounded-md text-zinc-400 hover:text-zinc-500 dark:hover:text-zinc-300 focus:outline-none"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
</Transition>
);
}