mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 12:25:22 +02:00
Refactor: Unified Top 50 Badge logic and shared component integration
This commit is contained in:
+135
-219
@@ -1,8 +1,9 @@
|
||||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { CombinedKPIs, FilterState } from '../types';
|
||||
import { DownloadIcon } from './Icons';
|
||||
import { DownloadIcon, FunnelIcon, TrendingIcon, ChartIcon } from './Icons';
|
||||
import { StockBadge } from './StockBadge';
|
||||
import { Top50Badge } from './Top50Badge';
|
||||
import { InColumnStockFilter } from './InColumnStockFilter';
|
||||
|
||||
interface AdsPerformanceProps {
|
||||
@@ -15,40 +16,86 @@ interface AdsPerformanceProps {
|
||||
stockMap?: Map<string, number>;
|
||||
stockFilter: string[];
|
||||
onStockFilterChange: (newFilters: string[]) => void;
|
||||
top50Mode: 'eu' | 'uk';
|
||||
}
|
||||
|
||||
type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits';
|
||||
|
||||
// Top 50 Badge Component
|
||||
const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'blue' | 'indigo' }> = ({ rank, label, theme = 'amber' }) => {
|
||||
const themeClasses = {
|
||||
amber: 'from-amber-500 to-orange-500 border-amber-400/50',
|
||||
blue: 'from-blue-500 to-cyan-500 border-blue-400/50',
|
||||
indigo: 'from-indigo-500 to-purple-500 border-indigo-400/50',
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center px-1.5 py-0.5 rounded text-[9px] font-black bg-gradient-to-r ${themeClasses[theme]} text-white shadow-sm border`}
|
||||
title={`Top ${rank} Best Seller 2025 ${label ? `(${label})` : ''}`}
|
||||
>
|
||||
<span className="mr-0.5">🏆</span>
|
||||
{label && <span className="mr-0.5 opacity-90">{label}</span>}
|
||||
{rank}
|
||||
</span>
|
||||
);
|
||||
// Define AdsProductKPIs type for clarity
|
||||
type AdsProductKPIs = CombinedKPIs & {
|
||||
acos: number;
|
||||
tacos: number;
|
||||
roas: number;
|
||||
ctr: number;
|
||||
cpc: number;
|
||||
cvrUnits: number;
|
||||
prevData?: AdsProductKPIs;
|
||||
};
|
||||
|
||||
const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange }) => {
|
||||
const AdsRow: React.FC<{
|
||||
item: AdsProductKPIs;
|
||||
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
|
||||
top50Mode: 'eu' | 'uk';
|
||||
stockMap?: Map<string, number>;
|
||||
}> = React.memo(({ item, top50Ranking, top50Mode, stockMap }) => {
|
||||
const asin = item.asin.trim().toUpperCase();
|
||||
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
||||
|
||||
if (top50Ranking) {
|
||||
if (top50Mode === 'eu') {
|
||||
const rankEU = top50Ranking.eu.get(asin);
|
||||
if (rankEU) ranks.push({ rank: rankEU, label: 'EU', theme: 'indigo' });
|
||||
} else {
|
||||
const rankUK = top50Ranking.uk.get(asin);
|
||||
if (rankUK) ranks.push({ rank: rankUK, label: 'UK', theme: 'blue' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-white/[0.02] transition-colors group">
|
||||
<td className="p-4 py-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{ranks.map((r, i) => (
|
||||
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
|
||||
))}
|
||||
<span className="text-white font-bold text-sm tracking-tight">{item.asin}</span>
|
||||
</div>
|
||||
<span className="text-[10px] uppercase font-black text-indigo-400 tracking-tighter">SKU: {item.sku}</span>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-[10px] text-slate-400 truncate max-w-[200px] leading-tight" title={item.title}>{item.title}</span>
|
||||
{stockMap && (
|
||||
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<TableCell value={item.salesTotal} prevValue={item.prevData?.salesTotal} format="currency" />
|
||||
<TableCell value={item.salesAds} prevValue={item.prevData?.salesAds} format="currency" />
|
||||
<TableCell value={item.salesOrganic} prevValue={item.prevData?.salesOrganic} format="currency" />
|
||||
<TableCell value={item.cost} prevValue={item.prevData?.cost} format="currency" />
|
||||
<TableCell value={item.impressions} prevValue={item.prevData?.impressions} format="number" />
|
||||
<TableCell value={item.clicks} prevValue={item.prevData?.clicks} format="number" />
|
||||
<TableCell value={item.conversions} prevValue={item.prevData?.conversions} format="number" />
|
||||
<TableCell value={item.cpc} prevValue={item.prevData?.cpc} format="number" prefix="$" inverse />
|
||||
<TableCell value={item.ctr} prevValue={item.prevData?.ctr} format="percent" />
|
||||
<TableCell value={item.cvrUnits} prevValue={item.prevData?.cvrUnits} format="percent" />
|
||||
<TableCell value={item.acos} prevValue={item.prevData?.acos} format="percent" inverse />
|
||||
<TableCell value={item.roas} prevValue={item.prevData?.roas} format="number" highlight />
|
||||
<TableCell value={item.tacos} prevValue={item.prevData?.tacos} format="percent" inverse />
|
||||
<TableCell value={item.glanceViews || 0} prevValue={item.prevData?.glanceViews} format="number" />
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange, top50Mode }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
||||
const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu');
|
||||
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;
|
||||
@@ -61,8 +108,6 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
||||
});
|
||||
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) {
|
||||
@@ -76,7 +121,6 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
||||
return null;
|
||||
}, [filters]);
|
||||
|
||||
// 1. Aggregate and Compare data by ASIN
|
||||
const aggregatedByAsin = useMemo(() => {
|
||||
const map = new Map<string, { curr: CombinedKPIs; prev?: CombinedKPIs }>();
|
||||
|
||||
@@ -90,7 +134,6 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
||||
const isPrevious = comparisonConfig && periodVal === comparisonConfig.prev;
|
||||
|
||||
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 },
|
||||
@@ -98,19 +141,31 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
||||
});
|
||||
} 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;
|
||||
target.glanceViews = (target.glanceViews || 0) + (item.glanceViews || 0);
|
||||
if (isCurrent) {
|
||||
entry.curr.salesTotal += item.salesTotal;
|
||||
entry.curr.unitsTotal += item.unitsTotal;
|
||||
entry.curr.salesAds += item.salesAds;
|
||||
entry.curr.unitsAds += item.unitsAds;
|
||||
entry.curr.cost += item.cost;
|
||||
entry.curr.clicks += item.clicks;
|
||||
entry.curr.impressions += item.impressions;
|
||||
entry.curr.conversions += item.conversions;
|
||||
entry.curr.salesOrganic += item.salesOrganic;
|
||||
entry.curr.glanceViews = (entry.curr.glanceViews || 0) + (item.glanceViews || 0);
|
||||
} else if (isPrevious) {
|
||||
if (!entry.prev) {
|
||||
entry.prev = { ...item, salesTotal: 0, unitsTotal: 0, salesAds: 0, unitsAds: 0, cost: 0, clicks: 0, impressions: 0, conversions: 0, salesOrganic: 0 };
|
||||
}
|
||||
entry.prev.salesTotal += item.salesTotal;
|
||||
entry.prev.unitsTotal += item.unitsTotal;
|
||||
entry.prev.salesAds += item.salesAds;
|
||||
entry.prev.unitsAds += item.unitsAds;
|
||||
entry.prev.cost += item.cost;
|
||||
entry.prev.clicks += item.clicks;
|
||||
entry.prev.impressions += item.impressions;
|
||||
entry.prev.conversions += item.conversions;
|
||||
entry.prev.salesOrganic += item.salesOrganic;
|
||||
entry.prev.glanceViews = (entry.prev.glanceViews || 0) + (item.glanceViews || 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -132,11 +187,10 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
||||
return {
|
||||
...currFinal,
|
||||
prevData: prevFinal
|
||||
};
|
||||
} as AdsProductKPIs;
|
||||
});
|
||||
}, [data, comparisonConfig]);
|
||||
|
||||
// 2. Global Totals
|
||||
const totals = useMemo(() => {
|
||||
const sum = (items: any[]) => items.reduce((acc, curr) => ({
|
||||
salesTotal: acc.salesTotal + curr.salesTotal,
|
||||
@@ -168,40 +222,29 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
||||
const currMetrics = calculateDerived(totals.curr);
|
||||
const prevMetrics = totals.prev ? calculateDerived(totals.prev) : null;
|
||||
|
||||
// 3. Filter and Sort
|
||||
const filteredAndSorted = useMemo(() => {
|
||||
let result = aggregatedByAsin;
|
||||
|
||||
// Top 50 Filter
|
||||
if (showOnlyTop50 && top50Ranking) {
|
||||
result = result.filter(item => {
|
||||
const asin = item.asin.trim().toUpperCase();
|
||||
if (top50Mode === 'eu') {
|
||||
return top50Ranking.eu.has(asin);
|
||||
} else {
|
||||
return top50Ranking.uk.has(asin);
|
||||
}
|
||||
});
|
||||
const currentRankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk;
|
||||
result = result.filter(p => currentRankMap.has(p.asin.toUpperCase()));
|
||||
}
|
||||
|
||||
// Search Filter
|
||||
result = result.filter(item =>
|
||||
item.asin.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.sku.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.title.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
if (searchTerm) {
|
||||
const lowSearch = searchTerm.toLowerCase();
|
||||
result = result.filter(item =>
|
||||
item.asin.toLowerCase().includes(lowSearch) ||
|
||||
item.sku.toLowerCase().includes(lowSearch) ||
|
||||
item.title.toLowerCase().includes(lowSearch)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort: by ranking when Top 50 is active, otherwise by sortConfig
|
||||
if (showOnlyTop50 && top50Ranking) {
|
||||
result.sort((a, b) => {
|
||||
const asinA = a.asin.trim().toUpperCase();
|
||||
const asinB = b.asin.trim().toUpperCase();
|
||||
const rankA = top50Mode === 'eu'
|
||||
? (top50Ranking.eu.get(asinA) || 999)
|
||||
: (top50Ranking.uk.get(asinA) || 999);
|
||||
const rankB = top50Mode === 'eu'
|
||||
? (top50Ranking.eu.get(asinB) || 999)
|
||||
: (top50Ranking.uk.get(asinB) || 999);
|
||||
const rankA = top50Mode === 'eu' ? (top50Ranking.eu.get(asinA) || 999) : (top50Ranking.uk.get(asinA) || 999);
|
||||
const rankB = top50Mode === 'eu' ? (top50Ranking.eu.get(asinB) || 999) : (top50Ranking.uk.get(asinB) || 999);
|
||||
return rankA - rankB;
|
||||
});
|
||||
} else {
|
||||
@@ -243,14 +286,10 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
||||
'TACOS (%)': Number(row.tacos.toFixed(2)),
|
||||
'Glance Views': row.glanceViews || 0,
|
||||
};
|
||||
|
||||
if (row.prevData) {
|
||||
rowData['Prev Total Sales'] = Math.round(row.prevData.salesTotal);
|
||||
rowData['Prev Ads Sales'] = Math.round(row.prevData.salesAds);
|
||||
rowData['Prev Ad Spend'] = Math.round(row.prevData.cost);
|
||||
rowData['Growth Sales (%)'] = row.prevData.salesTotal > 0 ? Number((((row.salesTotal - row.prevData.salesTotal) / row.prevData.salesTotal) * 100).toFixed(1)) : 0;
|
||||
}
|
||||
|
||||
return rowData;
|
||||
});
|
||||
|
||||
@@ -260,32 +299,20 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
||||
XLSX.writeFile(wb, `Ads_Performance_Export_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
}, [filteredAndSorted]);
|
||||
|
||||
const formatCurrency = (val: number) =>
|
||||
`$${Math.round(val).toLocaleString('de-DE')}`;
|
||||
|
||||
const formatNumber = (val: number) =>
|
||||
Math.round(val).toLocaleString('de-DE');
|
||||
|
||||
const formatPercent = (val: number) =>
|
||||
`${val.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 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-400">
|
||||
<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'}
|
||||
{aggregatedByAsin.length} products • {comparisonConfig ? `Comparison: ${comparisonConfig.prev} vs ${comparisonConfig.curr}` : 'Direct Overview'}
|
||||
</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={totals.curr.salesTotal} prevValue={totals.prev?.salesTotal} format="currency" color="text-emerald-400" />
|
||||
<SummaryCard label="ADS SALES" value={totals.curr.salesAds} prevValue={totals.prev?.salesAds} format="currency" color="text-orange-400" />
|
||||
@@ -304,7 +331,6 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
||||
<SummaryCard label="CVR (GV)" value={currMetrics.cvrGv} prevValue={prevMetrics?.cvrGv} format="percent" color="text-teal-300" />
|
||||
</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>
|
||||
@@ -314,53 +340,25 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Top 50 Filter Toggle */}
|
||||
{top50Ranking && (
|
||||
(top50Ranking.eu.size || 0) > 0 ||
|
||||
(top50Ranking.uk.size || 0) > 0
|
||||
) && (
|
||||
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm">
|
||||
<button
|
||||
onClick={() => setShowOnlyTop50(!showOnlyTop50)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${showOnlyTop50
|
||||
? 'bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-lg shadow-amber-500/20'
|
||||
: 'text-slate-400 hover:text-amber-400'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm">🏆</span>
|
||||
Top 50
|
||||
</button>
|
||||
|
||||
{showOnlyTop50 && (
|
||||
<div className="flex ml-1 p-0.5 bg-slate-900 rounded-lg border border-white/5">
|
||||
<button
|
||||
onClick={() => setTop50Mode('eu')}
|
||||
className={`px-2 py-1 rounded-md text-[10px] font-black uppercase tracking-widest transition-all ${top50Mode === 'eu' ? 'bg-indigo-500 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'}`}
|
||||
>
|
||||
EU
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTop50Mode('uk')}
|
||||
className={`px-2 py-1 rounded-md text-[10px] font-black uppercase tracking-widest transition-all ${top50Mode === 'uk' ? 'bg-blue-500 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'}`}
|
||||
>
|
||||
UK
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{top50Ranking && (top50Ranking.eu.size > 0 || top50Ranking.uk.size > 0) && (
|
||||
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm">
|
||||
<button
|
||||
onClick={() => setShowOnlyTop50(!showOnlyTop50)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${showOnlyTop50 ? 'bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-lg' : 'text-slate-400 hover:text-amber-400'}`}
|
||||
>
|
||||
<span className="text-sm">🏆</span>
|
||||
Top 50 ({top50Mode.toUpperCase()})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
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-48"
|
||||
className="bg-[#0B0E14] border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white focus:outline-none w-48"
|
||||
/>
|
||||
<button
|
||||
onClick={handleExportExcel}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-xs font-bold border border-white/10 transition-all shadow-lg active:scale-95"
|
||||
title="Export to Excel"
|
||||
>
|
||||
<button onClick={handleExportExcel} className="flex items-center gap-2 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-xs font-bold border border-white/10 transition-all">
|
||||
<DownloadIcon />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
@@ -369,15 +367,12 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
||||
|
||||
<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 relative z-30">
|
||||
<thead className="sticky top-0 z-20 bg-[#1A1F29]">
|
||||
<tr className="border-b border-white/5">
|
||||
<th className="p-4 font-black text-slate-400 uppercase tracking-widest min-w-[250px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>PRODUCT</span>
|
||||
<InColumnStockFilter
|
||||
currentFilters={stockFilter}
|
||||
onFilterChange={onStockFilterChange}
|
||||
/>
|
||||
<InColumnStockFilter currentFilters={stockFilter} onFilterChange={onStockFilterChange} />
|
||||
</div>
|
||||
</th>
|
||||
<SortableHeader label="TOTAL SALES" sortKey="salesTotal" activeSort={sortConfig} onSort={handleSort} />
|
||||
@@ -397,53 +392,9 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.03]">
|
||||
{filteredAndSorted.map((row: any) => {
|
||||
const asin = row.asin.trim().toUpperCase();
|
||||
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
||||
|
||||
if (top50Ranking) {
|
||||
const euRank = top50Ranking.eu.get(asin);
|
||||
const ukRank = top50Ranking.uk.get(asin);
|
||||
if (euRank) ranks.push({ rank: euRank, label: 'EU', theme: 'indigo' });
|
||||
if (ukRank) ranks.push({ rank: ukRank, label: 'UK', theme: 'blue' });
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex items-center gap-2">
|
||||
{ranks.map((r, i) => (
|
||||
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
|
||||
))}
|
||||
<span className="text-white font-bold text-sm tracking-tight">{row.asin}</span>
|
||||
</div>
|
||||
<span className="text-[10px] uppercase font-black text-indigo-400 tracking-tighter">SKU: {row.sku}</span>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-[10px] text-slate-400 truncate max-w-[200px] leading-tight" title={row.title}>{row.title}</span>
|
||||
{stockMap && (
|
||||
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
|
||||
)}
|
||||
</div>
|
||||
</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 />
|
||||
<TableCell value={row.glanceViews || 0} prevValue={row.prevData?.glanceViews} format="number" />
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{filteredAndSorted.map((p) => (
|
||||
<AdsRow key={p.id} item={p} top50Ranking={top50Ranking} top50Mode={top50Mode} stockMap={stockMap} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -452,35 +403,13 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
||||
);
|
||||
};
|
||||
|
||||
const SummaryCard = ({
|
||||
label,
|
||||
value,
|
||||
prevValue,
|
||||
format,
|
||||
prefix = "",
|
||||
decimals = 0,
|
||||
color = "text-white",
|
||||
inverse = false
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
prevValue?: number;
|
||||
format: 'currency' | 'number' | 'percent';
|
||||
prefix?: string;
|
||||
decimals?: number;
|
||||
color?: string;
|
||||
inverse?: boolean
|
||||
}) => {
|
||||
const growth = prevValue !== undefined && prevValue > 0
|
||||
? ((value - prevValue) / prevValue) * 100
|
||||
: null;
|
||||
|
||||
const SummaryCard = ({ label, value, prevValue, format, prefix = "", decimals = 0, color = "text-white", inverse = false }: any) => {
|
||||
const growth = prevValue !== undefined && prevValue > 0 ? ((value - prevValue) / prevValue) * 100 : null;
|
||||
const formatVal = (v: number) => {
|
||||
if (format === 'currency') return `$${Math.round(v).toLocaleString('de-DE')}`;
|
||||
if (format === 'percent') return v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + "%";
|
||||
return prefix + v.toLocaleString('de-DE', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||
};
|
||||
|
||||
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>
|
||||
@@ -503,21 +432,16 @@ const SummaryCard = ({
|
||||
);
|
||||
};
|
||||
|
||||
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 TableCell = ({ value, prevValue, format, prefix = "", inverse = false, highlight = false }: any) => {
|
||||
const growth = prevValue !== undefined && prevValue > 0 ? ((value - prevValue) / prevValue) * 100 : null;
|
||||
|
||||
const formatVal = (v: number) => {
|
||||
if (format === 'currency') return Math.round(v).toLocaleString('de-DE');
|
||||
if (format === 'number') {
|
||||
if (prefix === "$") {
|
||||
return "$" + v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
} else {
|
||||
return prefix + Math.round(v).toLocaleString('de-DE');
|
||||
}
|
||||
if (prefix === "$") return "$" + v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
return prefix + Math.round(v).toLocaleString('de-DE');
|
||||
}
|
||||
return v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + "%";
|
||||
};
|
||||
|
||||
return (
|
||||
<td className="p-4 text-right">
|
||||
<div className="flex flex-col items-end">
|
||||
@@ -537,20 +461,12 @@ const TableCell = ({ value, prevValue, format, prefix = "", inverse = false, hig
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const SortableHeader = ({ label, sortKey, activeSort, onSort }: {
|
||||
label: string;
|
||||
sortKey: SortKey;
|
||||
activeSort: { key: SortKey; direction: 'asc' | 'desc' };
|
||||
onSort: (key: SortKey) => void;
|
||||
}) => {
|
||||
const SortableHeader = ({ label, sortKey, activeSort, onSort }: any) => {
|
||||
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`}
|
||||
>
|
||||
<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'}`}>
|
||||
|
||||
Reference in New Issue
Block a user