Add complete Experiments tracking system

New Features:
- Experiments tab with full CRUD operations
- Create experiments by ASIN or product line groups
- Track pricing, advertising, content, and promotion experiments
- Performance analytics with baseline vs experiment comparison
- Visual experiment badges in Weekly Sales grid
- Experiment detail view with metrics and learnings

Technical Changes:
- Add Supabase experiments table migration
- New services/experiments.ts for CRUD + calculations
- New components: ExperimentsView, ExperimentDetail, ExperimentForm, ExperimentBadge
- Integrate experiment indicators in WeeklyGrid
- Add navigation tab (desktop + mobile)
- Type definitions in types.ts

Usage:
1. Run SQL migration in Supabase
2. Navigate to Experiments tab
3. Create experiments with ASINs, dates, hypothesis
4. View performance lift after completion
5. See active experiments marked in Weekly Sales

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Christian Vidal Wolf
2026-02-20 19:31:44 +01:00
co-authored by Qwen-Coder
parent a4919691b2
commit f93d92da0b
9 changed files with 1689 additions and 4 deletions
+115
View File
@@ -0,0 +1,115 @@
import React from 'react';
import { ActiveExperiment, ExperimentStatus } from '../types';
import { getExperimentStatusColor, getExperimentTypeColor, getExperimentIcon } from '../services/experiments';
interface ExperimentBadgeProps {
experiments: ActiveExperiment[];
onClick?: (experimentId: string) => void;
}
export const ExperimentBadge: React.FC<ExperimentBadgeProps> = ({ experiments, onClick }) => {
if (!experiments || experiments.length === 0) return null;
const activeExp = experiments.find(e => e.status === 'active');
const plannedExp = experiments.find(e => e.status === 'planned');
const hasPast = experiments.some(e => e.status === 'completed' || e.status === 'paused');
// Priority: active > planned > past
const displayExp = activeExp || plannedExp || experiments[0];
const getStatusBadge = (status: ExperimentStatus) => {
switch (status) {
case 'active': return '🟢';
case 'planned': return '🟡';
case 'completed': return '⚪';
case 'paused': return '⏸️';
}
};
const daysText = displayExp.days_remaining !== undefined
? displayExp.days_remaining > 0
? `${displayExp.days_remaining}d left`
: 'Ending soon'
: '';
return (
<div className="inline-flex items-center gap-1">
{experiments.length > 1 && (
<span className="text-[9px] text-slate-500 mr-1">+{experiments.length}</span>
)}
<button
onClick={(e) => {
e.stopPropagation();
onClick?.(displayExp.experiment_id);
}}
className={`
inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-medium
border ${getExperimentStatusColor(displayExp.status)}
hover:opacity-80 transition-opacity cursor-pointer
`}
title={`${displayExp.experiment_name}
Type: ${displayExp.type}
Start: ${displayExp.start_date}
End: ${displayExp.end_date || 'Ongoing'}
${daysText}`}
>
<span>{getExperimentIcon(displayExp.type)}</span>
<span className="hidden xl:inline truncate max-w-[80px]">{displayExp.experiment_name}</span>
{daysText && <span className="opacity-60">{daysText}</span>}
</button>
</div>
);
};
interface ExperimentStatusBadgeProps {
status: ExperimentStatus;
size?: 'sm' | 'md' | 'lg';
}
export const ExperimentStatusBadge: React.FC<ExperimentStatusBadgeProps> = ({ status, size = 'md' }) => {
const sizeClasses = {
sm: 'px-1.5 py-0.5 text-[9px]',
md: 'px-2 py-1 text-xs',
lg: 'px-3 py-1.5 text-sm',
};
const statusLabels = {
active: 'Active',
planned: 'Planned',
completed: 'Completed',
paused: 'Paused',
};
return (
<span className={`inline-flex items-center gap-1 rounded-full font-medium border ${getExperimentStatusColor(status)} ${sizeClasses[size]}`}>
{getStatusBadge(status)} {statusLabels[status]}
</span>
);
};
interface ExperimentTypeBadgeProps {
type: string;
size?: 'sm' | 'md' | 'lg';
}
export const ExperimentTypeBadge: React.FC<ExperimentTypeBadgeProps> = ({ type, size = 'md' }) => {
const sizeClasses = {
sm: 'px-1.5 py-0.5 text-[9px]',
md: 'px-2 py-1 text-xs',
lg: 'px-3 py-1.5 text-sm',
};
const typeLabels = {
pricing: 'Pricing',
advertising: 'Advertising',
content: 'Content',
promotion: 'Promotion',
};
return (
<span className={`inline-flex items-center gap-1 rounded-full font-medium border ${getExperimentTypeColor(type as any)} ${sizeClasses[size]}`}>
<span>{getExperimentIcon(type as any)}</span>
{typeLabels[type as keyof typeof typeLabels] || type}
</span>
);
};
+410
View File
@@ -0,0 +1,410 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Experiment, ExperimentCreateInput, ExperimentType, ExperimentStatus, ExperimentMetric } from '../types';
import { getExperiment, updateExperiment, deleteExperiment, calculateExperimentPerformance } from '../services/experiments';
import { ExperimentStatusBadge, ExperimentTypeBadge } from './ExperimentBadge';
interface ExperimentDetailProps {
experimentId: string | null;
onClose: () => void;
salesData?: any[];
}
const ExperimentDetail: React.FC<ExperimentDetailProps> = ({ experimentId, onClose, salesData = [] }) => {
const [experiment, setExperiment] = useState<Experiment | null>(null);
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState(false);
const [formData, setFormData] = useState<Partial<Experiment>>({});
const loadExperiment = useCallback(async () => {
if (!experimentId) return;
setLoading(true);
try {
const data = await getExperiment(experimentId);
setExperiment(data);
setFormData(data || {});
} catch (e: any) {
console.error('Failed to load experiment:', e);
} finally {
setLoading(false);
}
}, [experimentId]);
useEffect(() => {
if (experimentId) {
loadExperiment();
}
}, [experimentId, loadExperiment]);
const handleSave = async () => {
if (!experimentId) return;
setLoading(true);
try {
const updated = await updateExperiment(experimentId, formData);
setExperiment(updated);
setEditing(false);
} catch (e: any) {
alert(`Error saving: ${e.message}`);
} finally {
setLoading(false);
}
};
const handleCalculateResults = async () => {
if (!experiment || !salesData.length) return;
setLoading(true);
try {
const perf = await calculateExperimentPerformance(experiment, salesData);
const updated = await updateExperiment(experiment.id, perf);
setExperiment(updated);
} catch (e: any) {
alert(`Error calculating results: ${e.message}`);
} finally {
setLoading(false);
}
};
if (!experimentId) return null;
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-700">
<div className="flex items-center gap-3">
{editing ? (
<input
type="text"
value={formData.name || ''}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="text-xl font-bold bg-slate-800 border border-slate-600 rounded-lg px-3 py-1 text-white"
/>
) : (
<h2 className="text-xl font-bold text-white">{experiment?.name || 'Loading...'}</h2>
)}
{experiment && (
<div className="flex items-center gap-2">
<ExperimentStatusBadge status={experiment.status} />
<ExperimentTypeBadge type={experiment.type} />
</div>
)}
</div>
<div className="flex items-center gap-2">
{editing ? (
<>
<button
onClick={() => { setEditing(false); setFormData(experiment || {}); }}
className="px-3 py-1.5 text-sm text-slate-400 hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={loading}
className="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors"
>
Save
</button>
</>
) : (
<>
<button
onClick={() => setEditing(true)}
className="px-3 py-1.5 text-sm text-slate-400 hover:text-white transition-colors"
>
Edit
</button>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
>
<svg className="w-5 h-5" 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>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{loading && !experiment ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-indigo-500"></div>
</div>
) : experiment ? (
<div className="space-y-6">
{/* Performance Cards */}
{(experiment.experiment_units !== undefined || experiment.baseline_units !== undefined) && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<PerformanceCard
label="Baseline Units"
value={experiment.baseline_units?.toLocaleString() || '—'}
color="slate"
/>
<PerformanceCard
label="Experiment Units"
value={experiment.experiment_units?.toLocaleString() || '—'}
color="indigo"
/>
<PerformanceCard
label="Lift"
value={experiment.actual_lift_percent !== undefined
? `${experiment.actual_lift_percent >= 0 ? '+' : ''}${experiment.actual_lift_percent}%`
: '—'}
color={experiment.actual_lift_percent && experiment.actual_lift_percent >= 0 ? 'emerald' : 'red'}
/>
<PerformanceCard
label="Statistical Significance"
value={experiment.statistical_significance
? `${(experiment.statistical_significance * 100).toFixed(1)}%`
: '—'}
color="purple"
/>
</div>
)}
{/* Calculate Results Button */}
{experiment.status === 'completed' && experiment.experiment_units === undefined && salesData.length > 0 && (
<button
onClick={handleCalculateResults}
className="w-full py-3 bg-indigo-600/20 border border-indigo-500/30 hover:bg-indigo-600/30 text-indigo-400 rounded-xl font-medium transition-colors"
>
📊 Calculate Experiment Results
</button>
)}
{/* Details Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left Column */}
<div className="space-y-4">
<DetailSection title="📋 Overview">
{editing ? (
<>
<textarea
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white"
rows={3}
placeholder="Experiment description..."
/>
</>
) : (
<p className="text-slate-300 text-sm">{experiment.description || 'No description'}</p>
)}
</DetailSection>
<DetailSection title="🎯 Hypothesis">
{editing ? (
<textarea
value={formData.hypothesis || ''}
onChange={(e) => setFormData({ ...formData, hypothesis: e.target.value })}
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white"
rows={3}
placeholder="What do you expect to happen?"
/>
) : (
<p className="text-slate-300 text-sm">{experiment.hypothesis || 'No hypothesis defined'}</p>
)}
</DetailSection>
<DetailSection title="📏 Metrics">
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-400">Primary Metric:</span>
<span className="text-white font-medium capitalize">{experiment.primary_metric}</span>
</div>
{experiment.target_lift_percent && (
<div className="flex justify-between">
<span className="text-slate-400">Target Lift:</span>
<span className="text-white font-medium">{experiment.target_lift_percent}%</span>
</div>
)}
</div>
</DetailSection>
</div>
{/* Right Column */}
<div className="space-y-4">
<DetailSection title="🎪 Experiment Details">
<div className="space-y-2 text-sm">
{editing ? (
<>
<div className="grid grid-cols-2 gap-2">
<select
value={formData.status || 'planned'}
onChange={(e) => setFormData({ ...formData, status: e.target.value as ExperimentStatus })}
className="bg-slate-800 border border-slate-600 rounded-lg px-2 py-1.5 text-sm text-white"
>
<option value="planned">Planned</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="paused">Paused</option>
</select>
<select
value={formData.type || 'pricing'}
onChange={(e) => setFormData({ ...formData, type: e.target.value as ExperimentType })}
className="bg-slate-800 border border-slate-600 rounded-lg px-2 py-1.5 text-sm text-white"
>
<option value="pricing">Pricing</option>
<option value="advertising">Advertising</option>
<option value="content">Content</option>
<option value="promotion">Promotion</option>
</select>
</div>
<input
type="text"
value={formData.marketplace || ''}
onChange={(e) => setFormData({ ...formData, marketplace: e.target.value })}
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-2 py-1.5 text-sm text-white"
placeholder="Marketplace (DE, UK, FR, IT, ES)"
/>
</>
) : (
<>
<div className="flex justify-between">
<span className="text-slate-400">Marketplace:</span>
<span className="text-white font-medium">{experiment.marketplace}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Type:</span>
<span className="text-white font-medium capitalize">{experiment.type}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Status:</span>
<span className="text-white font-medium capitalize">{experiment.status}</span>
</div>
</>
)}
</div>
</DetailSection>
<DetailSection title="📅 Timeline">
<div className="space-y-2 text-sm">
{editing ? (
<>
<div>
<label className="text-xs text-slate-400 block mb-1">Start Date</label>
<input
type="date"
value={formData.start_date || ''}
onChange={(e) => setFormData({ ...formData, start_date: e.target.value })}
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-2 py-1.5 text-sm text-white"
/>
</div>
<div>
<label className="text-xs text-slate-400 block mb-1">End Date</label>
<input
type="date"
value={formData.end_date || ''}
onChange={(e) => setFormData({ ...formData, end_date: e.target.value })}
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-2 py-1.5 text-sm text-white"
/>
</div>
</>
) : (
<>
<div className="flex justify-between">
<span className="text-slate-400">Start Date:</span>
<span className="text-white">{new Date(experiment.start_date).toLocaleDateString()}</span>
</div>
{experiment.end_date && (
<div className="flex justify-between">
<span className="text-slate-400">End Date:</span>
<span className="text-white">{new Date(experiment.end_date).toLocaleDateString()}</span>
</div>
)}
{experiment.end_date && (
<div className="flex justify-between">
<span className="text-slate-400">Duration:</span>
<span className="text-white">
{Math.ceil((new Date(experiment.end_date).getTime() - new Date(experiment.start_date).getTime()) / (1000 * 60 * 60 * 24))} days
</span>
</div>
)}
</>
)}
</div>
</DetailSection>
<DetailSection title="👤 Owner">
{editing ? (
<input
type="text"
value={formData.owner || ''}
onChange={(e) => setFormData({ ...formData, owner: e.target.value })}
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-2 py-1.5 text-sm text-white"
placeholder="Owner name"
/>
) : (
<p className="text-slate-300 text-sm">{experiment.owner || 'Not assigned'}</p>
)}
</DetailSection>
</div>
</div>
{/* ASINs */}
<DetailSection title="📦 Target ASINs">
<div className="flex flex-wrap gap-2">
{experiment.asins.map((asin, idx) => (
<span
key={idx}
className="px-2.5 py-1 bg-slate-800 border border-slate-600 rounded-lg text-sm text-slate-300 font-mono"
>
{asin}
</span>
))}
</div>
</DetailSection>
{/* Learnings */}
<DetailSection title="💡 Learnings & Conclusions">
{editing ? (
<textarea
value={formData.learnings || ''}
onChange={(e) => setFormData({ ...formData, learnings: e.target.value })}
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white"
rows={4}
placeholder="What did you learn from this experiment?"
/>
) : (
<p className="text-slate-300 text-sm whitespace-pre-wrap">
{experiment.learnings || 'No learnings recorded yet'}
</p>
)}
</DetailSection>
</div>
) : null}
</div>
</div>
</div>
);
};
// Performance Card Component
const PerformanceCard: React.FC<{ label: string; value: string; color: string }> = ({ label, value, color }) => {
const colorClasses: Record<string, string> = {
slate: 'bg-slate-500/10 border-slate-500/30 text-slate-400',
indigo: 'bg-indigo-500/10 border-indigo-500/30 text-indigo-400',
emerald: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400',
red: 'bg-red-500/10 border-red-500/30 text-red-400',
purple: 'bg-purple-500/10 border-purple-500/30 text-purple-400',
};
return (
<div className={`p-4 rounded-xl border ${colorClasses[color]}`}>
<div className="text-xs opacity-70 mb-1">{label}</div>
<div className="text-xl font-bold">{value}</div>
</div>
);
};
// Detail Section Component
const DetailSection: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
<div className="bg-slate-800/30 border border-slate-700 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-300 mb-3">{title}</h3>
{children}
</div>
);
export default ExperimentDetail;
+338
View File
@@ -0,0 +1,338 @@
import React, { useState, useMemo } from 'react';
import { ExperimentCreateInput, ExperimentType, ExperimentMetric } from '../types';
import { createExperiment } from '../services/experiments';
interface ExperimentFormProps {
onClose: () => void;
onSuccess: () => void;
initialAsins?: string[];
initialMarketplace?: string;
}
const ExperimentForm: React.FC<ExperimentFormProps> = ({
onClose,
onSuccess,
initialAsins = [],
initialMarketplace = 'DE'
}) => {
const [formData, setFormData] = useState<ExperimentCreateInput>({
name: '',
description: '',
type: 'pricing',
asins: initialAsins,
marketplace: initialMarketplace,
start_date: new Date().toISOString().split('T')[0],
end_date: '',
hypothesis: '',
primary_metric: 'units',
target_lift_percent: 10,
owner: '',
});
const [loading, setLoading] = useState(false);
const [asinInput, setAsinInput] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.asins.length) {
alert('Please fill in required fields');
return;
}
setLoading(true);
try {
await createExperiment(formData);
onSuccess();
onClose();
} catch (error: any) {
alert(`Error creating experiment: ${error.message}`);
} finally {
setLoading(false);
}
};
const handleAddAsin = () => {
const newAsins = asinInput
.split(',')
.map(a => a.trim().toUpperCase())
.filter(a => a.length > 0);
const uniqueAsins = Array.from(new Set([...formData.asins, ...newAsins]));
setFormData({ ...formData, asins: uniqueAsins });
setAsinInput('');
};
const handleRemoveAsin = (asinToRemove: string) => {
setFormData({
...formData,
asins: formData.asins.filter(a => a !== asinToRemove),
});
};
const typeOptions: { value: ExperimentType; label: string; icon: string }[] = [
{ value: 'pricing', label: 'Pricing', icon: '💰' },
{ value: 'advertising', label: 'Advertising', icon: '📢' },
{ value: 'content', label: 'Content', icon: '📝' },
{ value: 'promotion', label: 'Promotion', icon: '🏷️' },
];
const metricOptions: { value: ExperimentMetric; label: string }[] = [
{ value: 'units', label: 'Units Sold' },
{ value: 'revenue', label: 'Revenue' },
{ value: 'acos', label: 'ACOS' },
{ value: 'ctr', label: 'CTR' },
{ value: 'cvr', label: 'Conversion Rate' },
{ value: 'bsr', label: 'BSR' },
];
const marketplaceOptions = ['DE', 'UK', 'FR', 'IT', 'ES'];
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-700">
<h2 className="text-xl font-bold text-white">🧪 Create New Experiment</h2>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
>
<svg className="w-5 h-5" 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>
{/* Form */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Name */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Experiment Name <span className="text-red-400">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="e.g., Q1 Price Reduction Test"
required
/>
</div>
{/* Type */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Experiment Type <span className="text-red-400">*</span>
</label>
<div className="grid grid-cols-2 gap-2">
{typeOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setFormData({ ...formData, type: opt.value })}
className={`p-3 rounded-lg border text-sm font-medium transition-colors flex items-center gap-2 ${
formData.type === opt.value
? 'bg-indigo-600/20 border-indigo-500 text-indigo-400'
: 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-500'
}`}
>
<span className="text-lg">{opt.icon}</span>
{opt.label}
</button>
))}
</div>
</div>
{/* Marketplace */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Marketplace <span className="text-red-400">*</span>
</label>
<div className="flex gap-2">
{marketplaceOptions.map((mp) => (
<button
key={mp}
type="button"
onClick={() => setFormData({ ...formData, marketplace: mp })}
className={`px-4 py-2 rounded-lg border text-sm font-medium transition-colors ${
formData.marketplace === mp
? 'bg-indigo-600/20 border-indigo-500 text-indigo-400'
: 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-500'
}`}
>
{mp}
</button>
))}
</div>
</div>
{/* ASINs */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Target ASINs <span className="text-red-400">*</span>
</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={asinInput}
onChange={(e) => setAsinInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddAsin())}
className="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Enter ASINs (comma-separated)"
/>
<button
type="button"
onClick={handleAddAsin}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg transition-colors"
>
Add
</button>
</div>
{formData.asins.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 bg-slate-800/50 border border-slate-700 rounded-lg">
{formData.asins.map((asin) => (
<span
key={asin}
className="inline-flex items-center gap-1 px-2.5 py-1 bg-slate-700 border border-slate-600 rounded-lg text-sm text-slate-300 font-mono"
>
{asin}
<button
type="button"
onClick={() => handleRemoveAsin(asin)}
className="text-slate-400 hover:text-red-400 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
))}
</div>
)}
</div>
{/* Timeline */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Start Date <span className="text-red-400">*</span>
</label>
<input
type="date"
value={formData.start_date}
onChange={(e) => setFormData({ ...formData, start_date: e.target.value })}
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
End Date
</label>
<input
type="date"
value={formData.end_date}
onChange={(e) => setFormData({ ...formData, end_date: e.target.value })}
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
{/* Hypothesis */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Hypothesis
</label>
<textarea
value={formData.hypothesis}
onChange={(e) => setFormData({ ...formData, hypothesis: e.target.value })}
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
rows={3}
placeholder="What do you expect to happen? e.g., 'Reducing price by 10% will increase units sold by 25%'"
/>
</div>
{/* Primary Metric & Target Lift */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Primary Metric
</label>
<select
value={formData.primary_metric}
onChange={(e) => setFormData({ ...formData, primary_metric: e.target.value as ExperimentMetric })}
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
{metricOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Target Lift (%)
</label>
<input
type="number"
value={formData.target_lift_percent}
onChange={(e) => setFormData({ ...formData, target_lift_percent: parseFloat(e.target.value) || 0 })}
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
min="0"
max="1000"
/>
</div>
</div>
{/* Owner */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Owner
</label>
<input
type="text"
value={formData.owner}
onChange={(e) => setFormData({ ...formData, owner: e.target.value })}
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Your name"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
rows={3}
placeholder="Additional details about this experiment..."
/>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4 border-t border-slate-700">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-white rounded-lg font-medium transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-2.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-600/50 text-white rounded-lg font-medium transition-colors"
>
{loading ? 'Creating...' : 'Create Experiment'}
</button>
</div>
</form>
</div>
</div>
);
};
export default ExperimentForm;
+306
View File
@@ -0,0 +1,306 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { ExperimentListItem, ExperimentType, ExperimentStatus } from '../types';
import { listExperiments, deleteExperiment, getExperimentStatusColor, getExperimentTypeColor, getExperimentIcon } from '../services/experiments';
import MultiSelectDropdown from './MultiSelectDropdown';
import { ExperimentBadge, ExperimentStatusBadge, ExperimentTypeBadge } from './ExperimentBadge';
interface ExperimentsViewProps {
onOpenDetail: (experimentId: string) => void;
onOpenCreate: () => void;
}
const ExperimentsView: React.FC<ExperimentsViewProps> = ({ onOpenDetail, onOpenCreate }) => {
const [experiments, setExperiments] = useState<ExperimentListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filters
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [typeFilter, setTypeFilter] = useState<string[]>([]);
const [marketplaceFilter, setMarketplaceFilter] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const statusOptions = ['planned', 'active', 'completed', 'paused'];
const typeOptions = ['pricing', 'advertising', 'content', 'promotion'];
const marketplaceOptions = ['DE', 'UK', 'FR', 'IT', 'ES'];
const loadExperiments = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await listExperiments({
status: statusFilter.length > 0 ? statusFilter as ExperimentStatus[] : undefined,
type: typeFilter.length > 0 ? typeFilter as ExperimentType[] : undefined,
marketplace: marketplaceFilter.length > 0 ? marketplaceFilter : undefined,
});
setExperiments(data);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [statusFilter, typeFilter, marketplaceFilter]);
useEffect(() => {
loadExperiments();
}, [loadExperiments]);
const filteredExperiments = useMemo(() => {
if (!searchQuery) return experiments;
const query = searchQuery.toLowerCase();
return experiments.filter(exp =>
exp.name.toLowerCase().includes(query) ||
exp.owner?.toLowerCase().includes(query)
);
}, [experiments, searchQuery]);
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm('Are you sure you want to delete this experiment?')) return;
try {
await deleteExperiment(id);
loadExperiments();
} catch (e: any) {
alert(`Error deleting experiment: ${e.message}`);
}
};
const handleDuplicate = async (experiment: ExperimentListItem, e: React.MouseEvent) => {
e.stopPropagation();
onOpenDetail(experiment.id);
// Duplicate functionality will be in detail view
};
// Stats
const stats = useMemo(() => ({
total: experiments.length,
active: experiments.filter(e => e.status === 'active').length,
planned: experiments.filter(e => e.status === 'planned').length,
avgLift: experiments.filter(e => e.actual_lift_percent !== undefined)
.reduce((sum, e) => sum + (e.actual_lift_percent || 0), 0) /
Math.max(1, experiments.filter(e => e.actual_lift_percent !== undefined).length),
}), [experiments]);
return (
<div className="p-6 pb-24 md:pb-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">🧪 Experiments</h1>
<p className="text-sm text-slate-400 mt-1">
Track and analyze your product experiments
</p>
</div>
<button
onClick={onOpenCreate}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg font-medium transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New Experiment
</button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<StatCard label="Total Experiments" value={stats.total} color="indigo" />
<StatCard label="Active" value={stats.active} color="emerald" />
<StatCard label="Planned" value={stats.planned} color="amber" />
<StatCard label="Avg. Lift" value={`${stats.avgLift.toFixed(1)}%`} color="purple" />
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 items-center mb-6 p-4 bg-slate-900/50 border border-slate-800 rounded-xl">
<MultiSelectDropdown
label="Status"
options={statusOptions}
selected={statusFilter}
onChange={setStatusFilter}
/>
<MultiSelectDropdown
label="Type"
options={typeOptions}
selected={typeFilter}
onChange={setTypeFilter}
/>
<MultiSelectDropdown
label="Marketplace"
options={marketplaceOptions}
selected={marketplaceFilter}
onChange={setMarketplaceFilter}
/>
<input
type="text"
placeholder="Search experiments..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 flex-1 min-w-[200px]"
/>
{(statusFilter.length > 0 || typeFilter.length > 0 || marketplaceFilter.length > 0 || searchQuery) && (
<button
onClick={() => {
setStatusFilter([]);
setTypeFilter([]);
setMarketplaceFilter([]);
setSearchQuery('');
}}
className="text-xs text-slate-400 hover:text-white underline whitespace-nowrap"
>
Clear all
</button>
)}
</div>
{/* Error State */}
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400">
Error: {error}
</div>
)}
{/* Table */}
<div className="bg-slate-900/50 border border-slate-800 rounded-xl overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-indigo-500"></div>
</div>
) : filteredExperiments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="text-4xl mb-4">🧪</div>
<h3 className="text-lg font-semibold text-slate-200">No experiments yet</h3>
<p className="text-slate-400 text-sm mt-1 mb-4">
Create your first experiment to start tracking performance
</p>
<button
onClick={onOpenCreate}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg font-medium transition-colors"
>
Create Experiment
</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-800/50 border-b border-slate-700">
<tr>
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Status</th>
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Type</th>
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Name</th>
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Marketplace</th>
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">ASINs</th>
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Duration</th>
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Progress</th>
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Lift</th>
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Owner</th>
<th className="text-right text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{filteredExperiments.map((exp) => (
<tr
key={exp.id}
onClick={() => onOpenDetail(exp.id)}
className="hover:bg-slate-800/30 cursor-pointer transition-colors"
>
<td className="px-4 py-3">
<ExperimentStatusBadge status={exp.status} size="sm" />
</td>
<td className="px-4 py-3">
<ExperimentTypeBadge type={exp.type} size="sm" />
</td>
<td className="px-4 py-3">
<span className="text-sm font-medium text-slate-200">{exp.name}</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-slate-400">{exp.marketplace}</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-slate-400">{exp.asin_count} ASINs</span>
</td>
<td className="px-4 py-3">
<div className="text-sm text-slate-400">
<div>{new Date(exp.start_date).toLocaleDateString()}</div>
{exp.end_date && (
<div className="text-xs text-slate-500">
{new Date(exp.end_date).toLocaleDateString()}
</div>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden max-w-[100px]">
<div
className="h-full bg-indigo-500 rounded-full"
style={{ width: `${exp.progress_percent}%` }}
/>
</div>
<span className="text-xs text-slate-400 w-10">{exp.progress_percent}%</span>
</div>
</td>
<td className="px-4 py-3">
{exp.actual_lift_percent !== undefined ? (
<span className={`text-sm font-medium ${exp.actual_lift_percent >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{exp.actual_lift_percent >= 0 ? '+' : ''}{exp.actual_lift_percent}%
</span>
) : (
<span className="text-sm text-slate-500"></span>
)}
</td>
<td className="px-4 py-3">
<span className="text-sm text-slate-400">{exp.owner || '—'}</span>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={(e) => handleDuplicate(exp, e)}
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors"
title="View / Edit"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<button
onClick={(e) => handleDelete(exp.id, e)}
className="p-1.5 text-slate-400 hover:text-red-400 hover:bg-slate-700 rounded transition-colors"
title="Delete"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
};
// Stat Card Component
const StatCard: React.FC<{ label: string; value: string | number; color: string }> = ({ label, value, color }) => {
const colorClasses: Record<string, string> = {
indigo: 'bg-indigo-500/10 border-indigo-500/30 text-indigo-400',
emerald: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400',
amber: 'bg-amber-500/10 border-amber-500/30 text-amber-400',
purple: 'bg-purple-500/10 border-purple-500/30 text-purple-400',
};
return (
<div className={`p-4 rounded-xl border ${colorClasses[color]}`}>
<div className="text-2xl font-bold">{value}</div>
<div className="text-xs opacity-70 mt-1">{label}</div>
</div>
);
};
export default ExperimentsView;
+10
View File
@@ -11,6 +11,8 @@ import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
import { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons';
import { ExcelFilter } from './ExcelFilter';
import ExperimentTracker from './ExperimentTracker';
import { ExperimentBadge } from './ExperimentBadge';
import { ActiveExperiment } from '../types';
interface WeeklyGridProps {
data: CombinedKPIs[];
@@ -31,6 +33,8 @@ interface WeeklyGridProps {
velocityMap?: Map<string, number>;
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
top50Mode?: 'eu' | 'uk';
experimentMap?: Map<string, ActiveExperiment[]>;
onOpenExperiment?: (experimentId: string) => void;
}
type SortConfig = {
@@ -199,6 +203,12 @@ const WeeklyRow: React.FC<{
avgWeeklySales={velocityMap?.get(asin)}
/>
<BuyBoxWarningBadge asin={asin} buyBoxLostMap={buyBoxLostMap} />
{experimentMap && (
<ExperimentBadge
experiments={experimentMap.get(asin) || []}
onClick={onOpenExperiment}
/>
)}
</div>
<span className="text-[9px] text-fuchsia-400/80 font-bold uppercase tracking-widest">{row.line}</span>
</div>