mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 10:55:24 +02:00
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:
co-authored by
Claude Opus 4.6
parent
fed39b18be
commit
2bce0a839b
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
Generated
+114
-2
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user