diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx
index c115c85..8644980 100644
--- a/app/sites/[id]/page.tsx
+++ b/app/sites/[id]/page.tsx
@@ -609,7 +609,7 @@ export default function SiteDashboardPage() {
{/* Campaigns Report */}
diff --git a/components/dashboard/AddFilterDropdown.tsx b/components/dashboard/AddFilterDropdown.tsx
index 6f70435..2ad4066 100644
--- a/components/dashboard/AddFilterDropdown.tsx
+++ b/components/dashboard/AddFilterDropdown.tsx
@@ -115,7 +115,7 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg
}`}
>
Filter
diff --git a/components/dashboard/Campaigns.tsx b/components/dashboard/Campaigns.tsx
index 431bac5..33433a3 100644
--- a/components/dashboard/Campaigns.tsx
+++ b/components/dashboard/Campaigns.tsx
@@ -5,52 +5,30 @@ import { logger } from '@/lib/utils/logger'
import Link from 'next/link'
import Image from 'next/image'
import { formatNumber } from '@ciphera-net/ui'
-import { Modal, ArrowRightIcon, Button } from '@ciphera-net/ui'
-import { TableSkeleton } from '@/components/skeletons'
-import { ChevronDownIcon, DownloadIcon } from '@ciphera-net/ui'
+import { Modal, ArrowRightIcon } from '@ciphera-net/ui'
+import { ListSkeleton } from '@/components/skeletons'
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'
+import { type DimensionFilter } from '@/lib/filters'
interface CampaignsProps {
siteId: string
dateRange: { start: string, end: string }
filters?: string
+ onFilter?: (filter: DimensionFilter) => void
}
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, filters }: CampaignsProps) {
+export default function Campaigns({ siteId, dateRange, filters, onFilter }: CampaignsProps) {
const [data, setData] = useState
([])
const [isLoading, setIsLoading] = useState(true)
const [isModalOpen, setIsModalOpen] = useState(false)
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(() => {
@@ -88,26 +66,19 @@ export default function Campaigns({ siteId, dateRange, filters }: CampaignsProps
}, [isModalOpen, siteId, dateRange, filters])
const sortedData = useMemo(
- () => sortCampaigns(data, sortKey, sortDir),
- [data, sortKey, sortDir]
+ () => [...data].sort((a, b) => b.visitors - a.visitors),
+ [data]
)
const sortedFullData = useMemo(
- () => sortCampaigns(fullData.length > 0 ? fullData : data, sortKey, sortDir),
- [fullData, data, sortKey, sortDir]
+ () => [...(fullData.length > 0 ? fullData : data)].sort((a, b) => b.visitors - a.visitors),
+ [fullData, data]
)
+
+ const totalVisitors = sortedData.reduce((sum, c) => sum + c.visitors, 0)
const hasData = data.length > 0
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')
- }
- }
+ const emptySlots = Math.max(0, LIMIT - displayedData.length)
function renderSourceIcon(source: string) {
const faviconUrl = getReferrerFavicon(source)
@@ -128,14 +99,21 @@ export default function Campaigns({ siteId, dateRange, filters }: CampaignsProps
return {getReferrerIcon(source)}
}
+ function getSecondaryLabel(item: CampaignStat): string | null {
+ const parts: string[] = []
+ if (item.medium) parts.push(item.medium)
+ if (item.campaign) parts.push(item.campaign)
+ return parts.length > 0 ? parts.join(' · ') : null
+ }
+
const handleExportCampaigns = () => {
- const rows = sortedData.length > 0 ? sortedData : data
+ const rows = sortedFullData.length > 0 ? sortedFullData : sortedData
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(',')
+ [r.source, r.medium || '', r.campaign || '', r.visitors, r.pageviews].join(',')
),
]
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' })
@@ -149,22 +127,6 @@ export default function Campaigns({ siteId, dateRange, filters }: CampaignsProps
URL.revokeObjectURL(url)
}
- const SortHeader = ({ label, colKey, className = '' }: { label: string; colKey: SortKey; className?: string }) => (
-
- )
-
return (
<>
@@ -172,127 +134,88 @@ export default function Campaigns({ siteId, dateRange, filters }: CampaignsProps
Campaigns
-
- {hasData && (
-
- )}
-
-
+
- {isLoading ? (
-
-
- {Array.from({ length: 7 }).map((_, i) => (
-
-
-
-
-
-
+
+ {isLoading ? (
+
+ ) : hasData ? (
+ <>
+ {displayedData.map((item) => {
+ const secondary = getSecondaryLabel(item)
+ return (
+
onFilter?.({ dimension: 'utm_source', operator: 'is', values: [item.source] })}
+ className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
+ >
+
+ {renderSourceIcon(item.source)}
+
+
+ {getReferrerDisplayName(item.source)}
+
+ {secondary && (
+
+ {secondary}
+
+ )}
+
+
+
+
+ {totalVisitors > 0 ? `${Math.round((item.visitors / totalVisitors) * 100)}%` : ''}
+
+
+ {formatNumber(item.visitors)}
+
+
+
+ )
+ })}
+ {showViewAll ? (
+
+ ) : (
+ Array.from({ length: emptySlots }).map((_, i) => (
+
+ ))
+ )}
+ >
+ ) : (
+
- ) : hasData ? (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {displayedData.map((item) => (
-
+ Track your marketing campaigns
+
+
+ Add UTM parameters to your links to see campaign performance here.
+
+
-
- {renderSourceIcon(item.source)}
-
- {getReferrerDisplayName(item.source)}
-
-
-
- {item.medium || EMPTY_LABEL}
-
-
- {item.campaign || EMPTY_LABEL}
-
-
- {formatNumber(item.visitors)}
-
-
- {formatNumber(item.pageviews)}
-
-
- ))}
- {showViewAll ? (
-
- ) : (
- Array.from({ length: emptySlots }).map((_, i) => (
-
- ))
- )}
-
- ) : (
-
-
-
- Track your marketing campaigns
-
-
- Add utm_source, utm_medium, and utm_campaign parameters to your links to see them here.
-
-
- Read documentation
-
-
-
- )}
+ )}
+
setIsModalOpen(false)}
title="All Campaigns"
>
-
+
{isLoadingFull ? (
) : (
<>
-
-
Source
-
Medium
-
Campaign
-
Visitors
-
Pageviews
-
- {sortedFullData.map((item) => (
-
+
- ))}
+ )
+ })}
>
)}