'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 => { 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('csv') const [filename, setFilename] = useState(`pulse_export_${formatDateISO(new Date())}`) const [includeHeader, setIncludeHeader] = useState(true) const [isExporting, setIsExporting] = useState(false) const [exportProgress, setExportProgress] = useState({ step: 0, total: 1, label: '' }) const [selectedFields, setSelectedFields] = useState>({ date: true, pageviews: true, visitors: true, bounce_rate: true, avg_duration: true, }) const handleFieldChange = (field: keyof DailyStat, checked: boolean) => { setSelectedFields((prev) => ({ ...prev, [field]: checked })) } // 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).filter((k) => selectedFields[k]) // Prepare data const exportData = data.map((item) => { const filteredItem: Record = {} 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) onClose() 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`) 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() } catch (e) { console.error('Export failed:', e) } finally { setIsExporting(false) } }, 0) }) } return (
{/* Filename & Format */}
setFilename(e.target.value)} placeholder="filename" />