Files
pulse/components/dashboard/ExportModal.tsx
Usman Baig a3c1af7c95 fix: frontend consistency audit — 55 files cleaned up
Consistency fixes:
- Extract getThisWeekRange/getThisMonthRange to shared lib/utils/dateRanges.ts
  (removed 4 identical copy-pasted definitions)
- Add error boundaries for behavior, cdn, search, pagespeed pages
  (4 new error.tsx files — previously fell through to generic parent error)
- Add "View setup guide" CTA to empty states on journeys and behavior pages
  (previously showed text with no actionable button)
- Fix non-lazy useState initializer in funnel detail page
- Fix Bot & Spam settings header from text-xl to text-2xl (matches all other sections)
- Add useMinimumLoading to PageSpeed skeleton (consistent with all other pages)

Cleanup:
- Remove 438 redundant dark: class prefixes (app is dark-mode only)
  text-neutral-500 dark:text-neutral-400 → text-neutral-400 (206 occurrences)
  text-neutral-900 dark:text-white → text-white (232 occurrences)
- Remove dead @stripe/react-stripe-js and @stripe/stripe-js packages
  (billing migrated to Polar, no code imports Stripe)
- Remove duplicate motion package (framer-motion is the one actually used)
2026-03-23 19:50:16 +01:00

509 lines
20 KiB
TypeScript

'use client'
import { useState, useCallback } from 'react'
import { Modal, Button, Checkbox, Input, Select } from '@ciphera-net/ui'
import * as XLSX from 'xlsx'
import jsPDF from 'jspdf'
import autoTable from 'jspdf-autotable'
import type { DailyStat } from './Chart'
import { formatNumber, formatDuration } from '@ciphera-net/ui'
import { formatDateISO, formatDate, formatDateTime } from '@/lib/utils/formatDate'
import { getReferrerDisplayName, mergeReferrersByDisplayName } from '@/lib/utils/icons'
import type { TopPage, TopReferrer, CampaignStat } from '@/lib/api/stats'
interface ExportModalProps {
isOpen: boolean
onClose: () => void
data: DailyStat[]
stats?: {
pageviews: number
visitors: number
bounce_rate: number
avg_duration: number
}
topPages?: TopPage[]
topReferrers?: TopReferrer[]
campaigns?: CampaignStat[]
}
type ExportFormat = 'csv' | 'json' | 'xlsx' | 'pdf'
const loadImage = (src: string): Promise<string> => {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'Anonymous'
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
const ctx = canvas.getContext('2d')
if (!ctx) return reject('Could not get canvas context')
ctx.drawImage(img, 0, 0)
resolve(canvas.toDataURL('image/png'))
}
img.onerror = reject
img.src = src
})
}
export default function ExportModal({ isOpen, onClose, data, stats, topPages, topReferrers, campaigns }: ExportModalProps) {
const [format, setFormat] = useState<ExportFormat>('csv')
const [filename, setFilename] = useState(`pulse_export_${formatDateISO(new Date())}`)
const [includeHeader, setIncludeHeader] = useState(true)
const [isExporting, setIsExporting] = useState(false)
const [exportDone, setExportDone] = useState(false)
const [exportProgress, setExportProgress] = useState({ step: 0, total: 1, label: '' })
const [selectedFields, setSelectedFields] = useState<Record<keyof DailyStat, boolean>>({
date: true,
pageviews: true,
visitors: true,
bounce_rate: true,
avg_duration: true,
})
const handleFieldChange = (field: keyof DailyStat, checked: boolean) => {
setSelectedFields((prev) => ({ ...prev, [field]: checked }))
}
const finishExport = useCallback(() => {
setExportDone(true)
setIsExporting(false)
setTimeout(() => {
setExportDone(false)
onClose()
}, 600)
}, [onClose])
// Yield to the UI thread so the browser can paint progress updates
const updateProgress = useCallback(async (step: number, total: number, label: string) => {
setExportProgress({ step, total, label })
await new Promise(resolve => setTimeout(resolve, 0))
}, [])
const handleExport = () => {
setIsExporting(true)
setExportProgress({ step: 0, total: 1, label: 'Preparing...' })
// Let the browser paint the loading state before starting heavy work
requestAnimationFrame(() => {
setTimeout(async () => {
try {
// Filter fields
const fields = (Object.keys(selectedFields) as Array<keyof DailyStat>).filter((k) => selectedFields[k])
// Prepare data
const exportData = data.map((item) => {
const filteredItem: Record<string, string | number> = {}
fields.forEach((field) => {
filteredItem[field] = item[field]
})
return filteredItem
})
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') {
await updateProgress(1, 2, 'Building spreadsheet...')
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)
finishExport()
return
} else if (format === 'pdf') {
const totalSteps = 3 + (topPages?.length ? 1 : 0) + (topReferrers?.length ? 1 : 0) + (campaigns?.length ? 1 : 0)
let currentStep = 0
const doc = new jsPDF()
// Header Section
await updateProgress(++currentStep, totalSteps, 'Building header...')
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)
doc.setFontSize(9)
doc.setTextColor(150, 150, 150)
const generatedDate = formatDate(new Date())
const dateRange = data.length > 0
? `${formatDate(new Date(data[0].date))} - ${formatDate(new Date(data[data.length - 1].date))}`
: 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' })
let startY = 35
// 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
}
await updateProgress(++currentStep, totalSteps, 'Generating data table...')
// 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 ? formatDateTime(date) : formatDate(date)
}
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) {
await updateProgress(++currentStep, totalSteps, 'Adding top pages...')
// 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) {
await updateProgress(++currentStep, totalSteps, 'Adding top referrers...')
// 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) {
await updateProgress(++currentStep, totalSteps, 'Adding campaigns...')
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] },
})
}
await updateProgress(totalSteps, totalSteps, 'Saving PDF...')
doc.save(`${filename || 'export'}.pdf`)
finishExport()
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)
finishExport()
} catch (e) {
console.error('Export failed:', e)
} finally {
setIsExporting(false)
}
}, 0)
})
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Export Data">
<div className="space-y-6">
{/* Filename & Format */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="filename" className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Filename
</label>
<Input
id="filename"
value={filename}
onChange={(e) => setFilename(e.target.value)}
placeholder="filename"
/>
</div>
<div className="space-y-2">
<label htmlFor="format" className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Format
</label>
<Select
id="format"
value={format}
onChange={(val) => setFormat(val as ExportFormat)}
options={[
{ value: 'csv', label: 'CSV' },
{ value: 'json', label: 'JSON' },
{ value: 'xlsx', label: 'Excel' },
{ value: 'pdf', label: 'PDF' },
]}
variant="input"
fullWidth
/>
</div>
</div>
{/* Fields Selection */}
<div className="space-y-3">
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Include Fields
</label>
<div className="grid grid-cols-2 gap-3">
<Checkbox
checked={selectedFields.date}
onCheckedChange={(c) => handleFieldChange('date', c)}
label="Date"
/>
<Checkbox
checked={selectedFields.pageviews}
onCheckedChange={(c) => handleFieldChange('pageviews', c)}
label="Pageviews"
/>
<Checkbox
checked={selectedFields.visitors}
onCheckedChange={(c) => handleFieldChange('visitors', c)}
label="Visitors"
/>
<Checkbox
checked={selectedFields.bounce_rate}
onCheckedChange={(c) => handleFieldChange('bounce_rate', c)}
label="Bounce Rate"
/>
<Checkbox
checked={selectedFields.avg_duration}
onCheckedChange={(c) => handleFieldChange('avg_duration', c)}
label="Avg Duration"
/>
</div>
</div>
{/* Additional Options */}
{format === 'csv' && (
<div className="pt-2 border-t border-neutral-100 dark:border-neutral-800">
<Checkbox
checked={includeHeader}
onCheckedChange={setIncludeHeader}
label="Include Header Row"
/>
</div>
)}
{/* Progress Bar */}
{(isExporting || exportDone) && (
<div className="space-y-2 pt-2">
<div className="flex items-center justify-between text-xs text-neutral-400">
<span>{exportDone ? 'Export complete' : exportProgress.label}</span>
<span>{exportDone ? '100%' : `${Math.round((exportProgress.step / exportProgress.total) * 100)}%`}</span>
</div>
<div className="h-1.5 w-full rounded-full bg-neutral-100 dark:bg-neutral-800 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ease-out ${exportDone ? 'bg-green-500' : 'bg-brand-orange'}`}
style={{ width: exportDone ? '100%' : `${(exportProgress.step / exportProgress.total) * 100}%` }}
/>
</div>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-4">
<Button variant="secondary" onClick={onClose} disabled={isExporting}>
Cancel
</Button>
<Button variant="primary" onClick={handleExport} disabled={isExporting || exportDone}>
{exportDone ? '✓ Done' : isExporting ? 'Exporting...' : 'Export Data'}
</Button>
</div>
</div>
</Modal>
)
}