feat: add Supabase vendor data integration (BSR, ratings, buy box)

- Create vendor_daily_data table schema and Supabase client service
- Add upload API route for Vendor Central CSV parsing and upsert
- Add VendorDataView with BSR trend, ratings, and buy box charts
- Integrate new Vendor tab into app navigation (desktop + mobile)
- Add vendor CSV upload card to FileUpload modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Vidal Wolf
2026-02-20 13:19:25 +01:00
co-authored by Claude Opus 4.6
parent fed39b18be
commit 2bce0a839b
10 changed files with 1400 additions and 7 deletions
+40 -2
View File
@@ -17,6 +17,7 @@ const WeeklyGrid = lazy(() => import('./components/WeeklyGrid'));
const TopMovers = lazy(() => import('./components/TopMovers'));
const AdsPerformance = lazy(() => import('./components/AdsPerformance'));
const ForecastView = lazy(() => import('./components/ForecastView'));
const VendorDataView = lazy(() => import('./components/VendorDataView'));
// Loading fallback component
const LoadingSpinner = () => (
@@ -43,7 +44,7 @@ const App: React.FC = () => {
const [trafficData, setTrafficData] = useState<TrafficRecord[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [view, setView] = useState<'dashboard' | 'table' | 'weekly' | 'movers' | 'ads' | 'forecast'>('dashboard'); // Added 'forecast' view
const [view, setView] = useState<'dashboard' | 'table' | 'weekly' | 'movers' | 'ads' | 'forecast' | 'vendor'>('dashboard');
const [forecastData, setForecastData] = useState<ProductForecastData[]>([]);
const [isChatOpen, setIsChatOpen] = useState(false);
const [activeUrl, setActiveUrl] = useState<string | null>(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL);
@@ -437,6 +438,28 @@ const App: React.FC = () => {
}
};
// Handle uploaded Vendor CSV (sends to Supabase via API)
const handleVendorUpload = async (file: File) => {
setSyncing(true);
try {
const text = await file.text();
const response = await fetch('/api/upload-vendor-data', {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: text,
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
alert(`Vendor data uploaded: ${result.rowsUpserted} rows processed.`);
setIsDataModalOpen(false);
} catch (error: any) {
console.error("Failed to upload vendor data", error);
alert(`Error uploading vendor data: ${error.message}`);
} finally {
setSyncing(false);
}
};
// 2. Schedule Auto-Refresh (Background)
useEffect(() => {
const checkAndRefresh = () => {
@@ -734,6 +757,13 @@ const App: React.FC = () => {
>
<ChartIcon /> <span className="hidden lg:inline">Fc 26</span>
</button>
<button
onClick={() => setView('vendor')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
${view === 'vendor' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<ChartIcon /> <span className="hidden lg:inline">Vendor</span>
</button>
</div>
</div>
</div>
@@ -841,6 +871,12 @@ const App: React.FC = () => {
/>
</div>
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'vendor' ? '' : 'hidden'}>
<VendorDataView />
</div>
</Suspense>
</div>
</>
)}
@@ -851,7 +887,7 @@ const App: React.FC = () => {
{/* 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">
<div className="grid grid-cols-6 gap-0">
<div className="grid grid-cols-7 gap-0">
{([
{ key: 'dashboard' as const, icon: <ChartIcon />, label: 'Home' },
{ key: 'table' as const, icon: <TableIcon />, label: 'Grid' },
@@ -859,6 +895,7 @@ const App: React.FC = () => {
{ key: 'movers' as const, icon: <TrendingIcon />, label: 'Movers' },
{ key: 'ads' as const, icon: <MegaphoneIcon />, label: 'Ads' },
{ key: 'forecast' as const, icon: <ChartIcon />, label: 'Fc 26' },
{ key: 'vendor' as const, icon: <ChartIcon />, label: 'Vendor' },
]).map(({ key, icon, label }) => (
<button
key={key}
@@ -890,6 +927,7 @@ const App: React.FC = () => {
onSalesUpload={handleSalesUpload}
onAdsUpload={handleAdsUpload}
onTrafficUpload={handleTrafficUpload}
onVendorUpload={handleVendorUpload}
onUrlSubmit={handleDataFetch}
isLoading={syncing}
activeUrl={activeUrl}
+104
View File
@@ -0,0 +1,104 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { createClient } from '@supabase/supabase-js';
import Papa from 'papaparse';
const supabase = createClient(
process.env.SUPABASE_URL || '',
process.env.SUPABASE_SERVICE_KEY || ''
);
function parseEUNumber(val: string | undefined | null): number | null {
if (!val || val.trim() === '') return null;
const cleaned = val.replace(/\./g, '').replace(',', '.');
const num = parseFloat(cleaned);
return isNaN(num) ? null : num;
}
function parseIntSafe(val: string | undefined | null): number | null {
if (!val || val.trim() === '') return null;
const cleaned = val.replace(/\./g, '').replace(',', '.');
const num = parseInt(cleaned, 10);
return isNaN(num) ? null : num;
}
interface CSVRow {
[key: string]: string;
}
export default async function handler(req: VercelRequest, res: VercelResponse) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.status(200).end();
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });
try {
const csvText = typeof req.body === 'string' ? req.body : req.body?.toString() || '';
if (!csvText.trim()) {
return res.status(400).json({ error: 'Empty CSV body' });
}
const parsed = Papa.parse<CSVRow>(csvText, {
header: true,
skipEmptyLines: true,
});
if (parsed.errors.length > 0) {
console.error('[upload-vendor-data] Parse errors:', parsed.errors.slice(0, 5));
}
const rows = parsed.data
.filter(row => row['Date'] && row['Market'] && row['ASIN'])
.map(row => ({
date: row['Date'],
market: row['Market'],
asin: row['ASIN'],
product_title: row['Product Title'] || null,
tags: row['Tags'] || null,
bsr_top_rank: parseIntSafe(row['Top Level Category (Rank)']),
bsr_top_category: row['Top Level Category (Name)'] || null,
bsr_detail_rank: parseIntSafe(row['Detail Level Category (Rank)']),
bsr_detail_category: row['Detail Level Category (Name)'] || null,
avg_rating: parseEUNumber(row['Average Rating']),
num_reviews: parseIntSafe(row['Number of Reviews']),
buybox_owner: row['Buybox Seller Name'] || null,
buybox_price: parseEUNumber(row['Buybox Price']),
amazon_has_buybox: row['Amazon Has Buybox'] === '1',
glance_views: parseIntSafe(row['Glance Views']),
}));
if (rows.length === 0) {
return res.status(400).json({ error: 'No valid rows found in CSV' });
}
// Upsert in batches of 500
const BATCH_SIZE = 500;
let totalUpserted = 0;
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
const batch = rows.slice(i, i + BATCH_SIZE);
const { error } = await supabase
.from('vendor_daily_data')
.upsert(batch, { onConflict: 'date,market,asin' });
if (error) {
console.error('[upload-vendor-data] Upsert error at batch', i, error);
throw error;
}
totalUpserted += batch.length;
}
console.log(`[upload-vendor-data] Successfully upserted ${totalUpserted} rows`);
res.status(200).json({
success: true,
rowsParsed: parsed.data.length,
rowsUpserted: totalUpserted,
});
} catch (error: any) {
console.error('[upload-vendor-data] Error:', error);
res.status(500).json({ error: error.message });
}
}
+33 -1
View File
@@ -6,6 +6,7 @@ interface FileUploadProps {
onSalesUpload: (file: File) => void;
onAdsUpload: (file: File) => void;
onTrafficUpload?: (file: File) => void;
onVendorUpload?: (file: File) => void;
onUrlSubmit: () => void;
isLoading: boolean;
activeUrl?: string | null;
@@ -17,6 +18,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
onSalesUpload,
onAdsUpload,
onTrafficUpload,
onVendorUpload,
onUrlSubmit,
isLoading,
activeUrl,
@@ -43,6 +45,12 @@ const FileUpload: React.FC<FileUploadProps> = ({
}
};
const handleVendorChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0 && onVendorUpload) {
onVendorUpload(e.target.files[0]);
}
};
const handleUrlSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (url.trim()) {
@@ -138,7 +146,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
</div>
{onTrafficUpload && (
<div className="rounded-xl border border-dashed border-slate-700 bg-slate-900/50 hover:bg-slate-900 transition-colors group h-full col-span-2">
<div className="rounded-xl border border-dashed border-slate-700 bg-slate-900/50 hover:bg-slate-900 transition-colors group h-full">
<label htmlFor="traffic-upload" className="cursor-pointer flex flex-col items-center justify-center gap-3 p-6 h-full">
<div className="p-3 bg-teal-500/10 rounded-full text-teal-400 group-hover:scale-110 transition-transform">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
@@ -161,6 +169,30 @@ const FileUpload: React.FC<FileUploadProps> = ({
</label>
</div>
)}
{onVendorUpload && (
<div className="rounded-xl border border-dashed border-slate-700 bg-slate-900/50 hover:bg-slate-900 transition-colors group h-full">
<label htmlFor="vendor-upload" className="cursor-pointer flex flex-col items-center justify-center gap-3 p-6 h-full">
<div className="p-3 bg-emerald-500/10 rounded-full text-emerald-400 group-hover:scale-110 transition-transform">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
</svg>
</div>
<div className="text-center">
<h3 className="font-semibold text-slate-200">Upload Vendor CSV</h3>
<p className="text-[10px] text-slate-500 mt-1">BSR, Ratings, Buy Box</p>
</div>
<input
id="vendor-upload"
type="file"
accept=".csv"
onChange={handleVendorChange}
disabled={isLoading}
className="hidden"
/>
</label>
</div>
)}
</div>
</div>
);
+310
View File
@@ -0,0 +1,310 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, BarChart, Bar } from 'recharts';
import MultiSelectDropdown from './MultiSelectDropdown';
import { fetchVendorData, fetchVendorFilterOptions, VendorDailyRow } from '../services/supabase';
const MARKET_COLORS: Record<string, string> = {
DE: '#3b82f6',
UK: '#ef4444',
IT: '#22c55e',
FR: '#a855f7',
ES: '#f59e0b',
};
interface WeeklyBSR {
weekLabel: string;
[key: string]: number | string | null; // e.g. DE_bsr, UK_bsr
}
interface WeeklyRating {
weekLabel: string;
avg_rating: number | null;
num_reviews: number | null;
}
interface WeeklyBuyBox {
weekLabel: string;
[key: string]: number | string | null; // e.g. DE_amazon_pct
}
// Get ISO week from date string
function getISOWeek(dateStr: string): string {
const d = new Date(dateStr);
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
const week1 = new Date(d.getFullYear(), 0, 4);
const weekNum = 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7);
return `${d.getFullYear()}-W${String(weekNum).padStart(2, '0')}`;
}
function aggregateToWeekly(rows: VendorDailyRow[], markets: string[]) {
// Group by week
const byWeek = new Map<string, VendorDailyRow[]>();
for (const row of rows) {
const wk = getISOWeek(row.date);
if (!byWeek.has(wk)) byWeek.set(wk, []);
byWeek.get(wk)!.push(row);
}
const sortedWeeks = [...byWeek.keys()].sort();
// BSR: min rank per week per market (best rank)
const bsrData: WeeklyBSR[] = sortedWeeks.map(wk => {
const weekRows = byWeek.get(wk)!;
const point: WeeklyBSR = { weekLabel: wk.replace(/^\d{4}-/, '') };
for (const m of markets) {
const marketRows = weekRows.filter(r => r.market === m && r.bsr_detail_rank != null);
if (marketRows.length > 0) {
// Average BSR across ASINs for that market+week
const avg = marketRows.reduce((s, r) => s + r.bsr_detail_rank!, 0) / marketRows.length;
point[`${m}_bsr`] = Math.round(avg);
} else {
point[`${m}_bsr`] = null;
}
}
return point;
});
// Ratings: average across all markets/ASINs per week
const ratingData: WeeklyRating[] = sortedWeeks.map(wk => {
const weekRows = byWeek.get(wk)!;
const withRating = weekRows.filter(r => r.avg_rating != null);
const withReviews = weekRows.filter(r => r.num_reviews != null);
return {
weekLabel: wk.replace(/^\d{4}-/, ''),
avg_rating: withRating.length > 0
? Math.round((withRating.reduce((s, r) => s + r.avg_rating!, 0) / withRating.length) * 10) / 10
: null,
num_reviews: withReviews.length > 0
? Math.round(withReviews.reduce((s, r) => s + r.num_reviews!, 0) / withReviews.length)
: null,
};
});
// Buy Box: % of rows where Amazon has buybox, per market per week
const buyBoxData: WeeklyBuyBox[] = sortedWeeks.map(wk => {
const weekRows = byWeek.get(wk)!;
const point: WeeklyBuyBox = { weekLabel: wk.replace(/^\d{4}-/, '') };
for (const m of markets) {
const marketRows = weekRows.filter(r => r.market === m && r.amazon_has_buybox != null);
if (marketRows.length > 0) {
const amazonCount = marketRows.filter(r => r.amazon_has_buybox === true).length;
point[`${m}_pct`] = Math.round((amazonCount / marketRows.length) * 100);
} else {
point[`${m}_pct`] = null;
}
}
return point;
});
return { bsrData, ratingData, buyBoxData };
}
const VendorDataView: React.FC = () => {
const [vendorRows, setVendorRows] = useState<VendorDailyRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filter options
const [availableMarkets, setAvailableMarkets] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<string[]>([]);
// Selected filters
const [selectedMarkets, setSelectedMarkets] = useState<string[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [asinSearch, setAsinSearch] = useState('');
// Load filter options on mount
useEffect(() => {
const loadFilters = async () => {
try {
const opts = await fetchVendorFilterOptions();
setAvailableMarkets(opts.markets);
setAvailableTags(opts.tags);
} catch (e: any) {
console.error('Failed to load vendor filter options', e);
}
};
loadFilters();
}, []);
// Fetch data when filters change
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchVendorData({
markets: selectedMarkets.length > 0 ? selectedMarkets : undefined,
tags: selectedTags.length > 0 ? selectedTags : undefined,
asins: asinSearch.trim() ? asinSearch.split(',').map(a => a.trim()).filter(Boolean) : undefined,
});
setVendorRows(data);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [selectedMarkets, selectedTags, asinSearch]);
useEffect(() => {
loadData();
}, [loadData]);
// Determine active markets from data
const activeMarkets = useMemo(() => {
const markets = [...new Set(vendorRows.map(r => r.market))].sort();
return markets.length > 0 ? markets : availableMarkets;
}, [vendorRows, availableMarkets]);
// Aggregate to weekly
const { bsrData, ratingData, buyBoxData } = useMemo(
() => aggregateToWeekly(vendorRows, activeMarkets),
[vendorRows, activeMarkets]
);
// Empty state
if (!loading && vendorRows.length === 0 && !error) {
return (
<div className="flex flex-col items-center justify-center h-[60vh] gap-4 text-center px-4">
<div className="p-4 bg-emerald-500/10 rounded-full text-emerald-400">
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
</svg>
</div>
<h2 className="text-xl font-bold text-slate-200">No Vendor Data Yet</h2>
<p className="text-sm text-slate-400 max-w-md">
Upload a Vendor Central daily CSV to see BSR trends, ratings, and Buy Box data.
Use the data source settings (top-right) to upload.
</p>
</div>
);
}
return (
<div className="space-y-6 p-4 pb-24 md:pb-4">
{/* Filters */}
<div className="flex flex-wrap gap-3 items-center">
<MultiSelectDropdown
label="Market"
options={availableMarkets}
selected={selectedMarkets}
onChange={setSelectedMarkets}
/>
<MultiSelectDropdown
label="Product Line"
options={availableTags}
selected={selectedTags}
onChange={setSelectedTags}
/>
<input
type="text"
placeholder="Search ASINs (comma-separated)"
value={asinSearch}
onChange={e => setAsinSearch(e.target.value)}
className="bg-slate-900 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 w-64"
/>
{loading && (
<div className="flex items-center gap-2 text-slate-400 text-sm">
<div className="w-4 h-4 border-2 border-slate-600 border-t-indigo-400 rounded-full animate-spin"></div>
Loading...
</div>
)}
{error && <span className="text-red-400 text-sm">Error: {error}</span>}
<span className="text-slate-500 text-xs ml-auto">{vendorRows.length.toLocaleString()} daily records</span>
</div>
{/* BSR Trend Chart */}
<div className="bg-slate-900/50 border border-slate-800 rounded-xl p-4">
<h3 className="text-lg font-bold text-slate-200 mb-4">BSR Detail Category Rank (Weekly Avg)</h3>
<p className="text-xs text-slate-500 mb-3">Lower rank = better position. Averaged across filtered ASINs per market.</p>
<ResponsiveContainer width="100%" height={350}>
<LineChart data={bsrData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="weekLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval="preserveStartEnd" />
<YAxis reversed tick={{ fill: '#94a3b8', fontSize: 11 }} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
labelStyle={{ color: '#e2e8f0' }}
/>
<Legend />
{activeMarkets.map(m => (
<Line
key={m}
type="monotone"
dataKey={`${m}_bsr`}
name={`${m} BSR`}
stroke={MARKET_COLORS[m] || '#6b7280'}
strokeWidth={2}
dot={false}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
{/* Ratings & Reviews Chart */}
<div className="bg-slate-900/50 border border-slate-800 rounded-xl p-4">
<h3 className="text-lg font-bold text-slate-200 mb-4">Average Rating & Reviews (Weekly)</h3>
<p className="text-xs text-slate-500 mb-3">Averaged across all filtered ASINs and markets.</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={ratingData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="weekLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval="preserveStartEnd" />
<YAxis domain={[0, 5]} tick={{ fill: '#94a3b8', fontSize: 11 }} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
labelStyle={{ color: '#e2e8f0' }}
/>
<Line type="monotone" dataKey="avg_rating" name="Avg Rating" stroke="#f59e0b" strokeWidth={2} dot={false} connectNulls />
</LineChart>
</ResponsiveContainer>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={ratingData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="weekLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval="preserveStartEnd" />
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
labelStyle={{ color: '#e2e8f0' }}
/>
<Bar dataKey="num_reviews" name="Avg Reviews" fill="#8b5cf6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Buy Box Chart */}
<div className="bg-slate-900/50 border border-slate-800 rounded-xl p-4">
<h3 className="text-lg font-bold text-slate-200 mb-4">Amazon Buy Box Ownership (Weekly %)</h3>
<p className="text-xs text-slate-500 mb-3">Percentage of daily records where Amazon holds the Buy Box, per market.</p>
<ResponsiveContainer width="100%" height={350}>
<BarChart data={buyBoxData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="weekLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval="preserveStartEnd" />
<YAxis domain={[0, 100]} tick={{ fill: '#94a3b8', fontSize: 11 }} tickFormatter={(v) => `${v}%`} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
labelStyle={{ color: '#e2e8f0' }}
formatter={(value: number) => `${value}%`}
/>
<Legend />
{activeMarkets.map(m => (
<Bar
key={m}
dataKey={`${m}_pct`}
name={`${m} Amazon BB %`}
fill={MARKET_COLORS[m] || '#6b7280'}
radius={[4, 4, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default VendorDataView;
@@ -0,0 +1,108 @@
# Supabase Vendor Daily Data Integration
## Overview
Store daily Vendor Central export data (BSR, ratings, buy box) in Supabase. Upload via CSV, display aggregated to weekly in a new "Vendor Data" view.
## Database Schema
Single flat table with composite unique key `(date, market, asin)`:
```sql
CREATE TABLE vendor_daily_data (
id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL,
market TEXT NOT NULL,
asin TEXT NOT NULL,
product_title TEXT,
tags TEXT,
bsr_top_rank INTEGER,
bsr_top_category TEXT,
bsr_detail_rank INTEGER,
bsr_detail_category TEXT,
avg_rating NUMERIC(3,1),
num_reviews INTEGER,
buybox_owner TEXT,
buybox_price NUMERIC(10,2),
amazon_has_buybox BOOLEAN,
glance_views INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(date, market, asin)
);
CREATE INDEX idx_vendor_daily_market_date ON vendor_daily_data(market, date);
CREATE INDEX idx_vendor_daily_asin ON vendor_daily_data(asin);
CREATE INDEX idx_vendor_daily_tags ON vendor_daily_data(tags);
```
## CSV Column Mapping
| CSV Column | DB Column | Transform |
|------------|-----------|-----------|
| Date | date | Parse as DATE |
| Market | market | Direct (DE, UK, IT, FR, ES) |
| ASIN | asin | Direct |
| Product Title | product_title | Direct |
| Tags | tags | Direct (product line) |
| Top Level Category (Rank) | bsr_top_rank | Parse INT |
| Top Level Category (Name) | bsr_top_category | Direct |
| Detail Level Category (Rank) | bsr_detail_rank | Parse INT |
| Detail Level Category (Name) | bsr_detail_category | Direct |
| Average Rating | avg_rating | EU decimal (3,8 -> 3.8) |
| Number of Reviews | num_reviews | Parse INT |
| Buybox Seller Name | buybox_owner | Direct |
| Buybox Price | buybox_price | EU decimal (2,49 -> 2.49) |
| Amazon Has Buybox | amazon_has_buybox | 1/0 -> boolean |
| Glance Views | glance_views | Parse INT |
## New Files
| File | Purpose |
|------|---------|
| `services/supabase.ts` | Client init + query helpers |
| `api/upload-vendor-data.ts` | Parse CSV, upsert to Supabase |
| `components/VendorDataView.tsx` | BSR/ratings/buybox charts |
## Data Flow
### Upload
1. User clicks "Upload Vendor CSV" in FileUpload.tsx
2. Client sends file to `/api/upload-vendor-data` (POST)
3. API parses CSV, extracts 15 columns, converts EU numbers
4. Upserts to Supabase in batches of 500
5. Returns `{ inserted, updated }` counts
### Display
1. VendorDataView queries Supabase directly (anon key, read-only)
2. Client aggregates daily -> weekly (AVG for ranks/ratings, latest for buybox)
3. Renders Recharts charts: BSR trend, ratings, buy box status
## View: VendorDataView.tsx
Three chart panels:
1. **BSR Trend** - Line chart, Y-axis inverted, lines per marketplace
2. **Ratings & Reviews** - Dual axis: avg rating + review count
3. **Buy Box Status** - % days Amazon vs Seller vs Other
Filters: marketplace, product line, ASIN, date range (reuse MultiSelectDropdown).
## Integration Points
- **App.tsx**: New `vendor` view state + nav button
- **FileUpload.tsx**: 4th upload card for Vendor CSV
- **package.json**: Add `@supabase/supabase-js`
- **Vercel env vars**: `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_KEY`
## Environment Variables
| Variable | Where Used | Purpose |
|----------|-----------|---------|
| SUPABASE_URL | Client + API | Supabase project URL |
| SUPABASE_ANON_KEY | Client | Read-only access |
| SUPABASE_SERVICE_KEY | API routes only | Write access for upserts |
## Security
- Anon key for client reads, service key for server writes
- No RLS (private internal tool)
- Env vars in Vercel, not hardcoded
@@ -0,0 +1,568 @@
# Supabase Vendor Data Integration — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Upload Vendor Central daily CSV data to Supabase and display BSR, ratings, and buy box trends in a new "Vendor Data" view.
**Architecture:** Client-side CSV parsing sends data to a Vercel API route that upserts to Supabase. A new React view queries Supabase directly (anon key) and renders Recharts charts with daily→weekly aggregation.
**Tech Stack:** React 19, TypeScript, Supabase (Postgres), Recharts, PapaParse, Vercel serverless functions, Tailwind CSS (CDN)
---
### Task 1: Create Supabase Table via SQL Editor
**Context:** Run this SQL in the Supabase Dashboard SQL Editor at https://qjioywarwdbxmdihyrti.supabase.co
**Step 1: Run the CREATE TABLE SQL**
Open Supabase Dashboard → SQL Editor → New Query → paste and run:
```sql
CREATE TABLE vendor_daily_data (
id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL,
market TEXT NOT NULL,
asin TEXT NOT NULL,
product_title TEXT,
tags TEXT,
bsr_top_rank INTEGER,
bsr_top_category TEXT,
bsr_detail_rank INTEGER,
bsr_detail_category TEXT,
avg_rating NUMERIC(3,1),
num_reviews INTEGER,
buybox_owner TEXT,
buybox_price NUMERIC(10,2),
amazon_has_buybox BOOLEAN,
glance_views INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(date, market, asin)
);
CREATE INDEX idx_vendor_daily_market_date ON vendor_daily_data(market, date);
CREATE INDEX idx_vendor_daily_asin ON vendor_daily_data(asin);
CREATE INDEX idx_vendor_daily_tags ON vendor_daily_data(tags);
```
**Step 2: Verify table exists**
Run: `SELECT count(*) FROM vendor_daily_data;` — should return 0.
---
### Task 2: Install Supabase Client + Configure Env Vars
**Files:**
- Modify: `package.json`
- Create: `.env.local` (add Supabase vars)
- Modify: `vite.config.ts` (expose env vars to client)
**Step 1: Install @supabase/supabase-js**
```bash
npm install @supabase/supabase-js
```
**Step 2: Add env vars to `.env.local`**
```
SUPABASE_URL=https://qjioywarwdbxmdihyrti.supabase.co
SUPABASE_ANON_KEY=<anon-key-from-supabase-dashboard>
SUPABASE_SERVICE_KEY=<service-role-key-from-supabase-dashboard>
```
**Step 3: Expose SUPABASE_URL and SUPABASE_ANON_KEY to the Vite client**
In `vite.config.ts`, add to the `define` block:
```ts
'process.env.SUPABASE_URL': JSON.stringify(
env.SUPABASE_URL || process.env.SUPABASE_URL || ""
),
'process.env.SUPABASE_ANON_KEY': JSON.stringify(
env.SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY || ""
),
```
**Step 4: Add Supabase env vars to Vercel**
In Vercel Dashboard → Settings → Environment Variables, add:
- `SUPABASE_URL` = `https://qjioywarwdbxmdihyrti.supabase.co`
- `SUPABASE_ANON_KEY` = (anon key)
- `SUPABASE_SERVICE_KEY` = (service role key)
**Step 5: Commit**
```bash
git add package.json package-lock.json vite.config.ts
git commit -m "chore: add @supabase/supabase-js and configure env vars"
```
---
### Task 3: Create Supabase Client Service
**Files:**
- Create: `services/supabase.ts`
**Step 1: Write the Supabase client module**
```ts
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.SUPABASE_URL || '';
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || '';
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
export interface VendorDailyRow {
id?: number;
date: string;
market: string;
asin: string;
product_title: string | null;
tags: string | null;
bsr_top_rank: number | null;
bsr_top_category: string | null;
bsr_detail_rank: number | null;
bsr_detail_category: string | null;
avg_rating: number | null;
num_reviews: number | null;
buybox_owner: string | null;
buybox_price: number | null;
amazon_has_buybox: boolean | null;
glance_views: number | null;
}
export interface VendorFilters {
markets?: string[];
tags?: string[];
asins?: string[];
dateFrom?: string;
dateTo?: string;
}
export const fetchVendorData = async (filters: VendorFilters): Promise<VendorDailyRow[]> => {
let query = supabase
.from('vendor_daily_data')
.select('*')
.order('date', { ascending: true });
if (filters.markets?.length) {
query = query.in('market', filters.markets);
}
if (filters.tags?.length) {
query = query.in('tags', filters.tags);
}
if (filters.asins?.length) {
query = query.in('asin', filters.asins);
}
if (filters.dateFrom) {
query = query.gte('date', filters.dateFrom);
}
if (filters.dateTo) {
query = query.lte('date', filters.dateTo);
}
// Supabase default limit is 1000, paginate if needed
const allRows: VendorDailyRow[] = [];
let offset = 0;
const pageSize = 1000;
let hasMore = true;
while (hasMore) {
const { data, error } = await query.range(offset, offset + pageSize - 1);
if (error) throw error;
if (data) {
allRows.push(...data);
hasMore = data.length === pageSize;
offset += pageSize;
} else {
hasMore = false;
}
}
return allRows;
};
export const fetchVendorFilterOptions = async (): Promise<{
markets: string[];
tags: string[];
asins: string[];
dateRange: { min: string; max: string } | null;
}> => {
// Get distinct markets
const { data: marketData } = await supabase
.from('vendor_daily_data')
.select('market')
.order('market');
// Get distinct tags
const { data: tagData } = await supabase
.from('vendor_daily_data')
.select('tags')
.order('tags');
// Get date range
const { data: dateData } = await supabase
.from('vendor_daily_data')
.select('date')
.order('date', { ascending: true })
.limit(1);
const { data: dateDataMax } = await supabase
.from('vendor_daily_data')
.select('date')
.order('date', { ascending: false })
.limit(1);
const markets = [...new Set((marketData || []).map(r => r.market))];
const tags = [...new Set((tagData || []).map(r => r.tags).filter(Boolean))];
return {
markets,
tags,
asins: [], // loaded on-demand when filters narrow
dateRange: dateData?.[0] && dateDataMax?.[0]
? { min: dateData[0].date, max: dateDataMax[0].date }
: null,
};
};
```
**Step 2: Commit**
```bash
git add services/supabase.ts
git commit -m "feat: add Supabase client with vendor data queries"
```
---
### Task 4: Create Upload API Route
**Files:**
- Create: `api/upload-vendor-data.ts`
**Step 1: Write the serverless upload function**
This route receives CSV text in the POST body, parses it with papaparse, maps columns, and upserts to Supabase using the service role key.
```ts
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { createClient } from '@supabase/supabase-js';
import Papa from 'papaparse';
const supabase = createClient(
process.env.SUPABASE_URL || '',
process.env.SUPABASE_SERVICE_KEY || ''
);
function parseEUNumber(val: string | undefined | null): number | null {
if (!val || val.trim() === '') return null;
// EU format: "1.234,56" → 1234.56 or "2,49" → 2.49
const cleaned = val.replace(/\./g, '').replace(',', '.');
const num = parseFloat(cleaned);
return isNaN(num) ? null : num;
}
function parseIntSafe(val: string | undefined | null): number | null {
if (!val || val.trim() === '') return null;
const cleaned = val.replace(/\./g, '').replace(',', '.');
const num = parseInt(cleaned, 10);
return isNaN(num) ? null : num;
}
interface CSVRow {
Date: string;
Market: string;
ASIN: string;
'Product Title': string;
Tags: string;
'Top Level Category (Rank)': string;
'Top Level Category (Name)': string;
'Detail Level Category (Rank)': string;
'Detail Level Category (Name)': string;
'Average Rating': string;
'Number of Reviews': string;
'Buybox Seller Name': string;
'Buybox Price': string;
'Amazon Has Buybox': string;
'Glance Views': string;
}
export default async function handler(req: VercelRequest, res: VercelResponse) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.status(200).end();
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });
try {
const csvText = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
const parsed = Papa.parse<CSVRow>(csvText, {
header: true,
skipEmptyLines: true,
});
if (parsed.errors.length > 0) {
console.error('[upload-vendor-data] Parse errors:', parsed.errors.slice(0, 5));
}
const rows = parsed.data
.filter(row => row.Date && row.Market && row.ASIN)
.map(row => ({
date: row.Date,
market: row.Market,
asin: row.ASIN,
product_title: row['Product Title'] || null,
tags: row.Tags || null,
bsr_top_rank: parseIntSafe(row['Top Level Category (Rank)']),
bsr_top_category: row['Top Level Category (Name)'] || null,
bsr_detail_rank: parseIntSafe(row['Detail Level Category (Rank)']),
bsr_detail_category: row['Detail Level Category (Name)'] || null,
avg_rating: parseEUNumber(row['Average Rating']),
num_reviews: parseIntSafe(row['Number of Reviews']),
buybox_owner: row['Buybox Seller Name'] || null,
buybox_price: parseEUNumber(row['Buybox Price']),
amazon_has_buybox: row['Amazon Has Buybox'] === '1',
glance_views: parseIntSafe(row['Glance Views']),
}));
// Upsert in batches of 500
const BATCH_SIZE = 500;
let totalUpserted = 0;
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
const batch = rows.slice(i, i + BATCH_SIZE);
const { error } = await supabase
.from('vendor_daily_data')
.upsert(batch, { onConflict: 'date,market,asin' });
if (error) {
console.error('[upload-vendor-data] Upsert error at batch', i, error);
throw error;
}
totalUpserted += batch.length;
}
res.status(200).json({
success: true,
rowsParsed: parsed.data.length,
rowsUpserted: totalUpserted,
});
} catch (error: any) {
console.error('[upload-vendor-data] Error:', error);
res.status(500).json({ error: error.message });
}
}
```
**Step 2: Commit**
```bash
git add api/upload-vendor-data.ts
git commit -m "feat: add vendor data upload API route with CSV parsing"
```
---
### Task 5: Add Vendor Upload to FileUpload.tsx
**Files:**
- Modify: `components/FileUpload.tsx` (add 4th upload card)
- Modify: `App.tsx` (add handler + pass prop)
**Step 1: Add `onVendorUpload` prop to FileUpload**
In `components/FileUpload.tsx`, add to interface:
```ts
onVendorUpload?: (file: File) => void;
```
Destructure it in the component props. Add a 4th upload card after the Traffic card, styled with a green/emerald theme.
**Step 2: Add vendor upload handler in App.tsx**
```ts
const handleVendorUpload = async (file: File) => {
setSyncing(true);
try {
const text = await file.text();
const response = await fetch('/api/upload-vendor-data', {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: text,
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
alert(`Vendor data uploaded: ${result.rowsUpserted} rows processed.`);
setIsDataModalOpen(false);
} catch (error: any) {
console.error("Failed to upload vendor data", error);
alert(`Error uploading vendor data: ${error.message}`);
} finally {
setSyncing(false);
}
};
```
Pass `onVendorUpload={handleVendorUpload}` to `<FileUpload>` in the modal.
**Step 3: Commit**
```bash
git add components/FileUpload.tsx App.tsx
git commit -m "feat: add vendor CSV upload button and handler"
```
---
### Task 6: Create VendorDataView Component
**Files:**
- Create: `components/VendorDataView.tsx`
**Step 1: Write the component**
The component:
1. On mount, fetches filter options from Supabase (markets, tags, date range)
2. Has filter dropdowns for market, product line (tags), and ASIN search
3. Fetches vendor data based on filters
4. Aggregates daily → weekly client-side
5. Renders 3 Recharts chart panels:
- BSR Detail Rank trend (Y-axis inverted, lower = better), one line per market
- Avg Rating + Review Count (dual Y-axis)
- Buy Box: % days Amazon has buybox per week, per market
Uses existing UI patterns from the codebase: dark slate theme, Tailwind classes, `MultiSelectDropdown` for filters.
Weekly aggregation logic:
- Group rows by ISO week (`YYYY-WW`)
- BSR rank → use MIN (best rank that week)
- Avg rating → use latest day's value
- Reviews → use MAX (cumulative)
- Buy box → % of days `amazon_has_buybox === true`
**Step 2: Commit**
```bash
git add components/VendorDataView.tsx
git commit -m "feat: add VendorDataView with BSR, ratings, and buybox charts"
```
---
### Task 7: Integrate VendorDataView into App.tsx
**Files:**
- Modify: `App.tsx`
**Step 1: Add imports and view state**
Add lazy import:
```ts
const VendorDataView = lazy(() => import('./components/VendorDataView'));
```
Update view type to include `'vendor'`:
```ts
const [view, setView] = useState<'dashboard' | 'table' | 'weekly' | 'movers' | 'ads' | 'forecast' | 'vendor'>('dashboard');
```
**Step 2: Add nav button (desktop)**
After the Forecast button in the desktop nav bar (around line 731), add:
```tsx
<button
onClick={() => setView('vendor')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
${view === 'vendor' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<ChartIcon /> <span className="hidden lg:inline">Vendor</span>
</button>
```
**Step 3: Add nav button (mobile)**
Add to the mobile bottom nav array (around line 855):
```ts
{ key: 'vendor' as const, icon: <ChartIcon />, label: 'Vendor' },
```
Update `grid-cols-6` to `grid-cols-7`.
**Step 4: Add view rendering**
After the forecast Suspense block (around line 843), add:
```tsx
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'vendor' ? '' : 'hidden'}>
<VendorDataView />
</div>
</Suspense>
```
**Step 5: Commit**
```bash
git add App.tsx
git commit -m "feat: integrate Vendor Data view into app navigation"
```
---
### Task 8: Add Vite Dev Proxy for Upload Route
**Files:**
- Modify: `vite.config.ts`
**Step 1: Add proxy for upload-vendor-data**
The upload API route needs a dev proxy. Since it's a POST to our own Vercel function (not Dropbox), we need to proxy to a local Vercel dev server or handle it differently.
For local dev, add a note that `vercel dev` should be used, or configure the proxy to forward to Supabase directly. Since the API route uses `@supabase/supabase-js` server-side, the simplest approach is to use `vercel dev` for testing the upload route locally.
No vite proxy needed for the upload route — it works in production via Vercel's `/api/*` routing. For local dev, use `vercel dev` instead of `npm run dev` when testing uploads.
**Step 2: Commit (if changes made)**
---
### Task 9: Build, Test, Deploy
**Step 1: Build**
```bash
npm run build
```
Fix any TypeScript errors.
**Step 2: Test locally**
```bash
npm run dev
```
Verify:
- New "Vendor" tab appears in nav
- VendorDataView loads (empty state with no data)
- Upload modal shows Vendor CSV upload card
**Step 3: Commit and push**
```bash
git add -A
git commit -m "feat: complete Supabase vendor data integration"
git push origin main
```
Vercel auto-deploys from main. After deploy, set env vars in Vercel dashboard if not already done.
**Step 4: Test upload on production**
Upload the sample CSV via the Vendor upload card. Verify data appears in Supabase table and charts render.
+114 -2
View File
@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@google/genai": "^1.30.0",
"@supabase/supabase-js": "^2.97.0",
"node-fetch": "^3.3.2",
"papaparse": "^5.5.3",
"react": "^19.2.0",
@@ -22,6 +23,9 @@
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@babel/code-frame": {
@@ -1450,6 +1454,86 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.97.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.97.0.tgz",
"integrity": "sha512-2Og/1lqp+AIavr8qS2X04aSl8RBY06y4LrtIAGxat06XoXYiDxKNQMQzWDAKm1EyZFZVRNH48DO5YvIZ7la5fQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.97.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.97.0.tgz",
"integrity": "sha512-fSaA0ZeBUS9hMgpGZt5shIZvfs3Mvx2ZdajQT4kv/whubqDBAp3GU5W8iIXy21MRvKmO2NpAj8/Q6y+ZkZyF/w==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "2.97.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.97.0.tgz",
"integrity": "sha512-g4Ps0eaxZZurvfv/KGoo2XPZNpyNtjth9aW8eho9LZWM0bUuBtxPZw3ZQ6ERSpEGogshR+XNgwlSPIwcuHCNww==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.97.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.97.0.tgz",
"integrity": "sha512-37Jw0NLaFP0CZd7qCan97D1zWutPrTSpgWxAw6Yok59JZoxp4IIKMrPeftJ3LZHmf+ILQOPy3i0pRDHM9FY36Q==",
"license": "MIT",
"dependencies": {
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.97.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.97.0.tgz",
"integrity": "sha512-9f6NniSBfuMxOWKwEFb+RjJzkfMdJUwv9oHuFJKfe/5VJR8cd90qw68m6Hn0ImGtwG37TUO+QHtoOechxRJ1Yg==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.97.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.97.0.tgz",
"integrity": "sha512-kTD91rZNO4LvRUHv4x3/4hNmsEd2ofkYhuba2VMUPRVef1RCmnHtm7rIws38Fg0yQnOSZOplQzafn0GSiy6GVg==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.97.0",
"@supabase/functions-js": "2.97.0",
"@supabase/postgrest-js": "2.97.0",
"@supabase/realtime-js": "2.97.0",
"@supabase/storage-js": "2.97.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@ts-morph/common": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.11.1.tgz",
@@ -1641,19 +1725,33 @@
"version": "22.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz",
"integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vercel/build-utils": {
"version": "13.2.10",
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.2.10.tgz",
@@ -3388,6 +3486,15 @@
"node": ">= 14"
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
@@ -4521,6 +4628,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
@@ -4568,7 +4681,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
+2 -1
View File
@@ -13,6 +13,7 @@
},
"dependencies": {
"@google/genai": "^1.30.0",
"@supabase/supabase-js": "^2.97.0",
"node-fetch": "^3.3.2",
"papaparse": "^5.5.3",
"react": "^19.2.0",
@@ -27,4 +28,4 @@
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}
}
+114
View File
@@ -0,0 +1,114 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.SUPABASE_URL || '';
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || '';
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
export interface VendorDailyRow {
id?: number;
date: string;
market: string;
asin: string;
product_title: string | null;
tags: string | null;
bsr_top_rank: number | null;
bsr_top_category: string | null;
bsr_detail_rank: number | null;
bsr_detail_category: string | null;
avg_rating: number | null;
num_reviews: number | null;
buybox_owner: string | null;
buybox_price: number | null;
amazon_has_buybox: boolean | null;
glance_views: number | null;
}
export interface VendorFilters {
markets?: string[];
tags?: string[];
asins?: string[];
dateFrom?: string;
dateTo?: string;
}
export const fetchVendorData = async (filters: VendorFilters): Promise<VendorDailyRow[]> => {
const allRows: VendorDailyRow[] = [];
let offset = 0;
const pageSize = 1000;
let hasMore = true;
while (hasMore) {
let query = supabase
.from('vendor_daily_data')
.select('*')
.order('date', { ascending: true })
.range(offset, offset + pageSize - 1);
if (filters.markets?.length) {
query = query.in('market', filters.markets);
}
if (filters.tags?.length) {
query = query.in('tags', filters.tags);
}
if (filters.asins?.length) {
query = query.in('asin', filters.asins);
}
if (filters.dateFrom) {
query = query.gte('date', filters.dateFrom);
}
if (filters.dateTo) {
query = query.lte('date', filters.dateTo);
}
const { data, error } = await query;
if (error) throw error;
if (data) {
allRows.push(...data);
hasMore = data.length === pageSize;
offset += pageSize;
} else {
hasMore = false;
}
}
return allRows;
};
export const fetchVendorFilterOptions = async (): Promise<{
markets: string[];
tags: string[];
dateRange: { min: string; max: string } | null;
}> => {
const { data: marketData } = await supabase
.from('vendor_daily_data')
.select('market');
const { data: tagData } = await supabase
.from('vendor_daily_data')
.select('tags');
const { data: dateMin } = await supabase
.from('vendor_daily_data')
.select('date')
.order('date', { ascending: true })
.limit(1);
const { data: dateMax } = await supabase
.from('vendor_daily_data')
.select('date')
.order('date', { ascending: false })
.limit(1);
const markets = [...new Set((marketData || []).map(r => r.market))].sort();
const tags = [...new Set((tagData || []).map(r => r.tags).filter(Boolean))].sort();
return {
markets,
tags,
dateRange: dateMin?.[0] && dateMax?.[0]
? { min: dateMin[0].date, max: dateMax[0].date }
: null,
};
};
+7 -1
View File
@@ -55,7 +55,13 @@ export default defineConfig(({ mode }) => {
),
'process.env.GEMINI_API_KEY': JSON.stringify(
env.GEMINI_API_KEY || env.API_KEY || process.env.GEMINI_API_KEY || process.env.API_KEY || ""
)
),
'process.env.SUPABASE_URL': JSON.stringify(
env.SUPABASE_URL || process.env.SUPABASE_URL || ""
),
'process.env.SUPABASE_ANON_KEY': JSON.stringify(
env.SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY || ""
),
},
resolve: {
alias: {