feat: substituir alertas nativos do navegador por sistema de toast notifications customizado em toda a plataforma e admin
Build and Deploy (Gitea Actions) / build-and-deploy (push) Successful in 2m40s Details

This commit is contained in:
Sidney 2026-07-19 21:09:20 -03:00
parent e8a5dad8a6
commit 50edd54561
14 changed files with 362 additions and 10 deletions

View File

@ -188,3 +188,102 @@ tr:hover td { background: var(--bg-card-hover); }
.revenue-chart { display: flex; align-items: flex-end; gap: 6px; height: 160px; padding: 16px 0; }
.chart-bar { flex: 1; border-radius: 4px 4px 0 0; background: var(--gradient-primary); transition: all 0.3s; min-width: 20px; cursor: pointer; position: relative; }
.chart-bar:hover { opacity: 0.8; }
/* === TOAST NOTIFICATIONS === */
.toast-container {
position: fixed;
top: 24px;
right: 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 420px;
width: calc(100% - 48px);
pointer-events: none;
}
.toast-item {
pointer-events: auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
background: var(--bg-card, #12121c);
border: 1px solid var(--border-color, rgba(255,255,255,0.12));
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 14px;
font-weight: 500;
animation: toastSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
backdrop-filter: blur(12px);
}
.toast-content {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
.toast-icon { flex-shrink: 0; }
.toast-icon.success { color: #10b981; }
.toast-icon.error { color: #ef4444; }
.toast-icon.warning { color: #f59e0b; }
.toast-icon.info { color: #6366f1; }
.toast-error {
border-left: 4px solid #ef4444;
background: linear-gradient(90deg, rgba(239,68,68,0.15) 0%, rgba(18,18,28,0.95) 100%);
}
.toast-warning {
border-left: 4px solid #f59e0b;
background: linear-gradient(90deg, rgba(245,158,11,0.15) 0%, rgba(18,18,28,0.95) 100%);
}
.toast-success {
border-left: 4px solid #10b981;
background: linear-gradient(90deg, rgba(16,185,129,0.15) 0%, rgba(18,18,28,0.95) 100%);
}
.toast-info {
border-left: 4px solid #6366f1;
background: linear-gradient(90deg, rgba(99,102,241,0.15) 0%, rgba(18,18,28,0.95) 100%);
}
.toast-message {
line-height: 1.4;
color: var(--text-primary, #f3f4f6);
}
.toast-close {
background: transparent;
border: none;
color: var(--text-secondary, #9ca3af);
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.toast-close:hover {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
@keyframes toastSlideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}

View File

@ -7,6 +7,7 @@ import Tenants from '@/components/Tenants';
import Plans from '@/components/Plans';
import Payments from '@/components/Payments';
import AdminSettings from '@/components/AdminSettings';
import ToastContainer from '@/components/Toast';
const navItems = [
{ section: 'Visão Geral' },
@ -291,6 +292,7 @@ export default function AdminPage() {
{renderPage()}
</div>
</main>
<ToastContainer />
</div>
);
}

View File

@ -0,0 +1,54 @@
'use client';
import { useState, useEffect } from 'react';
import { CheckCircle2, AlertTriangle, XCircle, Info, X } from 'lucide-react';
import { ToastData } from '@/lib/toast';
export default function ToastContainer() {
const [toasts, setToasts] = useState<ToastData[]>([]);
useEffect(() => {
const handleToast = (e: Event) => {
const customEvent = e as CustomEvent<ToastData>;
const newToast = customEvent.detail;
setToasts(prev => [...prev, newToast]);
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== newToast.id));
}, 5000);
};
window.addEventListener('agendapro-toast', handleToast);
return () => window.removeEventListener('agendapro-toast', handleToast);
}, []);
const removeToast = (id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
};
if (toasts.length === 0) return null;
return (
<div className="toast-container">
{toasts.map(t => {
const icons = {
success: <CheckCircle2 size={20} className="toast-icon success" />,
error: <XCircle size={20} className="toast-icon error" />,
warning: <AlertTriangle size={20} className="toast-icon warning" />,
info: <Info size={20} className="toast-icon info" />
};
return (
<div key={t.id} className={`toast-item toast-${t.type}`}>
<div className="toast-content">
{icons[t.type]}
<span className="toast-message">{t.message}</span>
</div>
<button className="toast-close" onClick={() => removeToast(t.id)}>
<X size={14} />
</button>
</div>
);
})}
</div>
);
}

16
admin/src/lib/toast.ts Normal file
View File

@ -0,0 +1,16 @@
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface ToastData {
id: string;
message: string;
type: ToastType;
}
export function showToast(message: string, type: ToastType = 'info') {
if (typeof window !== 'undefined') {
const event = new CustomEvent('agendapro-toast', {
detail: { message, type, id: Math.random().toString(36).substring(2, 9) }
});
window.dispatchEvent(event);
}
}

View File

@ -1,6 +1,8 @@
'use client';
import { useState, useEffect, use } from 'react';
import { Calendar, Clock, User, Phone, Mail, ChevronLeft, ChevronRight, Check, Scissors, MapPin, Star, Loader } from 'lucide-react';
import { showToast } from '@/lib/toast';
import ToastContainer from '@/components/Toast';
export default function PublicBookingPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params);
@ -147,7 +149,7 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
setBooked(true);
} else {
const errData = await res.json();
alert(errData.error || 'Erro ao realizar o agendamento.');
showToast(errData.error || 'Erro ao realizar o agendamento.', 'warning');
}
} catch (e) {
console.error(e);
@ -416,6 +418,7 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
Powered by <span style={{ fontWeight: 700, color: 'var(--accent)' }}>AgendaPRO</span>
</p>
</footer>
<ToastContainer />
</div>
);
}

View File

@ -898,3 +898,102 @@ tr:hover td { background: var(--bg-card-hover); }
.page-content { padding: 16px; }
.pricing-grid { grid-template-columns: 1fr; }
}
/* === TOAST NOTIFICATIONS === */
.toast-container {
position: fixed;
top: 24px;
right: 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 420px;
width: calc(100% - 48px);
pointer-events: none;
}
.toast-item {
pointer-events: auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
background: var(--bg-card, #12121c);
border: 1px solid var(--border-color, rgba(255,255,255,0.12));
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 14px;
font-weight: 500;
animation: toastSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
backdrop-filter: blur(12px);
}
.toast-content {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
.toast-icon { flex-shrink: 0; }
.toast-icon.success { color: #10b981; }
.toast-icon.error { color: #ef4444; }
.toast-icon.warning { color: #f59e0b; }
.toast-icon.info { color: #6366f1; }
.toast-error {
border-left: 4px solid #ef4444;
background: linear-gradient(90deg, rgba(239,68,68,0.15) 0%, rgba(18,18,28,0.95) 100%);
}
.toast-warning {
border-left: 4px solid #f59e0b;
background: linear-gradient(90deg, rgba(245,158,11,0.15) 0%, rgba(18,18,28,0.95) 100%);
}
.toast-success {
border-left: 4px solid #10b981;
background: linear-gradient(90deg, rgba(16,185,129,0.15) 0%, rgba(18,18,28,0.95) 100%);
}
.toast-info {
border-left: 4px solid #6366f1;
background: linear-gradient(90deg, rgba(99,102,241,0.15) 0%, rgba(18,18,28,0.95) 100%);
}
.toast-message {
line-height: 1.4;
color: var(--text-primary, #f3f4f6);
}
.toast-close {
background: transparent;
border: none;
color: var(--text-secondary, #9ca3af);
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.toast-close:hover {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
@keyframes toastSlideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}

View File

@ -11,6 +11,7 @@ import Services from '@/components/Services';
import Team from '@/components/Team';
import Settings from '@/components/Settings';
import Subscription from '@/components/Subscription';
import ToastContainer from '@/components/Toast';
// Helper para cookies
const getCookie = (name: string): string => {
@ -149,6 +150,7 @@ export default function Home() {
{renderPage()}
</div>
</main>
<ToastContainer />
</div>
);
}

View File

@ -2,6 +2,8 @@
import { useState } from 'react';
import { Mail, Lock, Shield, ArrowRight, Building, Globe } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { showToast } from '@/lib/toast';
import ToastContainer from '@/components/Toast';
export default function RegisterPage() {
const [name, setName] = useState('');
@ -33,10 +35,10 @@ export default function RegisterPage() {
// Redireciona para o painel principal do app
router.push('/');
} else {
alert(data.error || 'Erro ao criar conta');
showToast(data.error || 'Erro ao criar conta', 'error');
}
} catch (error) {
alert('Erro de conexão ao criar conta');
showToast('Erro de conexão ao criar conta', 'error');
} finally {
setLoading(false);
}
@ -116,6 +118,7 @@ export default function RegisterPage() {
</p>
</div>
</div>
<ToastContainer />
</div>
);
}

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { Plus, ChevronLeft, ChevronRight, X, Clock, User, Calendar } from 'lucide-react';
import { ChevronLeft, ChevronRight, Plus, Calendar, Clock, User, Phone, CheckCircle, AlertCircle, X, Mail } from 'lucide-react';
import { showToast } from '@/lib/toast';
const hours = Array.from({ length: 12 }, (_, i) => i + 8);
@ -289,7 +290,7 @@ export default function Agenda() {
setNewNotes('');
} else {
const errData = await res.json();
alert(errData.error || 'Erro ao realizar agendamento.');
showToast(errData.error || 'Erro ao realizar agendamento.', 'warning');
}
} catch (e) {
console.error(e);

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { Plus, Search, Clock, DollarSign, X, Edit, Trash2, AlertTriangle, Layers } from 'lucide-react';
import { showToast } from '@/lib/toast';
interface Service {
id: string; name: string; category: string; price: number; duration: number; type: 'fixed' | 'starting' | 'variable'; description: string;
@ -75,7 +76,7 @@ export default function Services() {
setEditingService(null);
} else {
const errData = await res.json();
alert(errData.error || 'Erro ao salvar serviço.');
showToast(errData.error || 'Erro ao salvar serviço.', 'warning');
}
} catch (e) {
console.error(e);

View File

@ -1,6 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { Check, Crown, Zap, Building2, Rocket, AlertTriangle, ShieldAlert, CreditCard, QrCode, FileText, CheckCircle2, ShieldCheck } from 'lucide-react';
import { showToast } from '@/lib/toast';
const staticPlans = [
{
@ -234,14 +235,14 @@ export default function Subscription({ currentPlan, onPlanChange, isLocked = fal
body: JSON.stringify({ slug, status: 'active' }),
});
alert('Pagamento simulado com sucesso! O sistema foi desbloqueado.');
showToast('Pagamento simulado com sucesso! O sistema foi desbloqueado.', 'success');
setSelectedPlan(null);
if (onPaymentSuccess) {
onPaymentSuccess();
}
} catch (err) {
console.warn('Erro ao conectar com API de simulação, utilizando fallback local de homologação.', err);
alert('Pagamento simulado localmente! O sistema foi desbloqueado (modo offline).');
showToast('Pagamento simulado localmente! O sistema foi desbloqueado (modo offline).', 'success');
setSelectedPlan(null);
if (onPaymentSuccess) {
onPaymentSuccess();
@ -466,7 +467,7 @@ export default function Subscription({ currentPlan, onPlanChange, isLocked = fal
<label className="form-label" style={{ fontSize: '11px', textTransform: 'uppercase' }}>Código Copia e Cola</label>
<div style={{ display: 'flex', gap: '8px' }}>
<input readOnly className="form-input" style={{ fontSize: '12px' }} value={successData.pixCode} />
<button className="btn btn-secondary" onClick={() => { navigator.clipboard.writeText(successData.pixCode); alert('Código copiado!'); }}>Copiar</button>
<button className="btn btn-secondary" onClick={() => { navigator.clipboard.writeText(successData.pixCode); showToast('Código Pix copiado!', 'success'); }}>Copiar</button>
</div>
</div>
</div>

View File

@ -1,6 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { Plus, X, Clock, Edit, Trash2, Star, AlertTriangle } from 'lucide-react';
import { showToast } from '@/lib/toast';
interface Professional {
id: string; name: string; role: string; phone: string; email: string; commission: number;
@ -71,7 +72,7 @@ export default function Team() {
setEditingProf(null);
} else {
const errData = await res.json();
alert(errData.error || 'Erro ao salvar profissional.');
showToast(errData.error || 'Erro ao salvar profissional.', 'warning');
}
} catch (e) {
console.error(e);

54
src/components/Toast.tsx Normal file
View File

@ -0,0 +1,54 @@
'use client';
import { useState, useEffect } from 'react';
import { CheckCircle2, AlertTriangle, XCircle, Info, X } from 'lucide-react';
import { ToastData } from '@/lib/toast';
export default function ToastContainer() {
const [toasts, setToasts] = useState<ToastData[]>([]);
useEffect(() => {
const handleToast = (e: Event) => {
const customEvent = e as CustomEvent<ToastData>;
const newToast = customEvent.detail;
setToasts(prev => [...prev, newToast]);
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== newToast.id));
}, 5000);
};
window.addEventListener('agendapro-toast', handleToast);
return () => window.removeEventListener('agendapro-toast', handleToast);
}, []);
const removeToast = (id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
};
if (toasts.length === 0) return null;
return (
<div className="toast-container">
{toasts.map(t => {
const icons = {
success: <CheckCircle2 size={20} className="toast-icon success" />,
error: <XCircle size={20} className="toast-icon error" />,
warning: <AlertTriangle size={20} className="toast-icon warning" />,
info: <Info size={20} className="toast-icon info" />
};
return (
<div key={t.id} className={`toast-item toast-${t.type}`}>
<div className="toast-content">
{icons[t.type]}
<span className="toast-message">{t.message}</span>
</div>
<button className="toast-close" onClick={() => removeToast(t.id)}>
<X size={14} />
</button>
</div>
);
})}
</div>
);
}

16
src/lib/toast.ts Normal file
View File

@ -0,0 +1,16 @@
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface ToastData {
id: string;
message: string;
type: ToastType;
}
export function showToast(message: string, type: ToastType = 'info') {
if (typeof window !== 'undefined') {
const event = new CustomEvent('agendapro-toast', {
detail: { message, type, id: Math.random().toString(36).substring(2, 9) }
});
window.dispatchEvent(event);
}
}