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) => ( + setIsModalOpen(false)} title="All Campaigns" > -
+
{isLoadingFull ? (
- +
) : ( <> -
-
Source
-
Medium
-
Campaign
-
Visitors
-
Pageviews
-
- {sortedFullData.map((item) => ( -
+ +
+ {sortedFullData.map((item) => { + const secondary = getSecondaryLabel(item) + return ( +
+
+ {renderSourceIcon(item.source)} +
+ + {getReferrerDisplayName(item.source)} + + {secondary && ( + + {secondary} + + )} +
+
+
+ + {formatNumber(item.visitors)} + + + {formatNumber(item.pageviews)} pv + +
-
- {item.medium || EMPTY_LABEL} -
-
- {item.campaign || EMPTY_LABEL} -
-
- {formatNumber(item.visitors)} -
-
- {formatNumber(item.pageviews)} -
-
- ))} + ) + })} )}