feat: enhance PDF export with top pages/referrers and move export button to page header
This commit is contained in:
@@ -11,8 +11,8 @@ import TopReferrers from '@/components/dashboard/TopReferrers'
|
|||||||
import Locations from '@/components/dashboard/Locations'
|
import Locations from '@/components/dashboard/Locations'
|
||||||
import TechSpecs from '@/components/dashboard/TechSpecs'
|
import TechSpecs from '@/components/dashboard/TechSpecs'
|
||||||
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
||||||
import { Select, DatePicker as DatePickerModal, Captcha } from '@ciphera-net/ui'
|
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
|
||||||
import { ZapIcon } from '@ciphera-net/ui'
|
import ExportModal from '@/components/dashboard/ExportModal'
|
||||||
|
|
||||||
// Helper to get date ranges
|
// Helper to get date ranges
|
||||||
const getDateRange = (days: number) => {
|
const getDateRange = (days: number) => {
|
||||||
@@ -45,6 +45,7 @@ export default function PublicDashboardPage() {
|
|||||||
// Date range state
|
// Date range state
|
||||||
const [dateRange, setDateRange] = useState(getDateRange(30))
|
const [dateRange, setDateRange] = useState(getDateRange(30))
|
||||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
||||||
|
const [isExportModalOpen, setIsExportModalOpen] = useState(false)
|
||||||
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour')
|
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour')
|
||||||
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>('day')
|
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>('day')
|
||||||
|
|
||||||
@@ -294,6 +295,14 @@ export default function PublicDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExportModalOpen(true)}
|
||||||
|
className="hidden md:flex items-center gap-2 px-3 py-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
|
||||||
|
>
|
||||||
|
<DownloadIcon className="w-4 h-4" />
|
||||||
|
<span>Export</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={
|
value={
|
||||||
dateRange.start === new Date().toISOString().split('T')[0] && dateRange.end === new Date().toISOString().split('T')[0]
|
dateRange.start === new Date().toISOString().split('T')[0] && dateRange.end === new Date().toISOString().split('T')[0]
|
||||||
@@ -454,6 +463,17 @@ export default function PublicDashboardPage() {
|
|||||||
setIsDatePickerOpen(false)
|
setIsDatePickerOpen(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<ExportModal
|
||||||
|
isOpen={isExportModalOpen}
|
||||||
|
onClose={() => setIsExportModalOpen(false)}
|
||||||
|
data={data.daily_stats || []}
|
||||||
|
stats={data.stats}
|
||||||
|
topPages={data.top_pages}
|
||||||
|
topReferrers={data.top_referrers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,8 @@ import {
|
|||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import type { TooltipProps } from 'recharts'
|
import type { TooltipProps } from 'recharts'
|
||||||
import { formatNumber, formatDuration } from '@/lib/utils/format'
|
import { formatNumber, formatDuration } from '@/lib/utils/format'
|
||||||
import { ArrowUpRightIcon, ArrowDownRightIcon, DownloadIcon, BarChartIcon } from '@ciphera-net/ui'
|
import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon } from '@ciphera-net/ui'
|
||||||
import { Button } from '@ciphera-net/ui'
|
|
||||||
import { Checkbox } from '@ciphera-net/ui'
|
import { Checkbox } from '@ciphera-net/ui'
|
||||||
import ExportModal from './ExportModal'
|
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
brand: '#FD5E0F',
|
brand: '#FD5E0F',
|
||||||
@@ -164,7 +162,6 @@ function formatAxisValue(value: number): string {
|
|||||||
export default function Chart({ data, prevData, stats, prevStats, interval }: ChartProps) {
|
export default function Chart({ data, prevData, stats, prevStats, interval }: ChartProps) {
|
||||||
const [metric, setMetric] = useState<MetricType>('visitors')
|
const [metric, setMetric] = useState<MetricType>('visitors')
|
||||||
const [showComparison, setShowComparison] = useState(false)
|
const [showComparison, setShowComparison] = useState(false)
|
||||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false)
|
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
|
|
||||||
const colors = useMemo(
|
const colors = useMemo(
|
||||||
@@ -214,10 +211,6 @@ export default function Chart({ data, prevData, stats, prevStats, interval }: Ch
|
|||||||
return Math.round(((current - previous) / previous) * 100)
|
return Math.round(((current - previous) / previous) * 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = () => {
|
|
||||||
setIsExportModalOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const metrics = [
|
const metrics = [
|
||||||
{
|
{
|
||||||
id: 'visitors',
|
id: 'visitors',
|
||||||
@@ -364,16 +357,6 @@ export default function Chart({ data, prevData, stats, prevStats, interval }: Ch
|
|||||||
|
|
||||||
{/* Vertical Separator */}
|
{/* Vertical Separator */}
|
||||||
<div className="h-4 w-px bg-neutral-200 dark:bg-neutral-800" />
|
<div className="h-4 w-px bg-neutral-200 dark:bg-neutral-800" />
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={handleExport}
|
|
||||||
className="h-8 px-3 text-xs gap-2"
|
|
||||||
title="Export to CSV"
|
|
||||||
>
|
|
||||||
<DownloadIcon className="w-3.5 h-3.5" />
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -494,12 +477,6 @@ export default function Chart({ data, prevData, stats, prevStats, interval }: Ch
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ExportModal
|
|
||||||
isOpen={isExportModalOpen}
|
|
||||||
onClose={() => setIsExportModalOpen(false)}
|
|
||||||
data={data}
|
|
||||||
stats={stats}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import jsPDF from 'jspdf'
|
|||||||
import autoTable from 'jspdf-autotable'
|
import autoTable from 'jspdf-autotable'
|
||||||
import type { DailyStat } from './Chart'
|
import type { DailyStat } from './Chart'
|
||||||
import { formatNumber, formatDuration } from '@/lib/utils/format'
|
import { formatNumber, formatDuration } from '@/lib/utils/format'
|
||||||
|
import type { TopPage, TopReferrer } from '@/lib/api/stats'
|
||||||
|
|
||||||
interface ExportModalProps {
|
interface ExportModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -18,6 +19,8 @@ interface ExportModalProps {
|
|||||||
bounce_rate: number
|
bounce_rate: number
|
||||||
avg_duration: number
|
avg_duration: number
|
||||||
}
|
}
|
||||||
|
topPages?: TopPage[]
|
||||||
|
topReferrers?: TopReferrer[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExportFormat = 'csv' | 'json' | 'xlsx' | 'pdf'
|
type ExportFormat = 'csv' | 'json' | 'xlsx' | 'pdf'
|
||||||
@@ -40,7 +43,7 @@ const loadImage = (src: string): Promise<string> => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExportModal({ isOpen, onClose, data, stats }: ExportModalProps) {
|
export default function ExportModal({ isOpen, onClose, data, stats, topPages, topReferrers }: ExportModalProps) {
|
||||||
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)
|
||||||
@@ -171,11 +174,17 @@ export default function ExportModal({ isOpen, onClose, data, stats }: ExportModa
|
|||||||
startY = 65 // Move table down
|
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 =>
|
const tableData = exportData.map(row =>
|
||||||
fields.map(field => {
|
fields.map(field => {
|
||||||
const val = row[field]
|
const val = row[field]
|
||||||
if (field === 'date' && typeof val === 'string') {
|
if (field === 'date' && typeof val === 'string') {
|
||||||
return new Date(val).toLocaleDateString()
|
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 (typeof val === 'number') {
|
||||||
if (field === 'bounce_rate') return `${Math.round(val)}%`
|
if (field === 'bounce_rate') return `${Math.round(val)}%`
|
||||||
@@ -226,6 +235,62 @@ export default function ExportModal({ isOpen, onClose, data, stats }: ExportModa
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let finalY = (doc as any).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 as any).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 referrersData = topReferrers.slice(0, 10).map(r => [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] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
doc.save(`${filename || 'export'}.pdf`)
|
doc.save(`${filename || 'export'}.pdf`)
|
||||||
onClose()
|
onClose()
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user