From 18e003dd809fda83497d3f65afb3f1368150f236 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Thu, 22 Jan 2026 13:25:06 +0100 Subject: [PATCH] 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 --- App.tsx | 11 +- components/AdsPerformance.tsx | 254 ++++++++++++++++++++++++++++++++++ services/dataProcessor.ts | 2 + types.ts | 1 + 4 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 components/AdsPerformance.tsx diff --git a/App.tsx b/App.tsx index ff157ab..b41ba79 100644 --- a/App.tsx +++ b/App.tsx @@ -8,13 +8,14 @@ import CrazeLogo from './components/CrazeLogo'; import { SalesRecord, FilterState, AggregatedData, AdsRecord } from './types'; // Imported AdsRecord import { processCSV, filterData, filterAdsData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, mergeSalesAndAdsData } from './services/dataProcessor'; import { queryGemini } from './services/geminiService'; -import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon } from './components/Icons'; +import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons'; import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage'; // Lazy load heavy components for better initial performance const DataGrid = lazy(() => import('./components/DataGrid')); const WeeklyGrid = lazy(() => import('./components/WeeklyGrid')); const TopMovers = lazy(() => import('./components/TopMovers')); +const AdsPerformance = lazy(() => import('./components/AdsPerformance')); // Loading fallback component const LoadingSpinner = () => ( @@ -456,6 +457,13 @@ const App: React.FC = () => { > Movers + @@ -485,6 +493,7 @@ const App: React.FC = () => { {view === 'table' && 0} adsData={filteredAdsData} />} {view === 'weekly' && } {view === 'movers' && } + {view === 'ads' && } diff --git a/components/AdsPerformance.tsx b/components/AdsPerformance.tsx new file mode 100644 index 0000000..e9613f7 --- /dev/null +++ b/components/AdsPerformance.tsx @@ -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 = ({ 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(); + + 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 ( +
+ {/* Header */} +
+
+

Performance Overview

+
+ + {aggregatedByAsin.length} records processed • All • All Years +
+
+
+ + {/* Summary Grid */} +
+ + + + + + + + + + + + + +
+ + {/* Table Section */} +
+
+
+

Product Inventory

+

Displaying total accumulated data for selected filters.

+
+
+ 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" + /> +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + {filteredAndSorted.map((row) => ( + + + + + + + + + + + + + + + + + ))} + +
PRODUCT
+
+ {row.asin} + SKU: {row.sku} + {row.title} +
+
{formatNumber(row.salesTotal)}{formatNumber(row.salesAds)}{formatNumber(row.salesOrganic)}{formatNumber(row.cost)}{formatNumber(row.impressions)}{formatNumber(row.clicks)}{formatNumber(row.conversions)}${row.cpc.toFixed(2)}{row.ctr.toFixed(2)}%{row.cvrUnits.toFixed(2)}%{row.acos.toFixed(2)}%{row.roas.toFixed(2)}{row.tacos.toFixed(2)}%
+
+
+
+ ); +}; + +const SummaryCard = ({ label, value, color = "text-white" }: { label: string; value: string; color?: string }) => ( +
+ {label} + {value} +
+); + +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 ( + 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`} + > +
+ {label} + + + + +
+ + ); +}; + +export default AdsPerformance; diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index bc6c5cf..4052bb2 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -569,6 +569,7 @@ export const mergeSalesAndAdsData = ( cost: adCost, clicks: adClicks, impressions: adImpressions, + conversions: ad?.conversions || 0, salesOrganic, unitsOrganic, paidSalesShare: salesTotal > 0 ? (adSales / salesTotal) * 100 : 0, @@ -606,6 +607,7 @@ export const mergeSalesAndAdsData = ( cost: ad.cost, clicks: ad.clicks, impressions: ad.impressions, + conversions: ad.conversions, salesOrganic: 0, unitsOrganic: 0, paidSalesShare: 0, diff --git a/types.ts b/types.ts index 7386738..6537a72 100644 --- a/types.ts +++ b/types.ts @@ -170,6 +170,7 @@ export interface CombinedKPIs { cost: number; clicks: number; impressions: number; + conversions: number; salesOrganic: number; unitsOrganic: number;