Refine RFM page table UX
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m27s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m27s
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useOutletContext } from 'react-router-dom';
|
import { Link, useOutletContext } from 'react-router-dom';
|
||||||
import { Database, Download, Filter, Loader2, Search, Sparkles, Users } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Download, Filter, Loader2, Search, Users } from 'lucide-react';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import { exportToCSV, fetchRfmAnalytics } from '../dataService';
|
import { exportToCSV, fetchRfmAnalytics } from '../dataService';
|
||||||
import type { DateRange, RfmAnalytics, RfmClient, RfmSegment } from '../types';
|
import type { DateRange, RfmAnalytics, RfmClient, RfmSegment } from '../types';
|
||||||
@@ -68,178 +68,6 @@ const segmentByScore: Record<string, string> = {
|
|||||||
'1-1': 'lost'
|
'1-1': 'lost'
|
||||||
};
|
};
|
||||||
|
|
||||||
const demoClients: RfmClient[] = [
|
|
||||||
{
|
|
||||||
name: 'Ana Paula Ribeiro',
|
|
||||||
phone: '+55 11 90000-0001',
|
|
||||||
monetary: 4820,
|
|
||||||
frequency: 12,
|
|
||||||
quantityPurchased: 38,
|
|
||||||
lastPurchaseDate: '2026-06-10',
|
|
||||||
recencyDays: 1,
|
|
||||||
recencyScore: 3,
|
|
||||||
frequencyScore: 3,
|
|
||||||
monetaryScore: 3,
|
|
||||||
valueScore: 3,
|
|
||||||
rfmScore: '333',
|
|
||||||
segmentKey: 'champions',
|
|
||||||
segmentLabel: 'Champions'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Bruno Ferreira',
|
|
||||||
phone: '+55 11 90000-0002',
|
|
||||||
monetary: 1680,
|
|
||||||
frequency: 5,
|
|
||||||
quantityPurchased: 14,
|
|
||||||
lastPurchaseDate: '2026-06-09',
|
|
||||||
recencyDays: 2,
|
|
||||||
recencyScore: 3,
|
|
||||||
frequencyScore: 2,
|
|
||||||
monetaryScore: 2,
|
|
||||||
valueScore: 2,
|
|
||||||
rfmScore: '322',
|
|
||||||
segmentKey: 'potential_loyalists',
|
|
||||||
segmentLabel: 'Potenciais Leais'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Carla Mendes',
|
|
||||||
phone: '+55 11 90000-0003',
|
|
||||||
monetary: 249,
|
|
||||||
frequency: 1,
|
|
||||||
quantityPurchased: 2,
|
|
||||||
lastPurchaseDate: '2026-06-08',
|
|
||||||
recencyDays: 3,
|
|
||||||
recencyScore: 3,
|
|
||||||
frequencyScore: 1,
|
|
||||||
monetaryScore: 1,
|
|
||||||
valueScore: 1,
|
|
||||||
rfmScore: '311',
|
|
||||||
segmentKey: 'new_customers',
|
|
||||||
segmentLabel: 'Novos Clientes'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Diego Nascimento',
|
|
||||||
phone: '+55 11 90000-0004',
|
|
||||||
monetary: 3920,
|
|
||||||
frequency: 10,
|
|
||||||
quantityPurchased: 31,
|
|
||||||
lastPurchaseDate: '2026-05-24',
|
|
||||||
recencyDays: 18,
|
|
||||||
recencyScore: 2,
|
|
||||||
frequencyScore: 3,
|
|
||||||
monetaryScore: 3,
|
|
||||||
valueScore: 3,
|
|
||||||
rfmScore: '233',
|
|
||||||
segmentKey: 'loyal_customers',
|
|
||||||
segmentLabel: 'Clientes Leais'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Elisa Costa',
|
|
||||||
phone: '+55 11 90000-0005',
|
|
||||||
monetary: 1210,
|
|
||||||
frequency: 4,
|
|
||||||
quantityPurchased: 9,
|
|
||||||
lastPurchaseDate: '2026-05-18',
|
|
||||||
recencyDays: 24,
|
|
||||||
recencyScore: 2,
|
|
||||||
frequencyScore: 2,
|
|
||||||
monetaryScore: 2,
|
|
||||||
valueScore: 2,
|
|
||||||
rfmScore: '222',
|
|
||||||
segmentKey: 'need_attention',
|
|
||||||
segmentLabel: 'Precisam de Atenção'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Felipe Azevedo',
|
|
||||||
phone: '+55 11 90000-0006',
|
|
||||||
monetary: 390,
|
|
||||||
frequency: 2,
|
|
||||||
quantityPurchased: 3,
|
|
||||||
lastPurchaseDate: '2026-05-12',
|
|
||||||
recencyDays: 30,
|
|
||||||
recencyScore: 2,
|
|
||||||
frequencyScore: 1,
|
|
||||||
monetaryScore: 1,
|
|
||||||
valueScore: 1,
|
|
||||||
rfmScore: '211',
|
|
||||||
segmentKey: 'about_to_sleep',
|
|
||||||
segmentLabel: 'Quase Dormindo'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Gabriela Martins',
|
|
||||||
phone: '+55 11 90000-0007',
|
|
||||||
monetary: 5350,
|
|
||||||
frequency: 14,
|
|
||||||
quantityPurchased: 44,
|
|
||||||
lastPurchaseDate: '2026-03-28',
|
|
||||||
recencyDays: 75,
|
|
||||||
recencyScore: 1,
|
|
||||||
frequencyScore: 3,
|
|
||||||
monetaryScore: 3,
|
|
||||||
valueScore: 3,
|
|
||||||
rfmScore: '133',
|
|
||||||
segmentKey: 'at_risk',
|
|
||||||
segmentLabel: 'Em Risco'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Henrique Lima',
|
|
||||||
phone: '+55 11 90000-0008',
|
|
||||||
monetary: 980,
|
|
||||||
frequency: 4,
|
|
||||||
quantityPurchased: 8,
|
|
||||||
lastPurchaseDate: '2026-03-06',
|
|
||||||
recencyDays: 97,
|
|
||||||
recencyScore: 1,
|
|
||||||
frequencyScore: 2,
|
|
||||||
monetaryScore: 2,
|
|
||||||
valueScore: 2,
|
|
||||||
rfmScore: '122',
|
|
||||||
segmentKey: 'hibernating',
|
|
||||||
segmentLabel: 'Hibernando'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Isabela Rocha',
|
|
||||||
phone: '+55 11 90000-0009',
|
|
||||||
monetary: 120,
|
|
||||||
frequency: 1,
|
|
||||||
quantityPurchased: 1,
|
|
||||||
lastPurchaseDate: '2026-01-29',
|
|
||||||
recencyDays: 133,
|
|
||||||
recencyScore: 1,
|
|
||||||
frequencyScore: 1,
|
|
||||||
monetaryScore: 1,
|
|
||||||
valueScore: 1,
|
|
||||||
rfmScore: '111',
|
|
||||||
segmentKey: 'lost',
|
|
||||||
segmentLabel: 'Perdidos'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const demoSegments: RfmSegment[] = rfmSegmentDefinitions.map(segment => {
|
|
||||||
const segmentClients = demoClients.filter(client => client.segmentKey === segment.key);
|
|
||||||
const totalRevenue = segmentClients.reduce((sum, client) => sum + client.monetary, 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...segment,
|
|
||||||
count: segmentClients.length,
|
|
||||||
totalRevenue,
|
|
||||||
averageRevenue: segmentClients.length ? totalRevenue / segmentClients.length : 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const demoAnalytics: RfmAnalytics = {
|
|
||||||
range: {
|
|
||||||
start: '2026-01-01',
|
|
||||||
end: '2026-06-11'
|
|
||||||
},
|
|
||||||
clients: demoClients,
|
|
||||||
segments: demoSegments,
|
|
||||||
matrix: {
|
|
||||||
recencyScores: [3, 2, 1],
|
|
||||||
valueScores: [1, 2, 3]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) => {
|
||||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||||
};
|
};
|
||||||
@@ -283,7 +111,8 @@ const Rfm = () => {
|
|||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [segmentFilter, setSegmentFilter] = useState('all');
|
const [segmentFilter, setSegmentFilter] = useState('all');
|
||||||
const [selectedSegmentKey, setSelectedSegmentKey] = useState('');
|
const [selectedSegmentKey, setSelectedSegmentKey] = useState('');
|
||||||
const [isDemoMode, setIsDemoMode] = useState(false);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||||
|
|
||||||
const loadRfm = useCallback(async (range: DateRange) => {
|
const loadRfm = useCallback(async (range: DateRange) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -308,7 +137,7 @@ const Rfm = () => {
|
|||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
}, [dateRange, loadRfm, refreshInterval]);
|
}, [dateRange, loadRfm, refreshInterval]);
|
||||||
|
|
||||||
const visibleAnalytics = isDemoMode ? demoAnalytics : analytics;
|
const visibleAnalytics = analytics;
|
||||||
const clients = visibleAnalytics?.clients || emptyClients;
|
const clients = visibleAnalytics?.clients || emptyClients;
|
||||||
const segments = useMemo(() => {
|
const segments = useMemo(() => {
|
||||||
const sourceSegments = visibleAnalytics?.segments || emptySegments;
|
const sourceSegments = visibleAnalytics?.segments || emptySegments;
|
||||||
@@ -323,13 +152,6 @@ const Rfm = () => {
|
|||||||
});
|
});
|
||||||
}, [visibleAnalytics]);
|
}, [visibleAnalytics]);
|
||||||
|
|
||||||
const toggleDemoMode = () => {
|
|
||||||
setIsDemoMode(current => !current);
|
|
||||||
setSegmentFilter('all');
|
|
||||||
setSearchTerm('');
|
|
||||||
setSelectedSegmentKey('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredClients = useMemo(() => {
|
const filteredClients = useMemo(() => {
|
||||||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||||
|
|
||||||
@@ -343,6 +165,10 @@ const Rfm = () => {
|
|||||||
});
|
});
|
||||||
}, [clients, searchTerm, segmentFilter]);
|
}, [clients, searchTerm, segmentFilter]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredClients.length / itemsPerPage);
|
||||||
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
|
const paginatedClients = filteredClients.slice(startIndex, startIndex + itemsPerPage);
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
const totalRevenue = clients.reduce((sum, client) => sum + client.monetary, 0);
|
const totalRevenue = clients.reduce((sum, client) => sum + client.monetary, 0);
|
||||||
const activeSegments = segments.filter(segment => segment.count > 0).length;
|
const activeSegments = segments.filter(segment => segment.count > 0).length;
|
||||||
@@ -407,18 +233,6 @@ const Rfm = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row flex-wrap gap-3 items-start sm:items-center">
|
<div className="flex flex-col sm:flex-row flex-wrap gap-3 items-start sm:items-center">
|
||||||
<button
|
|
||||||
onClick={toggleDemoMode}
|
|
||||||
className={`inline-flex items-center justify-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-bold transition-colors cursor-pointer ${
|
|
||||||
isDemoMode
|
|
||||||
? 'border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/15'
|
|
||||||
: 'border-dark-border bg-dark-card text-dark-text hover:border-brand-primary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isDemoMode ? <Database className="h-4 w-4" /> : <Sparkles className="h-4 w-4" />}
|
|
||||||
{isDemoMode ? 'Voltar para dados reais' : 'Ver dados demo'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
onChange={setDateRange}
|
onChange={setDateRange}
|
||||||
@@ -429,13 +243,7 @@ const Rfm = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isDemoMode && (
|
{isLoading && (
|
||||||
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm font-semibold text-amber-200">
|
|
||||||
Visualização com dados fictícios. Nenhum cliente real ou dado do banco foi alterado.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isDemoMode && isLoading && (
|
|
||||||
<div className="inline-flex items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-3 py-2 text-sm font-semibold text-dark-muted">
|
<div className="inline-flex items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-3 py-2 text-sm font-semibold text-dark-muted">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
||||||
Atualizando RFM
|
Atualizando RFM
|
||||||
@@ -513,6 +321,7 @@ const Rfm = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSegmentFilter(segment?.key || 'all');
|
setSegmentFilter(segment?.key || 'all');
|
||||||
setSelectedSegmentKey(segment?.key || '');
|
setSelectedSegmentKey(segment?.key || '');
|
||||||
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
className={`group min-h-28 rounded-xl border p-3 text-left transition-all hover:-translate-y-0.5 hover:border-brand-primary cursor-pointer ${
|
className={`group min-h-28 rounded-xl border p-3 text-left transition-all hover:-translate-y-0.5 hover:border-brand-primary cursor-pointer ${
|
||||||
isActive ? 'border-brand-primary ring-1 ring-brand-primary/50' : 'border-dark-border'
|
isActive ? 'border-brand-primary ring-1 ring-brand-primary/50' : 'border-dark-border'
|
||||||
@@ -563,6 +372,7 @@ const Rfm = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSegmentFilter(segment.key);
|
setSegmentFilter(segment.key);
|
||||||
setSelectedSegmentKey(segment.key);
|
setSelectedSegmentKey(segment.key);
|
||||||
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
className={`w-full rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${
|
className={`w-full rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${
|
||||||
segmentFilter === segment.key ? 'border-brand-primary bg-dark-input' : 'border-dark-border bg-transparent hover:border-brand-primary'
|
segmentFilter === segment.key ? 'border-brand-primary bg-dark-input' : 'border-dark-border bg-transparent hover:border-brand-primary'
|
||||||
@@ -626,7 +436,10 @@ const Rfm = () => {
|
|||||||
<Filter className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-dark-muted" />
|
<Filter className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-dark-muted" />
|
||||||
<select
|
<select
|
||||||
value={segmentFilter}
|
value={segmentFilter}
|
||||||
onChange={(event) => setSegmentFilter(event.target.value)}
|
onChange={(event) => {
|
||||||
|
setSegmentFilter(event.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
className="appearance-none bg-dark-input border border-dark-border text-dark-text text-sm rounded-xl pl-9 pr-8 py-2.5 focus:outline-none focus:border-brand-primary transition-colors shadow-sm cursor-pointer"
|
className="appearance-none bg-dark-input border border-dark-border text-dark-text text-sm rounded-xl pl-9 pr-8 py-2.5 focus:outline-none focus:border-brand-primary transition-colors shadow-sm cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value="all">Todos os segmentos</option>
|
<option value="all">Todos os segmentos</option>
|
||||||
@@ -642,7 +455,10 @@ const Rfm = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar cliente..."
|
placeholder="Buscar cliente..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(event) => setSearchTerm(event.target.value)}
|
onChange={(event) => {
|
||||||
|
setSearchTerm(event.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
className="w-full sm:w-72 bg-dark-input border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors shadow-sm"
|
className="w-full sm:w-72 bg-dark-input border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -673,32 +489,20 @@ const Rfm = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-dark-border">
|
<tbody className="divide-y divide-dark-border">
|
||||||
{filteredClients.map((client: RfmClient) => {
|
{paginatedClients.map((client: RfmClient) => {
|
||||||
const style = segmentStyles[client.segmentKey] || segmentStyles.lost;
|
const style = segmentStyles[client.segmentKey] || segmentStyles.lost;
|
||||||
return (
|
return (
|
||||||
<tr key={client.phone} className="hover:bg-dark-input/50 transition-colors">
|
<tr key={client.phone} className="hover:bg-dark-input/50 transition-colors">
|
||||||
<td className="px-6 py-3">
|
<td className="px-6 py-3">
|
||||||
{isDemoMode ? (
|
<Link to={`/clients/${encodeURIComponent(client.name)}`} className="flex items-center gap-3 hover:text-brand-primary transition-colors">
|
||||||
<div className="flex items-center gap-3">
|
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-dark-input text-dark-muted">
|
||||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-dark-input text-dark-muted">
|
<Users className="h-4 w-4" />
|
||||||
<Users className="h-4 w-4" />
|
</span>
|
||||||
</span>
|
<span>
|
||||||
<span>
|
<span className="block font-bold text-dark-text">{client.name}</span>
|
||||||
<span className="block font-bold text-dark-text">{client.name}</span>
|
<span className="block text-xs font-medium text-dark-muted">{client.phone || 'N/A'}</span>
|
||||||
<span className="block text-xs font-medium text-dark-muted">{client.phone || 'N/A'}</span>
|
</span>
|
||||||
</span>
|
</Link>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Link to={`/clients/${encodeURIComponent(client.name)}`} className="flex items-center gap-3 hover:text-brand-primary transition-colors">
|
|
||||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-dark-input text-dark-muted">
|
|
||||||
<Users className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<span className="block font-bold text-dark-text">{client.name}</span>
|
|
||||||
<span className="block text-xs font-medium text-dark-muted">{client.phone || 'N/A'}</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-3">
|
<td className="px-6 py-3">
|
||||||
<span className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-bold ${style.bg} ${style.text}`}>
|
<span className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-bold ${style.bg} ${style.text}`}>
|
||||||
@@ -732,6 +536,48 @@ const Rfm = () => {
|
|||||||
Nenhum cliente encontrado.
|
Nenhum cliente encontrado.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="px-6 py-4 border-t border-dark-border flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-dark-muted">
|
||||||
|
<span>Mostrar</span>
|
||||||
|
<select
|
||||||
|
value={itemsPerPage}
|
||||||
|
onChange={(event) => {
|
||||||
|
setItemsPerPage(Number(event.target.value));
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="bg-dark-card border border-dark-border rounded-lg px-2 py-1 focus:outline-none focus:border-brand-primary cursor-pointer text-dark-text"
|
||||||
|
>
|
||||||
|
<option value={10}>10</option>
|
||||||
|
<option value={20}>20</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
</select>
|
||||||
|
<span>clientes por página</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<span className="text-dark-muted">
|
||||||
|
Mostrando {filteredClients.length > 0 ? startIndex + 1 : 0} a {Math.min(startIndex + itemsPerPage, filteredClients.length)} de {filteredClients.length} clientes
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(page => Math.max(1, page - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="p-1 rounded-lg border border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:border-brand-primary transition-colors text-dark-muted hover:text-dark-text cursor-pointer bg-dark-card"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(page => Math.min(totalPages, page + 1))}
|
||||||
|
disabled={currentPage === totalPages || totalPages === 0}
|
||||||
|
className="p-1 rounded-lg border border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:border-brand-primary transition-colors text-dark-muted hover:text-dark-text cursor-pointer bg-dark-card"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user