mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 12:25:22 +02:00
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:
co-authored by
Qwen-Coder
parent
a4919691b2
commit
f93d92da0b
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user