Remove expand icon from all panel headers. Add a subtle "View all ›" link at the bottom of each data list that appears when there's more data than shown. Headers now only contain title and tabs.
359 lines
15 KiB
TypeScript
359 lines
15 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useMemo } from 'react'
|
|
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 { 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'
|
|
|
|
interface CampaignsProps {
|
|
siteId: string
|
|
dateRange: { start: string, end: string }
|
|
filters?: string
|
|
}
|
|
|
|
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) {
|
|
const [data, setData] = useState<CampaignStat[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
|
const [isBuilderOpen, setIsBuilderOpen] = useState(false)
|
|
const [fullData, setFullData] = useState<CampaignStat[]>([])
|
|
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
|
const [sortKey, setSortKey] = useState<SortKey>('visitors')
|
|
const [sortDir, setSortDir] = useState<SortDir>('desc')
|
|
const [faviconFailed, setFaviconFailed] = useState<Set<string>>(new Set())
|
|
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 10, filters)
|
|
setData(result)
|
|
} catch (e) {
|
|
logger.error(e)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
fetchData()
|
|
}, [siteId, dateRange, filters])
|
|
|
|
useEffect(() => {
|
|
if (isModalOpen) {
|
|
const fetchFullData = async () => {
|
|
setIsLoadingFull(true)
|
|
try {
|
|
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 100, filters)
|
|
setFullData(result)
|
|
} catch (e) {
|
|
logger.error(e)
|
|
} finally {
|
|
setIsLoadingFull(false)
|
|
}
|
|
}
|
|
fetchFullData()
|
|
} else {
|
|
setFullData([])
|
|
}
|
|
}, [isModalOpen, siteId, dateRange, filters])
|
|
|
|
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 ? 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 (
|
|
<Image
|
|
src={faviconUrl}
|
|
alt=""
|
|
width={20}
|
|
height={20}
|
|
className="w-5 h-5 flex-shrink-0 rounded object-contain"
|
|
onError={() => setFaviconFailed((prev) => new Set(prev).add(source))}
|
|
unoptimized
|
|
/>
|
|
)
|
|
}
|
|
return <span className="text-lg flex-shrink-0">{getReferrerIcon(source)}</span>
|
|
}
|
|
|
|
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 }) => (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSort(colKey)}
|
|
className={`inline-flex items-center gap-1 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-inset rounded ${className}`}
|
|
aria-label={`Sort by ${label}`}
|
|
>
|
|
{label}
|
|
{sortKey === colKey ? (
|
|
<ChevronDownIcon className={`w-3 h-3 text-brand-orange ${sortDir === 'asc' ? 'rotate-180' : ''}`} />
|
|
) : (
|
|
<span className="w-3 h-3 inline-block text-neutral-400" aria-hidden />
|
|
)}
|
|
</button>
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
|
Campaigns
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
{hasData && (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={handleExportCampaigns}
|
|
className="h-8 px-3 text-xs gap-2"
|
|
>
|
|
<DownloadIcon className="w-3.5 h-3.5" />
|
|
Export
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => setIsBuilderOpen(true)}
|
|
className="h-8 px-3 text-xs gap-2"
|
|
>
|
|
<PlusIcon className="w-3.5 h-3.5" />
|
|
Build URL
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="space-y-2 flex-1 min-h-[270px]">
|
|
<div className="grid grid-cols-12 gap-2 mb-2 px-2">
|
|
<div className="col-span-4 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
|
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
|
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
|
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
|
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
|
</div>
|
|
{Array.from({ length: 7 }).map((_, i) => (
|
|
<div key={`skeleton-${i}`} className="grid grid-cols-12 gap-2 h-9 px-2 -mx-2">
|
|
<div className="col-span-4 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
|
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
|
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
|
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
|
<div className="col-span-2 h-4 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : hasData ? (
|
|
<div className="space-y-2 flex-1 min-h-[270px]">
|
|
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2 px-2">
|
|
<div className="col-span-4">
|
|
<SortHeader label="Source" colKey="source" className="text-left" />
|
|
</div>
|
|
<div className="col-span-2">
|
|
<SortHeader label="Medium" colKey="medium" className="text-left" />
|
|
</div>
|
|
<div className="col-span-2">
|
|
<SortHeader label="Campaign" colKey="campaign" className="text-left" />
|
|
</div>
|
|
<div className="col-span-2 text-right">
|
|
<SortHeader label="Visitors" colKey="visitors" className="text-right justify-end" />
|
|
</div>
|
|
<div className="col-span-2 text-right">
|
|
<SortHeader label="Pageviews" colKey="pageviews" className="text-right justify-end" />
|
|
</div>
|
|
</div>
|
|
{displayedData.map((item) => (
|
|
<div
|
|
key={campaignRowKey(item)}
|
|
className="grid grid-cols-12 gap-2 items-center h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors text-sm"
|
|
>
|
|
<div className="col-span-4 flex items-center gap-3 truncate">
|
|
{renderSourceIcon(item.source)}
|
|
<span className="truncate text-neutral-900 dark:text-white font-medium" title={item.source}>
|
|
{getReferrerDisplayName(item.source)}
|
|
</span>
|
|
</div>
|
|
<div className="col-span-2 truncate text-neutral-500 dark:text-neutral-400" title={item.medium}>
|
|
{item.medium || EMPTY_LABEL}
|
|
</div>
|
|
<div className="col-span-2 truncate text-neutral-500 dark:text-neutral-400" title={item.campaign}>
|
|
{item.campaign || EMPTY_LABEL}
|
|
</div>
|
|
<div className="col-span-2 text-right font-semibold text-neutral-900 dark:text-white">
|
|
{formatNumber(item.visitors)}
|
|
</div>
|
|
<div className="col-span-2 text-right text-neutral-600 dark:text-neutral-400">
|
|
{formatNumber(item.pageviews)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{showViewAll ? (
|
|
<button
|
|
onClick={() => setIsModalOpen(true)}
|
|
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
|
|
>
|
|
View all
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
|
</svg>
|
|
</button>
|
|
) : (
|
|
Array.from({ length: emptySlots }).map((_, i) => (
|
|
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
|
))
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
|
|
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
|
<FaBullhorn className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
|
</div>
|
|
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
|
Track your marketing campaigns
|
|
</h4>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
|
|
Add <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs font-mono">utm_source</code>, <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs font-mono">utm_medium</code>, and <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-xs font-mono">utm_campaign</code> parameters to your links to see them here.
|
|
</p>
|
|
<Link
|
|
href="/installation"
|
|
className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange/20 rounded"
|
|
>
|
|
Read documentation
|
|
<ArrowRightIcon className="w-4 h-4" />
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Modal
|
|
isOpen={isModalOpen}
|
|
onClose={() => setIsModalOpen(false)}
|
|
title="All Campaigns"
|
|
>
|
|
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
|
{isLoadingFull ? (
|
|
<div className="py-4">
|
|
<TableSkeleton rows={10} cols={5} />
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2 px-2 sticky top-0 bg-white dark:bg-neutral-900 py-2 z-10">
|
|
<div className="col-span-4">Source</div>
|
|
<div className="col-span-2">Medium</div>
|
|
<div className="col-span-2">Campaign</div>
|
|
<div className="col-span-2 text-right">Visitors</div>
|
|
<div className="col-span-2 text-right">Pageviews</div>
|
|
</div>
|
|
{sortedFullData.map((item) => (
|
|
<div
|
|
key={campaignRowKey(item)}
|
|
className="grid grid-cols-12 gap-2 items-center py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors text-sm border-b border-neutral-100 dark:border-neutral-800 last:border-0"
|
|
>
|
|
<div className="col-span-4 flex items-center gap-3 truncate">
|
|
{renderSourceIcon(item.source)}
|
|
<span className="truncate text-neutral-900 dark:text-white font-medium" title={item.source}>
|
|
{getReferrerDisplayName(item.source)}
|
|
</span>
|
|
</div>
|
|
<div className="col-span-2 truncate text-neutral-500 dark:text-neutral-400" title={item.medium}>
|
|
{item.medium || EMPTY_LABEL}
|
|
</div>
|
|
<div className="col-span-2 truncate text-neutral-500 dark:text-neutral-400" title={item.campaign}>
|
|
{item.campaign || EMPTY_LABEL}
|
|
</div>
|
|
<div className="col-span-2 text-right font-semibold text-neutral-900 dark:text-white">
|
|
{formatNumber(item.visitors)}
|
|
</div>
|
|
<div className="col-span-2 text-right text-neutral-600 dark:text-neutral-400">
|
|
{formatNumber(item.pageviews)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={isBuilderOpen}
|
|
onClose={() => setIsBuilderOpen(false)}
|
|
title="Campaign URL Builder"
|
|
>
|
|
<div className="p-1">
|
|
<UtmBuilder initialSiteId={siteId} />
|
|
</div>
|
|
</Modal>
|
|
</>
|
|
)
|
|
}
|