mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 11:55: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
@@ -6,10 +6,11 @@ import FilterBar from './components/FilterBar';
|
|||||||
import AIChat from './components/AIChat';
|
import AIChat from './components/AIChat';
|
||||||
import CrazeLogo from './components/CrazeLogo';
|
import CrazeLogo from './components/CrazeLogo';
|
||||||
import { processCSV, processExcel, filterData, aggregateData, processAdsCSV, processAdsExcel, mergeSalesAndAdsData, processTrafficExcel, processStockExcel, filterAdsData, calculateForecastViewData, processVendorStockExcel, processUKInventoryExcel, calculateVelocityMap, getUniqueValues, processForecastExcel, processBuyBoxExcel, processVendorCSV } from './services/dataProcessor';
|
import { processCSV, processExcel, filterData, aggregateData, processAdsCSV, processAdsExcel, mergeSalesAndAdsData, processTrafficExcel, processStockExcel, filterAdsData, calculateForecastViewData, processVendorStockExcel, processUKInventoryExcel, calculateVelocityMap, getUniqueValues, processForecastExcel, processBuyBoxExcel, processVendorCSV } from './services/dataProcessor';
|
||||||
import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData } from './types';
|
import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData, ActiveExperiment } from './types';
|
||||||
import { queryGemini } from './services/geminiService';
|
import { queryGemini } from './services/geminiService';
|
||||||
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons';
|
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons';
|
||||||
import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage';
|
import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage';
|
||||||
|
import { getActiveExperimentsForASINs } from './services/experiments';
|
||||||
|
|
||||||
// Lazy load heavy components for better initial performance
|
// Lazy load heavy components for better initial performance
|
||||||
const DataGrid = lazy(() => import('./components/DataGrid'));
|
const DataGrid = lazy(() => import('./components/DataGrid'));
|
||||||
@@ -18,6 +19,9 @@ const TopMovers = lazy(() => import('./components/TopMovers'));
|
|||||||
const AdsPerformance = lazy(() => import('./components/AdsPerformance'));
|
const AdsPerformance = lazy(() => import('./components/AdsPerformance'));
|
||||||
const ForecastView = lazy(() => import('./components/ForecastView'));
|
const ForecastView = lazy(() => import('./components/ForecastView'));
|
||||||
const VendorDataView = lazy(() => import('./components/VendorDataView'));
|
const VendorDataView = lazy(() => import('./components/VendorDataView'));
|
||||||
|
const ExperimentsView = lazy(() => import('./components/ExperimentsView'));
|
||||||
|
const ExperimentDetail = lazy(() => import('./components/ExperimentDetail'));
|
||||||
|
const ExperimentForm = lazy(() => import('./components/ExperimentForm'));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const LoadingSpinner = () => (
|
const LoadingSpinner = () => (
|
||||||
@@ -44,7 +48,7 @@ const App: React.FC = () => {
|
|||||||
const [trafficData, setTrafficData] = useState<TrafficRecord[]>([]);
|
const [trafficData, setTrafficData] = useState<TrafficRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [view, setView] = useState<'dashboard' | 'table' | 'weekly' | 'movers' | 'ads' | 'forecast' | 'vendor'>('dashboard');
|
const [view, setView] = useState<'dashboard' | 'table' | 'weekly' | 'movers' | 'ads' | 'forecast' | 'vendor' | 'experiments'>('dashboard');
|
||||||
const [forecastData, setForecastData] = useState<ProductForecastData[]>([]);
|
const [forecastData, setForecastData] = useState<ProductForecastData[]>([]);
|
||||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||||
const [activeUrl, setActiveUrl] = useState<string | null>(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL);
|
const [activeUrl, setActiveUrl] = useState<string | null>(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL);
|
||||||
@@ -55,6 +59,13 @@ const App: React.FC = () => {
|
|||||||
const [lastForecastFile, setLastForecastFile] = useState<string | null>(null);
|
const [lastForecastFile, setLastForecastFile] = useState<string | null>(null);
|
||||||
const [buyBoxLostMap, setBuyBoxLostMap] = useState<Map<string, { countries: string[]; reasons: Record<string, string> }>>(new Map());
|
const [buyBoxLostMap, setBuyBoxLostMap] = useState<Map<string, { countries: string[]; reasons: Record<string, string> }>>(new Map());
|
||||||
|
|
||||||
|
// Experiments state
|
||||||
|
const [experimentMap, setExperimentMap] = useState<Map<string, ActiveExperiment[]>>(new Map());
|
||||||
|
const [selectedExperimentId, setSelectedExperimentId] = useState<string | null>(null);
|
||||||
|
const [showCreateExperiment, setShowCreateExperiment] = useState(false);
|
||||||
|
const [preselectedAsins, setPreselectedAsins] = useState<string[]>([]);
|
||||||
|
const [preselectedMarketplace, setPreselectedMarketplace] = useState<string>('');
|
||||||
|
|
||||||
// Modal State
|
// Modal State
|
||||||
const [isDataModalOpen, setIsDataModalOpen] = useState(false);
|
const [isDataModalOpen, setIsDataModalOpen] = useState(false);
|
||||||
|
|
||||||
@@ -238,6 +249,17 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleExperimentsFetch = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const allAsins = Array.from(new Set(rawData.map(r => r.asin.toUpperCase())));
|
||||||
|
const expMap = await getActiveExperimentsForASINs(allAsins);
|
||||||
|
setExperimentMap(expMap);
|
||||||
|
console.log('[App] Loaded experiments for', expMap.size, 'ASINs');
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch experiments", error);
|
||||||
|
}
|
||||||
|
}, [rawData]);
|
||||||
|
|
||||||
|
|
||||||
const handleForecastFetch = useCallback(async (sales: SalesRecord[], globalSales: SalesRecord[], activeFilters: FilterState) => {
|
const handleForecastFetch = useCallback(async (sales: SalesRecord[], globalSales: SalesRecord[], activeFilters: FilterState) => {
|
||||||
try {
|
try {
|
||||||
@@ -379,7 +401,11 @@ const App: React.FC = () => {
|
|||||||
handleVendorStockFetch();
|
handleVendorStockFetch();
|
||||||
handleBuyBoxFetch();
|
handleBuyBoxFetch();
|
||||||
|
|
||||||
// 1e. Fetch Forecast data
|
// 1e. Fetch Experiments data
|
||||||
|
console.log("Fetching Experiments data...");
|
||||||
|
handleExperimentsFetch();
|
||||||
|
|
||||||
|
// 1f. Fetch Forecast data
|
||||||
handleForecastFetch(cachedData || [], cachedData || [], filters);
|
handleForecastFetch(cachedData || [], cachedData || [], filters);
|
||||||
};
|
};
|
||||||
initApp();
|
initApp();
|
||||||
@@ -793,6 +819,13 @@ const App: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<ChartIcon /> <span className="hidden lg:inline">Vendor</span>
|
<ChartIcon /> <span className="hidden lg:inline">Vendor</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('experiments')}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
||||||
|
${view === 'experiments' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
||||||
|
>
|
||||||
|
<span className="text-lg">🧪</span> <span className="hidden lg:inline">Experiments</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -851,6 +884,8 @@ const App: React.FC = () => {
|
|||||||
top50Ranking={top50Ranking2025}
|
top50Ranking={top50Ranking2025}
|
||||||
velocityMap={velocityMap}
|
velocityMap={velocityMap}
|
||||||
buyBoxLostMap={buyBoxLostMap}
|
buyBoxLostMap={buyBoxLostMap}
|
||||||
|
experimentMap={experimentMap}
|
||||||
|
onOpenExperiment={(id) => setSelectedExperimentId(id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -906,6 +941,19 @@ const App: React.FC = () => {
|
|||||||
<VendorDataView />
|
<VendorDataView />
|
||||||
</div>
|
</div>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<div className={view === 'experiments' ? '' : 'hidden'}>
|
||||||
|
<ExperimentsView
|
||||||
|
onOpenDetail={(id) => setSelectedExperimentId(id)}
|
||||||
|
onOpenCreate={() => {
|
||||||
|
setPreselectedAsins([]);
|
||||||
|
setPreselectedMarketplace(filters.customer[0] || '');
|
||||||
|
setShowCreateExperiment(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -916,7 +964,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
{/* Mobile Bottom Navigation */}
|
{/* Mobile Bottom Navigation */}
|
||||||
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-slate-950/95 backdrop-blur border-t border-border md:hidden pb-safe">
|
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-slate-950/95 backdrop-blur border-t border-border md:hidden pb-safe">
|
||||||
<div className="grid grid-cols-7 gap-0">
|
<div className="grid grid-cols-8 gap-0">
|
||||||
{([
|
{([
|
||||||
{ key: 'dashboard' as const, icon: <ChartIcon />, label: 'Home' },
|
{ key: 'dashboard' as const, icon: <ChartIcon />, label: 'Home' },
|
||||||
{ key: 'table' as const, icon: <TableIcon />, label: 'Grid' },
|
{ key: 'table' as const, icon: <TableIcon />, label: 'Grid' },
|
||||||
@@ -925,6 +973,7 @@ const App: React.FC = () => {
|
|||||||
{ key: 'ads' as const, icon: <MegaphoneIcon />, label: 'Ads' },
|
{ key: 'ads' as const, icon: <MegaphoneIcon />, label: 'Ads' },
|
||||||
{ key: 'forecast' as const, icon: <ChartIcon />, label: 'Fc 26' },
|
{ key: 'forecast' as const, icon: <ChartIcon />, label: 'Fc 26' },
|
||||||
{ key: 'vendor' as const, icon: <ChartIcon />, label: 'Vendor' },
|
{ key: 'vendor' as const, icon: <ChartIcon />, label: 'Vendor' },
|
||||||
|
{ key: 'experiments' as const, icon: <span className="text-lg">🧪</span>, label: 'Exp' },
|
||||||
]).map(({ key, icon, label }) => (
|
]).map(({ key, icon, label }) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
@@ -969,6 +1018,31 @@ const App: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{/* Experiment Detail Modal */}
|
||||||
|
{selectedExperimentId && (
|
||||||
|
<ExperimentDetail
|
||||||
|
experimentId={selectedExperimentId}
|
||||||
|
onClose={() => setSelectedExperimentId(null)}
|
||||||
|
salesData={rawData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Experiment Modal */}
|
||||||
|
{showCreateExperiment && (
|
||||||
|
<ExperimentForm
|
||||||
|
onClose={() => {
|
||||||
|
setShowCreateExperiment(false);
|
||||||
|
setPreselectedAsins([]);
|
||||||
|
setPreselectedMarketplace('');
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
handleExperimentsFetch();
|
||||||
|
}}
|
||||||
|
initialAsins={preselectedAsins}
|
||||||
|
initialMarketplace={preselectedMarketplace}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</div >
|
</div >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons';
|
||||||
import { ExcelFilter } from './ExcelFilter';
|
import { ExcelFilter } from './ExcelFilter';
|
||||||
import ExperimentTracker from './ExperimentTracker';
|
import ExperimentTracker from './ExperimentTracker';
|
||||||
|
import { ExperimentBadge } from './ExperimentBadge';
|
||||||
|
import { ActiveExperiment } from '../types';
|
||||||
|
|
||||||
interface WeeklyGridProps {
|
interface WeeklyGridProps {
|
||||||
data: CombinedKPIs[];
|
data: CombinedKPIs[];
|
||||||
@@ -31,6 +33,8 @@ interface WeeklyGridProps {
|
|||||||
velocityMap?: Map<string, number>;
|
velocityMap?: Map<string, number>;
|
||||||
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
|
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
|
||||||
top50Mode?: 'eu' | 'uk';
|
top50Mode?: 'eu' | 'uk';
|
||||||
|
experimentMap?: Map<string, ActiveExperiment[]>;
|
||||||
|
onOpenExperiment?: (experimentId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortConfig = {
|
type SortConfig = {
|
||||||
@@ -199,6 +203,12 @@ const WeeklyRow: React.FC<{
|
|||||||
avgWeeklySales={velocityMap?.get(asin)}
|
avgWeeklySales={velocityMap?.get(asin)}
|
||||||
/>
|
/>
|
||||||
<BuyBoxWarningBadge asin={asin} buyBoxLostMap={buyBoxLostMap} />
|
<BuyBoxWarningBadge asin={asin} buyBoxLostMap={buyBoxLostMap} />
|
||||||
|
{experimentMap && (
|
||||||
|
<ExperimentBadge
|
||||||
|
experiments={experimentMap.get(asin) || []}
|
||||||
|
onClick={onOpenExperiment}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[9px] text-fuchsia-400/80 font-bold uppercase tracking-widest">{row.line}</span>
|
<span className="text-[9px] text-fuchsia-400/80 font-bold uppercase tracking-widest">{row.line}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import {
|
||||||
|
Experiment,
|
||||||
|
ExperimentCreateInput,
|
||||||
|
ExperimentListItem,
|
||||||
|
ActiveExperiment,
|
||||||
|
ExperimentType,
|
||||||
|
ExperimentStatus,
|
||||||
|
} from '../types';
|
||||||
|
import { SalesRecord } from '../types';
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.SUPABASE_URL || '';
|
||||||
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_KEY || '';
|
||||||
|
|
||||||
|
const getSupabase = () => {
|
||||||
|
if (!supabaseUrl || !supabaseServiceKey) {
|
||||||
|
throw new Error('Supabase credentials not configured');
|
||||||
|
}
|
||||||
|
return createClient(supabaseUrl, supabaseServiceKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ CRUD Operations ============
|
||||||
|
|
||||||
|
export const createExperiment = async (input: ExperimentCreateInput): Promise<Experiment> => {
|
||||||
|
const supabase = getSupabase();
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('experiments')
|
||||||
|
.insert([{
|
||||||
|
...input,
|
||||||
|
status: 'planned',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}])
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data as Experiment;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateExperiment = async (
|
||||||
|
id: string,
|
||||||
|
updates: Partial<Experiment>
|
||||||
|
): Promise<Experiment> => {
|
||||||
|
const supabase = getSupabase();
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('experiments')
|
||||||
|
.update({ ...updates, updated_at: new Date().toISOString() })
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data as Experiment;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteExperiment = async (id: string): Promise<void> => {
|
||||||
|
const supabase = getSupabase();
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('experiments')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getExperiment = async (id: string): Promise<Experiment | null> => {
|
||||||
|
const supabase = getSupabase();
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('experiments')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) return null;
|
||||||
|
return data as Experiment;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listExperiments = async (
|
||||||
|
filters?: {
|
||||||
|
status?: ExperimentStatus[];
|
||||||
|
type?: ExperimentType[];
|
||||||
|
marketplace?: string[];
|
||||||
|
asin?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}
|
||||||
|
): Promise<ExperimentListItem[]> => {
|
||||||
|
const supabase = getSupabase();
|
||||||
|
|
||||||
|
let query = supabase.from('experiments').select('*');
|
||||||
|
|
||||||
|
if (filters?.status?.length) {
|
||||||
|
query = query.in('status', filters.status);
|
||||||
|
}
|
||||||
|
if (filters?.type?.length) {
|
||||||
|
query = query.in('type', filters.type);
|
||||||
|
}
|
||||||
|
if (filters?.marketplace?.length) {
|
||||||
|
query = query.in('marketplace', filters.marketplace);
|
||||||
|
}
|
||||||
|
if (filters?.asin) {
|
||||||
|
query = query.contains('asins', [filters.asin]);
|
||||||
|
}
|
||||||
|
if (filters?.dateFrom) {
|
||||||
|
query = query.gte('start_date', filters.dateFrom);
|
||||||
|
}
|
||||||
|
if (filters?.dateTo) {
|
||||||
|
query = query.lte('end_date', filters.dateTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await query.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Transform to ExperimentListItem
|
||||||
|
return (data || []).map((exp: any) => {
|
||||||
|
const today = new Date();
|
||||||
|
const startDate = new Date(exp.start_date);
|
||||||
|
const endDate = exp.end_date ? new Date(exp.end_date) : null;
|
||||||
|
|
||||||
|
let progressPercent = 0;
|
||||||
|
if (endDate) {
|
||||||
|
const totalDays = endDate.getTime() - startDate.getTime();
|
||||||
|
const elapsedDays = today.getTime() - startDate.getTime();
|
||||||
|
progressPercent = Math.min(100, Math.max(0, (elapsedDays / totalDays) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: exp.id,
|
||||||
|
name: exp.name,
|
||||||
|
type: exp.type,
|
||||||
|
status: exp.status,
|
||||||
|
asin_count: exp.asins?.length || 0,
|
||||||
|
marketplace: exp.marketplace,
|
||||||
|
start_date: exp.start_date,
|
||||||
|
end_date: exp.end_date,
|
||||||
|
progress_percent: Math.round(progressPercent),
|
||||||
|
actual_lift_percent: exp.actual_lift_percent,
|
||||||
|
owner: exp.owner,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getActiveExperimentsForASINs = async (
|
||||||
|
asins: string[]
|
||||||
|
): Promise<Map<string, ActiveExperiment[]>> => {
|
||||||
|
const supabase = getSupabase();
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('experiments')
|
||||||
|
.select('*')
|
||||||
|
.eq('status', 'active')
|
||||||
|
.lte('start_date', today)
|
||||||
|
.or(`end_date.is.null,end_date.gte.${today}`);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const map = new Map<string, ActiveExperiment[]>();
|
||||||
|
|
||||||
|
for (const asin of asins) {
|
||||||
|
const experiments = (data || [])
|
||||||
|
.filter((exp: any) => exp.asins?.includes(asin))
|
||||||
|
.map((exp: any) => {
|
||||||
|
const endDate = exp.end_date ? new Date(exp.end_date) : null;
|
||||||
|
const daysRemaining = endDate
|
||||||
|
? Math.ceil((endDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
asin,
|
||||||
|
experiment_id: exp.id,
|
||||||
|
experiment_name: exp.name,
|
||||||
|
type: exp.type,
|
||||||
|
status: exp.status,
|
||||||
|
start_date: exp.start_date,
|
||||||
|
end_date: exp.end_date,
|
||||||
|
days_remaining: daysRemaining,
|
||||||
|
} as ActiveExperiment;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (experiments.length > 0) {
|
||||||
|
map.set(asin, experiments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Performance Calculations ============
|
||||||
|
|
||||||
|
export const calculateExperimentPerformance = async (
|
||||||
|
experiment: Experiment,
|
||||||
|
salesData: SalesRecord[]
|
||||||
|
): Promise<{
|
||||||
|
baseline_units: number;
|
||||||
|
baseline_revenue: number;
|
||||||
|
experiment_units: number;
|
||||||
|
experiment_revenue: number;
|
||||||
|
actual_lift_percent: number;
|
||||||
|
}> => {
|
||||||
|
const startDate = new Date(experiment.start_date);
|
||||||
|
const endDate = experiment.end_date ? new Date(experiment.end_date) : new Date();
|
||||||
|
|
||||||
|
// Calculate baseline period (same duration before experiment)
|
||||||
|
const durationMs = endDate.getTime() - startDate.getTime();
|
||||||
|
const baselineStart = new Date(startDate.getTime() - durationMs);
|
||||||
|
const baselineEnd = startDate;
|
||||||
|
|
||||||
|
// Filter sales data for experiment ASINs
|
||||||
|
const asinSet = new Set(experiment.asins.map(a => a.toUpperCase()));
|
||||||
|
|
||||||
|
const baselineData = salesData.filter(r => {
|
||||||
|
const recordDate = new Date(`${r.year}-${getMonthNumber(r.month)}-01`);
|
||||||
|
return asinSet.has(r.asin.toUpperCase()) &&
|
||||||
|
recordDate >= baselineStart && recordDate < baselineEnd;
|
||||||
|
});
|
||||||
|
|
||||||
|
const experimentData = salesData.filter(r => {
|
||||||
|
const recordDate = new Date(`${r.year}-${getMonthNumber(r.month)}-01`);
|
||||||
|
return asinSet.has(r.asin.toUpperCase()) &&
|
||||||
|
recordDate >= startDate && recordDate <= endDate;
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseline_units = baselineData.reduce((sum, r) => sum + r.units, 0);
|
||||||
|
const baseline_revenue = baselineData.reduce((sum, r) => sum + r.sellOut, 0);
|
||||||
|
const experiment_units = experimentData.reduce((sum, r) => sum + r.units, 0);
|
||||||
|
const experiment_revenue = experimentData.reduce((sum, r) => sum + r.sellOut, 0);
|
||||||
|
|
||||||
|
const actual_lift_percent = baseline_units > 0
|
||||||
|
? ((experiment_units - baseline_units) / baseline_units) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseline_units,
|
||||||
|
baseline_revenue,
|
||||||
|
experiment_units,
|
||||||
|
experiment_revenue,
|
||||||
|
actual_lift_percent: Math.round(actual_lift_percent * 10) / 10,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateExperimentResults = async (
|
||||||
|
experimentId: string,
|
||||||
|
salesData: SalesRecord[]
|
||||||
|
): Promise<Experiment> => {
|
||||||
|
const experiment = await getExperiment(experimentId);
|
||||||
|
if (!experiment) throw new Error('Experiment not found');
|
||||||
|
|
||||||
|
const performance = await calculateExperimentPerformance(experiment, salesData);
|
||||||
|
|
||||||
|
return updateExperiment(experimentId, {
|
||||||
|
...performance,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Helper Functions ============
|
||||||
|
|
||||||
|
function getMonthNumber(monthStr: string): number {
|
||||||
|
const months: Record<string, number> = {
|
||||||
|
'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
|
||||||
|
'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12
|
||||||
|
};
|
||||||
|
return months[monthStr.split('-')[0]] || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getExperimentStatusColor = (status: ExperimentStatus): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active': return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30';
|
||||||
|
case 'planned': return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
|
||||||
|
case 'completed': return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
|
||||||
|
case 'paused': return 'bg-red-500/20 text-red-400 border-red-500/30';
|
||||||
|
default: return 'bg-slate-500/20 text-slate-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getExperimentTypeColor = (type: ExperimentType): string => {
|
||||||
|
switch (type) {
|
||||||
|
case 'pricing': return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||||
|
case 'advertising': return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
|
||||||
|
case 'content': return 'bg-pink-500/20 text-pink-400 border-pink-500/30';
|
||||||
|
case 'promotion': return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
|
||||||
|
default: return 'bg-slate-500/20 text-slate-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getExperimentIcon = (type: ExperimentType): string => {
|
||||||
|
switch (type) {
|
||||||
|
case 'pricing': return '💰';
|
||||||
|
case 'advertising': return '📢';
|
||||||
|
case 'content': return '📝';
|
||||||
|
case 'promotion': return '🏷️';
|
||||||
|
default: return '🧪';
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
-- Experiments Tracking Table
|
||||||
|
CREATE TABLE IF NOT EXISTS experiments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('pricing', 'advertising', 'content', 'promotion')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('planned', 'active', 'completed', 'paused')),
|
||||||
|
|
||||||
|
-- ASIN targeting
|
||||||
|
asins TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
marketplace TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Timing
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Hypothesis & Goals
|
||||||
|
hypothesis TEXT,
|
||||||
|
primary_metric TEXT NOT NULL DEFAULT 'units',
|
||||||
|
target_lift_percent NUMERIC,
|
||||||
|
|
||||||
|
-- Results (auto-calculated)
|
||||||
|
baseline_units NUMERIC,
|
||||||
|
baseline_revenue NUMERIC,
|
||||||
|
experiment_units NUMERIC,
|
||||||
|
experiment_revenue NUMERIC,
|
||||||
|
actual_lift_percent NUMERIC,
|
||||||
|
statistical_significance NUMERIC,
|
||||||
|
|
||||||
|
-- Notes
|
||||||
|
learnings TEXT,
|
||||||
|
owner TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for performance
|
||||||
|
CREATE INDEX idx_experiments_status ON experiments(status);
|
||||||
|
CREATE INDEX idx_experiments_marketplace ON experiments(marketplace);
|
||||||
|
CREATE INDEX idx_experiments_type ON experiments(type);
|
||||||
|
CREATE INDEX idx_experiments_dates ON experiments(start_date, end_date);
|
||||||
|
CREATE INDEX idx_experiments_asins ON experiments USING GIN(asins);
|
||||||
|
|
||||||
|
-- Updated_at trigger
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER update_experiments_updated_at
|
||||||
|
BEFORE UPDATE ON experiments
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- RLS Policies (optional - enable if you need row-level security)
|
||||||
|
-- ALTER TABLE experiments ENABLE ROW LEVEL SECURITY;
|
||||||
@@ -279,3 +279,75 @@ export interface VendorCSVRow {
|
|||||||
'Amazon Has Buybox': string;
|
'Amazon Has Buybox': string;
|
||||||
'Glance Views': string;
|
'Glance Views': string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Experiment Tracking Types
|
||||||
|
export type ExperimentType = 'pricing' | 'advertising' | 'content' | 'promotion';
|
||||||
|
export type ExperimentStatus = 'planned' | 'active' | 'completed' | 'paused';
|
||||||
|
export type ExperimentMetric = 'units' | 'revenue' | 'acos' | 'ctr' | 'cvr' | 'bsr';
|
||||||
|
|
||||||
|
export interface Experiment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
type: ExperimentType;
|
||||||
|
status: ExperimentStatus;
|
||||||
|
asins: string[];
|
||||||
|
marketplace: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
hypothesis?: string;
|
||||||
|
primary_metric: ExperimentMetric;
|
||||||
|
target_lift_percent?: number;
|
||||||
|
|
||||||
|
// Results
|
||||||
|
baseline_units?: number;
|
||||||
|
baseline_revenue?: number;
|
||||||
|
experiment_units?: number;
|
||||||
|
experiment_revenue?: number;
|
||||||
|
actual_lift_percent?: number;
|
||||||
|
statistical_significance?: number;
|
||||||
|
|
||||||
|
learnings?: string;
|
||||||
|
owner?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExperimentCreateInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
type: ExperimentType;
|
||||||
|
asins: string[];
|
||||||
|
marketplace: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date?: string;
|
||||||
|
hypothesis?: string;
|
||||||
|
primary_metric: ExperimentMetric;
|
||||||
|
target_lift_percent?: number;
|
||||||
|
owner?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExperimentListItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: ExperimentType;
|
||||||
|
status: ExperimentStatus;
|
||||||
|
asin_count: number;
|
||||||
|
marketplace: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date?: string;
|
||||||
|
progress_percent: number;
|
||||||
|
actual_lift_percent?: number;
|
||||||
|
owner?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveExperiment {
|
||||||
|
asin: string;
|
||||||
|
experiment_id: string;
|
||||||
|
experiment_name: string;
|
||||||
|
type: ExperimentType;
|
||||||
|
status: ExperimentStatus;
|
||||||
|
start_date: string;
|
||||||
|
end_date?: string;
|
||||||
|
days_remaining?: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user