frontend
This commit is contained in:
183
src/api/client.js
Normal file
183
src/api/client.js
Normal file
@@ -0,0 +1,183 @@
|
||||
const BASE = import.meta.env.VITE_API_BASE_URL || '';
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(message, status) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||
...options,
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
`Could not reach the API at ${BASE || '(same origin)'}${path}. Is the backend running ` +
|
||||
`(uvicorn app.main:app) and reachable?`,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = `Request failed (${res.status})`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = body.detail || JSON.stringify(body);
|
||||
} catch {
|
||||
/* ignore parse errors, keep generic message */
|
||||
}
|
||||
throw new ApiError(detail, res.status);
|
||||
}
|
||||
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function qs(params = {}) {
|
||||
const usp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '') usp.set(k, v);
|
||||
});
|
||||
const s = usp.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
// Browse endpoints intentionally request a very high `limit` explicitly
|
||||
// (rather than relying on the backend's default) so that the UI always
|
||||
// shows the FULL catalog for a brand / for "All brands", however many
|
||||
// thousands of products that ends up being. Do not lower this to a small
|
||||
// page size unless you also add real pagination controls to the grid -
|
||||
// silently truncating the product list here was the exact bug this
|
||||
// constant is guarding against.
|
||||
const SHOW_ALL_PRODUCTS_LIMIT = 100000;
|
||||
|
||||
export const api = {
|
||||
getHealth: () => request('/api/health'),
|
||||
|
||||
getBrands: () => request('/api/brands'),
|
||||
|
||||
getBrandCategories: (brand) => request(`/api/brands/${encodeURIComponent(brand)}/categories`),
|
||||
|
||||
getBrandProducts: (brand, { category, limit = SHOW_ALL_PRODUCTS_LIMIT, offset = 0 } = {}) =>
|
||||
request(`/api/brands/${encodeURIComponent(brand)}/products${qs({ category, limit, offset })}`),
|
||||
|
||||
getAllProducts: ({ category, limit = SHOW_ALL_PRODUCTS_LIMIT, offset = 0 } = {}) =>
|
||||
request(`/api/products${qs({ category, limit, offset })}`),
|
||||
|
||||
getProductDetail: (brand, imageId) =>
|
||||
request(`/api/brands/${encodeURIComponent(brand)}/products/${encodeURIComponent(imageId)}`),
|
||||
|
||||
search: (q, { brand, category, top_k = 50 } = {}) =>
|
||||
request(`/api/search${qs({ q, brand, category, top_k })}`),
|
||||
|
||||
chat: ({ query, brand, category, top_k, history }) =>
|
||||
request('/api/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query, brand, category, top_k, history }),
|
||||
}),
|
||||
|
||||
generateCatalog: ({ brand, max_products = 50 }) =>
|
||||
request('/api/catalog/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ brand, max_products }),
|
||||
}),
|
||||
|
||||
getJobStatus: (jobId) => request(`/api/catalog/jobs/${jobId}`),
|
||||
|
||||
// --- Store Intelligence (v3.0) ---
|
||||
getStores: () => request('/api/stores'),
|
||||
getStore: (storeId) => request(`/api/stores/${encodeURIComponent(storeId)}`),
|
||||
getStoreProducts: (storeId, { category, in_stock_only, limit = 500, offset = 0 } = {}) =>
|
||||
request(`/api/stores/${encodeURIComponent(storeId)}/products${qs({ category, in_stock_only, limit, offset })}`),
|
||||
getProductAcrossStores: (brand, imageId) =>
|
||||
request(`/api/products/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/stores`),
|
||||
|
||||
getStoreDiscounts: (storeId) => request(`/api/stores/${encodeURIComponent(storeId)}/discounts`),
|
||||
getProductDiscount: (storeId, brand, imageId) =>
|
||||
request(`/api/stores/${encodeURIComponent(storeId)}/discounts/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}`),
|
||||
|
||||
getTrending: ({ window = 'weekly', scope = 'overall', scope_value, top_k = 10 } = {}) =>
|
||||
request(`/api/trending${qs({ window, scope, scope_value, top_k })}`),
|
||||
|
||||
getRecommendations: (brand, imageId, topK = 5) =>
|
||||
request(`/api/recommendations/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}${qs({ top_k: topK })}`),
|
||||
|
||||
getStoreAnalytics: (storeId) => request(`/api/analytics/store/${encodeURIComponent(storeId)}`),
|
||||
getStoreComparison: () => request('/api/analytics/compare'),
|
||||
getProductAnalytics: (brand, imageId) => request(`/api/analytics/product/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}`),
|
||||
getTopProducts: ({ by = 'revenue', order = 'top', limit = 10 } = {}) =>
|
||||
request(`/api/analytics/top-products${qs({ by, order, limit })}`),
|
||||
|
||||
seedStoreIntelligence: ({ reset_orders = true, days = 90, seed = 42 } = {}) =>
|
||||
request('/api/admin/store-intelligence/seed', { method: 'POST', body: JSON.stringify({ reset_orders, days, seed }) }),
|
||||
trainStoreModels: ({ models } = {}) =>
|
||||
request('/api/admin/store-intelligence/train', { method: 'POST', body: JSON.stringify({ models: models || null }) }),
|
||||
getStoreIntelligenceJob: (jobId) => request(`/api/admin/store-intelligence/jobs/${jobId}`),
|
||||
|
||||
// --- AI Nutritional Intelligence Module ---
|
||||
getNutrition: (brand, imageId) =>
|
||||
request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}`),
|
||||
getNutritionHealthScore: (brand, imageId) =>
|
||||
request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/health-score`),
|
||||
getNutritionInsights: (brand, imageId) =>
|
||||
request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/insights`),
|
||||
getNutritionAlternatives: (brand, imageId, topK = 5) =>
|
||||
request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/alternatives${qs({ top_k: topK })}`),
|
||||
getNutritionSimilar: (brand, imageId, topK = 5) =>
|
||||
request(`/api/nutrition/${encodeURIComponent(brand)}/${encodeURIComponent(imageId)}/similar${qs({ top_k: topK })}`),
|
||||
compareNutrition: (products) => request(`/api/nutrition/compare${qs({ products: products.join(',') })}`),
|
||||
|
||||
getDietCompatibleProducts: (tag, { category, exclude_allergen, limit = 20, offset = 0 } = {}) =>
|
||||
request(`/api/nutrition/diet/${encodeURIComponent(tag)}${qs({ category, exclude_allergen, limit, offset })}`),
|
||||
getHighProteinProducts: ({ category, limit = 20, offset = 0 } = {}) =>
|
||||
request(`/api/nutrition/high-protein${qs({ category, limit, offset })}`),
|
||||
getLowSugarProducts: ({ category, limit = 20, offset = 0 } = {}) =>
|
||||
request(`/api/nutrition/low-sugar${qs({ category, limit, offset })}`),
|
||||
getHighFiberProducts: ({ category, limit = 20, offset = 0 } = {}) =>
|
||||
request(`/api/nutrition/high-fiber${qs({ category, limit, offset })}`),
|
||||
filterNutritionProducts: ({ sort_by = 'health_score', order = 'desc', category, diet_tag, exclude_allergen, limit = 20, offset = 0 } = {}) =>
|
||||
request(`/api/nutrition/products${qs({ sort_by, order, category, diet_tag, exclude_allergen, limit, offset })}`),
|
||||
|
||||
getNutritionAnalyticsDashboard: (limit = 10) => request(`/api/nutrition/analytics/dashboard${qs({ limit })}`),
|
||||
getNutritionLeaderboards: (limit = 10) => request(`/api/nutrition/analytics/leaderboards${qs({ limit })}`),
|
||||
getNutritionRankings: (limit = 10) => request(`/api/nutrition/analytics/rankings${qs({ limit })}`),
|
||||
getNutritionDistribution: () => request('/api/nutrition/analytics/distribution'),
|
||||
|
||||
getPersonalizedNutritionRecommendations: (customerId, topK = 8) =>
|
||||
request(`/api/nutrition/recommendations/${encodeURIComponent(customerId)}${qs({ top_k: topK })}`),
|
||||
|
||||
enrichNutrition: ({ skip_if_verified = true, generate_narrative = true, max_products } = {}) =>
|
||||
request('/api/admin/nutrition-intelligence/enrich', {
|
||||
method: 'POST', body: JSON.stringify({ skip_if_verified, generate_narrative, max_products: max_products || null }),
|
||||
}),
|
||||
trainNutritionModels: () => request('/api/admin/nutrition-intelligence/train', { method: 'POST', body: JSON.stringify({}) }),
|
||||
getNutritionIntelligenceJob: (jobId) => request(`/api/admin/nutrition-intelligence/jobs/${jobId}`),
|
||||
getNutritionEnrichmentStatus: () => request('/api/admin/nutrition-intelligence/status'),
|
||||
|
||||
// --- Excel / CSV Upload API ---
|
||||
uploadFile: async (tabType, file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch(`${BASE}/api/upload/${encodeURIComponent(tabType)}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = `Upload failed (${res.status})`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = body.detail || JSON.stringify(body);
|
||||
} catch {}
|
||||
throw new ApiError(detail, res.status);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getTemplateUrl: (tabType) => `${BASE}/api/upload/template/${encodeURIComponent(tabType)}`,
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
Reference in New Issue
Block a user