Add product line targeting for experiments

- Add option to target entire product line instead of specific ASINs
- Support pasting multiple ASINs separated by spaces OR commas
- Add ASIN format validation (9-10 alphanumeric characters)
- Load available product lines from cached sales data
- Add handleCreateExperimentForLine function in App.tsx
- Update ExperimentForm with target type selector (ASIN vs Line)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Christian Vidal Wolf
2026-02-21 00:40:30 +01:00
co-authored by Qwen-Coder
parent 9736a3d591
commit 46d87127c8
2 changed files with 159 additions and 46 deletions
+11
View File
@@ -65,6 +65,7 @@ const App: React.FC = () => {
const [showCreateExperiment, setShowCreateExperiment] = useState(false);
const [preselectedAsins, setPreselectedAsins] = useState<string[]>([]);
const [preselectedMarketplace, setPreselectedMarketplace] = useState<string>('');
const [preselectedLine, setPreselectedLine] = useState<string>('');
// Modal State
const [isDataModalOpen, setIsDataModalOpen] = useState(false);
@@ -340,6 +341,14 @@ const App: React.FC = () => {
setView('ads');
}, []);
// Create experiment for a specific line
const handleCreateExperimentForLine = useCallback((line: string) => {
if (!line) return;
setPreselectedLine(line);
setPreselectedMarketplace(filters.customer[0] || '');
setShowCreateExperiment(true);
}, [filters.customer]);
// 1. Initial Load from Cache (IndexedDB) or Auto-Fetch Permanent URL
useEffect(() => {
const initApp = async () => {
@@ -1034,12 +1043,14 @@ const App: React.FC = () => {
setShowCreateExperiment(false);
setPreselectedAsins([]);
setPreselectedMarketplace('');
setPreselectedLine('');
}}
onSuccess={() => {
handleExperimentsFetch();
}}
initialAsins={preselectedAsins}
initialMarketplace={preselectedMarketplace}
initialLine={preselectedLine}
/>
)}
+148 -46
View File
@@ -7,13 +7,15 @@ interface ExperimentFormProps {
onSuccess: () => void;
initialAsins?: string[];
initialMarketplace?: string;
initialLine?: string;
}
const ExperimentForm: React.FC<ExperimentFormProps> = ({
onClose,
const ExperimentForm: React.FC<ExperimentFormProps> = ({
onClose,
onSuccess,
initialAsins = [],
initialMarketplace = 'DE'
initialMarketplace = 'DE',
initialLine = ''
}) => {
const [formData, setFormData] = useState<ExperimentCreateInput>({
name: '',
@@ -31,6 +33,42 @@ const ExperimentForm: React.FC<ExperimentFormProps> = ({
const [loading, setLoading] = useState(false);
const [asinInput, setAsinInput] = useState('');
const [targetType, setTargetType] = useState<'asin' | 'line'>(initialAsins.length > 0 ? 'asin' : initialLine ? 'line' : 'asin');
const [selectedLine, setSelectedLine] = useState(initialLine);
// Get available product lines from localStorage (sales data)
const availableLines = useMemo(() => {
try {
const cachedData = localStorage.getItem('craze_sales_data');
if (cachedData) {
const data = JSON.parse(cachedData);
const lines = new Set(data.map((r: any) => r.line).filter(Boolean));
return Array.from(lines).sort();
}
} catch (e) {
console.error('Failed to load product lines:', e);
}
return [];
}, []);
const handleAddAsin = () => {
// Support both comma and space separated ASINs
const newAsins = asinInput
.split(/[\s,]+/) // Split by space or comma
.map(a => a.trim().toUpperCase())
.filter(a => a.length > 0 && /^[A-Z0-9]{9,10}$/.test(a)); // Validate ASIN format
const uniqueAsins = Array.from(new Set([...formData.asins, ...newAsins]));
setFormData({ ...formData, asins: uniqueAsins });
setAsinInput('');
};
const handleSelectLine = (line: string) => {
setSelectedLine(line);
// For line targeting, we don't pre-populate ASINs
// The backend will handle filtering by line
setFormData({ ...formData, asins: [`LINE:${line}`] });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -62,13 +100,6 @@ const ExperimentForm: React.FC<ExperimentFormProps> = ({
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: '📢' },
@@ -153,51 +184,122 @@ const ExperimentForm: React.FC<ExperimentFormProps> = ({
</div>
</div>
{/* ASINs */}
{/* Target Type Selector */}
<div>
<label className="block text-xs font-medium text-slate-300 mb-1">
Target ASINs <span className="text-red-400">*</span>
<label className="block text-xs font-medium text-slate-300 mb-2">
Target Type <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-1.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="ASINs (comma-separated)"
/>
<div className="flex gap-2">
<button
type="button"
onClick={handleAddAsin}
className="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded-lg text-sm transition-colors"
onClick={() => {
setTargetType('asin');
setFormData({ ...formData, asins: [] });
setSelectedLine('');
}}
className={`flex-1 px-3 py-2 rounded-lg text-xs font-bold border transition-all ${
targetType === 'asin'
? 'bg-indigo-600/20 border-indigo-500 text-indigo-400'
: 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-500'
}`}
>
Add
🎯 Specific ASINs
</button>
<button
type="button"
onClick={() => {
setTargetType('line');
setFormData({ ...formData, asins: [] });
setAsinInput('');
}}
className={`flex-1 px-3 py-2 rounded-lg text-xs font-bold border transition-all ${
targetType === 'line'
? 'bg-indigo-600/20 border-indigo-500 text-indigo-400'
: 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-500'
}`}
>
📦 Entire Product Line
</button>
</div>
{formData.asins.length > 0 && (
<div className="flex flex-wrap gap-1.5 p-2 bg-slate-800/50 border border-slate-700 rounded-lg max-h-20 overflow-y-auto">
{formData.asins.map((asin) => (
<span
key={asin}
className="inline-flex items-center gap-1 px-2 py-1 bg-slate-700 border border-slate-600 rounded text-xs 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 h-3" 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>
{targetType === 'asin' ? (
/* ASIN Input */
<div>
<label className="block text-xs font-medium text-slate-300 mb-1">
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-1.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Paste ASINs (space or comma separated)"
/>
<button
type="button"
onClick={handleAddAsin}
className="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded-lg text-sm transition-colors"
>
Add
</button>
</div>
{formData.asins.length > 0 && !formData.asins.some(a => a.startsWith('LINE:')) && (
<div className="flex flex-wrap gap-1.5 p-2 bg-slate-800/50 border border-slate-700 rounded-lg max-h-20 overflow-y-auto">
{formData.asins.map((asin) => (
<span
key={asin}
className="inline-flex items-center gap-1 px-2 py-1 bg-slate-700 border border-slate-600 rounded text-xs text-slate-300 font-mono"
>
{asin}
<button
type="button"
onClick={() => {
setFormData({
...formData,
asins: formData.asins.filter(a => a !== asin)
});
}}
className="text-slate-400 hover:text-red-400 transition-colors"
>
<svg className="w-3 h-3" 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>
)}
<p className="text-[10px] text-slate-500 mt-1">
💡 Tip: Paste multiple ASINs separated by spaces or commas
</p>
</div>
) : (
/* Product Line Input */
<div>
<label className="block text-xs font-medium text-slate-300 mb-1">
Product Line <span className="text-red-400">*</span>
</label>
<select
value={selectedLine}
onChange={(e) => handleSelectLine(e.target.value)}
className="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">Select a product line...</option>
{availableLines.map((line) => (
<option key={line} value={line}>{line}</option>
))}
</select>
{selectedLine && (
<p className="text-[10px] text-emerald-400 mt-1">
Will target all ASINs in <strong>{selectedLine}</strong>
</p>
)}
</div>
)}
{/* Timeline */}
<div className="grid grid-cols-2 gap-4">
<div>