feat: add Customer Portal (Área do Cliente & Central de Acessos) with 23 preloaded items and WhatsApp export

This commit is contained in:
2026-10-15 12:37:25 -03:00
parent 512cb4ea7e
commit d16ff4f70e
7 changed files with 1551 additions and 31 deletions
+358
View File
@@ -0,0 +1,358 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Bookmark,
Search,
Plus,
Share2,
Download,
Upload,
RotateCcw,
Check,
Copy,
LayoutGrid,
Sparkles,
CheckCircle2,
AlertCircle,
Info
} from 'lucide-react';
import {
CATEGORIES,
INITIAL_PORTAL_ITEMS,
getStoredPortalItems,
savePortalItems,
generateWhatsAppDescriptionText
} from '../../utils/customerPortalData';
import PortalItemCard from './PortalItemCard';
import ModalPortalItem from './ModalPortalItem';
import ModalConfirm from '../wpp/ModalConfirm';
export default function CustomerPortal() {
const [items, setItems] = useState([]);
const [selectedCategory, setSelectedCategory] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
const [copiedWhatsapp, setCopiedWhatsapp] = useState(false);
const [toasts, setToasts] = useState([]);
// Modals state
const [isModalItemOpen, setIsModalItemOpen] = useState(false);
const [itemToEdit, setItemToEdit] = useState(null);
const [confirmModal, setConfirmModal] = useState({
isOpen: false,
title: '',
message: '',
description: '',
confirmText: 'Confirmar',
type: 'danger',
onConfirm: () => {}
});
const fileInputRef = useRef(null);
useEffect(() => {
setItems(getStoredPortalItems());
}, []);
const updateItemsState = (newItems) => {
setItems(newItems);
savePortalItems(newItems);
};
const showToast = (message, type = 'info') => {
const id = Date.now().toString() + Math.random().toString();
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3500);
};
// Salvar Item (Criar ou Editar)
const handleSaveItem = (itemData) => {
let updated;
const exists = items.some(i => i.id === itemData.id);
if (exists) {
updated = items.map(i => i.id === itemData.id ? itemData : i);
showToast(`Acesso "${itemData.title}" atualizado!`, 'success');
} else {
updated = [itemData, ...items];
showToast(`Acesso "${itemData.title}" adicionado!`, 'success');
}
updateItemsState(updated);
};
// Excluir Item com Modal Confirm Estilizado
const handleDeleteItem = (item) => {
setConfirmModal({
isOpen: true,
title: 'Excluir Acesso',
message: `Deseja realmente remover "${item.title}" da sua Área do Cliente?`,
description: 'Este atalho deixará de ser exibido na lista e nos backups.',
confirmText: 'Excluir Acesso',
type: 'danger',
onConfirm: () => {
const updated = items.filter(i => i.id !== item.id);
updateItemsState(updated);
showToast(`Acesso "${item.title}" removido.`, 'info');
}
});
};
// Restaurar dados originais
const handleResetDefaults = () => {
setConfirmModal({
isOpen: true,
title: 'Restaurar Acessos Originais',
message: 'Deseja restaurar a lista com todos os 23 acessos padrões do grupo de WhatsApp?',
description: 'Itens personalizados que você tiver adicionado serão substituídos pela lista padrão original.',
confirmText: 'Restaurar Padrão',
type: 'warning',
onConfirm: () => {
updateItemsState(INITIAL_PORTAL_ITEMS);
showToast('Lista restaurada com sucesso!', 'success');
}
});
};
// Copiar texto pronto para o WhatsApp
const handleCopyWhatsAppText = () => {
const text = generateWhatsAppDescriptionText(items);
navigator.clipboard.writeText(text);
setCopiedWhatsapp(true);
showToast('Texto completo copiado para colar no WhatsApp!', 'success');
setTimeout(() => setCopiedWhatsapp(false), 2500);
};
// Exportar Backup JSON
const handleExportJson = () => {
const data = JSON.stringify(items, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `area-do-cliente-backup-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('Backup JSON exportado com sucesso!', 'success');
};
// Importar Backup JSON
const handleImportJson = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const json = JSON.parse(event.target.result);
if (Array.isArray(json)) {
updateItemsState(json);
showToast(`${json.length} acessos importados com sucesso!`, 'success');
} else {
showToast('Formato inválido. O arquivo JSON deve ser uma lista de acessos.', 'error');
}
} catch (err) {
showToast('Erro ao processar JSON: ' + err.message, 'error');
}
};
reader.readAsText(file);
};
// Filtro por Categoria e Busca
const filteredItems = items.filter(item => {
const matchesCategory = selectedCategory === 'all' || item.category === selectedCategory;
const q = searchQuery.toLowerCase().trim();
if (!q) return matchesCategory;
const matchesSearch =
(item.title && item.title.toLowerCase().includes(q)) ||
(item.url && item.url.toLowerCase().includes(q)) ||
(item.secondaryUrl && item.secondaryUrl.toLowerCase().includes(q)) ||
(item.username && item.username.toLowerCase().includes(q)) ||
(item.ipHost && item.ipHost.toLowerCase().includes(q)) ||
(item.description && item.description.toLowerCase().includes(q)) ||
(item.wifiNetwork && item.wifiNetwork.toLowerCase().includes(q)) ||
(item.tags && item.tags.some(t => t.toLowerCase().includes(q)));
return matchesCategory && matchesSearch;
});
return (
<div className="portal-container">
{/* Top Banner & Header */}
<div className="portal-header-card">
<div className="portal-header-left">
<div className="portal-header-icon-box">
<Bookmark size={26} />
</div>
<div>
<h2 className="portal-header-title">Área do Cliente & Central de Acessos</h2>
<p className="portal-header-sub">
Todos os links, logins, IPs de servidores e acessos centralizados do seu grupo de WhatsApp.
</p>
</div>
</div>
<div className="portal-header-actions">
<button
type="button"
className="btn-whatsapp-export"
onClick={handleCopyWhatsAppText}
title="Copiar lista formatada para a descrição do WhatsApp"
>
{copiedWhatsapp ? <Check size={16} /> : <Share2 size={16} />}
<span>{copiedWhatsapp ? 'Copiado para WhatsApp!' : 'Copiar p/ WhatsApp'}</span>
</button>
<button
type="button"
className="btn-portal-new"
onClick={() => {
setItemToEdit(null);
setIsModalItemOpen(true);
}}
>
<Plus size={16} />
<span>Novo Acesso</span>
</button>
</div>
</div>
{/* Toolbar: Busca, Categorias e Ferramentas */}
<div className="portal-toolbar">
{/* Campo de Busca */}
<div className="portal-search-box">
<Search size={16} className="text-muted" />
<input
type="text"
placeholder="Buscar por nome, URL, IP ou login..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="portal-search-input"
/>
{searchQuery && (
<button type="button" className="btn-clear-search" onClick={() => setSearchQuery('')}>
×
</button>
)}
</div>
{/* Ferramentas de Backup e Restauração */}
<div className="portal-tools-group">
<button
type="button"
className="btn-portal-tool"
onClick={handleExportJson}
title="Exportar backup em formato JSON"
>
<Download size={14} />
<span>Exportar JSON</span>
</button>
<button
type="button"
className="btn-portal-tool"
onClick={() => fileInputRef.current?.click()}
title="Importar backup JSON"
>
<Upload size={14} />
<span>Importar</span>
</button>
<input type="file" ref={fileInputRef} onChange={handleImportJson} accept=".json" style={{ display: 'none' }} />
<button
type="button"
className="btn-portal-tool"
onClick={handleResetDefaults}
title="Restaurar os 23 acessos padrões originais"
>
<RotateCcw size={14} />
<span>Restaurar Padrão</span>
</button>
</div>
</div>
{/* Filtro de Categorias */}
<div className="portal-categories-bar">
{CATEGORIES.map(cat => {
const count = cat.id === 'all'
? items.length
: items.filter(i => i.category === cat.id).length;
return (
<button
key={cat.id}
type="button"
className={`portal-cat-btn ${selectedCategory === cat.id ? 'portal-cat-active' : ''}`}
onClick={() => setSelectedCategory(cat.id)}
>
<span>{cat.label}</span>
<span className="cat-count-badge">{count}</span>
</button>
);
})}
</div>
{/* Grid de Cards de Acesso */}
<div className="portal-cards-grid">
{filteredItems.map(item => (
<PortalItemCard
key={item.id}
item={item}
onEdit={(it) => {
setItemToEdit(it);
setIsModalItemOpen(true);
}}
onDelete={handleDeleteItem}
/>
))}
{filteredItems.length === 0 && (
<div className="portal-empty-state">
<LayoutGrid size={48} className="text-muted" />
<h3>Nenhum acesso encontrado</h3>
<p>
{searchQuery
? `Nenhum resultado corresponde à busca "${searchQuery}".`
: 'Nenhum acesso cadastrado nesta categoria. Clique em "+ Novo Acesso" para adicionar.'}
</p>
</div>
)}
</div>
{/* Modal de Criação / Edição de Acesso */}
<ModalPortalItem
isOpen={isModalItemOpen}
onClose={() => setIsModalItemOpen(false)}
onSave={handleSaveItem}
itemToEdit={itemToEdit}
/>
{/* Modal de Confirmação Estilizado */}
<ModalConfirm
isOpen={confirmModal.isOpen}
onClose={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
onConfirm={confirmModal.onConfirm}
title={confirmModal.title}
message={confirmModal.message}
description={confirmModal.description}
confirmText={confirmModal.confirmText}
type={confirmModal.type}
/>
{/* Toasts */}
<div className="wpp-toasts-container">
{toasts.map(t => (
<div key={t.id} className={`wpp-toast toast-${t.type}`}>
{t.type === 'success' && <CheckCircle2 size={16} />}
{t.type === 'error' && <AlertCircle size={16} />}
{t.type === 'warning' && <AlertCircle size={16} />}
{t.type === 'info' && <Info size={16} />}
<span>{t.message}</span>
</div>
))}
</div>
</div>
);
}