feat: add Customer Portal (Área do Cliente & Central de Acessos) with 23 preloaded items and WhatsApp export
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user