Files
CrazeAnalytix/components/AdsPerformance.tsx
T

348 lines
20 KiB
TypeScript
Raw Normal View History

2026-01-22 13:25:06 +01:00
import React, { useMemo, useState } from 'react';
import { CombinedKPIs, FilterState } from '../types';
2026-01-22 13:25:06 +01:00
interface AdsPerformanceProps {
data: CombinedKPIs[];
filters: FilterState;
2026-01-22 13:25:06 +01:00
}
type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits';
const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters }) => {
2026-01-22 13:25:06 +01:00
const [searchTerm, setSearchTerm] = useState('');
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({
key: 'cost',
direction: 'desc'
});
// Detect comparison mode: exactly 2 weeks, 2 months, or 2 years
const comparisonConfig = useMemo(() => {
let periods: string[] = [];
let type: 'week' | 'month' | 'year' | null = null;
if (filters.week.length === 2) {
periods = [...filters.week].sort((a, b) => {
const wA = parseInt(a.replace('W', ''));
const wB = parseInt(b.replace('W', ''));
return wA - wB;
});
type = 'week';
} else if (filters.month.length === 2) {
// Simple sort for abbreviated months might be tricky without year,
// but usually users pick consecutive ones.
periods = [...filters.month];
type = 'month';
} else if (filters.year.length === 2) {
periods = [...filters.year].sort();
type = 'year';
}
if (type && periods.length === 2) {
return { type, prev: periods[0], curr: periods[1] };
}
return null;
}, [filters]);
// 1. Aggregate and Compare data by ASIN
2026-01-22 13:25:06 +01:00
const aggregatedByAsin = useMemo(() => {
const map = new Map<string, { curr: CombinedKPIs; prev?: CombinedKPIs }>();
2026-01-22 13:25:06 +01:00
data.forEach(item => {
const asin = item.asin.trim().toUpperCase();
const periodVal = comparisonConfig?.type === 'week' ? `W${item.week}` :
comparisonConfig?.type === 'month' ? item.month :
comparisonConfig?.type === 'year' ? item.year.toString() : null;
const isCurrent = !comparisonConfig || periodVal === comparisonConfig.curr;
const isPrevious = comparisonConfig && periodVal === comparisonConfig.prev;
2026-01-22 13:25:06 +01:00
if (!map.has(asin)) {
// Initialize with empty record if it's new
const defaultRec: CombinedKPIs = { ...item, salesTotal: 0, unitsTotal: 0, salesAds: 0, unitsAds: 0, cost: 0, clicks: 0, impressions: 0, conversions: 0, salesOrganic: 0 };
map.set(asin, {
curr: isCurrent ? { ...item } : { ...defaultRec },
prev: isPrevious ? { ...item } : undefined
});
2026-01-22 13:25:06 +01:00
} else {
const entry = map.get(asin)!;
const target = isCurrent ? entry.curr : (isPrevious ? (entry.prev || (entry.prev = { ...item, salesTotal: 0, unitsTotal: 0, salesAds: 0, unitsAds: 0, cost: 0, clicks: 0, impressions: 0, conversions: 0, salesOrganic: 0 })) : null);
if (target) {
target.salesTotal += item.salesTotal;
target.unitsTotal += item.unitsTotal;
target.salesAds += item.salesAds;
target.unitsAds += item.unitsAds;
target.cost += item.cost;
target.clicks += item.clicks;
target.impressions += item.impressions;
target.conversions += item.conversions;
target.salesOrganic += item.salesOrganic;
}
2026-01-22 13:25:06 +01:00
}
});
return Array.from(map.values()).map(({ curr, prev }) => {
const calcMetrics = (item: CombinedKPIs) => ({
...item,
acos: item.salesAds > 0 ? (item.cost / item.salesAds) * 100 : 0,
tacos: item.salesTotal > 0 ? (item.cost / item.salesTotal) * 100 : 0,
roas: item.cost > 0 ? item.salesAds / item.cost : 0,
ctr: item.impressions > 0 ? (item.clicks / item.impressions) * 100 : 0,
cpc: item.clicks > 0 ? item.cost / item.clicks : 0,
cvrUnits: item.clicks > 0 ? (item.conversions / item.clicks) * 100 : 0,
});
const currFinal = calcMetrics(curr);
const prevFinal = prev ? calcMetrics(prev) : undefined;
2026-01-22 13:25:06 +01:00
return {
...currFinal,
prevData: prevFinal
2026-01-22 13:25:06 +01:00
};
});
}, [data, comparisonConfig]);
2026-01-22 13:25:06 +01:00
// 2. Global Totals
2026-01-22 13:25:06 +01:00
const totals = useMemo(() => {
const sum = (items: any[]) => items.reduce((acc, curr) => ({
2026-01-22 13:25:06 +01:00
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 });
2026-01-22 13:25:06 +01:00
const currTotals = sum(aggregatedByAsin);
const prevTotals = comparisonConfig ? sum(aggregatedByAsin.map(a => a.prevData).filter(Boolean)) : null;
return { curr: currTotals, prev: prevTotals };
}, [aggregatedByAsin, comparisonConfig]);
const calculateDerived = (t: any) => ({
acos: t.salesAds > 0 ? (t.cost / t.salesAds) * 100 : 0,
tacos: t.salesTotal > 0 ? (t.cost / t.salesTotal) * 100 : 0,
roas: t.cost > 0 ? t.salesAds / t.cost : 0,
ctr: t.impressions > 0 ? (t.clicks / t.impressions) * 100 : 0,
cpc: t.clicks > 0 ? t.cost / t.clicks : 0,
cvr: t.clicks > 0 ? (t.conversions / t.clicks) * 100 : 0,
});
const currMetrics = calculateDerived(totals.curr);
const prevMetrics = totals.prev ? calculateDerived(totals.prev) : null;
2026-01-22 13:25:06 +01:00
// 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) =>
`$${Math.round(val).toLocaleString('de-DE')}`;
2026-01-22 13:25:06 +01:00
const formatNumber = (val: number) =>
Math.round(val).toLocaleString('de-DE');
2026-01-22 13:25:06 +01:00
const formatPercent = (val: number) =>
`${val.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%`;
2026-01-22 13:25:06 +01:00
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} products
{comparisonConfig ? ` Comparison: ${comparisonConfig.prev} vs ${comparisonConfig.curr}` : ' Direct Overview'}
</span>
2026-01-22 13:25:06 +01:00
</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.curr.salesTotal)} prevValue={totals.prev?.salesTotal} color="text-emerald-400" />
<SummaryCard label="ADS SALES" value={formatCurrency(totals.curr.salesAds)} prevValue={totals.prev?.salesAds} color="text-orange-400" />
<SummaryCard label="ORGANIC SALES" value={formatCurrency(totals.curr.salesOrganic)} prevValue={totals.prev?.salesOrganic} color="text-blue-400" />
<SummaryCard label="AD SPEND" value={formatCurrency(totals.curr.cost)} prevValue={totals.prev?.cost} color="text-indigo-400" />
<SummaryCard label="IMPR." value={formatNumber(totals.curr.impressions)} prevValue={totals.prev?.impressions} />
<SummaryCard label="CLICKS" value={formatNumber(totals.curr.clicks)} prevValue={totals.prev?.clicks} />
<SummaryCard label="ADS ORDERS" value={formatNumber(totals.curr.conversions)} prevValue={totals.prev?.conversions} />
<SummaryCard label="CPC" value={`$${currMetrics.cpc.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} prevValue={prevMetrics?.cpc} inverse />
<SummaryCard label="CTR" value={formatPercent(currMetrics.ctr)} prevValue={prevMetrics?.ctr} />
<SummaryCard label="CVR" value={formatPercent(currMetrics.cvr)} prevValue={prevMetrics?.cvr} />
<SummaryCard label="ACOS" value={formatPercent(currMetrics.acos)} prevValue={prevMetrics?.acos} inverse />
<SummaryCard label="ROAS" value={currMetrics.roas.toFixed(2)} prevValue={prevMetrics?.roas} color="text-blue-300" />
<SummaryCard label="TACOS" value={formatPercent(currMetrics.tacos)} prevValue={prevMetrics?.tacos} inverse />
2026-01-22 13:25:06 +01:00
</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">
{comparisonConfig ? `Comparing ${comparisonConfig.curr} vs ${comparisonConfig.prev}` : 'Displaying total data for selected filters.'}
</p>
2026-01-22 13:25:06 +01:00
</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: any) => (
2026-01-22 13:25:06 +01:00
<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>
<TableCell value={row.salesTotal} prevValue={row.prevData?.salesTotal} format="currency" />
<TableCell value={row.salesAds} prevValue={row.prevData?.salesAds} format="currency" />
<TableCell value={row.salesOrganic} prevValue={row.prevData?.salesOrganic} format="currency" />
<TableCell value={row.cost} prevValue={row.prevData?.cost} format="currency" />
<TableCell value={row.impressions} prevValue={row.prevData?.impressions} format="number" />
<TableCell value={row.clicks} prevValue={row.prevData?.clicks} format="number" />
<TableCell value={row.conversions} prevValue={row.prevData?.conversions} format="number" />
<TableCell value={row.cpc} prevValue={row.prevData?.cpc} format="number" prefix="$" inverse />
<TableCell value={row.ctr} prevValue={row.prevData?.ctr} format="percent" />
<TableCell value={row.cvrUnits} prevValue={row.prevData?.cvrUnits} format="percent" />
<TableCell value={row.acos} prevValue={row.prevData?.acos} format="percent" inverse />
<TableCell value={row.roas} prevValue={row.prevData?.roas} format="number" highlight />
<TableCell value={row.tacos} prevValue={row.prevData?.tacos} format="percent" inverse />
2026-01-22 13:25:06 +01:00
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
const SummaryCard = ({ label, value, prevValue, color = "text-white", inverse = false }: { label: string; value: string; prevValue?: number; color?: string; inverse?: boolean }) => {
// Correctly parse value regardless of locale formatting (handles dots as thousands and commas as decimals)
const numericValue = parseFloat(value.replace(/[^\d,]/g, '').replace(',', '.'));
const growth = prevValue !== undefined && prevValue > 0
? ((numericValue - prevValue) / prevValue) * 100
: null;
return (
<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 uppercase`}>{label}</span>
<div className="flex flex-col items-center">
<span className="text-sm font-bold text-white transition-all">{value}</span>
{growth !== null && (
<span className={`text-[9px] font-bold mt-0.5 ${(growth > 0 ? !inverse : inverse) ? 'text-emerald-400' : 'text-rose-500'}`}>
{growth > 0 ? '↑' : '↓'} {Math.abs(growth).toFixed(1)}%
</span>
)}
</div>
</div>
);
};
const TableCell = ({ value, prevValue, format, prefix = "", inverse = false, highlight = false }: { value: number; prevValue?: number; format: 'currency' | 'number' | 'percent'; prefix?: string; inverse?: boolean; highlight?: boolean }) => {
const growth = prevValue !== undefined && prevValue > 0 ? ((value - prevValue) / prevValue) * 100 : null;
let displayValue = "";
if (format === 'currency') displayValue = Math.round(value).toLocaleString('de-DE');
else if (format === 'number') {
if (prefix === "$") {
displayValue = "$" + value.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} else {
displayValue = prefix + Math.round(value).toLocaleString('de-DE');
}
}
else if (format === 'percent') displayValue = value.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + "%";
return (
<td className="p-4 text-right">
<div className="flex flex-col items-end">
<span className={`font-bold ${highlight ? 'text-blue-300' : 'text-white'}`}>{displayValue}</span>
{growth !== null && (
<span className={`text-[9px] font-medium ${(growth > 0 ? !inverse : inverse) ? 'text-emerald-400' : 'text-rose-400'} opacity-80`}>
{growth > 0 ? '+' : ''}{growth.toFixed(1)}%
</span>
)}
</div>
</td>
);
}
2026-01-22 13:25:06 +01:00
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;