From f10b903a808cad576136eb4d739e152ab911dcdd Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 10 Mar 2026 20:45:49 +0100 Subject: [PATCH] perf: add export loading state and virtual scrolling for large lists Export modal now shows a loading indicator and doesn't freeze the UI. Large list modals use virtual scrolling for smooth performance. --- components/dashboard/Campaigns.tsx | 66 +-- components/dashboard/ContentStats.tsx | 54 ++- components/dashboard/ExportModal.tsx | 583 +++++++++++++------------- components/dashboard/Locations.tsx | 68 +-- components/dashboard/TechSpecs.tsx | 56 +-- components/dashboard/TopReferrers.tsx | 50 ++- components/dashboard/VirtualList.tsx | 53 +++ package-lock.json | 28 ++ package.json | 1 + 9 files changed, 546 insertions(+), 413 deletions(-) create mode 100644 components/dashboard/VirtualList.tsx diff --git a/components/dashboard/Campaigns.tsx b/components/dashboard/Campaigns.tsx index 7e4b35c..397f5b9 100644 --- a/components/dashboard/Campaigns.tsx +++ b/components/dashboard/Campaigns.tsx @@ -7,6 +7,7 @@ import Image from 'next/image' import { formatNumber } from '@ciphera-net/ui' import { Modal, ArrowRightIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' +import VirtualList from './VirtualList' import { getCampaigns, CampaignStat } from '@/lib/api/stats' import { getReferrerFavicon, getReferrerIcon, getReferrerDisplayName } from '@/lib/utils/icons' import { Megaphone, FrameCornersIcon } from '@phosphor-icons/react' @@ -225,7 +226,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" /> -
+
{isLoadingFull ? (
@@ -246,38 +247,43 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp Export CSV
- {filteredCampaigns.map((item) => ( -
{ if (onFilter) { onFilter({ dimension: 'utm_source', operator: 'is', values: [item.source] }); setIsModalOpen(false) } }} - className={`flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} - > -
- {renderSourceIcon(item.source)} -
-
- {getReferrerDisplayName(item.source)} -
-
- {item.medium || '—'} - · - {item.campaign || '—'} + ( +
{ if (onFilter) { onFilter({ dimension: 'utm_source', operator: 'is', values: [item.source] }); setIsModalOpen(false) } }} + className={`flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} + > +
+ {renderSourceIcon(item.source)} +
+
+ {getReferrerDisplayName(item.source)} +
+
+ {item.medium || '—'} + · + {item.campaign || '—'} +
+
+ + {modalTotal > 0 ? `${Math.round((item.visitors / modalTotal) * 100)}%` : ''} + + + {formatNumber(item.visitors)} + + + {formatNumber(item.pageviews)} pv + +
-
- - {modalTotal > 0 ? `${Math.round((item.visitors / modalTotal) * 100)}%` : ''} - - - {formatNumber(item.visitors)} - - - {formatNumber(item.pageviews)} pv - -
-
- ))} + )} + /> ) })()} diff --git a/components/dashboard/ContentStats.tsx b/components/dashboard/ContentStats.tsx index ab8a029..4ed61aa 100644 --- a/components/dashboard/ContentStats.tsx +++ b/components/dashboard/ContentStats.tsx @@ -9,6 +9,7 @@ import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/sta import { FrameCornersIcon } from '@phosphor-icons/react' import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' +import VirtualList from './VirtualList' import { type DimensionFilter } from '@/lib/filters' interface ContentStatsProps { @@ -209,7 +210,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain, className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" />
-
+
{isLoadingFull ? (
@@ -217,28 +218,35 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain, ) : (() => { const modalData = (fullData.length > 0 ? fullData : data).filter(p => !modalSearch || p.path.toLowerCase().includes(modalSearch.toLowerCase())) const modalTotal = modalData.reduce((sum, p) => sum + p.pageviews, 0) - return modalData.map((page) => { - const canFilter = onFilter && page.path - return ( -
{ if (canFilter) { onFilter({ dimension: 'page', operator: 'is', values: [page.path] }); setIsModalOpen(false) } }} - className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} - > -
- {page.path} -
-
- - {modalTotal > 0 ? `${Math.round((page.pageviews / modalTotal) * 100)}%` : ''} - - - {formatNumber(page.pageviews)} - -
-
- ) - }) + return ( + { + const canFilter = onFilter && page.path + return ( +
{ if (canFilter) { onFilter({ dimension: 'page', operator: 'is', values: [page.path] }); setIsModalOpen(false) } }} + className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} + > +
+ {page.path} +
+
+ + {modalTotal > 0 ? `${Math.round((page.pageviews / modalTotal) * 100)}%` : ''} + + + {formatNumber(page.pageviews)} + +
+
+ ) + }} + /> + ) })()}
diff --git a/components/dashboard/ExportModal.tsx b/components/dashboard/ExportModal.tsx index 8a347ac..184c2b1 100644 --- a/components/dashboard/ExportModal.tsx +++ b/components/dashboard/ExportModal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useCallback } from 'react' import { Modal, Button, Checkbox, Input, Select } from '@ciphera-net/ui' import * as XLSX from 'xlsx' import jsPDF from 'jspdf' @@ -49,6 +49,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to const [format, setFormat] = useState('csv') const [filename, setFilename] = useState(`pulse_export_${new Date().toISOString().split('T')[0]}`) const [includeHeader, setIncludeHeader] = useState(true) + const [isExporting, setIsExporting] = useState(false) const [selectedFields, setSelectedFields] = useState>({ date: true, pageviews: true, @@ -61,300 +62,312 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to setSelectedFields((prev) => ({ ...prev, [field]: checked })) } - const handleExport = async () => { - // Filter fields - const fields = (Object.keys(selectedFields) as Array).filter((k) => selectedFields[k]) - - // Prepare data - const exportData = data.map((item) => { - const filteredItem: Record = {} - fields.forEach((field) => { - filteredItem[field] = item[field] - }) - return filteredItem - }) + const handleExport = () => { + setIsExporting(true) + // Let the browser paint the loading state before starting heavy work + requestAnimationFrame(() => { + setTimeout(async () => { + try { + // Filter fields + const fields = (Object.keys(selectedFields) as Array).filter((k) => selectedFields[k]) - let content = '' - let mimeType = '' - let extension = '' + // Prepare data + const exportData = data.map((item) => { + const filteredItem: Record = {} + fields.forEach((field) => { + filteredItem[field] = item[field] + }) + return filteredItem + }) - if (format === 'csv') { - const header = fields.join(',') - const rows = exportData.map((row) => - fields.map((field) => { - const val = row[field] - if (field === 'date' && typeof val === 'string') { - return new Date(val).toISOString() + let content = '' + let mimeType = '' + let extension = '' + + if (format === 'csv') { + const header = fields.join(',') + const rows = exportData.map((row) => + fields.map((field) => { + const val = row[field] + if (field === 'date' && typeof val === 'string') { + return new Date(val).toISOString() + } + return val + }).join(',') + ) + content = (includeHeader ? header + '\n' : '') + rows.join('\n') + mimeType = 'text/csv;charset=utf-8;' + extension = 'csv' + } else if (format === 'xlsx') { + const ws = XLSX.utils.json_to_sheet(exportData) + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, 'Data') + if (campaigns && campaigns.length > 0) { + const campaignsSheet = XLSX.utils.json_to_sheet( + campaigns.map(c => ({ + Source: getReferrerDisplayName(c.source), + Medium: c.medium || '—', + Campaign: c.campaign || '—', + Visitors: c.visitors, + Pageviews: c.pageviews, + })) + ) + XLSX.utils.book_append_sheet(wb, campaignsSheet, 'Campaigns') } - return val - }).join(',') - ) - content = (includeHeader ? header + '\n' : '') + rows.join('\n') - mimeType = 'text/csv;charset=utf-8;' - extension = 'csv' - } else if (format === 'xlsx') { - const ws = XLSX.utils.json_to_sheet(exportData) - const wb = XLSX.utils.book_new() - XLSX.utils.book_append_sheet(wb, ws, 'Data') - if (campaigns && campaigns.length > 0) { - const campaignsSheet = XLSX.utils.json_to_sheet( - campaigns.map(c => ({ - Source: getReferrerDisplayName(c.source), - Medium: c.medium || '—', - Campaign: c.campaign || '—', - Visitors: c.visitors, - Pageviews: c.pageviews, - })) - ) - XLSX.utils.book_append_sheet(wb, campaignsSheet, 'Campaigns') - } - const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) - const blob = new Blob([wbout], { type: 'application/octet-stream' }) - - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.setAttribute('href', url) - link.setAttribute('download', `${filename || 'export'}.${extension || 'xlsx'}`) - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - onClose() - return - } else if (format === 'pdf') { - const doc = new jsPDF() - - // Header Section - try { - // Logo - const logoData = await loadImage('/pulse_icon_no_margins.png') - doc.addImage(logoData, 'PNG', 14, 12, 12, 12) // x, y, w, h - - // Title - doc.setFontSize(22) - doc.setTextColor(249, 115, 22) // Brand Orange #F97316 - doc.text('Pulse', 32, 20) - - doc.setFontSize(12) - doc.setTextColor(100, 100, 100) - doc.text('Analytics Export', 32, 25) - } catch (e) { - // Fallback if logo fails - doc.setFontSize(22) - doc.setTextColor(249, 115, 22) - doc.text('Pulse Analytics', 14, 20) - } + const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) + const blob = new Blob([wbout], { type: 'application/octet-stream' }) - // Metadata (Top Right) - doc.setFontSize(9) - doc.setTextColor(150, 150, 150) - const generatedDate = new Date().toLocaleDateString() - const dateRange = data.length > 0 - ? `${new Date(data[0].date).toLocaleDateString()} - ${new Date(data[data.length - 1].date).toLocaleDateString()}` - : generatedDate - - const pageWidth = doc.internal.pageSize.width - doc.text(`Generated: ${generatedDate}`, pageWidth - 14, 18, { align: 'right' }) - doc.text(`Range: ${dateRange}`, pageWidth - 14, 23, { align: 'right' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.setAttribute('href', url) + link.setAttribute('download', `${filename || 'export'}.${extension || 'xlsx'}`) + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + onClose() + return + } else if (format === 'pdf') { + const doc = new jsPDF() - let startY = 35 + // Header Section + try { + // Logo + const logoData = await loadImage('/pulse_icon_no_margins.png') + doc.addImage(logoData, 'PNG', 14, 12, 12, 12) // x, y, w, h - // Summary Section - if (stats) { - const summaryY = 35 - const cardWidth = (pageWidth - 28 - 15) / 4 // 4 cards with 5mm gap - const cardHeight = 20 - - const drawCard = (x: number, label: string, value: string) => { - doc.setFillColor(255, 247, 237) // Very light orange - doc.setDrawColor(254, 215, 170) // Light orange border - doc.roundedRect(x, summaryY, cardWidth, cardHeight, 2, 2, 'FD') - - doc.setFontSize(8) + // Title + doc.setFontSize(22) + doc.setTextColor(249, 115, 22) // Brand Orange #F97316 + doc.text('Pulse', 32, 20) + + doc.setFontSize(12) + doc.setTextColor(100, 100, 100) + doc.text('Analytics Export', 32, 25) + } catch (e) { + // Fallback if logo fails + doc.setFontSize(22) + doc.setTextColor(249, 115, 22) + doc.text('Pulse Analytics', 14, 20) + } + + // Metadata (Top Right) + doc.setFontSize(9) doc.setTextColor(150, 150, 150) - doc.text(label, x + 3, summaryY + 6) - - doc.setFontSize(12) - doc.setTextColor(23, 23, 23) // Neutral 900 - doc.setFont('helvetica', 'bold') - doc.text(value, x + 3, summaryY + 14) - doc.setFont('helvetica', 'normal') - } + const generatedDate = new Date().toLocaleDateString() + const dateRange = data.length > 0 + ? `${new Date(data[0].date).toLocaleDateString()} - ${new Date(data[data.length - 1].date).toLocaleDateString()}` + : generatedDate - drawCard(14, 'Unique Visitors', formatNumber(stats.visitors)) - drawCard(14 + cardWidth + 5, 'Total Pageviews', formatNumber(stats.pageviews)) - drawCard(14 + (cardWidth + 5) * 2, 'Bounce Rate', `${Math.round(stats.bounce_rate)}%`) - drawCard(14 + (cardWidth + 5) * 3, 'Avg Duration', formatDuration(stats.avg_duration)) - - startY = 65 // Move table down - } + const pageWidth = doc.internal.pageSize.width + doc.text(`Generated: ${generatedDate}`, pageWidth - 14, 18, { align: 'right' }) + doc.text(`Range: ${dateRange}`, pageWidth - 14, 23, { align: 'right' }) - // Check if data is hourly (same date for multiple rows) - const isHourly = data.length > 1 && data[0].date.split('T')[0] === data[1].date.split('T')[0] + let startY = 35 - const tableData = exportData.map(row => - fields.map(field => { - const val = row[field] - if (field === 'date' && typeof val === 'string') { - const date = new Date(val) - return isHourly - ? date.toLocaleString('en-US', { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }) - : date.toLocaleDateString() + // Summary Section + if (stats) { + const summaryY = 35 + const cardWidth = (pageWidth - 28 - 15) / 4 // 4 cards with 5mm gap + const cardHeight = 20 + + const drawCard = (x: number, label: string, value: string) => { + doc.setFillColor(255, 247, 237) // Very light orange + doc.setDrawColor(254, 215, 170) // Light orange border + doc.roundedRect(x, summaryY, cardWidth, cardHeight, 2, 2, 'FD') + + doc.setFontSize(8) + doc.setTextColor(150, 150, 150) + doc.text(label, x + 3, summaryY + 6) + + doc.setFontSize(12) + doc.setTextColor(23, 23, 23) // Neutral 900 + doc.setFont('helvetica', 'bold') + doc.text(value, x + 3, summaryY + 14) + doc.setFont('helvetica', 'normal') + } + + drawCard(14, 'Unique Visitors', formatNumber(stats.visitors)) + drawCard(14 + cardWidth + 5, 'Total Pageviews', formatNumber(stats.pageviews)) + drawCard(14 + (cardWidth + 5) * 2, 'Bounce Rate', `${Math.round(stats.bounce_rate)}%`) + drawCard(14 + (cardWidth + 5) * 3, 'Avg Duration', formatDuration(stats.avg_duration)) + + startY = 65 // Move table down + } + + // Check if data is hourly (same date for multiple rows) + const isHourly = data.length > 1 && data[0].date.split('T')[0] === data[1].date.split('T')[0] + + const tableData = exportData.map(row => + fields.map(field => { + const val = row[field] + if (field === 'date' && typeof val === 'string') { + const date = new Date(val) + return isHourly + ? date.toLocaleString('en-US', { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }) + : date.toLocaleDateString() + } + if (typeof val === 'number') { + if (field === 'bounce_rate') return `${Math.round(val)}%` + if (field === 'avg_duration') return formatDuration(val) + if (field === 'pageviews' || field === 'visitors') return formatNumber(val) + } + return val ?? '' + }) + ) + + autoTable(doc, { + startY: startY, + head: [fields.map(f => f.charAt(0).toUpperCase() + f.slice(1).replace('_', ' '))], + body: tableData as (string | number)[][], + styles: { + font: 'helvetica', + fontSize: 9, + cellPadding: 4, + lineColor: [229, 231, 235], // Neutral 200 + lineWidth: 0.1, + }, + headStyles: { + fillColor: [249, 115, 22], // Brand Orange + textColor: [255, 255, 255], + fontStyle: 'bold', + halign: 'left' + }, + columnStyles: { + 0: { halign: 'left' }, // Date + 1: { halign: 'right' }, // Pageviews + 2: { halign: 'right' }, // Visitors + 3: { halign: 'right' }, // Bounce Rate + 4: { halign: 'right' }, // Avg Duration + }, + alternateRowStyles: { + fillColor: [255, 250, 245], // Very very light orange + }, + didDrawPage: (data) => { + // Footer + const pageSize = doc.internal.pageSize + const pageHeight = pageSize.height ? pageSize.height : pageSize.getHeight() + doc.setFontSize(8) + doc.setTextColor(150, 150, 150) + doc.text('Powered by Ciphera', 14, pageHeight - 10) + + const str = 'Page ' + doc.getNumberOfPages() + doc.text(str, pageSize.width - 14, pageHeight - 10, { align: 'right' }) + } + }) + + let finalY = doc.lastAutoTable.finalY + 10 + + // Top Pages Table + if (topPages && topPages.length > 0) { + // Check if we need a new page + if (finalY + 40 > doc.internal.pageSize.height) { + doc.addPage() + finalY = 20 + } + + doc.setFontSize(14) + doc.setTextColor(23, 23, 23) + doc.text('Top Pages', 14, finalY) + finalY += 5 + + const pagesData = topPages.slice(0, 10).map(p => [p.path, formatNumber(p.pageviews)]) + + autoTable(doc, { + startY: finalY, + head: [['Path', 'Pageviews']], + body: pagesData, + styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 }, + headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' }, + columnStyles: { 1: { halign: 'right' } }, + alternateRowStyles: { fillColor: [255, 250, 245] }, + }) + + finalY = doc.lastAutoTable.finalY + 10 + } + + // Top Referrers Table + if (topReferrers && topReferrers.length > 0) { + // Check if we need a new page + if (finalY + 40 > doc.internal.pageSize.height) { + doc.addPage() + finalY = 20 + } + + doc.setFontSize(14) + doc.setTextColor(23, 23, 23) + doc.text('Top Referrers', 14, finalY) + finalY += 5 + + const mergedReferrers = mergeReferrersByDisplayName(topReferrers) + const referrersData = mergedReferrers.slice(0, 10).map(r => [getReferrerDisplayName(r.referrer), formatNumber(r.pageviews)]) + + autoTable(doc, { + startY: finalY, + head: [['Referrer', 'Pageviews']], + body: referrersData, + styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 }, + headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' }, + columnStyles: { 1: { halign: 'right' } }, + alternateRowStyles: { fillColor: [255, 250, 245] }, + }) + + finalY = doc.lastAutoTable.finalY + 10 + } + + // Campaigns Table + if (campaigns && campaigns.length > 0) { + if (finalY + 40 > doc.internal.pageSize.height) { + doc.addPage() + finalY = 20 + } + doc.setFontSize(14) + doc.setTextColor(23, 23, 23) + doc.text('Campaigns', 14, finalY) + finalY += 5 + const campaignsData = campaigns.slice(0, 10).map(c => [ + getReferrerDisplayName(c.source), + c.medium || '—', + c.campaign || '—', + formatNumber(c.visitors), + formatNumber(c.pageviews), + ]) + autoTable(doc, { + startY: finalY, + head: [['Source', 'Medium', 'Campaign', 'Visitors', 'Pageviews']], + body: campaignsData, + styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 }, + headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' }, + columnStyles: { 3: { halign: 'right' }, 4: { halign: 'right' } }, + alternateRowStyles: { fillColor: [255, 250, 245] }, + }) + } + + doc.save(`${filename || 'export'}.pdf`) + onClose() + return + } else { + content = JSON.stringify(exportData, null, 2) + mimeType = 'application/json;charset=utf-8;' + extension = 'json' } - if (typeof val === 'number') { - if (field === 'bounce_rate') return `${Math.round(val)}%` - if (field === 'avg_duration') return formatDuration(val) - if (field === 'pageviews' || field === 'visitors') return formatNumber(val) - } - return val ?? '' - }) - ) - autoTable(doc, { - startY: startY, - head: [fields.map(f => f.charAt(0).toUpperCase() + f.slice(1).replace('_', ' '))], - body: tableData as (string | number)[][], - styles: { - font: 'helvetica', - fontSize: 9, - cellPadding: 4, - lineColor: [229, 231, 235], // Neutral 200 - lineWidth: 0.1, - }, - headStyles: { - fillColor: [249, 115, 22], // Brand Orange - textColor: [255, 255, 255], - fontStyle: 'bold', - halign: 'left' - }, - columnStyles: { - 0: { halign: 'left' }, // Date - 1: { halign: 'right' }, // Pageviews - 2: { halign: 'right' }, // Visitors - 3: { halign: 'right' }, // Bounce Rate - 4: { halign: 'right' }, // Avg Duration - }, - alternateRowStyles: { - fillColor: [255, 250, 245], // Very very light orange - }, - didDrawPage: (data) => { - // Footer - const pageSize = doc.internal.pageSize - const pageHeight = pageSize.height ? pageSize.height : pageSize.getHeight() - doc.setFontSize(8) - doc.setTextColor(150, 150, 150) - doc.text('Powered by Ciphera', 14, pageHeight - 10) - - const str = 'Page ' + doc.getNumberOfPages() - doc.text(str, pageSize.width - 14, pageHeight - 10, { align: 'right' }) + const blob = new Blob([content], { type: mimeType }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.setAttribute('href', url) + link.setAttribute('download', `${filename || 'export'}.${extension}`) + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + onClose() + } catch (e) { + console.error('Export failed:', e) + } finally { + setIsExporting(false) } - }) - - let finalY = doc.lastAutoTable.finalY + 10 - - // Top Pages Table - if (topPages && topPages.length > 0) { - // Check if we need a new page - if (finalY + 40 > doc.internal.pageSize.height) { - doc.addPage() - finalY = 20 - } - - doc.setFontSize(14) - doc.setTextColor(23, 23, 23) - doc.text('Top Pages', 14, finalY) - finalY += 5 - - const pagesData = topPages.slice(0, 10).map(p => [p.path, formatNumber(p.pageviews)]) - - autoTable(doc, { - startY: finalY, - head: [['Path', 'Pageviews']], - body: pagesData, - styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 }, - headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' }, - columnStyles: { 1: { halign: 'right' } }, - alternateRowStyles: { fillColor: [255, 250, 245] }, - }) - - finalY = doc.lastAutoTable.finalY + 10 - } - - // Top Referrers Table - if (topReferrers && topReferrers.length > 0) { - // Check if we need a new page - if (finalY + 40 > doc.internal.pageSize.height) { - doc.addPage() - finalY = 20 - } - - doc.setFontSize(14) - doc.setTextColor(23, 23, 23) - doc.text('Top Referrers', 14, finalY) - finalY += 5 - - const mergedReferrers = mergeReferrersByDisplayName(topReferrers) - const referrersData = mergedReferrers.slice(0, 10).map(r => [getReferrerDisplayName(r.referrer), formatNumber(r.pageviews)]) - - autoTable(doc, { - startY: finalY, - head: [['Referrer', 'Pageviews']], - body: referrersData, - styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 }, - headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' }, - columnStyles: { 1: { halign: 'right' } }, - alternateRowStyles: { fillColor: [255, 250, 245] }, - }) - - finalY = doc.lastAutoTable.finalY + 10 - } - - // Campaigns Table - if (campaigns && campaigns.length > 0) { - if (finalY + 40 > doc.internal.pageSize.height) { - doc.addPage() - finalY = 20 - } - doc.setFontSize(14) - doc.setTextColor(23, 23, 23) - doc.text('Campaigns', 14, finalY) - finalY += 5 - const campaignsData = campaigns.slice(0, 10).map(c => [ - getReferrerDisplayName(c.source), - c.medium || '—', - c.campaign || '—', - formatNumber(c.visitors), - formatNumber(c.pageviews), - ]) - autoTable(doc, { - startY: finalY, - head: [['Source', 'Medium', 'Campaign', 'Visitors', 'Pageviews']], - body: campaignsData, - styles: { font: 'helvetica', fontSize: 9, cellPadding: 3 }, - headStyles: { fillColor: [249, 115, 22], textColor: [255, 255, 255], fontStyle: 'bold' }, - columnStyles: { 3: { halign: 'right' }, 4: { halign: 'right' } }, - alternateRowStyles: { fillColor: [255, 250, 245] }, - }) - } - - doc.save(`${filename || 'export'}.pdf`) - onClose() - return - } else { - content = JSON.stringify(exportData, null, 2) - mimeType = 'application/json;charset=utf-8;' - extension = 'json' - } - - const blob = new Blob([content], { type: mimeType }) - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.setAttribute('href', url) - link.setAttribute('download', `${filename || 'export'}.${extension}`) - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - - onClose() + }, 0) + }) } return ( @@ -440,11 +453,11 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to {/* Actions */}
- -
diff --git a/components/dashboard/Locations.tsx b/components/dashboard/Locations.tsx index f9d3406..68fb707 100644 --- a/components/dashboard/Locations.tsx +++ b/components/dashboard/Locations.tsx @@ -13,6 +13,7 @@ const DottedMap = dynamic(() => import('./DottedMap'), { ssr: false }) const Globe = dynamic(() => import('./Globe'), { ssr: false }) import { Modal, GlobeIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' +import VirtualList from './VirtualList' import { ShieldCheck, Detective, Broadcast, FrameCornersIcon } from '@phosphor-icons/react' import { getCountries, getCities, getRegions } from '@/lib/api/stats' import { type DimensionFilter } from '@/lib/filters' @@ -334,7 +335,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" />
-
+
{isLoadingFull ? (
@@ -347,35 +348,42 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' return label.toLowerCase().includes(search) }) const modalTotal = modalData.reduce((sum, item) => sum + item.pageviews, 0) - return modalData.map((item) => { - const dim = TAB_TO_DIMENSION[activeTab] - const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city - const canFilter = onFilter && dim && filterValue - return ( -
{ if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [filterValue!] }); setIsModalOpen(false) } }} - className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} - > -
- {getFlagComponent(item.country ?? '')} - - {activeTab === 'countries' ? getCountryName(item.country ?? '') : - activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') : - getCityName(item.city ?? '')} - -
-
- - {modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''} - - - {formatNumber(item.pageviews)} - -
-
- ) - }) + return ( + { + const dim = TAB_TO_DIMENSION[activeTab] + const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city + const canFilter = onFilter && dim && filterValue + return ( +
{ if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [filterValue!] }); setIsModalOpen(false) } }} + className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} + > +
+ {getFlagComponent(item.country ?? '')} + + {activeTab === 'countries' ? getCountryName(item.country ?? '') : + activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') : + getCityName(item.city ?? '')} + +
+
+ + {modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''} + + + {formatNumber(item.pageviews)} + +
+
+ ) + }} + /> + ) })()}
diff --git a/components/dashboard/TechSpecs.tsx b/components/dashboard/TechSpecs.tsx index 59cdec4..cd2114f 100644 --- a/components/dashboard/TechSpecs.tsx +++ b/components/dashboard/TechSpecs.tsx @@ -9,6 +9,7 @@ import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons' import { Monitor, FrameCornersIcon } from '@phosphor-icons/react' import { Modal, GridIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' +import VirtualList from './VirtualList' import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats' import { type DimensionFilter } from '@/lib/filters' @@ -235,7 +236,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" />
-
+
{isLoadingFull ? (
@@ -244,29 +245,36 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co const modalData = (fullData.length > 0 ? fullData : data).filter(item => !modalSearch || item.name.toLowerCase().includes(modalSearch.toLowerCase())) const modalTotal = modalData.reduce((sum, item) => sum + item.pageviews, 0) const dim = TAB_TO_DIMENSION[activeTab] - return modalData.map((item) => { - const canFilter = onFilter && dim - return ( -
{ if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [item.name] }); setIsModalOpen(false) } }} - className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} - > -
- {item.icon && {item.icon}} - {capitalize(item.name)} -
-
- - {modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''} - - - {formatNumber(item.pageviews)} - -
-
- ) - }) + return ( + { + const canFilter = onFilter && dim + return ( +
{ if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [item.name] }); setIsModalOpen(false) } }} + className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} + > +
+ {item.icon && {item.icon}} + {capitalize(item.name)} +
+
+ + {modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''} + + + {formatNumber(item.pageviews)} + +
+
+ ) + }} + /> + ) })()}
diff --git a/components/dashboard/TopReferrers.tsx b/components/dashboard/TopReferrers.tsx index 83b172b..78e4fdd 100644 --- a/components/dashboard/TopReferrers.tsx +++ b/components/dashboard/TopReferrers.tsx @@ -8,6 +8,7 @@ import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeRefer import { FrameCornersIcon } from '@phosphor-icons/react' import { Modal, GlobeIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' +import VirtualList from './VirtualList' import { getTopReferrers, TopReferrer } from '@/lib/api/stats' import { type DimensionFilter } from '@/lib/filters' @@ -165,7 +166,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" />
-
+
{isLoadingFull ? (
@@ -173,26 +174,33 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI ) : (() => { const modalData = mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).filter(r => !modalSearch || getReferrerDisplayName(r.referrer).toLowerCase().includes(modalSearch.toLowerCase())) const modalTotal = modalData.reduce((sum, r) => sum + r.pageviews, 0) - return modalData.map((ref) => ( -
{ if (onFilter) { onFilter({ dimension: 'referrer', operator: 'is', values: [ref.referrer] }); setIsModalOpen(false) } }} - className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} - > -
- {renderReferrerIcon(ref.referrer)} - {getReferrerDisplayName(ref.referrer)} -
-
- - {modalTotal > 0 ? `${Math.round((ref.pageviews / modalTotal) * 100)}%` : ''} - - - {formatNumber(ref.pageviews)} - -
-
- )) + return ( + ( +
{ if (onFilter) { onFilter({ dimension: 'referrer', operator: 'is', values: [ref.referrer] }); setIsModalOpen(false) } }} + className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} + > +
+ {renderReferrerIcon(ref.referrer)} + {getReferrerDisplayName(ref.referrer)} +
+
+ + {modalTotal > 0 ? `${Math.round((ref.pageviews / modalTotal) * 100)}%` : ''} + + + {formatNumber(ref.pageviews)} + +
+
+ )} + /> + ) })()}
diff --git a/components/dashboard/VirtualList.tsx b/components/dashboard/VirtualList.tsx new file mode 100644 index 0000000..71f8ec3 --- /dev/null +++ b/components/dashboard/VirtualList.tsx @@ -0,0 +1,53 @@ +'use client' + +import { useRef } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' + +interface VirtualListProps { + items: T[] + estimateSize: number + className?: string + renderItem: (item: T, index: number) => React.ReactNode +} + +export default function VirtualList({ items, estimateSize, className, renderItem }: VirtualListProps) { + const parentRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => estimateSize, + overscan: 10, + }) + + // For small lists (< 50 items), render directly without virtualization + if (items.length < 50) { + return ( +
+ {items.map((item, index) => renderItem(item, index))} +
+ ) + } + + return ( +
+
+ {virtualizer.getVirtualItems().map((virtualRow) => ( +
+ {renderItem(items[virtualRow.index], virtualRow.index)} +
+ ))} +
+
+ ) +} diff --git a/package-lock.json b/package-lock.json index 89e22e0..4d30b79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@simplewebauthn/browser": "^13.2.2", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", + "@tanstack/react-virtual": "^3.13.21", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "cobe": "^0.6.5", @@ -5405,6 +5406,33 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.21.tgz", + "integrity": "sha512-SYXFrmrbPgXBvf+HsOsKhFgqSe4M6B29VHOsX9Jih9TlNkNkDWx0hWMiMLUghMEzyUz772ndzdEeCEBx+3GIZw==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.21" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.21", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.21.tgz", + "integrity": "sha512-ww+fmLHyCbPSf7JNbWZP3g7wl6SdNo3ah5Aiw+0e9FDErkVHLKprYUrwTm7dF646FtEkN/KkAKPYezxpmvOjxw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index 3b4a9fb..ad5f188 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@simplewebauthn/browser": "^13.2.2", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", + "@tanstack/react-virtual": "^3.13.21", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "cobe": "^0.6.5",