mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 12:15:23 +02:00
feat: implement Ads Performance tab
- types.ts: Add conversions field to CombinedKPIs - dataProcessor.ts: Populate conversions in mergeSalesAndAdsData - AdsPerformance.tsx: New component with aggregated advertising metrics and detailed product table - App.tsx: Add Ads view to switcher and render AdsPerformance component - Icons.tsx: Added icon support for the new Ads tab
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { CombinedKPIs } from '../types';
|
||||
|
||||
interface AdsPerformanceProps {
|
||||
data: CombinedKPIs[];
|
||||
}
|
||||
|
||||
type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits';
|
||||
|
||||
const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({
|
||||
key: 'cost',
|
||||
direction: 'desc'
|
||||
});
|
||||
|
||||
// 1. Aggregate data by ASIN
|
||||
const aggregatedByAsin = useMemo(() => {
|
||||
const map = new Map<string, CombinedKPIs>();
|
||||
|
||||
data.forEach(item => {
|
||||
const asin = item.asin.trim().toUpperCase();
|
||||
if (!map.has(asin)) {
|
||||
map.set(asin, { ...item });
|
||||
} else {
|
||||
const existing = map.get(asin)!;
|
||||
existing.salesTotal += item.salesTotal;
|
||||
existing.unitsTotal += item.unitsTotal;
|
||||
existing.salesAds += item.salesAds;
|
||||
existing.unitsAds += item.unitsAds;
|
||||
existing.cost += item.cost;
|
||||
existing.clicks += item.clicks;
|
||||
existing.impressions += item.impressions;
|
||||
existing.conversions += item.conversions;
|
||||
existing.salesOrganic += item.salesOrganic;
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(map.values()).map(item => {
|
||||
// Recalculate derived metrics for the aggregate
|
||||
const salesTotal = item.salesTotal;
|
||||
const adSales = item.salesAds;
|
||||
const adCost = item.cost;
|
||||
const adClicks = item.clicks;
|
||||
const adImpressions = item.impressions;
|
||||
const adConversions = item.conversions;
|
||||
|
||||
return {
|
||||
...item,
|
||||
acos: adSales > 0 ? (adCost / adSales) * 100 : 0,
|
||||
tacos: salesTotal > 0 ? (adCost / salesTotal) * 100 : 0,
|
||||
roas: adCost > 0 ? adSales / adCost : 0,
|
||||
ctr: adImpressions > 0 ? (adClicks / adImpressions) * 100 : 0,
|
||||
cpc: adClicks > 0 ? adCost / adClicks : 0,
|
||||
cvrUnits: adClicks > 0 ? (adConversions / adClicks) * 100 : 0,
|
||||
};
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
// 2. Global Totals for Summary Header
|
||||
const totals = useMemo(() => {
|
||||
return aggregatedByAsin.reduce((acc, curr) => ({
|
||||
salesTotal: acc.salesTotal + curr.salesTotal,
|
||||
salesAds: acc.salesAds + curr.salesAds,
|
||||
salesOrganic: acc.salesOrganic + curr.salesOrganic,
|
||||
cost: acc.cost + curr.cost,
|
||||
impressions: acc.impressions + curr.impressions,
|
||||
clicks: acc.clicks + curr.clicks,
|
||||
conversions: acc.conversions + curr.conversions,
|
||||
}), {
|
||||
salesTotal: 0,
|
||||
salesAds: 0,
|
||||
salesOrganic: 0,
|
||||
cost: 0,
|
||||
impressions: 0,
|
||||
clicks: 0,
|
||||
conversions: 0,
|
||||
});
|
||||
}, [aggregatedByAsin]);
|
||||
|
||||
const globalMetrics = {
|
||||
acos: totals.salesAds > 0 ? (totals.cost / totals.salesAds) * 100 : 0,
|
||||
tacos: totals.salesTotal > 0 ? (totals.cost / totals.salesTotal) * 100 : 0,
|
||||
roas: totals.cost > 0 ? totals.salesAds / totals.cost : 0,
|
||||
ctr: totals.impressions > 0 ? (totals.clicks / totals.impressions) * 100 : 0,
|
||||
cpc: totals.clicks > 0 ? totals.cost / totals.clicks : 0,
|
||||
cvr: totals.clicks > 0 ? (totals.conversions / totals.clicks) * 100 : 0,
|
||||
};
|
||||
|
||||
// 3. Filter and Sort
|
||||
const filteredAndSorted = useMemo(() => {
|
||||
let result = aggregatedByAsin.filter(item =>
|
||||
item.asin.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.sku.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.title.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
result.sort((a, b) => {
|
||||
const valA = a[sortConfig.key] as number;
|
||||
const valB = b[sortConfig.key] as number;
|
||||
return sortConfig.direction === 'asc' ? valA - valB : valB - valA;
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [aggregatedByAsin, searchTerm, sortConfig]);
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
setSortConfig(prev => ({
|
||||
key,
|
||||
direction: prev.key === key && prev.direction === 'desc' ? 'asc' : 'desc'
|
||||
}));
|
||||
};
|
||||
|
||||
const formatCurrency = (val: number) =>
|
||||
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(val);
|
||||
|
||||
const formatNumber = (val: number) =>
|
||||
new Intl.NumberFormat('en-US').format(val);
|
||||
|
||||
const formatPercent = (val: number) =>
|
||||
`${val.toFixed(2)}%`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-fade-in p-6 bg-[#0B0E14] min-h-screen text-slate-300">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">Performance Overview</h1>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
<span>{aggregatedByAsin.length} records processed • All • All Years</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 xl:grid-cols-13 gap-4 bg-[#141820] p-4 rounded-xl border border-white/5 shadow-xl">
|
||||
<SummaryCard label="TOTAL SALES" value={formatCurrency(totals.salesTotal)} color="text-emerald-400" />
|
||||
<SummaryCard label="ADS SALES" value={formatCurrency(totals.salesAds)} color="text-orange-400" />
|
||||
<SummaryCard label="ORGANIC SALES" value={formatCurrency(totals.salesOrganic)} color="text-blue-400" />
|
||||
<SummaryCard label="AD SPEND" value={formatCurrency(totals.cost)} color="text-indigo-400" />
|
||||
<SummaryCard label="IMPR." value={formatNumber(totals.impressions)} />
|
||||
<SummaryCard label="CLICKS" value={formatNumber(totals.clicks)} />
|
||||
<SummaryCard label="ADS ORDERS" value={formatNumber(totals.conversions)} />
|
||||
<SummaryCard label="CPC" value={`$${globalMetrics.cpc.toFixed(2)}`} />
|
||||
<SummaryCard label="CTR" value={formatPercent(globalMetrics.ctr)} />
|
||||
<SummaryCard label="CVR" value={formatPercent(globalMetrics.cvr)} />
|
||||
<SummaryCard label="ACOS" value={formatPercent(globalMetrics.acos)} />
|
||||
<SummaryCard label="ROAS" value={globalMetrics.roas.toFixed(2)} color="text-blue-300" />
|
||||
<SummaryCard label="TACOS" value={formatPercent(globalMetrics.tacos)} />
|
||||
</div>
|
||||
|
||||
{/* Table Section */}
|
||||
<div className="bg-[#141820] rounded-xl border border-white/10 overflow-hidden shadow-2xl flex-1 flex flex-col">
|
||||
<div className="p-4 border-b border-white/5 flex justify-between items-center bg-[#1A1F29]">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-white uppercase tracking-wider">Product Inventory</h2>
|
||||
<p className="text-[10px] text-slate-500 mt-0.5">Displaying total accumulated data for selected filters.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search ASIN, SKU or Title..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="bg-[#0B0E14] border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto flex-1 custom-scrollbar">
|
||||
<table className="w-full text-left border-collapse text-[11px]">
|
||||
<thead className="sticky top-0 z-20 bg-[#1A1F29] shadow-sm">
|
||||
<tr className="border-b border-white/5">
|
||||
<th className="p-4 font-black text-slate-400 uppercase tracking-widest min-w-[250px]">PRODUCT</th>
|
||||
<SortableHeader label="TOTAL SALES" sortKey="salesTotal" activeSort={sortConfig} onSort={handleSort} />
|
||||
<SortableHeader label="ADS SALES" sortKey="salesAds" activeSort={sortConfig} onSort={handleSort} />
|
||||
<SortableHeader label="ORGANIC SALES" sortKey="salesOrganic" activeSort={sortConfig} onSort={handleSort} />
|
||||
<SortableHeader label="AD SPEND" sortKey="cost" activeSort={sortConfig} onSort={handleSort} />
|
||||
<SortableHeader label="IMPR." sortKey="impressions" activeSort={sortConfig} onSort={handleSort} />
|
||||
<SortableHeader label="CLICKS" sortKey="clicks" activeSort={sortConfig} onSort={handleSort} />
|
||||
<SortableHeader label="ADS ORDERS" sortKey="conversions" activeSort={sortConfig} onSort={handleSort} />
|
||||
<SortableHeader label="CPC" sortKey="cpc" activeSort={sortConfig} onSort={handleSort} />
|
||||
<SortableHeader label="CTR" sortKey="ctr" activeSort={sortConfig} onSort={handleSort} />
|
||||
<SortableHeader label="CVR" sortKey="cvrUnits" activeSort={sortConfig} onSort={handleSort} />
|
||||
<SortableHeader label="ACOS" sortKey="acos" activeSort={sortConfig} onSort={handleSort} />
|
||||
<SortableHeader label="ROAS" sortKey="roas" activeSort={sortConfig} onSort={handleSort} />
|
||||
<SortableHeader label="TACOS" sortKey="tacos" activeSort={sortConfig} onSort={handleSort} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.03]">
|
||||
{filteredAndSorted.map((row) => (
|
||||
<tr key={row.id} className="hover:bg-white/[0.02] transition-colors group">
|
||||
<td className="p-4 py-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-white font-bold text-sm tracking-tight">{row.asin}</span>
|
||||
<span className="text-[10px] uppercase font-black text-indigo-400 tracking-tighter">SKU: {row.sku}</span>
|
||||
<span className="text-[10px] text-slate-500 truncate max-w-[220px] leading-tight" title={row.title}>{row.title}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-right font-bold text-white">{formatNumber(row.salesTotal)}</td>
|
||||
<td className="p-4 text-right font-bold text-white">{formatNumber(row.salesAds)}</td>
|
||||
<td className="p-4 text-right font-bold text-white">{formatNumber(row.salesOrganic)}</td>
|
||||
<td className="p-4 text-right font-bold text-white">{formatNumber(row.cost)}</td>
|
||||
<td className="p-4 text-right text-slate-400">{formatNumber(row.impressions)}</td>
|
||||
<td className="p-4 text-right text-slate-400">{formatNumber(row.clicks)}</td>
|
||||
<td className="p-4 text-right text-slate-400">{formatNumber(row.conversions)}</td>
|
||||
<td className="p-4 text-right text-slate-400">${row.cpc.toFixed(2)}</td>
|
||||
<td className="p-4 text-right text-slate-400">{row.ctr.toFixed(2)}%</td>
|
||||
<td className="p-4 text-right text-slate-400">{row.cvrUnits.toFixed(2)}%</td>
|
||||
<td className="p-4 text-right text-slate-400">{row.acos.toFixed(2)}%</td>
|
||||
<td className="p-4 text-right font-bold text-blue-300">{row.roas.toFixed(2)}</td>
|
||||
<td className="p-4 text-right text-slate-400">{row.tacos.toFixed(2)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SummaryCard = ({ label, value, color = "text-white" }: { label: string; value: string; color?: string }) => (
|
||||
<div className="flex flex-col items-center justify-center p-2 border-r last:border-r-0 border-white/5 w-full min-w-[80px]">
|
||||
<span className={`${color} text-[10px] font-black tracking-widest mb-1 text-center leading-tight`}>{label}</span>
|
||||
<span className="text-sm font-bold text-white">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SortableHeader = ({ label, sortKey, activeSort, onSort }: {
|
||||
label: string;
|
||||
sortKey: SortKey;
|
||||
activeSort: { key: SortKey; direction: 'asc' | 'desc' };
|
||||
onSort: (key: SortKey) => void;
|
||||
}) => {
|
||||
const isActive = activeSort.key === sortKey;
|
||||
return (
|
||||
<th
|
||||
onClick={() => onSort(sortKey)}
|
||||
className={`p-4 font-black text-slate-400 uppercase tracking-widest text-right cursor-pointer hover:text-white transition-colors group min-w-[100px] whitespace-nowrap`}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<span className="text-[10px]">{label}</span>
|
||||
<span className={`text-[10px] flex flex-col leading-[0.5] transition-opacity ${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-40'}`}>
|
||||
<span className={isActive && activeSort.direction === 'asc' ? 'text-indigo-400' : ''}>▴</span>
|
||||
<span className={isActive && activeSort.direction === 'desc' ? 'text-indigo-400' : ''}>▾</span>
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdsPerformance;
|
||||
Reference in New Issue
Block a user