mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 15:55:23 +02:00
394 lines
17 KiB
TypeScript
394 lines
17 KiB
TypeScript
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
import { ExperimentListItem, ExperimentType, ExperimentStatus, CombinedKPIs } from '../types';
|
|
import { listExperiments, deleteExperiment, getExperimentStatusColor, getExperimentTypeColor, getExperimentIcon, getExperimentAsins } from '../services/experiments';
|
|
import MultiSelectDropdown from './MultiSelectDropdown';
|
|
import { ExperimentBadge, ExperimentStatusBadge, ExperimentTypeBadge } from './ExperimentBadge';
|
|
import { LineChart, Line, ResponsiveContainer, YAxis } from 'recharts';
|
|
|
|
const getChartMetricColor = (metric: string) => {
|
|
switch (metric) {
|
|
case 'cvr': return '#34d399'; // emerald
|
|
case 'gv': return '#a78bfa'; // purple
|
|
case 'acos': return '#fb7185'; // rose
|
|
case 'revenue': return '#fbbf24'; // amber
|
|
default: return '#818cf8'; // indigo for units
|
|
}
|
|
};
|
|
|
|
const ExperimentSparkline: React.FC<{ experiment: ExperimentListItem; salesData: CombinedKPIs[] }> = ({ experiment, salesData }) => {
|
|
const chartData = useMemo(() => {
|
|
if (!experiment?.asins || !salesData?.length) return [];
|
|
|
|
const asinSet = getExperimentAsins(experiment.asins, salesData);
|
|
const weeklyMap = new Map<string, any>();
|
|
|
|
salesData.forEach(r => {
|
|
const asin = (r.asin || '').toUpperCase();
|
|
if (!asinSet.has(asin)) return;
|
|
|
|
const weekNum = r.week || 1;
|
|
const yearStr = r.year || new Date().getFullYear();
|
|
const weekStr = `${yearStr}-W${String(weekNum).padStart(2, '0')}`;
|
|
|
|
if (!weeklyMap.has(weekStr)) {
|
|
const d = new Date(yearStr, 0, 1 + (weekNum - 1) * 7);
|
|
weeklyMap.set(weekStr, {
|
|
name: weekStr,
|
|
timestamp: d.getTime(),
|
|
units: 0,
|
|
revenue: 0,
|
|
gv: 0,
|
|
spend: 0,
|
|
});
|
|
}
|
|
|
|
const w = weeklyMap.get(weekStr);
|
|
w.units += r.unitsTotal || 0;
|
|
w.revenue += r.salesTotal || 0;
|
|
w.gv += r.glanceViews || 0;
|
|
w.spend += r.cost || 0;
|
|
});
|
|
|
|
const arr = Array.from(weeklyMap.values()).map(w => ({
|
|
...w,
|
|
cvr: w.gv > 0 ? (w.units / w.gv) * 100 : 0,
|
|
acos: w.revenue > 0 ? (w.spend / w.revenue) * 100 : 0
|
|
})).sort((a, b) => a.timestamp - b.timestamp);
|
|
|
|
return arr.slice(-12);
|
|
}, [experiment, salesData]);
|
|
|
|
if (chartData.length < 2) return <div className="h-8 w-16 bg-slate-800/50 rounded animate-pulse" />;
|
|
|
|
const metric = experiment.primary_metric || 'units';
|
|
|
|
return (
|
|
<div className="h-10 w-24">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart data={chartData}>
|
|
<YAxis domain={['dataMin', 'dataMax']} hide />
|
|
<Line
|
|
type="monotone"
|
|
dataKey={metric === 'bsr' ? 'units' : metric === 'ctr' ? 'gv' : metric}
|
|
stroke={getChartMetricColor(metric)}
|
|
strokeWidth={2}
|
|
dot={false}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface ExperimentsViewProps {
|
|
onOpenDetail: (experimentId: string) => void;
|
|
onOpenCreate: () => void;
|
|
salesData?: CombinedKPIs[];
|
|
}
|
|
|
|
const ExperimentsView: React.FC<ExperimentsViewProps> = ({ onOpenDetail, onOpenCreate, salesData = [] }) => {
|
|
const [experiments, setExperiments] = useState<ExperimentListItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Filters
|
|
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
|
const [typeFilter, setTypeFilter] = useState<string[]>([]);
|
|
const [marketplaceFilter, setMarketplaceFilter] = useState<string[]>([]);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
const statusOptions = ['planned', 'active', 'completed', 'paused'];
|
|
const typeOptions = ['pricing', 'advertising', 'content', 'promotion'];
|
|
const marketplaceOptions = ['DE', 'UK', 'FR', 'IT', 'ES'];
|
|
|
|
const loadExperiments = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = await listExperiments({
|
|
status: statusFilter.length > 0 ? statusFilter as ExperimentStatus[] : undefined,
|
|
type: typeFilter.length > 0 ? typeFilter as ExperimentType[] : undefined,
|
|
marketplace: marketplaceFilter.length > 0 ? marketplaceFilter : undefined,
|
|
});
|
|
setExperiments(data);
|
|
} catch (e: any) {
|
|
setError(e.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [statusFilter, typeFilter, marketplaceFilter]);
|
|
|
|
useEffect(() => {
|
|
loadExperiments();
|
|
}, [loadExperiments]);
|
|
|
|
const filteredExperiments = useMemo(() => {
|
|
if (!searchQuery) return experiments;
|
|
const query = searchQuery.toLowerCase();
|
|
return experiments.filter(exp =>
|
|
exp.name.toLowerCase().includes(query) ||
|
|
exp.owner?.toLowerCase().includes(query)
|
|
);
|
|
}, [experiments, searchQuery]);
|
|
|
|
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (!confirm('Are you sure you want to delete this experiment?')) return;
|
|
|
|
try {
|
|
await deleteExperiment(id);
|
|
loadExperiments();
|
|
} catch (e: any) {
|
|
alert(`Error deleting experiment: ${e.message}`);
|
|
}
|
|
};
|
|
|
|
const handleDuplicate = async (experiment: ExperimentListItem, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onOpenDetail(experiment.id);
|
|
// Duplicate functionality will be in detail view
|
|
};
|
|
|
|
// Stats
|
|
const stats = useMemo(() => ({
|
|
total: experiments.length,
|
|
active: experiments.filter(e => e.status === 'active').length,
|
|
planned: experiments.filter(e => e.status === 'planned').length,
|
|
avgLift: experiments.filter(e => e.actual_lift_percent !== undefined)
|
|
.reduce((sum, e) => sum + (e.actual_lift_percent || 0), 0) /
|
|
Math.max(1, experiments.filter(e => e.actual_lift_percent !== undefined).length),
|
|
}), [experiments]);
|
|
|
|
return (
|
|
<div className="p-6 pb-24 md:pb-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">🧪 Experiments</h1>
|
|
<p className="text-sm text-slate-400 mt-1">
|
|
Track and analyze your product experiments
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={onOpenCreate}
|
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg font-medium transition-colors flex items-center gap-2"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
|
</svg>
|
|
New Experiment
|
|
</button>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
<StatCard label="Total Experiments" value={stats.total} color="indigo" />
|
|
<StatCard label="Active" value={stats.active} color="emerald" />
|
|
<StatCard label="Planned" value={stats.planned} color="amber" />
|
|
<StatCard label="Avg. Lift" value={`${stats.avgLift.toFixed(1)}%`} color="purple" />
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap gap-3 items-center mb-6 p-4 bg-slate-900/50 border border-slate-800 rounded-xl">
|
|
<MultiSelectDropdown
|
|
label="Status"
|
|
options={statusOptions}
|
|
selected={statusFilter}
|
|
onChange={setStatusFilter}
|
|
/>
|
|
<MultiSelectDropdown
|
|
label="Type"
|
|
options={typeOptions}
|
|
selected={typeFilter}
|
|
onChange={setTypeFilter}
|
|
/>
|
|
<MultiSelectDropdown
|
|
label="Marketplace"
|
|
options={marketplaceOptions}
|
|
selected={marketplaceFilter}
|
|
onChange={setMarketplaceFilter}
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Search experiments..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 flex-1 min-w-[200px]"
|
|
/>
|
|
{(statusFilter.length > 0 || typeFilter.length > 0 || marketplaceFilter.length > 0 || searchQuery) && (
|
|
<button
|
|
onClick={() => {
|
|
setStatusFilter([]);
|
|
setTypeFilter([]);
|
|
setMarketplaceFilter([]);
|
|
setSearchQuery('');
|
|
}}
|
|
className="text-xs text-slate-400 hover:text-white underline whitespace-nowrap"
|
|
>
|
|
Clear all
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Error State */}
|
|
{error && (
|
|
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400">
|
|
Error: {error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Table */}
|
|
<div className="bg-slate-900/50 border border-slate-800 rounded-xl overflow-hidden">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-indigo-500"></div>
|
|
</div>
|
|
) : filteredExperiments.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<div className="text-4xl mb-4">🧪</div>
|
|
<h3 className="text-lg font-semibold text-slate-200">No experiments yet</h3>
|
|
<p className="text-slate-400 text-sm mt-1 mb-4">
|
|
Create your first experiment to start tracking performance
|
|
</p>
|
|
<button
|
|
onClick={onOpenCreate}
|
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg font-medium transition-colors"
|
|
>
|
|
Create Experiment
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-slate-800/50 border-b border-slate-700">
|
|
<tr>
|
|
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Status</th>
|
|
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Type</th>
|
|
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Name</th>
|
|
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Marketplace</th>
|
|
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">ASINs</th>
|
|
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Duration</th>
|
|
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Progress</th>
|
|
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Impact</th>
|
|
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Owner</th>
|
|
<th className="text-right text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-800">
|
|
{filteredExperiments.map((exp) => (
|
|
<tr
|
|
key={exp.id}
|
|
onClick={() => onOpenDetail(exp.id)}
|
|
className="hover:bg-slate-800/30 cursor-pointer transition-colors"
|
|
>
|
|
<td className="px-4 py-3">
|
|
<ExperimentStatusBadge status={exp.status} size="sm" />
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<ExperimentTypeBadge type={exp.type} size="sm" />
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="text-sm font-medium text-slate-200">{exp.name}</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="text-sm text-slate-400">{exp.marketplace}</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="text-sm text-slate-400">{exp.asin_count} ASINs</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="text-sm text-slate-400">
|
|
<div>{new Date(exp.start_date).toLocaleDateString()}</div>
|
|
{exp.end_date && (
|
|
<div className="text-xs text-slate-500">
|
|
→ {new Date(exp.end_date).toLocaleDateString()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden max-w-[100px]">
|
|
<div
|
|
className="h-full bg-indigo-500 rounded-full"
|
|
style={{ width: `${exp.progress_percent}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-xs text-slate-400 w-10">{exp.progress_percent}%</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-3">
|
|
{salesData.length > 0 && <ExperimentSparkline experiment={exp} salesData={salesData} />}
|
|
<div className="w-16">
|
|
{exp.actual_lift_percent !== undefined ? (
|
|
<span className={`text-sm font-bold ${exp.actual_lift_percent >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
|
{exp.actual_lift_percent >= 0 ? '+' : ''}{exp.actual_lift_percent}%
|
|
</span>
|
|
) : (
|
|
<span className="text-sm text-slate-500 font-medium">—</span>
|
|
)}
|
|
<div className="text-[10px] text-slate-500 capitalize leading-none mt-0.5" style={{ color: getChartMetricColor(exp.primary_metric || 'units') }}>
|
|
{exp.primary_metric || 'units'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="text-sm text-slate-400">{exp.owner || '—'}</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<div className="flex items-center justify-end gap-2">
|
|
<button
|
|
onClick={(e) => handleDuplicate(exp, e)}
|
|
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors"
|
|
title="View / Edit"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={(e) => handleDelete(exp.id, e)}
|
|
className="p-1.5 text-slate-400 hover:text-red-400 hover:bg-slate-700 rounded transition-colors"
|
|
title="Delete"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Stat Card Component
|
|
const StatCard: React.FC<{ label: string; value: string | number; color: string }> = ({ label, value, color }) => {
|
|
const colorClasses: Record<string, string> = {
|
|
indigo: 'bg-indigo-500/10 border-indigo-500/30 text-indigo-400',
|
|
emerald: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400',
|
|
amber: 'bg-amber-500/10 border-amber-500/30 text-amber-400',
|
|
purple: 'bg-purple-500/10 border-purple-500/30 text-purple-400',
|
|
};
|
|
|
|
return (
|
|
<div className={`p-4 rounded-xl border ${colorClasses[color]}`}>
|
|
<div className="text-2xl font-bold">{value}</div>
|
|
<div className="text-xs opacity-70 mt-1">{label}</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
|
|
|
|
export default ExperimentsView;
|