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
@@ -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.