mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 13:45:23 +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,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;
|
||||
Reference in New Issue
Block a user