mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 15:35:22 +02:00
1184 lines
59 KiB
TypeScript
1184 lines
59 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' | '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>
|
|
);
|
|
};
|
|
|
|
// ====================================================================
|
|
// 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;
|
|
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 prob = Math.round((experiment.verdict_probability || 0) * 100);
|
|
const radius = 60;
|
|
const circumference = 2 * Math.PI * radius;
|
|
const offset = circumference - (prob / 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">{prob}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-center mb-10">
|
|
<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>
|
|
</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>
|
|
|
|
<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">$65,000</span>
|
|
</div>
|
|
<div className="h-2.5 w-full bg-[#1e293b] rounded-full overflow-hidden leading-none">
|
|
<div className="h-full bg-[#10b981] rounded-full w-full"></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">$42,000</span>
|
|
</div>
|
|
<div className="h-2.5 w-full bg-[#1e293b] rounded-full overflow-hidden leading-none">
|
|
<div className="h-full bg-[#6366f1] rounded-full w-[65%]"></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]">$18,000</span>
|
|
</div>
|
|
<div className="h-2.5 w-full bg-[#1e293b] rounded-full overflow-hidden leading-none">
|
|
<div className="h-full bg-[#475569] rounded-full w-[25%]"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom Info Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div className="bg-[#1e293b]/40 border border-white/5 rounded-3xl p-6 md:p-8 relative">
|
|
<div className="w-12 h-12 rounded-full bg-indigo-500/10 flex items-center justify-center text-indigo-400 mb-6">
|
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
|
</div>
|
|
<div className="text-[10px] font-black uppercase tracking-widest text-[#94a3b8] mb-1">Test Timeline</div>
|
|
<div className="text-2xl font-black text-white mb-6">12 Weeks</div>
|
|
|
|
<div className="border-t border-[#334155] pt-5 mb-5 space-y-1">
|
|
<div className="text-[10px] font-black uppercase tracking-widest text-[#64748b]">Active Test Period</div>
|
|
<div className="text-[15px] font-bold text-white">Jan 01 - Mar 25, 2026</div>
|
|
<div className="text-[11px] text-[#4f46e5] font-semibold">Weeks 1-12 (2026)</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<div className="text-[10px] font-black uppercase tracking-widest text-[#64748b]">Comparison Baseline</div>
|
|
<div className="text-[15px] font-bold text-[#94a3b8]">Oct 01 - Dec 24, 2025</div>
|
|
<div className="text-[11px] text-[#64748b] font-medium">Weeks 40-51 (2025)</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-[#6366f1] rounded-3xl p-8 relative overflow-hidden flex flex-col justify-center">
|
|
<div className="absolute -bottom-10 -right-10 opacity-[0.15]">
|
|
<svg className="w-64 h-64" viewBox="0 0 24 24" fill="currentColor"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" /></svg>
|
|
</div>
|
|
<div className="relative z-10 flex items-center gap-2 text-[#e0e7ff] text-[10px] font-black uppercase tracking-widest mb-4">
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>
|
|
Statistical Insight
|
|
</div>
|
|
<p className="relative z-10 text-xl font-bold text-white leading-relaxed">
|
|
Results are normalized against the Pre-test Baseline to account for category seasonality.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-[#1e293b]/40 border border-white/5 rounded-3xl p-6 md:p-8 flex flex-col justify-center items-start">
|
|
<div className="w-16 h-16 rounded-2xl border border-amber-500/20 bg-amber-500/10 flex items-center justify-center text-amber-500 mb-6">
|
|
<span className="text-3xl font-serif">T</span>
|
|
</div>
|
|
<div className="text-[10px] font-black uppercase tracking-widest text-white mb-3">Content Strategy</div>
|
|
<p className="text-[15px] text-[#94a3b8] leading-relaxed max-w-xs font-medium">
|
|
Focus on high-velocity benefit descriptors in Treatment B.
|
|
</p>
|
|
</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(/[\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,
|
|
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);
|
|
}
|
|
};
|
|
|
|
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 or LINE:PREMIUM_COLLECTION"
|
|
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]}`}>
|
|
<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;
|