Files
CrazeAnalytix/docs/plans/2026-02-20-supabase-vendor-data-plan.md
T
Christian Vidal WolfandClaude Opus 4.6 2bce0a839b 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>
2026-02-20 13:19:25 +01:00

16 KiB

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:

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

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:

'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

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

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

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.

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

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:

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

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

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

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:

const VendorDataView = lazy(() => import('./components/VendorDataView'));

Update view type to include 'vendor':

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:

<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):

{ 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:

<Suspense fallback={<LoadingSpinner />}>
  <div className={view === 'vendor' ? '' : 'hidden'}>
    <VendorDataView />
  </div>
</Suspense>

Step 5: Commit

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

npm run build

Fix any TypeScript errors.

Step 2: Test locally

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

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.