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.
This commit is contained in:
@@ -7,6 +7,7 @@ import Image from 'next/image'
|
|||||||
import { formatNumber } from '@ciphera-net/ui'
|
import { formatNumber } from '@ciphera-net/ui'
|
||||||
import { Modal, ArrowRightIcon } from '@ciphera-net/ui'
|
import { Modal, ArrowRightIcon } from '@ciphera-net/ui'
|
||||||
import { ListSkeleton } from '@/components/skeletons'
|
import { ListSkeleton } from '@/components/skeletons'
|
||||||
|
import VirtualList from './VirtualList'
|
||||||
import { getCampaigns, CampaignStat } from '@/lib/api/stats'
|
import { getCampaigns, CampaignStat } from '@/lib/api/stats'
|
||||||
import { getReferrerFavicon, getReferrerIcon, getReferrerDisplayName } from '@/lib/utils/icons'
|
import { getReferrerFavicon, getReferrerIcon, getReferrerDisplayName } from '@/lib/utils/icons'
|
||||||
import { Megaphone, FrameCornersIcon } from '@phosphor-icons/react'
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 max-h-[80vh] overflow-y-auto pr-2">
|
<div className="max-h-[80vh]">
|
||||||
{isLoadingFull ? (
|
{isLoadingFull ? (
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<ListSkeleton rows={10} />
|
<ListSkeleton rows={10} />
|
||||||
@@ -246,38 +247,43 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
|
|||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{filteredCampaigns.map((item) => (
|
<VirtualList
|
||||||
<div
|
items={filteredCampaigns}
|
||||||
key={`${item.source}|${item.medium}|${item.campaign}`}
|
estimateSize={36}
|
||||||
onClick={() => { if (onFilter) { onFilter({ dimension: 'utm_source', operator: 'is', values: [item.source] }); setIsModalOpen(false) } }}
|
className="max-h-[80vh] overflow-y-auto pr-2"
|
||||||
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' : ''}`}
|
renderItem={(item) => (
|
||||||
>
|
<div
|
||||||
<div className="flex-1 flex items-center gap-3 min-w-0">
|
key={`${item.source}|${item.medium}|${item.campaign}`}
|
||||||
{renderSourceIcon(item.source)}
|
onClick={() => { if (onFilter) { onFilter({ dimension: 'utm_source', operator: 'is', values: [item.source] }); setIsModalOpen(false) } }}
|
||||||
<div className="min-w-0">
|
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' : ''}`}
|
||||||
<div className="text-neutral-900 dark:text-white font-medium truncate text-sm" title={item.source}>
|
>
|
||||||
{getReferrerDisplayName(item.source)}
|
<div className="flex-1 flex items-center gap-3 min-w-0">
|
||||||
</div>
|
{renderSourceIcon(item.source)}
|
||||||
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
|
<div className="min-w-0">
|
||||||
<span>{item.medium || '—'}</span>
|
<div className="text-neutral-900 dark:text-white font-medium truncate text-sm" title={item.source}>
|
||||||
<span>·</span>
|
{getReferrerDisplayName(item.source)}
|
||||||
<span className="truncate">{item.campaign || '—'}</span>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||||
|
<span>{item.medium || '—'}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="truncate">{item.campaign || '—'}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-4 ml-4 text-sm">
|
||||||
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
|
{modalTotal > 0 ? `${Math.round((item.visitors / modalTotal) * 100)}%` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-neutral-900 dark:text-white">
|
||||||
|
{formatNumber(item.visitors)}
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-400 dark:text-neutral-500 w-16 text-right">
|
||||||
|
{formatNumber(item.pageviews)} pv
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 ml-4 text-sm">
|
)}
|
||||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
/>
|
||||||
{modalTotal > 0 ? `${Math.round((item.visitors / modalTotal) * 100)}%` : ''}
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-neutral-900 dark:text-white">
|
|
||||||
{formatNumber(item.visitors)}
|
|
||||||
</span>
|
|
||||||
<span className="text-neutral-400 dark:text-neutral-500 w-16 text-right">
|
|
||||||
{formatNumber(item.pageviews)} pv
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/sta
|
|||||||
import { FrameCornersIcon } from '@phosphor-icons/react'
|
import { FrameCornersIcon } from '@phosphor-icons/react'
|
||||||
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui'
|
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui'
|
||||||
import { ListSkeleton } from '@/components/skeletons'
|
import { ListSkeleton } from '@/components/skeletons'
|
||||||
|
import VirtualList from './VirtualList'
|
||||||
import { type DimensionFilter } from '@/lib/filters'
|
import { type DimensionFilter } from '@/lib/filters'
|
||||||
|
|
||||||
interface ContentStatsProps {
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 max-h-[80vh] overflow-y-auto pr-2">
|
<div className="max-h-[80vh]">
|
||||||
{isLoadingFull ? (
|
{isLoadingFull ? (
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<ListSkeleton rows={10} />
|
<ListSkeleton rows={10} />
|
||||||
@@ -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 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)
|
const modalTotal = modalData.reduce((sum, p) => sum + p.pageviews, 0)
|
||||||
return modalData.map((page) => {
|
return (
|
||||||
const canFilter = onFilter && page.path
|
<VirtualList
|
||||||
return (
|
items={modalData}
|
||||||
<div
|
estimateSize={36}
|
||||||
key={page.path}
|
className="max-h-[80vh] overflow-y-auto pr-2"
|
||||||
onClick={() => { if (canFilter) { onFilter({ dimension: 'page', operator: 'is', values: [page.path] }); setIsModalOpen(false) } }}
|
renderItem={(page) => {
|
||||||
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' : ''}`}
|
const canFilter = onFilter && page.path
|
||||||
>
|
return (
|
||||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
|
<div
|
||||||
<span className="truncate">{page.path}</span>
|
key={page.path}
|
||||||
</div>
|
onClick={() => { if (canFilter) { onFilter({ dimension: 'page', operator: 'is', values: [page.path] }); setIsModalOpen(false) } }}
|
||||||
<div className="flex items-center gap-2 ml-4">
|
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' : ''}`}
|
||||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
>
|
||||||
{modalTotal > 0 ? `${Math.round((page.pageviews / modalTotal) * 100)}%` : ''}
|
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
|
||||||
</span>
|
<span className="truncate">{page.path}</span>
|
||||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
</div>
|
||||||
{formatNumber(page.pageviews)}
|
<div className="flex items-center gap-2 ml-4">
|
||||||
</span>
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
</div>
|
{modalTotal > 0 ? `${Math.round((page.pageviews / modalTotal) * 100)}%` : ''}
|
||||||
</div>
|
</span>
|
||||||
)
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
})
|
{formatNumber(page.pageviews)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { Modal, Button, Checkbox, Input, Select } from '@ciphera-net/ui'
|
import { Modal, Button, Checkbox, Input, Select } from '@ciphera-net/ui'
|
||||||
import * as XLSX from 'xlsx'
|
import * as XLSX from 'xlsx'
|
||||||
import jsPDF from 'jspdf'
|
import jsPDF from 'jspdf'
|
||||||
@@ -49,6 +49,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
|
|||||||
const [format, setFormat] = useState<ExportFormat>('csv')
|
const [format, setFormat] = useState<ExportFormat>('csv')
|
||||||
const [filename, setFilename] = useState(`pulse_export_${new Date().toISOString().split('T')[0]}`)
|
const [filename, setFilename] = useState(`pulse_export_${new Date().toISOString().split('T')[0]}`)
|
||||||
const [includeHeader, setIncludeHeader] = useState(true)
|
const [includeHeader, setIncludeHeader] = useState(true)
|
||||||
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
const [selectedFields, setSelectedFields] = useState<Record<keyof DailyStat, boolean>>({
|
const [selectedFields, setSelectedFields] = useState<Record<keyof DailyStat, boolean>>({
|
||||||
date: true,
|
date: true,
|
||||||
pageviews: true,
|
pageviews: true,
|
||||||
@@ -61,300 +62,312 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
|
|||||||
setSelectedFields((prev) => ({ ...prev, [field]: checked }))
|
setSelectedFields((prev) => ({ ...prev, [field]: checked }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = () => {
|
||||||
// Filter fields
|
setIsExporting(true)
|
||||||
const fields = (Object.keys(selectedFields) as Array<keyof DailyStat>).filter((k) => selectedFields[k])
|
// Let the browser paint the loading state before starting heavy work
|
||||||
|
requestAnimationFrame(() => {
|
||||||
// Prepare data
|
setTimeout(async () => {
|
||||||
const exportData = data.map((item) => {
|
try {
|
||||||
const filteredItem: Record<string, string | number> = {}
|
// Filter fields
|
||||||
fields.forEach((field) => {
|
const fields = (Object.keys(selectedFields) as Array<keyof DailyStat>).filter((k) => selectedFields[k])
|
||||||
filteredItem[field] = item[field]
|
|
||||||
})
|
|
||||||
return filteredItem
|
|
||||||
})
|
|
||||||
|
|
||||||
let content = ''
|
// Prepare data
|
||||||
let mimeType = ''
|
const exportData = data.map((item) => {
|
||||||
let extension = ''
|
const filteredItem: Record<string, string | number> = {}
|
||||||
|
fields.forEach((field) => {
|
||||||
|
filteredItem[field] = item[field]
|
||||||
|
})
|
||||||
|
return filteredItem
|
||||||
|
})
|
||||||
|
|
||||||
if (format === 'csv') {
|
let content = ''
|
||||||
const header = fields.join(',')
|
let mimeType = ''
|
||||||
const rows = exportData.map((row) =>
|
let extension = ''
|
||||||
fields.map((field) => {
|
|
||||||
const val = row[field]
|
if (format === 'csv') {
|
||||||
if (field === 'date' && typeof val === 'string') {
|
const header = fields.join(',')
|
||||||
return new Date(val).toISOString()
|
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
|
const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' })
|
||||||
}).join(',')
|
const blob = new Blob([wbout], { type: 'application/octet-stream' })
|
||||||
)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Metadata (Top Right)
|
const url = URL.createObjectURL(blob)
|
||||||
doc.setFontSize(9)
|
const link = document.createElement('a')
|
||||||
doc.setTextColor(150, 150, 150)
|
link.setAttribute('href', url)
|
||||||
const generatedDate = new Date().toLocaleDateString()
|
link.setAttribute('download', `${filename || 'export'}.${extension || 'xlsx'}`)
|
||||||
const dateRange = data.length > 0
|
document.body.appendChild(link)
|
||||||
? `${new Date(data[0].date).toLocaleDateString()} - ${new Date(data[data.length - 1].date).toLocaleDateString()}`
|
link.click()
|
||||||
: generatedDate
|
document.body.removeChild(link)
|
||||||
|
onClose()
|
||||||
const pageWidth = doc.internal.pageSize.width
|
return
|
||||||
doc.text(`Generated: ${generatedDate}`, pageWidth - 14, 18, { align: 'right' })
|
} else if (format === 'pdf') {
|
||||||
doc.text(`Range: ${dateRange}`, pageWidth - 14, 23, { align: 'right' })
|
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
|
// Title
|
||||||
if (stats) {
|
doc.setFontSize(22)
|
||||||
const summaryY = 35
|
doc.setTextColor(249, 115, 22) // Brand Orange #F97316
|
||||||
const cardWidth = (pageWidth - 28 - 15) / 4 // 4 cards with 5mm gap
|
doc.text('Pulse', 32, 20)
|
||||||
const cardHeight = 20
|
|
||||||
|
doc.setFontSize(12)
|
||||||
const drawCard = (x: number, label: string, value: string) => {
|
doc.setTextColor(100, 100, 100)
|
||||||
doc.setFillColor(255, 247, 237) // Very light orange
|
doc.text('Analytics Export', 32, 25)
|
||||||
doc.setDrawColor(254, 215, 170) // Light orange border
|
} catch (e) {
|
||||||
doc.roundedRect(x, summaryY, cardWidth, cardHeight, 2, 2, 'FD')
|
// Fallback if logo fails
|
||||||
|
doc.setFontSize(22)
|
||||||
doc.setFontSize(8)
|
doc.setTextColor(249, 115, 22)
|
||||||
|
doc.text('Pulse Analytics', 14, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata (Top Right)
|
||||||
|
doc.setFontSize(9)
|
||||||
doc.setTextColor(150, 150, 150)
|
doc.setTextColor(150, 150, 150)
|
||||||
doc.text(label, x + 3, summaryY + 6)
|
const generatedDate = new Date().toLocaleDateString()
|
||||||
|
const dateRange = data.length > 0
|
||||||
doc.setFontSize(12)
|
? `${new Date(data[0].date).toLocaleDateString()} - ${new Date(data[data.length - 1].date).toLocaleDateString()}`
|
||||||
doc.setTextColor(23, 23, 23) // Neutral 900
|
: generatedDate
|
||||||
doc.setFont('helvetica', 'bold')
|
|
||||||
doc.text(value, x + 3, summaryY + 14)
|
|
||||||
doc.setFont('helvetica', 'normal')
|
|
||||||
}
|
|
||||||
|
|
||||||
drawCard(14, 'Unique Visitors', formatNumber(stats.visitors))
|
const pageWidth = doc.internal.pageSize.width
|
||||||
drawCard(14 + cardWidth + 5, 'Total Pageviews', formatNumber(stats.pageviews))
|
doc.text(`Generated: ${generatedDate}`, pageWidth - 14, 18, { align: 'right' })
|
||||||
drawCard(14 + (cardWidth + 5) * 2, 'Bounce Rate', `${Math.round(stats.bounce_rate)}%`)
|
doc.text(`Range: ${dateRange}`, pageWidth - 14, 23, { align: 'right' })
|
||||||
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)
|
let startY = 35
|
||||||
const isHourly = data.length > 1 && data[0].date.split('T')[0] === data[1].date.split('T')[0]
|
|
||||||
|
|
||||||
const tableData = exportData.map(row =>
|
// Summary Section
|
||||||
fields.map(field => {
|
if (stats) {
|
||||||
const val = row[field]
|
const summaryY = 35
|
||||||
if (field === 'date' && typeof val === 'string') {
|
const cardWidth = (pageWidth - 28 - 15) / 4 // 4 cards with 5mm gap
|
||||||
const date = new Date(val)
|
const cardHeight = 20
|
||||||
return isHourly
|
|
||||||
? date.toLocaleString('en-US', { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
|
const drawCard = (x: number, label: string, value: string) => {
|
||||||
: date.toLocaleDateString()
|
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, {
|
const blob = new Blob([content], { type: mimeType })
|
||||||
startY: startY,
|
const url = URL.createObjectURL(blob)
|
||||||
head: [fields.map(f => f.charAt(0).toUpperCase() + f.slice(1).replace('_', ' '))],
|
const link = document.createElement('a')
|
||||||
body: tableData as (string | number)[][],
|
link.setAttribute('href', url)
|
||||||
styles: {
|
link.setAttribute('download', `${filename || 'export'}.${extension}`)
|
||||||
font: 'helvetica',
|
document.body.appendChild(link)
|
||||||
fontSize: 9,
|
link.click()
|
||||||
cellPadding: 4,
|
document.body.removeChild(link)
|
||||||
lineColor: [229, 231, 235], // Neutral 200
|
|
||||||
lineWidth: 0.1,
|
onClose()
|
||||||
},
|
} catch (e) {
|
||||||
headStyles: {
|
console.error('Export failed:', e)
|
||||||
fillColor: [249, 115, 22], // Brand Orange
|
} finally {
|
||||||
textColor: [255, 255, 255],
|
setIsExporting(false)
|
||||||
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' })
|
|
||||||
}
|
}
|
||||||
})
|
}, 0)
|
||||||
|
})
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -440,11 +453,11 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-3 pt-4">
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
<Button variant="secondary" onClick={onClose}>
|
<Button variant="secondary" onClick={onClose} disabled={isExporting}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onClick={handleExport}>
|
<Button variant="primary" onClick={handleExport} disabled={isExporting}>
|
||||||
Export Data
|
{isExporting ? 'Exporting...' : 'Export Data'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const DottedMap = dynamic(() => import('./DottedMap'), { ssr: false })
|
|||||||
const Globe = dynamic(() => import('./Globe'), { ssr: false })
|
const Globe = dynamic(() => import('./Globe'), { ssr: false })
|
||||||
import { Modal, GlobeIcon } from '@ciphera-net/ui'
|
import { Modal, GlobeIcon } from '@ciphera-net/ui'
|
||||||
import { ListSkeleton } from '@/components/skeletons'
|
import { ListSkeleton } from '@/components/skeletons'
|
||||||
|
import VirtualList from './VirtualList'
|
||||||
import { ShieldCheck, Detective, Broadcast, FrameCornersIcon } from '@phosphor-icons/react'
|
import { ShieldCheck, Detective, Broadcast, FrameCornersIcon } from '@phosphor-icons/react'
|
||||||
import { getCountries, getCities, getRegions } from '@/lib/api/stats'
|
import { getCountries, getCities, getRegions } from '@/lib/api/stats'
|
||||||
import { type DimensionFilter } from '@/lib/filters'
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 max-h-[80vh] overflow-y-auto pr-2">
|
<div className="max-h-[80vh]">
|
||||||
{isLoadingFull ? (
|
{isLoadingFull ? (
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<ListSkeleton rows={10} />
|
<ListSkeleton rows={10} />
|
||||||
@@ -347,35 +348,42 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
|||||||
return label.toLowerCase().includes(search)
|
return label.toLowerCase().includes(search)
|
||||||
})
|
})
|
||||||
const modalTotal = modalData.reduce((sum, item) => sum + item.pageviews, 0)
|
const modalTotal = modalData.reduce((sum, item) => sum + item.pageviews, 0)
|
||||||
return modalData.map((item) => {
|
return (
|
||||||
const dim = TAB_TO_DIMENSION[activeTab]
|
<VirtualList
|
||||||
const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city
|
items={modalData}
|
||||||
const canFilter = onFilter && dim && filterValue
|
estimateSize={36}
|
||||||
return (
|
className="max-h-[80vh] overflow-y-auto pr-2"
|
||||||
<div
|
renderItem={(item) => {
|
||||||
key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`}
|
const dim = TAB_TO_DIMENSION[activeTab]
|
||||||
onClick={() => { if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [filterValue!] }); setIsModalOpen(false) } }}
|
const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city
|
||||||
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' : ''}`}
|
const canFilter = onFilter && dim && filterValue
|
||||||
>
|
return (
|
||||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
<div
|
||||||
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
|
key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`}
|
||||||
<span className="truncate">
|
onClick={() => { if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [filterValue!] }); setIsModalOpen(false) } }}
|
||||||
{activeTab === 'countries' ? getCountryName(item.country ?? '') :
|
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' : ''}`}
|
||||||
activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') :
|
>
|
||||||
getCityName(item.city ?? '')}
|
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||||
</span>
|
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
|
||||||
</div>
|
<span className="truncate">
|
||||||
<div className="flex items-center gap-2 ml-4">
|
{activeTab === 'countries' ? getCountryName(item.country ?? '') :
|
||||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') :
|
||||||
{modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''}
|
getCityName(item.city ?? '')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
</div>
|
||||||
{formatNumber(item.pageviews)}
|
<div className="flex items-center gap-2 ml-4">
|
||||||
</span>
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
</div>
|
{modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''}
|
||||||
</div>
|
</span>
|
||||||
)
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
})
|
{formatNumber(item.pageviews)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons'
|
|||||||
import { Monitor, FrameCornersIcon } from '@phosphor-icons/react'
|
import { Monitor, FrameCornersIcon } from '@phosphor-icons/react'
|
||||||
import { Modal, GridIcon } from '@ciphera-net/ui'
|
import { Modal, GridIcon } from '@ciphera-net/ui'
|
||||||
import { ListSkeleton } from '@/components/skeletons'
|
import { ListSkeleton } from '@/components/skeletons'
|
||||||
|
import VirtualList from './VirtualList'
|
||||||
import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats'
|
import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats'
|
||||||
import { type DimensionFilter } from '@/lib/filters'
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 max-h-[80vh] overflow-y-auto pr-2">
|
<div className="max-h-[80vh]">
|
||||||
{isLoadingFull ? (
|
{isLoadingFull ? (
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<ListSkeleton rows={10} />
|
<ListSkeleton rows={10} />
|
||||||
@@ -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 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 modalTotal = modalData.reduce((sum, item) => sum + item.pageviews, 0)
|
||||||
const dim = TAB_TO_DIMENSION[activeTab]
|
const dim = TAB_TO_DIMENSION[activeTab]
|
||||||
return modalData.map((item) => {
|
return (
|
||||||
const canFilter = onFilter && dim
|
<VirtualList
|
||||||
return (
|
items={modalData}
|
||||||
<div
|
estimateSize={36}
|
||||||
key={item.name}
|
className="max-h-[80vh] overflow-y-auto pr-2"
|
||||||
onClick={() => { if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [item.name] }); setIsModalOpen(false) } }}
|
renderItem={(item) => {
|
||||||
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' : ''}`}
|
const canFilter = onFilter && dim
|
||||||
>
|
return (
|
||||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
<div
|
||||||
{item.icon && <span className="text-lg">{item.icon}</span>}
|
key={item.name}
|
||||||
<span className="truncate">{capitalize(item.name)}</span>
|
onClick={() => { if (canFilter) { onFilter({ dimension: dim, operator: 'is', values: [item.name] }); setIsModalOpen(false) } }}
|
||||||
</div>
|
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' : ''}`}
|
||||||
<div className="flex items-center gap-2 ml-4">
|
>
|
||||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||||
{modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''}
|
{item.icon && <span className="text-lg">{item.icon}</span>}
|
||||||
</span>
|
<span className="truncate">{capitalize(item.name)}</span>
|
||||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
</div>
|
||||||
{formatNumber(item.pageviews)}
|
<div className="flex items-center gap-2 ml-4">
|
||||||
</span>
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
</div>
|
{modalTotal > 0 ? `${Math.round((item.pageviews / modalTotal) * 100)}%` : ''}
|
||||||
</div>
|
</span>
|
||||||
)
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
})
|
{formatNumber(item.pageviews)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeRefer
|
|||||||
import { FrameCornersIcon } from '@phosphor-icons/react'
|
import { FrameCornersIcon } from '@phosphor-icons/react'
|
||||||
import { Modal, GlobeIcon } from '@ciphera-net/ui'
|
import { Modal, GlobeIcon } from '@ciphera-net/ui'
|
||||||
import { ListSkeleton } from '@/components/skeletons'
|
import { ListSkeleton } from '@/components/skeletons'
|
||||||
|
import VirtualList from './VirtualList'
|
||||||
import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
|
import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
|
||||||
import { type DimensionFilter } from '@/lib/filters'
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 max-h-[80vh] overflow-y-auto pr-2">
|
<div className="max-h-[80vh]">
|
||||||
{isLoadingFull ? (
|
{isLoadingFull ? (
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<ListSkeleton rows={10} />
|
<ListSkeleton rows={10} />
|
||||||
@@ -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 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)
|
const modalTotal = modalData.reduce((sum, r) => sum + r.pageviews, 0)
|
||||||
return modalData.map((ref) => (
|
return (
|
||||||
<div
|
<VirtualList
|
||||||
key={ref.referrer}
|
items={modalData}
|
||||||
onClick={() => { if (onFilter) { onFilter({ dimension: 'referrer', operator: 'is', values: [ref.referrer] }); setIsModalOpen(false) } }}
|
estimateSize={36}
|
||||||
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' : ''}`}
|
className="max-h-[80vh] overflow-y-auto pr-2"
|
||||||
>
|
renderItem={(ref) => (
|
||||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
<div
|
||||||
{renderReferrerIcon(ref.referrer)}
|
key={ref.referrer}
|
||||||
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
onClick={() => { if (onFilter) { onFilter({ dimension: 'referrer', operator: 'is', values: [ref.referrer] }); setIsModalOpen(false) } }}
|
||||||
</div>
|
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' : ''}`}
|
||||||
<div className="flex items-center gap-2 ml-4">
|
>
|
||||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||||
{modalTotal > 0 ? `${Math.round((ref.pageviews / modalTotal) * 100)}%` : ''}
|
{renderReferrerIcon(ref.referrer)}
|
||||||
</span>
|
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
||||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
</div>
|
||||||
{formatNumber(ref.pageviews)}
|
<div className="flex items-center gap-2 ml-4">
|
||||||
</span>
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
</div>
|
{modalTotal > 0 ? `${Math.round((ref.pageviews / modalTotal) * 100)}%` : ''}
|
||||||
</div>
|
</span>
|
||||||
))
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
|
{formatNumber(ref.pageviews)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
53
components/dashboard/VirtualList.tsx
Normal file
53
components/dashboard/VirtualList.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
|
|
||||||
|
interface VirtualListProps<T> {
|
||||||
|
items: T[]
|
||||||
|
estimateSize: number
|
||||||
|
className?: string
|
||||||
|
renderItem: (item: T, index: number) => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VirtualList<T>({ items, estimateSize, className, renderItem }: VirtualListProps<T>) {
|
||||||
|
const parentRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<div className={className}>
|
||||||
|
{items.map((item, index) => renderItem(item, index))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={parentRef} className={className} style={{ overflow: 'auto' }}>
|
||||||
|
<div style={{ height: `${virtualizer.getTotalSize()}px`, width: '100%', position: 'relative' }}>
|
||||||
|
{virtualizer.getVirtualItems().map((virtualRow) => (
|
||||||
|
<div
|
||||||
|
key={virtualRow.key}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: `${virtualRow.size}px`,
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderItem(items[virtualRow.index], virtualRow.index)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
package-lock.json
generated
28
package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
"@stripe/react-stripe-js": "^5.6.0",
|
"@stripe/react-stripe-js": "^5.6.0",
|
||||||
"@stripe/stripe-js": "^8.7.0",
|
"@stripe/stripe-js": "^8.7.0",
|
||||||
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cobe": "^0.6.5",
|
"cobe": "^0.6.5",
|
||||||
@@ -5405,6 +5406,33 @@
|
|||||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
"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": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
"@stripe/react-stripe-js": "^5.6.0",
|
"@stripe/react-stripe-js": "^5.6.0",
|
||||||
"@stripe/stripe-js": "^8.7.0",
|
"@stripe/stripe-js": "^8.7.0",
|
||||||
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cobe": "^0.6.5",
|
"cobe": "^0.6.5",
|
||||||
|
|||||||
Reference in New Issue
Block a user