-
- {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",