diff --git a/.env.example b/.env.example index 629d98f..8c5539b 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,24 @@ -# Leave this empty/unset to use Vite's dev proxy (see vite.config.js), -# which forwards /api/* to http://localhost:8000 with no CORS setup needed. +# Base URL of the FastAPI backend. # -# Set it only if your backend runs somewhere else (a different host/port, -# or in production where the frontend is served separately from the API). -# VITE_API_BASE_URL=http://localhost:8000 +# Leave it unset for local development: Vite's dev proxy (see vite.config.js) +# forwards /api/* to http://127.0.0.1:8000, so relative requests just work and +# no CORS setup is needed. +# +# Set it whenever the API is served from a different origin than the app. In +# production the React app is served from catalogue.nearle.ai.in and the API +# lives on its own subdomain, so the deployed build needs: +# +# VITE_API_BASE_URL=https://mcp.catalogue.nearle.ai.in +# +# No trailing slash - src/api/client.js concatenates paths that already start +# with "/api". +# +# IMPORTANT: Vite inlines this at BUILD time, not at run time. Setting it on +# the running container does nothing; it has to be passed to `npm run build`. +# The Dockerfile exposes it as a build arg for exactly this reason: +# +# docker build --build-arg VITE_API_BASE_URL=https://mcp.catalogue.nearle.ai.in . +# +# Whatever origin you set here must also appear in the backend's +# API_CORS_ORIGINS, or the browser will block every response. +# VITE_API_BASE_URL=https://mcp.catalogue.nearle.ai.in diff --git a/Dockerfile b/Dockerfile index aa1d129..6e9fa9a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,9 +6,22 @@ COPY package.json package-lock.json ./ RUN npm ci COPY . . -# VITE_API_BASE_URL is intentionally left unset: nginx.conf proxies /api/* -# to the backend container, so the built app can keep using relative -# same-origin requests (see src/api/client.js). + +# Which origin the built app sends its API calls to. +# +# Vite inlines env vars at build time, so this MUST be a build arg - setting it +# on the running container has no effect, the value is already baked into the +# JS bundle. +# +# docker build --build-arg VITE_API_BASE_URL=https://mcp.catalogue.nearle.ai.in . +# +# Left empty by default, which keeps requests relative and same-origin so the +# nginx /api/* proxy below serves them. Set it when the API is on its own +# domain - and add this app's origin to the backend's API_CORS_ORIGINS, or the +# browser will block the responses. +ARG VITE_API_BASE_URL="" +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL + RUN npm run build # ---- Serve stage ---- diff --git a/src/api/client.js b/src/api/client.js index 222238b..316b1f5 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -88,6 +88,49 @@ async function request(path, options = {}) { return res.json(); } +/** + * POST a file as multipart/form-data. + * + * Deliberately not routed through request(): the browser must set its own + * Content-Type so it can append the multipart boundary, and request()'s JSON + * default would corrupt the body. Everything else request() does still has to + * happen by hand here - most importantly the Authorization header, whose + * absence is exactly what used to make every upload in the app 401. + */ +async function upload(path, file, { fieldName = 'file' } = {}) { + const formData = new FormData(); + formData.append(fieldName, file); + + let res; + try { + res = await fetch(`${BASE}${path}`, { + method: 'POST', + headers: authHeaders(), + body: formData, + }); + } catch { + throw new ApiError( + `Could not reach the API at ${BASE || '(same origin)'}${path}. Is the backend running ` + + `and reachable?`, + 0 + ); + } + + if (!res.ok) { + let detail = `Upload failed (${res.status})`; + try { + const body = await res.json(); + detail = body.detail || JSON.stringify(body); + } catch { + /* ignore parse errors, keep generic message */ + } + if (res.status === 401 && onUnauthorized) onUnauthorized(detail); + throw new ApiError(detail, res.status); + } + + return res.json(); +} + function qs(params = {}) { const usp = new URLSearchParams(); Object.entries(params).forEach(([k, v]) => { @@ -219,30 +262,33 @@ export const api = { getNutritionEnrichmentStatus: () => request('/api/admin/nutrition-intelligence/status'), // --- Excel / CSV Upload API --- - // Raw fetch rather than request(): the browser must set its own multipart - // Content-Type with the boundary, so the JSON default in request() would - // corrupt the body. The auth header still has to be added by hand here. - uploadFile: async (tabType, file) => { - const formData = new FormData(); - formData.append('file', file); - const res = await fetch(`${BASE}/api/upload/${encodeURIComponent(tabType)}`, { - method: 'POST', - headers: authHeaders(), - body: formData, - }); - if (!res.ok) { - let detail = `Upload failed (${res.status})`; - try { - const body = await res.json(); - detail = body.detail || JSON.stringify(body); - } catch {} - if (res.status === 401 && onUnauthorized) onUnauthorized(detail); - throw new ApiError(detail, res.status); - } - return res.json(); - }, + uploadFile: (tabType, file) => upload(`/api/upload/${encodeURIComponent(tabType)}`, file), getTemplateUrl: (tabType) => `${BASE}/api/upload/template/${encodeURIComponent(tabType)}`, + + // --- Admin: training datasets & discount allocation --- + // Guarded by `upload_train_test` / `allocate_discounts` on the server, so + // these must carry the bearer token - see app/api/routers/admin_train.py. + getProjectDetails: () => request('/api/admin/training/project-details'), + + uploadTrainingDataset: (file) => upload('/api/admin/training/upload-dataset', file), + + allocateDiscounts: ({ rules, store_id = null }) => + request('/api/admin/training/allocate-discounts', { + method: 'POST', + body: JSON.stringify({ store_id, rules }), + }), + + // --- User workspace: single + batch product entry --- + // Guarded by `add_product` / `upload_batch_products` - see + // app/api/routers/user_products.py. + addUserProduct: (payload) => + request('/api/user/products/add', { + method: 'POST', + body: JSON.stringify(payload), + }), + + uploadUserProductsFile: (file) => upload('/api/user/products/upload-file', file), }; export { ApiError }; diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx index fd3bd60..f9af1e6 100644 --- a/src/pages/AdminPage.jsx +++ b/src/pages/AdminPage.jsx @@ -37,14 +37,11 @@ export function AdminPage() { const [discountPct, setDiscountPct] = useState(25); const [allocating, setAllocating] = useState(false); const [allocationResult, setAllocationResult] = useState(null); + const [allocationError, setAllocationError] = useState(null); const fetchProjectDetails = async () => { try { - const res = await fetch('/api/admin/training/project-details'); - if (res.ok) { - const data = await res.json(); - setProjectDetails(data); - } + setProjectDetails(await api.getProjectDetails()); } catch (e) { console.warn('Could not fetch project details', e); } finally { @@ -95,54 +92,32 @@ export function AdminPage() { setUploadError(null); setDatasetResult(null); - const formData = new FormData(); - formData.append('file', uploadFile); - try { - const res = await fetch('/api/admin/training/upload-dataset', { - method: 'POST', - body: formData, - }); - - if (!res.ok) { - const errData = await res.json(); - throw new Error(errData.detail || 'Upload failed'); - } - - const data = await res.json(); - setDatasetResult(data); - setUploadingDataset(false); + setDatasetResult(await api.uploadTrainingDataset(uploadFile)); } catch (err) { - setUploadingDataset(false); setUploadError(err.message); + } finally { + setUploadingDataset(false); } }; const handleAllocateDiscounts = async (e) => { e.preventDefault(); setAllocating(true); + setAllocationError(null); + setAllocationResult(null); try { - const payload = { - rules: [ - { min_stock: Number(minStock), max_stock: Number(maxStock), discount_pct: Number(discountPct) }, - { min_stock: 21, max_stock: 50, discount_pct: 15.0 }, - { min_stock: 51, max_stock: 100, discount_pct: 10.0 }, - { min_stock: 101, max_stock: 10000, discount_pct: 5.0 }, - ], - }; - - const res = await fetch('/api/admin/training/allocate-discounts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (res.ok) { - const data = await res.json(); - setAllocationResult(data); - } - } catch (e) { - console.warn('Failed to allocate discounts', e); + const rules = [ + { min_stock: Number(minStock), max_stock: Number(maxStock), discount_pct: Number(discountPct) }, + { min_stock: 21, max_stock: 50, discount_pct: 15.0 }, + { min_stock: 51, max_stock: 100, discount_pct: 10.0 }, + { min_stock: 101, max_stock: 10000, discount_pct: 5.0 }, + ]; + setAllocationResult(await api.allocateDiscounts({ rules })); + } catch (err) { + // Previously this only acted on res.ok, so a 401 or 403 left the panel + // looking idle and gave no clue that nothing had been allocated. + setAllocationError(err.message); } finally { setAllocating(false); } @@ -380,6 +355,12 @@ export function AdminPage() { + {allocationError && ( +
+ {allocationError} +
+ )} + {allocationResult && (
diff --git a/src/pages/UserPage.jsx b/src/pages/UserPage.jsx index 8ef990c..9d56ce8 100644 --- a/src/pages/UserPage.jsx +++ b/src/pages/UserPage.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; +import { api } from '../api/client'; import { NavigationHeader } from '../components/NavigationHeader'; import { ProductCard } from '../components/ProductCard'; import { ProductModal } from '../components/ProductModal'; @@ -165,18 +166,13 @@ export function UserPage() { const fetchStoreProductsCards = async (storeId) => { setLoadingCards(true); try { - const res = await fetch(`/api/stores/${storeId}/products?limit=100`); - if (res.ok) { - const data = await res.json(); - const itemsList = Array.isArray(data) - ? data - : Array.isArray(data?.products) - ? data.products - : []; - setStoreProductsRaw(itemsList); - } else { - setStoreProductsRaw([]); - } + const data = await api.getStoreProducts(storeId, { limit: 100 }); + const itemsList = Array.isArray(data) + ? data + : Array.isArray(data?.products) + ? data.products + : []; + setStoreProductsRaw(itemsList); } catch (e) { console.warn('Failed to load store products for cards:', e); setStoreProductsRaw([]); @@ -193,18 +189,13 @@ export function UserPage() { if (!bName) return; setLoadingBrand(true); try { - const res = await fetch(`/api/brands/${encodeURIComponent(bName)}/products?limit=50`); - if (res.ok) { - const data = await res.json(); - const itemsList = Array.isArray(data) - ? data - : Array.isArray(data?.products) - ? data.products - : []; - setBrandProducts(itemsList); - } else { - setBrandProducts([]); - } + const data = await api.getBrandProducts(bName, { limit: 50 }); + const itemsList = Array.isArray(data) + ? data + : Array.isArray(data?.products) + ? data.products + : []; + setBrandProducts(itemsList); } catch (e) { console.warn('Failed to fetch brand products', e); setBrandProducts([]); @@ -250,18 +241,7 @@ export function UserPage() { image_url: imageUrl || undefined, }; - const res = await fetch('/api/user/products/add', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!res.ok) { - const errData = await res.json(); - throw new Error(errData.detail || 'Failed to add product'); - } - - const data = await res.json(); + const data = await api.addUserProduct(payload); setSingleResult(data); setSubmittingSingle(false); @@ -307,21 +287,8 @@ export function UserPage() { setBatchResult(null); setUploadingBatch(true); - const formData = new FormData(); - formData.append('file', batchFile); - try { - const res = await fetch('/api/user/products/upload-file', { - method: 'POST', - body: formData, - }); - - if (!res.ok) { - const errData = await res.json(); - throw new Error(errData.detail || 'Batch file upload failed'); - } - - const data = await res.json(); + const data = await api.uploadUserProductsFile(batchFile); setBatchResult(data); setUploadingBatch(false); @@ -367,6 +334,7 @@ export function UserPage() { document.body.appendChild(link); link.click(); document.body.removeChild(link); + URL.revokeObjectURL(url); }; // Convert Store products into rich Card objects with 100% type-safe conversions and title-specific images