Files
CrazeAnalytix/components/ExperimentsView.tsx
T

1197 lines
60 KiB
TypeScript
Raw Normal View History

2026-02-20 19:31:44 +01:00
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import {
Experiment,
ExperimentCreateInput,
ExperimentListItem,
ExperimentType,
ExperimentStatus,
ExperimentMetric,
ExperimentVerdict,
ExperimentChangeAnnotation,
DiDMetricResult,
CombinedKPIs,
} from '../types';
import {
listExperiments,
getExperiment,
createExperiment,
updateExperiment,
deleteExperiment,
getExperimentStatusColor,
getExperimentTypeColor,
getExperimentIcon,
getVerdictColor,
getVerdictLabel,
METRIC_LABELS,
formatMetricValue,
} from '../services/experiments';
import { computeDiD, computeVerdict, buildCounterfactualSeries, TrendDataPoint } from '../services/experimentAnalysis';
2026-02-20 19:31:44 +01:00
import MultiSelectDropdown from './MultiSelectDropdown';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts';
// ============ Sub-view type ============
type SubView = 'list' | 'detail' | 'create' | 'edit';
// ============ Constants ============
const STATUS_OPTIONS = ['planned', 'active', 'completed', 'paused'];
const TYPE_OPTIONS: ExperimentType[] = ['content', 'pricing', 'advertising', 'seo', 'promotion'];
const MARKETPLACE_OPTIONS = ['DE', 'UK', 'FR', 'IT', 'ES'];
const METRIC_OPTIONS: { value: ExperimentMetric; label: string }[] = [
{ value: 'units', label: 'Units Sold' },
{ value: 'sessions', label: 'Sessions' },
{ value: 'cvr', label: 'Conversion Rate (CVR)' },
{ value: 'ctr', label: 'Click-Through Rate (CTR)' },
{ value: 'roas', label: 'ROAS' },
{ value: 'revenue', label: 'Revenue' },
{ value: 'acos', label: 'ACOS' },
];
// ============ Main Component ============
interface ExperimentsViewProps {
salesData?: CombinedKPIs[];
onExperimentsFetch?: () => void;
}
const ExperimentsView: React.FC<ExperimentsViewProps> = ({ salesData = [], onExperimentsFetch }) => {
const [subView, setSubView] = useState<SubView>('list');
const [selectedId, setSelectedId] = useState<string | null>(null);
const navigateToDetail = useCallback((id: string) => {
setSelectedId(id);
setSubView('detail');
}, []);
const navigateToCreate = useCallback(() => {
setSelectedId(null);
setSubView('create');
}, []);
const navigateToEdit = useCallback((id: string) => {
setSelectedId(id);
setSubView('edit');
}, []);
const navigateToList = useCallback(() => {
setSelectedId(null);
setSubView('list');
}, []);
const handleSaved = useCallback((id: string) => {
setSelectedId(id);
setSubView('detail');
onExperimentsFetch?.();
}, [onExperimentsFetch]);
return (
<div className="p-4 md:p-6 pb-24 md:pb-6 max-w-[1400px] mx-auto">
{subView === 'list' && (
<ExperimentListView
salesData={salesData}
onOpenDetail={navigateToDetail}
onOpenCreate={navigateToCreate}
/>
)}
{subView === 'detail' && selectedId && (
<ExperimentDetailView
experimentId={selectedId}
salesData={salesData}
onBack={navigateToList}
onEdit={() => navigateToEdit(selectedId)}
onExperimentsFetch={onExperimentsFetch}
/>
)}
{subView === 'create' && (
<ExperimentFormView
salesData={salesData}
onBack={navigateToList}
onSaved={handleSaved}
/>
)}
{subView === 'edit' && selectedId && (
<ExperimentEditWrapper
experimentId={selectedId}
salesData={salesData}
onBack={() => navigateToDetail(selectedId)}
onSaved={handleSaved}
/>
)}
</div>
);
};
2026-02-20 19:31:44 +01:00
// ====================================================================
// LIST VIEW
// ====================================================================
2026-02-20 19:31:44 +01:00
const ExperimentListView: React.FC<{
salesData: CombinedKPIs[];
onOpenDetail: (id: string) => void;
onOpenCreate: () => void;
}> = ({ salesData, onOpenDetail, onOpenCreate }) => {
2026-02-20 19:31:44 +01:00
const [experiments, setExperiments] = useState<ExperimentListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [typeFilter, setTypeFilter] = useState<string[]>([]);
const [marketplaceFilter, setMarketplaceFilter] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
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]);
2026-02-20 19:31:44 +01:00
const filtered = useMemo(() => {
2026-02-20 19:31:44 +01:00
if (!searchQuery) return experiments;
const q = searchQuery.toLowerCase();
return experiments.filter(e =>
e.name.toLowerCase().includes(q) || e.owner?.toLowerCase().includes(q)
2026-02-20 19:31:44 +01:00
);
}, [experiments, searchQuery]);
const stats = useMemo(() => {
const winners = experiments.filter(e => e.verdict === 'winner').length;
const withLift = experiments.filter(e => e.verdict_probability != null);
const avgProb = withLift.length > 0
? withLift.reduce((s, e) => s + (e.verdict_probability || 0), 0) / withLift.length
: 0;
return {
total: experiments.length,
active: experiments.filter(e => e.status === 'active').length,
winners,
avgProb: Math.round(avgProb * 100),
};
}, [experiments]);
2026-02-20 19:31:44 +01:00
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm('Delete this experiment? This action cannot be undone.')) return;
2026-02-20 19:31:44 +01:00
try {
await deleteExperiment(id);
loadExperiments();
} catch (e: any) {
alert(`Error: ${e.message}`);
2026-02-20 19:31:44 +01:00
}
};
return (
<>
2026-02-20 19:31:44 +01:00
{/* Header */}
<div className="flex items-center justify-between mb-8">
2026-02-20 19:31:44 +01:00
<div>
<h1 className="text-2xl font-bold text-white tracking-tight">Experiments</h1>
2026-02-20 19:31:44 +01:00
<p className="text-sm text-slate-400 mt-1">
Measure the impact of your changes with scientific rigor
2026-02-20 19:31:44 +01:00
</p>
</div>
<button
onClick={onOpenCreate}
className="px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg font-medium transition-colors text-sm flex items-center gap-2"
2026-02-20 19:31:44 +01:00
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
2026-02-20 19:31:44 +01:00
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New Experiment
</button>
</div>
{/* Stat Cards */}
2026-02-20 19:31:44 +01:00
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<StatCard label="Total" value={stats.total} accent="indigo" />
<StatCard label="Active" value={stats.active} accent="emerald" />
<StatCard label="Winners" value={stats.winners} accent="amber" />
<StatCard label="Avg Confidence" value={`${stats.avgProb}%`} accent="purple" />
2026-02-20 19:31:44 +01:00
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 items-center mb-6 p-3 bg-slate-900/60 border border-slate-800 rounded-xl">
<MultiSelectDropdown label="Status" options={STATUS_OPTIONS} selected={statusFilter} onChange={setStatusFilter} />
<MultiSelectDropdown label="Type" options={TYPE_OPTIONS} selected={typeFilter} onChange={setTypeFilter} />
<MultiSelectDropdown label="Market" options={MARKETPLACE_OPTIONS} selected={marketplaceFilter} onChange={setMarketplaceFilter} />
<div className="relative flex-1 min-w-[200px]">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input
type="text"
placeholder="Search by name or owner..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="w-full bg-slate-800/80 border border-slate-700 rounded-lg pl-9 pr-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
2026-02-20 19:31:44 +01:00
{(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 transition-colors"
2026-02-20 19:31:44 +01:00
>
Clear all
</button>
)}
</div>
{/* Error */}
2026-02-20 19:31:44 +01:00
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
2026-02-20 19:31:44 +01:00
</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-16">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-indigo-500 border-t-transparent" />
2026-02-20 19:31:44 +01:00
</div>
) : filtered.length === 0 ? (
<EmptyState onOpenCreate={onOpenCreate} />
2026-02-20 19:31:44 +01:00
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-800">
<Th>Status</Th>
<Th>Type</Th>
<Th>Name</Th>
<Th>Market</Th>
<Th align="center">ASINs</Th>
<Th align="center">Control</Th>
<Th>Duration</Th>
<Th>Verdict</Th>
<Th>Owner</Th>
<Th align="right">Actions</Th>
2026-02-20 19:31:44 +01:00
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{filtered.map(exp => (
2026-02-20 19:31:44 +01:00
<tr
key={exp.id}
onClick={() => onOpenDetail(exp.id)}
className="hover:bg-slate-800/30 cursor-pointer transition-colors group"
2026-02-20 19:31:44 +01:00
>
<td className="px-4 py-3">
<StatusBadge status={exp.status} />
2026-02-20 19:31:44 +01:00
</td>
<td className="px-4 py-3">
<TypeBadge type={exp.type} />
2026-02-20 19:31:44 +01:00
</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 text-center">
<span className="text-sm text-slate-400">{exp.asin_count}</span>
</td>
<td className="px-4 py-3 text-center">
<span className="text-sm text-slate-500">
{exp.control_asin_count > 0 ? exp.control_asin_count : '—'}
</span>
2026-02-20 19:31:44 +01:00
</td>
<td className="px-4 py-3">
<div className="text-xs text-slate-400">
{new Date(exp.start_date).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })}
2026-02-20 19:31:44 +01:00
{exp.end_date && (
<span className="text-slate-500">
{' → '}{new Date(exp.end_date).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })}
</span>
2026-02-20 19:31:44 +01:00
)}
</div>
</td>
<td className="px-4 py-3">
<VerdictBadge verdict={exp.verdict} probability={exp.verdict_probability} />
2026-02-20 19:31:44 +01:00
</td>
<td className="px-4 py-3">
<span className="text-sm text-slate-400">{exp.owner || '—'}</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
2026-02-20 19:31:44 +01:00
<button
onClick={e => { e.stopPropagation(); onOpenDetail(exp.id); }}
2026-02-20 19:31:44 +01:00
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors"
title="View"
2026-02-20 19:31:44 +01:00
>
<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)}
2026-02-20 19:31:44 +01:00
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>
</>
2026-02-20 19:31:44 +01:00
);
};
// ====================================================================
// DETAIL VIEW
// ====================================================================
const ExperimentDetailView: React.FC<{
experimentId: string;
salesData: CombinedKPIs[];
onBack: () => void;
onEdit: () => void;
onExperimentsFetch?: () => void;
}> = ({ experimentId, salesData, onBack, onEdit, onExperimentsFetch }) => {
const [experiment, setExperiment] = useState<Experiment | null>(null);
const [loading, setLoading] = useState(true);
const [analyzing, setAnalyzing] = useState(false);
const [editing, setEditing] = useState(false);
const [editLearnings, setEditLearnings] = useState('');
const loadExperiment = useCallback(async () => {
setLoading(true);
try {
const data = await getExperiment(experimentId);
setExperiment(data);
if (data) setEditLearnings(data.learnings || '');
} catch (e: any) {
console.error('Failed to load experiment:', e);
} finally {
setLoading(false);
}
}, [experimentId]);
useEffect(() => { loadExperiment(); }, [loadExperiment]);
const handleRunAnalysis = async () => {
if (!experiment || salesData.length === 0) return;
setAnalyzing(true);
try {
const didResults = computeDiD(experiment, salesData);
const { verdict, probability } = computeVerdict(didResults, experiment.primary_metric);
const updated = await updateExperiment(experiment.id, {
did_results: didResults,
verdict,
verdict_probability: probability,
});
setExperiment(updated);
onExperimentsFetch?.();
} catch (e: any) {
alert(`Analysis error: ${e.message}`);
} finally {
setAnalyzing(false);
}
};
const handleSaveLearnings = async () => {
if (!experiment) return;
try {
const updated = await updateExperiment(experiment.id, { learnings: editLearnings });
setExperiment(updated);
setEditing(false);
} catch (e: any) {
alert(`Error: ${e.message}`);
}
};
const handleUpdateStatus = async (status: ExperimentStatus) => {
if (!experiment) return;
try {
const updated = await updateExperiment(experiment.id, { status });
setExperiment(updated);
onExperimentsFetch?.();
} catch (e: any) {
alert(`Error: ${e.message}`);
}
};
const handleDelete = async () => {
if (!experiment) return;
if (!confirm('Delete this experiment permanently?')) return;
try {
await deleteExperiment(experiment.id);
onBack();
onExperimentsFetch?.();
} catch (e: any) {
alert(`Error: ${e.message}`);
}
};
const trendData = useMemo<TrendDataPoint[]>(() => {
if (!experiment || salesData.length === 0) return [];
return buildCounterfactualSeries(experiment, salesData, experiment.primary_metric);
}, [experiment, salesData]);
const didResults = experiment?.did_results;
const primaryResult = didResults?.metrics[experiment?.primary_metric || 'units'];
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-indigo-500 border-t-transparent" />
</div>
);
}
if (!experiment) {
return (
<div className="text-center py-24">
<p className="text-slate-400">Experiment not found</p>
<button onClick={onBack} className="mt-4 text-indigo-400 hover:text-indigo-300 text-sm">Back to list</button>
</div>
);
}
const probRaw = (experiment.verdict_probability || 0) * 100;
const probDisplay = Number.isInteger(Math.round(probRaw * 10) / 10)
? probRaw.toFixed(0)
: probRaw.toFixed(1);
const radius = 60;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (probRaw / 100) * circumference;
return (
<div className="bg-[#0b1120] text-slate-200 min-h-screen -mx-4 md:-mx-6 -mt-4 md:-mt-6 p-6 md:p-8 font-sans">
{/* Header */}
<div className="flex flex-col xl:flex-row items-start justify-between gap-4 mb-8">
<div>
<div className="flex items-center gap-3 mb-3">
<span className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 text-[10px] font-bold uppercase tracking-wider px-2.5 py-1 rounded">
EXPERIMENT RESULT
</span>
<span className="text-slate-400 text-sm font-medium">ASIN: {experiment.asins?.[0] || 'Multiple'}</span>
<StatusBadge status={experiment.status} />
</div>
<h1 className="text-3xl md:text-3xl lg:text-4xl font-extrabold text-white mb-3 tracking-tight">
{experiment.name}
</h1>
<div className="flex flex-wrap items-center gap-4 text-sm text-slate-400 font-medium">
<div className="flex items-center gap-1.5 whitespace-nowrap">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<span>Test: {new Date(experiment.start_date).toLocaleDateString('en-GB', { month: 'short', day: '2-digit', year: 'numeric' })} - {experiment.end_date ? new Date(experiment.end_date).toLocaleDateString('en-GB', { month: 'short', day: '2-digit', year: 'numeric' }) : 'Ongoing'}</span>
</div>
<div className="w-1 h-1 rounded-full bg-slate-600 hidden md:block" />
<div className="flex items-center gap-1.5 text-indigo-400 whitespace-nowrap">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
<span className="uppercase tracking-widest text-[11px] font-bold">PRIMARY GOAL: {METRIC_LABELS[experiment.primary_metric] || experiment.primary_metric}</span>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{experiment.status === 'planned' && (
<button onClick={() => handleUpdateStatus('active')} className="px-4 py-2.5 bg-emerald-600/20 hover:bg-emerald-600/30 border border-emerald-500/30 rounded-lg transition-colors text-emerald-400 font-semibold text-sm">
Start
</button>
)}
{experiment.status === 'active' && (
<button onClick={() => handleUpdateStatus('completed')} className="px-4 py-2.5 bg-indigo-600/20 hover:bg-indigo-600/30 border border-indigo-500/30 rounded-lg transition-colors text-indigo-400 font-semibold text-sm">
Complete
</button>
)}
<button
onClick={handleRunAnalysis}
disabled={analyzing || salesData.length === 0}
className="px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors text-white font-semibold text-sm flex items-center gap-2 shadow-[0_0_15px_rgba(79,70,229,0.3)]"
>
{analyzing ? (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent" />
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
)}
Run Analysis
</button>
<button onClick={onEdit} className="p-2.5 bg-transparent hover:bg-white/5 border border-white/10 rounded-lg transition-colors text-slate-300">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
</button>
<button onClick={handleDelete} className="p-2.5 bg-transparent hover:bg-red-500/10 border border-white/10 hover:border-red-500/30 rounded-lg transition-colors text-slate-300 hover:text-red-400">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
<button onClick={onBack} className="p-2.5 px-4 bg-transparent hover:bg-white/5 border border-white/10 rounded-lg transition-colors text-slate-300 font-semibold text-sm ml-2">
Back
</button>
</div>
</div>
{/* Main Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
{/* Left Column (Metrics + Content) */}
<div className="lg:col-span-2 space-y-8">
{/* Key Performance Metrics */}
<div>
<div className="flex items-center gap-2 mb-4">
<svg className="w-5 h-5 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>
<h2 className="text-xl font-bold text-white">Key Performance Metrics</h2>
</div>
<div className="bg-[#0b1120]">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-white/10">
<th className="py-3 text-[10px] uppercase tracking-widest text-[#64748b] font-black w-1/3">KPI Metric</th>
<th className="py-3 text-[10px] uppercase tracking-widest text-[#64748b] font-black">Control (A)</th>
<th className="py-3 text-[10px] uppercase tracking-widest text-[#64748b] font-black">Treatment (B)</th>
<th className="py-3 text-[10px] uppercase tracking-widest text-[#64748b] font-black">Impact Delta</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{/* Render specific metrics: Units, Revenue, CVR, BSR/ACOS */}
{['units', 'revenue', 'cvr', 'acos', 'sessions', 'roas', 'ctr'].map(metric => {
const res = didResults?.metrics?.[metric];
if (!res) {
return (
<tr key={metric} className="hover:bg-white/[0.02] transition-colors">
<td className="py-4 text-[13px] font-bold text-slate-200">
{METRIC_LABELS[metric] || metric}
{metric === experiment.primary_metric && (
<span className="ml-2 bg-indigo-500/20 text-indigo-400 border border-indigo-500/20 text-[9px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded">PRIMARY</span>
)}
</td>
<td className="py-4 text-[14px] text-[#64748b] font-medium"></td>
<td className="py-4 text-[14px] text-white font-bold"></td>
<td className="py-4 text-sm font-bold text-slate-500"></td>
</tr>
);
}
const isPositiveGood = !['acos', 'bsr'].includes(metric);
const isGood = isPositiveGood ? res.lift_percent >= 0 : res.lift_percent <= 0;
return (
<tr key={metric} className="hover:bg-white/[0.02] transition-colors">
<td className="py-4 text-[13px] font-bold text-slate-200">
{METRIC_LABELS[metric] || metric}
{metric === experiment.primary_metric && (
<span className="ml-2 bg-indigo-500/20 text-indigo-400 border border-indigo-500/20 text-[9px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded">PRIMARY</span>
)}
</td>
<td className="py-4 text-[14px] text-[#64748b] font-medium">{formatMetricValue(res.treatment_before, metric)}</td>
<td className="py-4 text-[14px] text-white font-bold">{formatMetricValue(res.treatment_after, metric)}</td>
<td className={`py-4 text-sm font-bold flex items-center gap-1 ${isGood ? 'text-emerald-400' : 'text-[#f43f5e]'}`}>
<div className="flex flex-col">
<span className="flex items-center gap-1">
<svg className={`w-3.5 h-3.5 ${isGood ? (res.lift_percent >= 0 ? '' : 'rotate-180') : 'rotate-180'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" /></svg>
{res.lift_percent >= 0 ? '+' : ''}{res.lift_percent.toFixed(1)}%
</span>
<span className="text-[10px] text-[#64748b] font-medium mt-0.5 ml-1">
DiD: {res.did_estimate >= 0 ? '+' : ''}{formatMetricValue(res.did_estimate, metric)}
</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Content Comparison */}
<div>
<div className="flex items-center gap-2 mb-4 mt-8">
<svg className="w-5 h-5 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h7" /></svg>
<h2 className="text-xl font-bold text-white">Content Comparison</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-[#0f172a] border border-white/5 rounded-2xl p-6 relative overflow-hidden">
<div className="text-[10px] uppercase tracking-widest text-[#64748b] font-black mb-3 text-right">Original</div>
<p className="text-sm text-[#94a3b8] leading-relaxed font-medium">
{experiment.changes[0]?.before_value || 'Original content based on baseline metrics.'}
</p>
</div>
<div className="bg-[#1e1b4b]/40 border border-[#4f46e5]/40 rounded-2xl p-6 relative overflow-hidden">
<div className="text-[10px] uppercase tracking-widest text-[#818cf8] font-black mb-3 text-right">Variant B (Winner)</div>
<p className="text-sm text-white leading-relaxed font-bold">
{experiment.changes[0]?.after_value || 'New optimized content tested in this experiment.'}
</p>
</div>
</div>
</div>
</div>
{/* Right Column (Widgets) */}
<div className="space-y-8">
{/* Probability of Success */}
<div className="bg-[#0b1120] border-l lg:border-t-0 border-[#1e293b] lg:pl-10 h-full">
<div className="flex items-center gap-2 mb-8">
<svg className="w-5 h-5 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
<h2 className="text-xl font-bold text-white">Probability of Success</h2>
</div>
<div className="flex flex-col items-center justify-center my-10 relative">
<svg width="180" height="180" className="transform -rotate-90 text-emerald-500/10">
<circle cx="90" cy="90" r={radius} stroke="currentColor" strokeWidth="12" fill="transparent" />
<circle cx="90" cy="90" r={radius} stroke="#10b981" strokeWidth="12" fill="transparent"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-1000 ease-in-out"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center flex-col">
<span className="text-5xl font-black text-white">{probDisplay}%</span>
</div>
</div>
<div className="flex justify-center mb-10">
{experiment.verdict === 'winner' ? (
<span className="bg-emerald-500/10 text-[#10b981] border border-emerald-500/20 text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full inline-flex items-center gap-1.5 shadow-[0_0_15px_rgba(16,185,129,0.1)]">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
Significant Confidence Reached
</span>
) : experiment.verdict === 'loser' ? (
<span className="bg-red-500/10 text-red-400 border border-red-500/20 text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full inline-flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
Negative Effect Confirmed
</span>
) : (
<span className="bg-red-500/10 text-red-400 border border-red-500/20 text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full inline-flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
Unsuccessful
</span>
)}
</div>
{/* Estimated Annual Impact */}
<div className="border-t border-white/5 pt-8">
<div className="flex items-center gap-2 mb-8">
<svg className="w-5 h-5 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 2v20m0 0l-3-3m3 3l3-3m-6-11h6c1.11 0 2 .89 2 2s-.89 2-2 2h-6c-1.11 0-2 .89-2 2s.89 2 2 2h2" /></svg>
<h2 className="text-lg font-bold text-white">Estimated Annual Impact</h2>
</div>
{(() => {
const revenueResult = didResults?.metrics?.['revenue'];
if (!revenueResult) {
return <div className="text-sm text-slate-500 italic">Run analysis to see projected impact</div>;
}
const weeklyIncremental = revenueResult.did_estimate;
// Annual projection: weekly increment * 52 weeks
const expectedAnnual = weeklyIncremental * 52;
// If the impact is negative, we show 0 or the actual loss depending on design choice.
// Given the visual context "Forecast", we'll show the actual projection but cap at 0 for progress bars.
const optimisticAnnual = expectedAnnual > 0 ? expectedAnnual * 1.5 : expectedAnnual * 0.5;
const conservativeAnnual = expectedAnnual > 0 ? expectedAnnual * 0.5 : expectedAnnual * 1.5;
const formatImpact = (val: number) => {
return val.toLocaleString('de-DE', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0
});
};
const maxVal = Math.max(Math.abs(optimisticAnnual), Math.abs(expectedAnnual), 10000);
const getWidth = (val: number) => `${Math.max(5, Math.min(100, (Math.abs(val) / maxVal) * 100))}%`;
return (
<div className="space-y-6">
<div>
<div className="flex justify-between text-[10px] uppercase font-black tracking-widest mb-2.5">
<span className="text-emerald-400 opacity-80">Optimistic Forecast</span>
<span className="text-emerald-400">{formatImpact(optimisticAnnual)}</span>
</div>
<div className="h-2.5 w-full bg-[#1e293b] rounded-full overflow-hidden leading-none">
<div
className={`h-full ${optimisticAnnual >= 0 ? 'bg-[#10b981]' : 'bg-red-500'} rounded-full transition-all duration-1000`}
style={{ width: getWidth(optimisticAnnual) }}
></div>
</div>
</div>
<div>
<div className="flex justify-between text-[10px] uppercase font-black tracking-widest mb-2.5">
<span className="text-indigo-400 opacity-80">Expected Gain</span>
<span className="text-indigo-400">{formatImpact(expectedAnnual)}</span>
</div>
<div className="h-2.5 w-full bg-[#1e293b] rounded-full overflow-hidden leading-none">
<div
className={`h-full ${expectedAnnual >= 0 ? 'bg-[#6366f1]' : 'bg-red-400'} rounded-full transition-all duration-1000`}
style={{ width: getWidth(expectedAnnual) }}
></div>
</div>
</div>
<div>
<div className="flex justify-between text-[10px] uppercase font-black tracking-widest mb-2.5">
<span className="text-[#64748b]">Conservative Baseline</span>
<span className="text-[#64748b]">{formatImpact(conservativeAnnual)}</span>
</div>
<div className="h-2.5 w-full bg-[#1e293b] rounded-full overflow-hidden leading-none">
<div
className={`h-full ${conservativeAnnual >= 0 ? 'bg-[#475569]' : 'bg-red-900'} rounded-full transition-all duration-1000`}
style={{ width: getWidth(conservativeAnnual) }}
></div>
</div>
</div>
</div>
);
})()}
</div>
</div>
</div>
</div>
</div>
);
};
// ====================================================================
// FORM VIEW
// ====================================================================
const ExperimentFormView: React.FC<{
salesData: CombinedKPIs[];
initialData?: Experiment;
onBack: () => void;
onSaved: (id: string) => void;
}> = ({ salesData, initialData, onBack, onSaved }) => {
const [name, setName] = useState(initialData?.name || '');
const [description, setDescription] = useState(initialData?.description || '');
const [type, setType] = useState<ExperimentType>(initialData?.type || 'content');
const [marketplace, setMarketplace] = useState(initialData?.marketplace || 'DE');
const [owner, setOwner] = useState(initialData?.owner || '');
const [hypothesis, setHypothesis] = useState(initialData?.hypothesis || '');
const [primaryMetric, setPrimaryMetric] = useState<ExperimentMetric>(initialData?.primary_metric || 'units');
const [startDate, setStartDate] = useState(initialData?.start_date?.split('T')[0] || new Date().toISOString().split('T')[0]);
const [endDate, setEndDate] = useState(initialData?.end_date?.split('T')[0] || '');
const [enableCustomBaseline, setEnableCustomBaseline] = useState(!!(initialData?.baseline_start_date && initialData?.baseline_end_date));
const [baselineStartDate, setBaselineStartDate] = useState(initialData?.baseline_start_date?.split('T')[0] || '');
const [baselineEndDate, setBaselineEndDate] = useState(initialData?.baseline_end_date?.split('T')[0] || '');
const [treatmentAsins, setTreatmentAsins] = useState(initialData?.asins?.join(', ') || '');
const [controlAsins, setControlAsins] = useState(initialData?.control_asins?.join(', ') || '');
const [changes, setChanges] = useState<ExperimentChangeAnnotation[]>(
initialData?.changes?.length ? initialData.changes : [{ field: '', before_value: '', after_value: '' }]
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const parseAsins = (raw: string): string[] => {
return raw
.split(/[,;\n]+/)
.flatMap(chunk => {
const trimmed = chunk.trim().toUpperCase();
if (trimmed.startsWith('LINE:')) return [trimmed];
return trimmed.split(/\s+/);
})
.filter(s => s.length >= 5);
};
const addChange = () => {
setChanges([...changes, { field: '', before_value: '', after_value: '' }]);
};
const updateChange = (index: number, key: keyof ExperimentChangeAnnotation, value: string) => {
const updated = [...changes];
updated[index] = { ...updated[index], [key]: value };
setChanges(updated);
};
const removeChange = (index: number) => {
setChanges(changes.filter((_, i) => i !== index));
};
const handleSubmit = async () => {
setError('');
if (!name.trim()) { setError('Name is required'); return; }
const asins = parseAsins(treatmentAsins);
if (asins.length === 0) { setError('At least one treatment ASIN is required'); return; }
setSubmitting(true);
try {
const validChanges = changes.filter(c => c.field.trim() && (c.before_value.trim() || c.after_value.trim()));
const input: ExperimentCreateInput = {
name: name.trim(),
description: description.trim() || undefined,
type,
asins,
control_asins: parseAsins(controlAsins),
marketplace,
start_date: startDate,
end_date: endDate || undefined,
baseline_start_date: enableCustomBaseline && baselineStartDate ? baselineStartDate : undefined,
baseline_end_date: enableCustomBaseline && baselineEndDate ? baselineEndDate : undefined,
hypothesis: hypothesis.trim() || undefined,
primary_metric: primaryMetric,
changes: validChanges,
owner: owner.trim() || undefined,
};
if (initialData) {
const updated = await updateExperiment(initialData.id, input);
onSaved(updated.id);
} else {
const created = await createExperiment(input);
onSaved(created.id);
}
} catch (e: any) {
setError(e.message);
} finally {
setSubmitting(false);
}
2026-02-20 19:31:44 +01:00
};
return (
<>
<button onClick={onBack} className="flex items-center gap-1.5 text-sm text-slate-400 hover:text-white transition-colors mb-6">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
Back to experiments
</button>
<h1 className="text-2xl font-bold text-white tracking-tight mb-8">
{initialData ? "Edit Experiment" : "New Experiment"}
</h1>
<div className="max-w-3xl space-y-8">
{/* Basic Info */}
<Card title="Basic Information">
<div className="space-y-4">
<FormField label="Experiment Name" required>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="e.g. Price reduction test - Premium Line"
className="w-full 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 focus:border-indigo-500"
/>
</FormField>
<FormField label="Description">
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
placeholder="Brief description of the experiment..."
className="w-full 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 focus:border-indigo-500 resize-y"
/>
</FormField>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<FormField label="Type">
<select value={type} onChange={e => setType(e.target.value as ExperimentType)} className="w-full 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 focus:border-indigo-500">
{TYPE_OPTIONS.map(t => (
<option key={t} value={t}>{getExperimentIcon(t)} {t.charAt(0).toUpperCase() + t.slice(1)}</option>
))}
</select>
</FormField>
<FormField label="Marketplace">
<select value={marketplace} onChange={e => setMarketplace(e.target.value)} className="w-full 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 focus:border-indigo-500">
{MARKETPLACE_OPTIONS.map(m => (
<option key={m} value={m}>{m}</option>
))}
<option value="All">All Markets</option>
</select>
</FormField>
<FormField label="Primary Metric">
<select value={primaryMetric} onChange={e => setPrimaryMetric(e.target.value as ExperimentMetric)} className="w-full 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 focus:border-indigo-500">
{METRIC_OPTIONS.map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</FormField>
<FormField label="Owner">
<input
type="text"
value={owner}
onChange={e => setOwner(e.target.value)}
placeholder="Your name"
className="w-full 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 focus:border-indigo-500"
/>
</FormField>
</div>
</div>
</Card>
{/* Hypothesis */}
<Card title="Hypothesis">
<textarea
value={hypothesis}
onChange={e => setHypothesis(e.target.value)}
rows={3}
placeholder="What do you expect to happen? e.g. 'Reducing price by 12% will increase units sold by at least 20% while maintaining net margin'"
className="w-full 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 focus:border-indigo-500 resize-y"
/>
</Card>
{/* Change Annotations */}
<Card title="Changes Made">
<p className="text-xs text-slate-500 mb-4">Document exactly what you changed. This helps correlate results with specific actions.</p>
<div className="space-y-3">
{changes.map((c, i) => (
<div key={i} className="flex items-center gap-3">
<input
type="text"
value={c.field}
onChange={e => updateChange(i, 'field', e.target.value)}
placeholder="Field (e.g. Price)"
className="w-full 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 focus:border-indigo-500 w-32"
/>
<input
type="text"
value={c.before_value}
onChange={e => updateChange(i, 'before_value', e.target.value)}
placeholder="Before"
className="w-full 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 focus:border-indigo-500 flex-1"
/>
<svg className="w-4 h-4 text-slate-500 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
<input
type="text"
value={c.after_value}
onChange={e => updateChange(i, 'after_value', e.target.value)}
placeholder="After"
className="w-full 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 focus:border-indigo-500 flex-1"
/>
{changes.length > 1 && (
<button onClick={() => removeChange(i)} className="p-1.5 text-slate-500 hover:text-red-400 transition-colors shrink-0">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
))}
<button onClick={addChange} className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors flex items-center gap-1">
<svg className="w-3 h-3" 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>
Add another change
</button>
</div>
</Card>
{/* Treatment ASINs */}
<Card title="Treatment ASINs">
<p className="text-xs text-slate-500 mb-3">
The products you're making changes to. Paste ASINs separated by commas, spaces, or newlines. Use LINE:ProductLine for entire product lines.
</p>
<textarea
value={treatmentAsins}
onChange={e => setTreatmentAsins(e.target.value)}
rows={3}
placeholder="B09ABC1234, B09DEF5678&#10;or LINE:MAGIC DOUGH"
className="w-full 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 focus:border-indigo-500 resize-y font-mono text-xs"
/>
{treatmentAsins && (
<div className="mt-2 text-xs text-slate-500">{parseAsins(treatmentAsins).length} ASINs detected</div>
)}
</Card>
{/* Control Group */}
<Card title="Control Group (Optional)">
<p className="text-xs text-slate-500 mb-3">
Similar products that you are NOT changing. This enables Difference-in-Differences analysis to isolate your change from market trends.
</p>
<textarea
value={controlAsins}
onChange={e => setControlAsins(e.target.value)}
rows={3}
placeholder="B09GHI9012, B09JKL3456"
className="w-full 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 focus:border-indigo-500 resize-y font-mono text-xs"
/>
{controlAsins && (
<div className="mt-2 text-xs text-slate-500">{parseAsins(controlAsins).length} control ASINs detected</div>
)}
</Card>
{/* Timeline */}
<Card title="Timeline">
<div className="grid grid-cols-2 gap-4 mb-6">
<FormField label="Start Date" required>
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} className="w-full 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 focus:border-indigo-500" />
</FormField>
<FormField label="End Date">
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} className="w-full 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 focus:border-indigo-500" />
</FormField>
</div>
<div className="border-t border-slate-700/50 pt-4">
<label className="flex items-center gap-2 cursor-pointer mb-4">
<input
type="checkbox"
checked={enableCustomBaseline}
onChange={e => setEnableCustomBaseline(e.target.checked)}
className="w-4 h-4 rounded-md border-slate-600 bg-slate-800 text-indigo-500 focus:ring-indigo-500/50"
/>
<span className="text-sm font-medium text-slate-300">Set Custom Baseline Period (Solves Seasonality)</span>
</label>
{enableCustomBaseline && (
<div className="grid grid-cols-2 gap-4 bg-indigo-500/5 p-4 rounded-xl border border-indigo-500/20">
<div className="col-span-2 text-xs text-slate-400 mb-1">
Instead of dynamically looking right before the start date, specify an exact previous period (like year-over-year).
</div>
<FormField label="Baseline Start Date">
<input type="date" value={baselineStartDate} onChange={e => setBaselineStartDate(e.target.value)} className="w-full 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 focus:border-indigo-500" />
</FormField>
<FormField label="Baseline End Date">
<input type="date" value={baselineEndDate} onChange={e => setBaselineEndDate(e.target.value)} className="w-full 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 focus:border-indigo-500" />
</FormField>
</div>
)}
</div>
</Card>
{/* Error + Submit */}
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
<div className="flex items-center gap-3 pt-2">
<button
onClick={handleSubmit}
disabled={submitting}
className="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg font-medium text-sm transition-colors flex items-center gap-2"
>
{submitting && <div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent" />}
{initialData ? "Save Changes" : "Create Experiment"}
</button>
<button onClick={onBack} className="px-4 py-2.5 text-slate-400 hover:text-white text-sm transition-colors">
Cancel
</button>
</div>
</div>
</>
);
};
const ExperimentEditWrapper: React.FC<{
experimentId: string;
salesData: CombinedKPIs[];
onBack: () => void;
onSaved: (id: string) => void;
}> = ({ experimentId, salesData, onBack, onSaved }) => {
const [experiment, setExperiment] = useState<Experiment | null>(null);
useEffect(() => {
getExperiment(experimentId).then(setExperiment);
}, [experimentId]);
if (!experiment) return <div className="flex items-center justify-center p-12 text-slate-400"><div className="animate-spin rounded-full h-8 w-8 border-2 border-indigo-500 border-t-transparent mr-4" /> Loading experiment data...</div>;
return <ExperimentFormView salesData={salesData} initialData={experiment} onBack={onBack} onSaved={onSaved} />;
};
// ====================================================================
// SHARED UI COMPONENTS
// ====================================================================
const StatCard: React.FC<{ label: string; value: string | number; accent: string }> = ({ label, value, accent }) => {
const colors: Record<string, string> = {
indigo: 'border-indigo-500/20 text-indigo-400',
emerald: 'border-emerald-500/20 text-emerald-400',
amber: 'border-amber-500/20 text-amber-400',
purple: 'border-purple-500/20 text-purple-400',
};
return (
<div className={`p-4 rounded-xl border bg-slate-900/40 ${colors[accent]}`}>
2026-02-20 19:31:44 +01:00
<div className="text-2xl font-bold">{value}</div>
<div className="text-xs text-slate-500 mt-1">{label}</div>
2026-02-20 19:31:44 +01:00
</div>
);
};
const Card: React.FC<{ title: string; className?: string; children: React.ReactNode }> = ({ title, className = '', children }) => (
<div className={`bg-slate-900/50 border border-slate-800 rounded-xl p-5 ${className}`}>
<h3 className="text-sm font-medium text-slate-300 mb-4">{title}</h3>
{children}
</div>
);
const Th: React.FC<{ children: React.ReactNode; align?: 'left' | 'right' | 'center' }> = ({ children, align = 'left' }) => (
<th className={`text-${align} text-[11px] font-medium text-slate-500 uppercase tracking-wider px-4 py-3`}>{children}</th>
);
const FormField: React.FC<{ label: string; required?: boolean; children: React.ReactNode }> = ({ label, required, children }) => (
<div>
<label className="block text-xs font-medium text-slate-400 mb-1.5">
{label}{required && <span className="text-red-400 ml-0.5">*</span>}
</label>
{children}
</div>
);
const StatusBadge: React.FC<{ status: ExperimentStatus }> = ({ status }) => (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium border ${getExperimentStatusColor(status)}`}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
const TypeBadge: React.FC<{ type: ExperimentType }> = ({ type }) => (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium border ${getExperimentTypeColor(type)}`}>
<span>{getExperimentIcon(type)}</span>
{type.charAt(0).toUpperCase() + type.slice(1)}
</span>
);
const VerdictBadge: React.FC<{ verdict?: ExperimentVerdict; probability?: number }> = ({ verdict, probability }) => {
if (!verdict) return <span className="text-xs text-slate-600">—</span>;
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] font-semibold border ${getVerdictColor(verdict)}`}>
{verdict === 'winner' && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />}
{verdict === 'loser' && <span className="w-1.5 h-1.5 rounded-full bg-red-400" />}
{verdict === 'inconclusive' && <span className="w-1.5 h-1.5 rounded-full bg-slate-400" />}
{getVerdictLabel(verdict)}
{probability != null && (
<span className="opacity-60">{Math.round(probability * 100)}%</span>
)}
</span>
);
};
const EmptyState: React.FC<{ onOpenCreate: () => void }> = ({ onOpenCreate }) => (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 rounded-2xl bg-indigo-500/10 flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-indigo-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" />
</svg>
</div>
<h3 className="text-lg font-semibold text-white mb-1">No experiments yet</h3>
<p className="text-sm text-slate-400 mb-6 max-w-sm">
Start measuring the real impact of your product changes with scientific rigor.
</p>
<button
onClick={onOpenCreate}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-colors"
>
Create your first experiment
</button>
</div>
);
2026-02-20 19:31:44 +01:00
export default ExperimentsView;