mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 22:05:24 +02:00
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>
307 lines
14 KiB
TypeScript
307 lines
14 KiB
TypeScript
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;
|