diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a84ca..e365824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to Pulse (frontend and product) are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Pulse uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a **0.x.y** version scheme while in initial development. The leading `0` indicates that the public API and behaviour may change until we release **1.0.0**. +## [0.4.0-alpha] - 2026-02-11 + +### Changed + +- **Campaigns block improvements (PULSE-53).** The Campaigns card now supports sortable columns (Source, Medium, Campaign, Visitors, Pageviews), source favicons with display names (matching Top Referrers), a Pageviews column, and em-dash (—) for empty Medium/Campaign. Loading state uses a skeleton instead of a spinner. Rows use stable keys for better React reconciliation. An Export button exports campaigns to CSV; the main dashboard Export (PDF/Excel) also includes campaigns when available. + ## [0.3.0-alpha] - 2026-02-11 ### Changed @@ -29,7 +35,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), --- -[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.3.0-alpha...HEAD +[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.4.0-alpha...HEAD +[0.4.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.3.0-alpha...v0.4.0-alpha [0.3.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.2.0-alpha...v0.3.0-alpha [0.2.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.1.0-alpha...v0.2.0-alpha [0.1.0-alpha]: https://github.com/ciphera-net/pulse/releases/tag/v0.1.0-alpha diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index cba847b..7d5a319 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -5,7 +5,7 @@ import { useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { motion } from 'framer-motion' import { getSite, type Site } from '@/lib/api/sites' -import { getStats, getRealtime, getDailyStats, getTopPages, getTopReferrers, getCountries, getCities, getRegions, getBrowsers, getOS, getDevices, getScreenResolutions, getEntryPages, getExitPages, getDashboard, getPerformanceByPage, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats' +import { getStats, getRealtime, getDailyStats, getTopPages, getTopReferrers, getCountries, getCities, getRegions, getBrowsers, getOS, getDevices, getScreenResolutions, getEntryPages, getExitPages, getDashboard, getCampaigns, getPerformanceByPage, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats' import { formatNumber, formatDuration, getDateRange } from '@/lib/utils/format' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@/lib/utils/authErrors' @@ -50,6 +50,7 @@ export default function SiteDashboardPage() { const [performance, setPerformance] = useState<{ lcp: number, cls: number, inp: number }>({ lcp: 0, cls: 0, inp: 0 }) const [performanceByPage, setPerformanceByPage] = useState(null) const [goalCounts, setGoalCounts] = useState>([]) + const [campaigns, setCampaigns] = useState([]) const [dateRange, setDateRange] = useState(getDateRange(30)) const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) const [isExportModalOpen, setIsExportModalOpen] = useState(false) @@ -163,7 +164,7 @@ export default function SiteDashboardPage() { setLoading(true) const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval - const [data, prevStatsData, prevDailyStatsData] = await Promise.all([ + const [data, prevStatsData, prevDailyStatsData, campaignsData] = await Promise.all([ getDashboard(siteId, dateRange.start, dateRange.end, 10, interval), (async () => { const prevRange = getPreviousDateRange(dateRange.start, dateRange.end) @@ -172,7 +173,8 @@ export default function SiteDashboardPage() { (async () => { const prevRange = getPreviousDateRange(dateRange.start, dateRange.end) return getDailyStats(siteId, prevRange.start, prevRange.end, interval) - })() + })(), + getCampaigns(siteId, dateRange.start, dateRange.end, 100), ]) setSite(data.site) @@ -197,6 +199,7 @@ export default function SiteDashboardPage() { setPerformance(data.performance || { lcp: 0, cls: 0, inp: 0 }) setPerformanceByPage(data.performance_by_page ?? null) setGoalCounts(Array.isArray(data.goal_counts) ? data.goal_counts : []) + setCampaigns(Array.isArray(campaignsData) ? campaignsData : []) } catch (error: unknown) { toast.error(getAuthErrorMessage(error) || 'Failed to load data: ' + ((error as Error)?.message || 'Unknown error')) } finally { @@ -439,6 +442,7 @@ export default function SiteDashboardPage() { stats={stats} topPages={topPages} topReferrers={topReferrers} + campaigns={campaigns} /> ) diff --git a/components/dashboard/Campaigns.tsx b/components/dashboard/Campaigns.tsx index b67765c..9e3a923 100644 --- a/components/dashboard/Campaigns.tsx +++ b/components/dashboard/Campaigns.tsx @@ -1,10 +1,12 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import Link from 'next/link' import { formatNumber } from '@/lib/utils/format' import { Modal, ArrowRightIcon, Button } from '@ciphera-net/ui' +import { ChevronDownIcon, DownloadIcon } from '@ciphera-net/ui' import { getCampaigns, CampaignStat } from '@/lib/api/stats' +import { getReferrerFavicon, getReferrerIcon, getReferrerDisplayName } from '@/lib/utils/icons' import { FaBullhorn } from 'react-icons/fa' import { PlusIcon } from '@radix-ui/react-icons' import UtmBuilder from '@/components/tools/UtmBuilder' @@ -15,6 +17,26 @@ interface CampaignsProps { } const LIMIT = 7 +const EMPTY_LABEL = '—' + +type SortKey = 'source' | 'medium' | 'campaign' | 'visitors' | 'pageviews' +type SortDir = 'asc' | 'desc' + +function sortCampaigns(data: CampaignStat[], key: SortKey, dir: SortDir): CampaignStat[] { + return [...data].sort((a, b) => { + const av = key === 'visitors' ? a.visitors : key === 'pageviews' ? a.pageviews : (a[key] || '').toLowerCase() + const bv = key === 'visitors' ? b.visitors : key === 'pageviews' ? b.pageviews : (b[key] || '').toLowerCase() + if (typeof av === 'number' && typeof bv === 'number') { + return dir === 'asc' ? av - bv : bv - av + } + const cmp = String(av).localeCompare(String(bv)) + return dir === 'asc' ? cmp : -cmp + }) +} + +function campaignRowKey(item: CampaignStat): string { + return `${item.source}|${item.medium}|${item.campaign}` +} export default function Campaigns({ siteId, dateRange }: CampaignsProps) { const [data, setData] = useState([]) @@ -23,6 +45,9 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) { const [isBuilderOpen, setIsBuilderOpen] = useState(false) const [fullData, setFullData] = useState([]) const [isLoadingFull, setIsLoadingFull] = useState(false) + const [sortKey, setSortKey] = useState('visitors') + const [sortDir, setSortDir] = useState('desc') + const [faviconFailed, setFaviconFailed] = useState>(new Set()) useEffect(() => { const fetchData = async () => { @@ -58,11 +83,81 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) { } }, [isModalOpen, siteId, dateRange]) + const sortedData = useMemo( + () => sortCampaigns(data, sortKey, sortDir), + [data, sortKey, sortDir] + ) + const sortedFullData = useMemo( + () => sortCampaigns(fullData.length > 0 ? fullData : data, sortKey, sortDir), + [fullData, data, sortKey, sortDir] + ) const hasData = data.length > 0 - const displayedData = hasData ? data.slice(0, LIMIT) : [] + const displayedData = hasData ? sortedData.slice(0, LIMIT) : [] const emptySlots = Math.max(0, LIMIT - displayedData.length) const showViewAll = hasData && data.length > LIMIT + const handleSort = (key: SortKey) => { + if (sortKey === key) { + setSortDir(d => d === 'asc' ? 'desc' : 'asc') + } else { + setSortKey(key) + setSortDir(key === 'visitors' || key === 'pageviews' ? 'desc' : 'asc') + } + } + + function renderSourceIcon(source: string) { + const faviconUrl = getReferrerFavicon(source) + const useFavicon = faviconUrl && !faviconFailed.has(source) + if (useFavicon) { + return ( + setFaviconFailed((prev) => new Set(prev).add(source))} + /> + ) + } + return {getReferrerIcon(source)} + } + + const handleExportCampaigns = () => { + const rows = sortedData.length > 0 ? sortedData : data + if (rows.length === 0) return + const header = ['Source', 'Medium', 'Campaign', 'Visitors', 'Pageviews'] + const csvRows = [ + header.join(','), + ...rows.map(r => + [r.source, r.medium || EMPTY_LABEL, r.campaign || EMPTY_LABEL, r.visitors, r.pageviews].join(',') + ), + ] + const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.setAttribute('href', url) + link.setAttribute('download', `campaigns_${dateRange.start}_${dateRange.end}.csv`) + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + + const SortHeader = ({ label, colKey, className = '' }: { label: string; colKey: SortKey; className?: string }) => ( + + ) + return ( <>
@@ -71,6 +166,16 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) { Campaigns
+ {hasData && ( + + )}
{isLoading ? ( -
-
-

Loading...

+
+
+
+
+
+
+
+
+ {Array.from({ length: 7 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))}
) : hasData ? (
-
Source
-
Medium
-
Campaign
-
Visitors
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
- {displayedData.map((item, index) => ( -
-
- {item.source} + {displayedData.map((item) => ( +
+
+ {renderSourceIcon(item.source)} + + {getReferrerDisplayName(item.source)} +
-
- {item.medium || '-'} +
+ {item.medium || EMPTY_LABEL}
-
- {item.campaign || '-'} +
+ {item.campaign || EMPTY_LABEL}
{formatNumber(item.visitors)}
+
+ {formatNumber(item.pageviews)} +
))} {Array.from({ length: emptySlots }).map((_, i) => ( @@ -161,24 +300,34 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) { <>
Source
-
Medium
-
Campaign
+
Medium
+
Campaign
Visitors
+
Pageviews
- {(fullData.length > 0 ? fullData : data).map((item, index) => ( -
-
- {item.source} + {sortedFullData.map((item) => ( +
+
+ {renderSourceIcon(item.source)} + + {getReferrerDisplayName(item.source)} +
-
- {item.medium || '-'} +
+ {item.medium || EMPTY_LABEL}
-
- {item.campaign || '-'} +
+ {item.campaign || EMPTY_LABEL}
{formatNumber(item.visitors)}
+
+ {formatNumber(item.pageviews)} +
))} diff --git a/components/dashboard/ExportModal.tsx b/components/dashboard/ExportModal.tsx index 90f29b3..bef7d2e 100644 --- a/components/dashboard/ExportModal.tsx +++ b/components/dashboard/ExportModal.tsx @@ -8,7 +8,7 @@ import autoTable from 'jspdf-autotable' import type { DailyStat } from './Chart' import { formatNumber, formatDuration } from '@/lib/utils/format' import { getReferrerDisplayName, mergeReferrersByDisplayName } from '@/lib/utils/icons' -import type { TopPage, TopReferrer } from '@/lib/api/stats' +import type { TopPage, TopReferrer, CampaignStat } from '@/lib/api/stats' interface ExportModalProps { isOpen: boolean @@ -22,6 +22,7 @@ interface ExportModalProps { } topPages?: TopPage[] topReferrers?: TopReferrer[] + campaigns?: CampaignStat[] } type ExportFormat = 'csv' | 'json' | 'xlsx' | 'pdf' @@ -44,7 +45,7 @@ const loadImage = (src: string): Promise => { }) } -export default function ExportModal({ isOpen, onClose, data, stats, topPages, topReferrers }: ExportModalProps) { +export default function ExportModal({ isOpen, onClose, data, stats, topPages, topReferrers, campaigns }: ExportModalProps) { const [format, setFormat] = useState('csv') const [filename, setFilename] = useState(`pulse_export_${new Date().toISOString().split('T')[0]}`) const [includeHeader, setIncludeHeader] = useState(true) @@ -94,7 +95,19 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to } 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") + 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' }) @@ -291,6 +304,36 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to columnStyles: { 1: { halign: 'right' } }, alternateRowStyles: { fillColor: [255, 250, 245] }, }) + + finalY = (doc as any).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`) diff --git a/package.json b/package.json index 97b583e..eb44bf5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-frontend", - "version": "0.3.0-alpha", + "version": "0.4.0-alpha", "private": true, "scripts": { "dev": "next dev",