Files
CrazeAnalytix/components/ExperimentsView.tsx
Christian Vidal WolfandClaude Opus 4.6 24df5935cc feat: rebuild Experiments tab with Difference-in-Differences analysis and Bayesian verdicts
Replace the basic CRUD experiment tracker with a scientifically rigorous A/B testing system:

- Add DiD analysis engine (services/experimentAnalysis.ts) that computes treatment vs control
  group comparisons across 7 metrics (units, sessions, CVR, CTR, ROAS, revenue, ACOS)
- Implement Bayesian verdict system (Winner/Loser/Inconclusive) using posterior probability
  with normal CDF approximation (Abramowitz & Stegun erf, no external deps)
- Build counterfactual time series for trend charts (actual vs estimated without change)
- Rewrite ExperimentsView as single component with 3 inline sub-views (list, detail, create)
  replacing the previous modal-based ExperimentDetail and ExperimentForm
- Add control group support, change annotations (before→after diffs), and SEO experiment type
- New types: ExperimentChangeAnnotation, DiDMetricResult, DifferenceInDifferencesResult,
  ExperimentVerdict
- Simplify App.tsx by removing experiment modal state (5 useState hooks eliminated)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:42:40 +01:00

1199 lines
53 KiB
TypeScript

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';
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';
// ============ 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(() => {
setSubView('create');
}, []);
const navigateToList = useCallback(() => {
setSelectedId(null);
setSubView('list');
}, []);
const handleCreated = 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}
onExperimentsFetch={onExperimentsFetch}
/>
)}
{subView === 'create' && (
<ExperimentCreateView
salesData={salesData}
onBack={navigateToList}
onCreated={handleCreated}
/>
)}
</div>
);
};
// ====================================================================
// LIST VIEW
// ====================================================================
const ExperimentListView: React.FC<{
salesData: CombinedKPIs[];
onOpenDetail: (id: string) => void;
onOpenCreate: () => void;
}> = ({ salesData, onOpenDetail, onOpenCreate }) => {
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]);
const filtered = useMemo(() => {
if (!searchQuery) return experiments;
const q = searchQuery.toLowerCase();
return experiments.filter(e =>
e.name.toLowerCase().includes(q) || e.owner?.toLowerCase().includes(q)
);
}, [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]);
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm('Delete this experiment? This action cannot be undone.')) return;
try {
await deleteExperiment(id);
loadExperiments();
} catch (e: any) {
alert(`Error: ${e.message}`);
}
};
return (
<>
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white tracking-tight">Experiments</h1>
<p className="text-sm text-slate-400 mt-1">
Measure the impact of your changes with scientific rigor
</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"
>
<svg className="w-4 h-4" 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>
{/* Stat Cards */}
<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" />
</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>
{(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"
>
Clear all
</button>
)}
</div>
{/* Error */}
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{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-16">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-indigo-500 border-t-transparent" />
</div>
) : filtered.length === 0 ? (
<EmptyState onOpenCreate={onOpenCreate} />
) : (
<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>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{filtered.map(exp => (
<tr
key={exp.id}
onClick={() => onOpenDetail(exp.id)}
className="hover:bg-slate-800/30 cursor-pointer transition-colors group"
>
<td className="px-4 py-3">
<StatusBadge status={exp.status} />
</td>
<td className="px-4 py-3">
<TypeBadge type={exp.type} />
</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>
</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' })}
{exp.end_date && (
<span className="text-slate-500">
{' → '}{new Date(exp.end_date).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })}
</span>
)}
</div>
</td>
<td className="px-4 py-3">
<VerdictBadge verdict={exp.verdict} probability={exp.verdict_probability} />
</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">
<button
onClick={e => { e.stopPropagation(); onOpenDetail(exp.id); }}
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors"
title="View"
>
<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>
</>
);
};
// ====================================================================
// DETAIL VIEW
// ====================================================================
const ExperimentDetailView: React.FC<{
experimentId: string;
salesData: CombinedKPIs[];
onBack: () => void;
onExperimentsFetch?: () => void;
}> = ({ experimentId, salesData, onBack, 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>
);
}
return (
<>
{/* Back Button + Header */}
<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>
{/* Header */}
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4 mb-8">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl font-bold text-white tracking-tight">{experiment.name}</h1>
<StatusBadge status={experiment.status} />
<TypeBadge type={experiment.type} />
</div>
{experiment.description && (
<p className="text-sm text-slate-400 max-w-2xl">{experiment.description}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{experiment.status === 'planned' && (
<button onClick={() => handleUpdateStatus('active')} className="px-3 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium transition-colors">
Start Experiment
</button>
)}
{experiment.status === 'active' && (
<button onClick={() => handleUpdateStatus('completed')} className="px-3 py-2 bg-slate-600 hover:bg-slate-500 text-white rounded-lg text-sm font-medium transition-colors">
Complete
</button>
)}
<button
onClick={handleRunAnalysis}
disabled={analyzing || salesData.length === 0}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
>
{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" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
)}
Run Analysis
</button>
<button
onClick={handleDelete}
className="p-2 text-slate-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
title="Delete experiment"
>
<svg className="w-5 h-5" 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>
</div>
{/* Verdict Card (if results exist) */}
{experiment.verdict && primaryResult && (
<div className={`mb-8 p-6 rounded-2xl border ${getVerdictColor(experiment.verdict)} relative overflow-hidden`}>
<div className="relative z-10 flex flex-col md:flex-row items-center gap-6">
<div className="text-center md:text-left">
<div className="text-xs uppercase tracking-widest opacity-60 mb-1">Primary Metric Verdict</div>
<div className="text-3xl font-bold">
{getVerdictLabel(experiment.verdict)}
</div>
<div className="text-sm opacity-80 mt-1">
{METRIC_LABELS[experiment.primary_metric] || experiment.primary_metric}
</div>
</div>
<div className="flex-1" />
<div className="flex items-center gap-8">
<div className="text-center">
<div className="text-xs text-slate-400 mb-1">Lift</div>
<div className={`text-2xl font-bold ${primaryResult.lift_percent >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{primaryResult.lift_percent >= 0 ? '+' : ''}{primaryResult.lift_percent}%
</div>
</div>
<div className="text-center">
<div className="text-xs text-slate-400 mb-1">Confidence</div>
<div className="text-2xl font-bold text-white">
{Math.round((experiment.verdict_probability || 0) * 100)}%
</div>
</div>
<div className="text-center">
<div className="text-xs text-slate-400 mb-1">DiD Effect</div>
<div className="text-2xl font-bold text-white">
{primaryResult.did_estimate >= 0 ? '+' : ''}{formatMetricValue(primaryResult.did_estimate, experiment.primary_metric)}
</div>
</div>
</div>
</div>
{/* Confidence bar */}
<div className="mt-4 h-1.5 bg-slate-700/50 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
experiment.verdict === 'winner' ? 'bg-emerald-400' :
experiment.verdict === 'loser' ? 'bg-red-400' : 'bg-slate-400'
}`}
style={{ width: `${Math.round((experiment.verdict_probability || 0) * 100)}%` }}
/>
</div>
</div>
)}
{/* Info Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
{/* Hypothesis */}
<Card title="Hypothesis" className="lg:col-span-2">
<p className="text-sm text-slate-300 leading-relaxed">
{experiment.hypothesis || 'No hypothesis defined'}
</p>
</Card>
{/* Timeline */}
<Card title="Timeline">
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-slate-400">Start</span>
<span className="text-white font-medium">{new Date(experiment.start_date).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
</div>
{experiment.end_date && (
<div className="flex justify-between text-sm">
<span className="text-slate-400">End</span>
<span className="text-white font-medium">{new Date(experiment.end_date).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-slate-400">Primary Metric</span>
<span className="text-indigo-400 font-medium">{METRIC_LABELS[experiment.primary_metric] || experiment.primary_metric}</span>
</div>
{experiment.owner && (
<div className="flex justify-between text-sm">
<span className="text-slate-400">Owner</span>
<span className="text-white">{experiment.owner}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-slate-400">Marketplace</span>
<span className="text-white">{experiment.marketplace || 'All'}</span>
</div>
</div>
</Card>
</div>
{/* Change Annotations */}
{experiment.changes && experiment.changes.length > 0 && (
<Card title="Changes Made" className="mb-8">
<div className="space-y-2">
{experiment.changes.map((c, i) => (
<div key={i} className="flex items-center gap-3 py-2 px-3 bg-slate-800/50 rounded-lg">
<span className="text-xs font-medium text-slate-400 uppercase tracking-wide w-24 shrink-0">{c.field}</span>
<code className="text-sm text-red-400/80 bg-red-500/10 px-2 py-0.5 rounded">{c.before_value}</code>
<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>
<code className="text-sm text-emerald-400/80 bg-emerald-500/10 px-2 py-0.5 rounded">{c.after_value}</code>
</div>
))}
</div>
</Card>
)}
{/* ASINs */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<Card title={`Treatment ASINs (${experiment.asins?.length || 0})`}>
<div className="flex flex-wrap gap-2">
{(experiment.asins || []).map(a => (
<span key={a} className="text-xs font-mono bg-indigo-500/10 text-indigo-300 px-2 py-1 rounded border border-indigo-500/20">{a}</span>
))}
{(!experiment.asins || experiment.asins.length === 0) && (
<span className="text-sm text-slate-500">No ASINs defined</span>
)}
</div>
</Card>
<Card title={`Control Group (${experiment.control_asins?.length || 0})`}>
<div className="flex flex-wrap gap-2">
{(experiment.control_asins || []).map(a => (
<span key={a} className="text-xs font-mono bg-slate-500/10 text-slate-300 px-2 py-1 rounded border border-slate-500/20">{a}</span>
))}
{(!experiment.control_asins || experiment.control_asins.length === 0) && (
<span className="text-sm text-slate-500">No control group using simple before/after comparison</span>
)}
</div>
</Card>
</div>
{/* Trend Chart */}
{trendData.length > 2 && experiment.did_results && (
<Card title="Performance Trend: Actual vs Counterfactual" className="mb-8">
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={trendData} margin={{ top: 10, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
dataKey="week"
tick={{ fill: '#94a3b8', fontSize: 11 }}
tickFormatter={w => w.split('-')[1] || w}
stroke="#475569"
/>
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} stroke="#475569" />
<RechartsTooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null;
const actual = payload.find(p => p.dataKey === 'actual');
const counter = payload.find(p => p.dataKey === 'counterfactual');
return (
<div className="bg-slate-800 border border-slate-700 rounded-lg p-3 shadow-xl">
<div className="text-xs text-slate-400 mb-2">{label}</div>
<div className="flex items-center gap-2 text-sm">
<div className="w-2 h-2 rounded-full bg-indigo-500" />
<span className="text-slate-300">Actual:</span>
<span className="text-white font-medium">{formatMetricValue(Number(actual?.value || 0), experiment.primary_metric)}</span>
</div>
<div className="flex items-center gap-2 text-sm mt-1">
<div className="w-2 h-2 rounded-full bg-slate-400" />
<span className="text-slate-300">Counterfactual:</span>
<span className="text-white font-medium">{formatMetricValue(Number(counter?.value || 0), experiment.primary_metric)}</span>
</div>
</div>
);
}}
/>
{experiment.start_date && (
<ReferenceLine
x={(() => {
const sd = new Date(experiment.start_date);
const week = Math.ceil((sd.getTime() - new Date(sd.getFullYear(), 0, 1).getTime()) / (7 * 86400000));
return `${sd.getFullYear()}-W${String(week).padStart(2, '0')}`;
})()}
stroke="#f59e0b"
strokeDasharray="4 4"
label={{ value: 'Start', position: 'top', fill: '#f59e0b', fontSize: 11 }}
/>
)}
<Line type="monotone" dataKey="actual" stroke="#6366f1" strokeWidth={2.5} dot={false} name="Actual" />
<Line type="monotone" dataKey="counterfactual" stroke="#64748b" strokeWidth={2} strokeDasharray="6 3" dot={false} name="Counterfactual" />
</LineChart>
</ResponsiveContainer>
</div>
<div className="flex items-center justify-center gap-6 mt-3">
<div className="flex items-center gap-2 text-xs text-slate-400">
<div className="w-6 h-0.5 bg-indigo-500 rounded" />
Actual performance
</div>
<div className="flex items-center gap-2 text-xs text-slate-400">
<div className="w-6 h-0.5 bg-slate-500 rounded" style={{ backgroundImage: 'repeating-linear-gradient(90deg, #64748b 0, #64748b 4px, transparent 4px, transparent 7px)' }} />
Counterfactual (without change)
</div>
</div>
</Card>
)}
{/* DiD Metric Cards */}
{didResults && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-8">
{(Object.entries(didResults.metrics) as [string, DiDMetricResult][]).map(([metric, result]) => {
const isPrimary = metric === experiment.primary_metric;
return (
<div
key={metric}
className={`p-4 rounded-xl border transition-colors ${
isPrimary ? 'border-indigo-500/40 bg-indigo-500/5' : 'border-slate-800 bg-slate-900/50'
}`}
>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium text-slate-400 uppercase tracking-wide">
{METRIC_LABELS[metric] || metric}
</span>
{isPrimary && (
<span className="text-[10px] bg-indigo-500/20 text-indigo-400 px-1.5 py-0.5 rounded font-medium">PRIMARY</span>
)}
</div>
<div className={`text-xl font-bold ${result.lift_percent >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{result.lift_percent >= 0 ? '+' : ''}{result.lift_percent}%
</div>
<div className="text-xs text-slate-500 mt-1">
DiD: {result.did_estimate >= 0 ? '+' : ''}{formatMetricValue(result.did_estimate, metric)}
</div>
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
result.posterior_prob_positive >= 0.9 ? 'bg-emerald-400' :
result.posterior_prob_positive <= 0.1 ? 'bg-red-400' : 'bg-slate-400'
}`}
style={{ width: `${Math.round(result.posterior_prob_positive * 100)}%` }}
/>
</div>
<span className="text-[10px] text-slate-500">{Math.round(result.posterior_prob_positive * 100)}%</span>
</div>
</div>
);
})}
</div>
)}
{/* Before / After Summary Table */}
{didResults && (
<Card title="Before vs After Comparison" className="mb-8">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-800">
<Th>Metric</Th>
<Th align="right">Treatment Before</Th>
<Th align="right">Treatment After</Th>
<Th align="right">Control Before</Th>
<Th align="right">Control After</Th>
<Th align="right">DiD Effect</Th>
<Th align="right">Lift</Th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{(Object.entries(didResults.metrics) as [string, DiDMetricResult][]).map(([metric, r]) => (
<tr key={metric} className={metric === experiment.primary_metric ? 'bg-indigo-500/5' : ''}>
<td className="px-4 py-2.5 text-slate-300 font-medium">{METRIC_LABELS[metric] || metric}</td>
<td className="px-4 py-2.5 text-right text-slate-400">{formatMetricValue(r.treatment_before, metric)}</td>
<td className="px-4 py-2.5 text-right text-white font-medium">{formatMetricValue(r.treatment_after, metric)}</td>
<td className="px-4 py-2.5 text-right text-slate-400">{r.control_before ? formatMetricValue(r.control_before, metric) : '—'}</td>
<td className="px-4 py-2.5 text-right text-slate-400">{r.control_after ? formatMetricValue(r.control_after, metric) : '—'}</td>
<td className={`px-4 py-2.5 text-right font-medium ${r.did_estimate >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{r.did_estimate >= 0 ? '+' : ''}{formatMetricValue(r.did_estimate, metric)}
</td>
<td className={`px-4 py-2.5 text-right font-bold ${r.lift_percent >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{r.lift_percent >= 0 ? '+' : ''}{r.lift_percent}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{/* Learnings */}
<Card title="Learnings & Conclusions" className="mb-8">
{editing ? (
<div className="space-y-3">
<textarea
value={editLearnings}
onChange={e => setEditLearnings(e.target.value)}
rows={5}
className="w-full bg-slate-800 border border-slate-700 rounded-lg p-3 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-y"
placeholder="What did you learn from this experiment?"
/>
<div className="flex gap-2">
<button onClick={handleSaveLearnings} className="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg transition-colors">
Save
</button>
<button onClick={() => { setEditing(false); setEditLearnings(experiment.learnings || ''); }} className="px-3 py-1.5 text-slate-400 hover:text-white text-sm transition-colors">
Cancel
</button>
</div>
</div>
) : (
<div
onClick={() => setEditing(true)}
className="cursor-pointer hover:bg-slate-800/30 rounded-lg p-2 -m-2 transition-colors"
>
{experiment.learnings ? (
<p className="text-sm text-slate-300 leading-relaxed whitespace-pre-wrap">{experiment.learnings}</p>
) : (
<p className="text-sm text-slate-500 italic">Click to add learnings...</p>
)}
</div>
)}
</Card>
</>
);
};
// ====================================================================
// CREATE VIEW
// ====================================================================
const ExperimentCreateView: React.FC<{
salesData: CombinedKPIs[];
onBack: () => void;
onCreated: (id: string) => void;
}> = ({ salesData, onBack, onCreated }) => {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [type, setType] = useState<ExperimentType>('content');
const [marketplace, setMarketplace] = useState('DE');
const [owner, setOwner] = useState('');
const [hypothesis, setHypothesis] = useState('');
const [primaryMetric, setPrimaryMetric] = useState<ExperimentMetric>('units');
const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0]);
const [endDate, setEndDate] = useState('');
const [treatmentAsins, setTreatmentAsins] = useState('');
const [controlAsins, setControlAsins] = useState('');
const [changes, setChanges] = useState<ExperimentChangeAnnotation[]>([{ field: '', before_value: '', after_value: '' }]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const parseAsins = (raw: string): string[] => {
return raw
.split(/[\s,;\n]+/)
.map(s => s.trim().toUpperCase())
.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,
hypothesis: hypothesis.trim() || undefined,
primary_metric: primaryMetric,
changes: validChanges,
owner: owner.trim() || undefined,
};
const created = await createExperiment(input);
onCreated(created.id);
} catch (e: any) {
setError(e.message);
} finally {
setSubmitting(false);
}
};
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">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="input-field"
/>
</FormField>
<FormField label="Description">
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
placeholder="Brief description of the experiment..."
className="input-field 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="input-field">
{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="input-field">
{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="input-field">
{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="input-field"
/>
</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="input-field 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="input-field w-32"
/>
<input
type="text"
value={c.before_value}
onChange={e => updateChange(i, 'before_value', e.target.value)}
placeholder="Before"
className="input-field 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="input-field 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:PREMIUM_COLLECTION"
className="input-field 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="input-field 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">
<FormField label="Start Date" required>
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} className="input-field" />
</FormField>
<FormField label="End Date">
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} className="input-field" />
</FormField>
</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" />}
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>
</>
);
};
// ====================================================================
// 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]}`}>
<div className="text-2xl font-bold">{value}</div>
<div className="text-xs text-slate-500 mt-1">{label}</div>
</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>
);
export default ExperimentsView;