import product catalogue

This commit is contained in:
2026-07-16 17:05:06 +05:30
parent 8a1527cef8
commit 11baf732b7
35 changed files with 2722 additions and 1513 deletions

View File

@@ -0,0 +1,117 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
const API_BASE = import.meta.env.VITE_FIESTA_URL || "https://fiesta.nearle.app/live/api/v1";
export interface CatalogueProduct {
id: number;
brand: string;
product_name: string;
category?: string;
images?: string[];
size?: string;
product_sku?: string;
price_range?: string; // display only — never an exact price
}
export interface ImportedRef {
brand: string;
catalogueid: number;
}
export interface ImportCatalogueProductRequest {
tenantid: number;
locationid: number;
brand: string; // bridge key part 1
catalogueid: number; // bridge key part 2 — the catalogue row's `id`
categoryid: number; // this tenant's own category
subcategoryid: number; // this tenant's own subcategory
quantity: number;
stocktype: "in" | "out";
status: string;
retailprice: number;
productcost: number;
taxpercent: number;
}
async function apiGet<T>(url: URL): Promise<T[]> {
const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) {
throw new Error(`Catalogue API failed: ${res.status} ${res.statusText}`);
}
const json = await res.json();
if (Array.isArray(json)) return json;
if (json && typeof json === 'object' && 'details' in json) {
return json.details || [];
}
return [];
}
// brand omitted → the entire catalogue, all brands merged.
export async function getCatalogueProducts(opts: {
brand?: string; keyword?: string; pageno?: number; pagesize?: number;
} = {}) {
const { brand, keyword, pageno = 1, pagesize = 50 } = opts;
const url = new URL(`${API_BASE}/catalogue/getproducts`);
if (brand) url.searchParams.set("brand", brand);
if (keyword) url.searchParams.set("keyword", keyword);
url.searchParams.set("pageno", String(pageno));
url.searchParams.set("pagesize", String(pagesize));
const products = await apiGet<CatalogueProduct>(url);
return { products, total: products.length };
}
// brand omitted → imported refs across every brand.
export async function getImportedCatalogueRefs(tenantid: number, brand?: string) {
const url = new URL(`${API_BASE}/products/getimportedcatalogueproducts`);
url.searchParams.set("tenantid", String(tenantid));
if (brand) url.searchParams.set("brand", brand);
const refs = await apiGet<ImportedRef>(url);
return new Set(refs.map((r) => `${r.brand}:${r.catalogueid}`));
}
export async function importCatalogueProducts(items: ImportCatalogueProductRequest[]) {
const res = await fetch(`${API_BASE}/products/importcatalogueproduct`, {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify(items),
});
if (!res.ok) throw new Error("Failed to import products");
const json = await res.json();
if (json && typeof json === 'object' && 'status' in json && !json.status) {
throw new Error(json.message || "Failed to import products");
}
return json;
}
export async function removeFromStoreCatalogue(tenantid: number, locationid: number, productid: number) {
const res = await fetch(`${API_BASE}/products/deleteproductlocation`, {
method: "DELETE",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify({ tenantid, locationid, productid }),
});
if (!res.ok) throw new Error("Failed to remove product from store");
const json = await res.json();
if (json && typeof json === 'object' && 'status' in json && !json.status) {
throw new Error(json.message || "Failed to remove product from store");
}
return json;
}
export async function getBrands() {
const url = new URL(`${API_BASE}/catalogue/getbrands`);
const brands = await apiGet<{ brand: string; count: number }>(url);
return brands;
}
export async function getProductSubcategories(tenantid: number, categoryid?: number) {
const url = new URL(`${API_BASE}/products/getproductsubcategories`);
url.searchParams.set("tenantid", String(tenantid));
if (categoryid) url.searchParams.set("categoryid", String(categoryid));
const subcategories = await apiGet<{ subcategoryid: number; subcategoryname: string }>(url);
return subcategories;
}

View File

@@ -20,11 +20,11 @@
const FIESTA_BASE = import.meta.env.VITE_FIESTA_URL || 'https://fiesta.nearle.app/live/api/v1/web';
const FIESTA_MOB_BASE = import.meta.env.VITE_FIESTA_MOB_URL || 'https://fiesta.nearle.app/live/api/v1/mob';
/** Tenant / location scope shared by the merchant console (Ragul Stores, Coimbatore). */
export const FIESTA_TENANT_ID = 1087;
/** Tenant / location scope shared by the merchant console. */
export const FIESTA_TENANT_ID = 1135;
export const FIESTA_APPLOCATION_ID = 1;
/** Primary outlet for this tenant — the one carrying live orders/stock. */
export const FIESTA_PRIMARY_LOCATION_ID = 1097;
export const FIESTA_PRIMARY_LOCATION_ID = 1170;
export type Row = Record<string, unknown>;
type QueryParams = Record<string, string | number | undefined | null>;
@@ -167,7 +167,13 @@ export async function getOrderSummary(
todate: string,
locationid?: number,
): Promise<FiestaOrderSummary | null> {
const row = firstRow<Row>(await fiestaGet('orders/getordersummary', { tenantid, locationid, fromdate, todate }));
const row = firstRow<Row>(await fiestaGet('orders/getordersummary', {
tenantid,
locationid,
applocationid: locationid, // send both to bypass backend bugs
fromdate,
todate
}));
if (!row) return null;
return {
total: num(row.total),
@@ -192,9 +198,9 @@ export interface FiestaLocationSummary {
cancelled: number;
}
/** /orders/getlocationsummary?tenantid= — per-outlet order rollup. */
export async function getLocationSummary(tenantid: number): Promise<FiestaLocationSummary[]> {
return toRows<Row>(await fiestaGet('orders/getlocationsummary', { tenantid })).map((r) => ({
/** /orders/getlocationsummary?tenantid=&fromdate=&todate= — per-outlet order rollup. */
export async function getLocationSummary(tenantid: number, fromdate?: string, todate?: string): Promise<FiestaLocationSummary[]> {
return toRows<Row>(await fiestaGet('orders/getlocationsummary', { tenantid, fromdate, todate })).map((r) => ({
locationid: num(r.locationid),
locationname: str(r.locationname),
total: num(r.total),
@@ -622,9 +628,11 @@ export function cleanTenantLocations(rows: Row[]): Row[] {
});
}
/** /tenants/gettenantlocations?tenantid= — outlet locations for a tenant (test rows stripped). */
export async function getTenantLocations(tenantid: number): Promise<Row[]> {
return cleanTenantLocations(toRows(await fiestaGet('tenants/gettenantlocations', { tenantid })));
/** /tenants/gettenantlocations?tenantid=&userid= — outlet locations for a tenant (test rows stripped). */
export async function getTenantLocations(tenantid: number, userid?: number): Promise<Row[]> {
const params: Record<string, any> = { tenantid };
if (userid) params.userid = userid;
return cleanTenantLocations(toRows(await fiestaGet('tenants/gettenantlocations', params)));
}
/** /tenants/getalltenants?applocationid=&status=&pageno=&pagesize= — active tenants. */
@@ -737,7 +745,7 @@ export async function getProductStocks(opts: {
);
}
/** /products/getproductlocations?tenantid=&locationid=&subcategoryid=&pageno=&pagesize= —
/** /products/getlocationproducts?tenantid=&locationid=&subcategoryid=&pageno=&pagesize= —
* geofenced per-outlet inventory. */
export async function getProductLocations(opts: {
tenantid: number;
@@ -747,7 +755,7 @@ export async function getProductLocations(opts: {
pagesize?: number;
}): Promise<Row[]> {
return toRows(
await fiestaGet('products/getproductlocations', {
await fiestaGet('products/getlocationproducts', {
tenantid: opts.tenantid,
locationid: opts.locationid,
subcategoryid: opts.subcategoryid,
@@ -783,13 +791,35 @@ export interface CreateProductLocationInput {
tenantid: number;
locationid: number;
productid: number;
qty: number;
quantity?: number; // User prompt specified quantity
qty?: number; // Keep for backwards compatibility if needed
stocktype?: string;
status?: string;
price?: number;
}
/** POST /products/createproductlocation — Add a product to a store catalogue / inventory. */
/** POST /products/createproductlocation — Add a product to a store catalogue / inventory. (Expects array payload) */
export async function createProductLocation(input: CreateProductLocationInput): Promise<Row> {
return fiestaSend<Row>('products/createproductlocation', 'POST', input);
const payload = {
tenantid: input.tenantid,
locationid: input.locationid,
productid: input.productid,
quantity: input.quantity ?? input.qty ?? 0,
stocktype: input.stocktype || 'in',
status: input.status || 'Active',
};
return fiestaSend<Row>('products/createproductlocation', 'POST', [payload]);
}
export interface DeleteProductLocationInput {
tenantid: number;
locationid: number;
productid: number;
}
/** DELETE /products/deleteproductlocation — Remove a product from a store catalogue. */
export async function deleteProductLocation(input: DeleteProductLocationInput): Promise<Row> {
return fiestaSend<Row>('products/deleteproductlocation', 'DELETE', input);
}
export interface StockRequestInput {
@@ -1211,3 +1241,40 @@ export async function getSalesSummary(opts: {
const res = await fiestaGet<{ details: SalesSummaryResponse }>('v1/web/reports/sales-summary', opts);
return res.details;
}
// ════════════════════════════════════════════════════════════════════════════
// GLOBAL CATALOGUE
// ════════════════════════════════════════════════════════════════════════════
/** GET /v1/web/catalogue/getbrands — List all brands + product count per brand */
export async function getGlobalBrands(): Promise<Row[]> {
return toRows(await fiestaGet('catalogue/getbrands'));
}
/** GET /v1/web/catalogue/getcategories — List categories available for that brand */
export async function getGlobalCategories(opts: { brand: string }): Promise<string[]> {
const res = await fiestaGet<{ details: string[] }>('catalogue/getcategories', { brand: opts.brand });
return Array.isArray(res.details) ? res.details : [];
}
/** GET /v1/web/catalogue/getproducts — Product list */
export async function getGlobalProducts(opts: {
brand: string;
category?: string;
keyword?: string;
pageno?: number;
pagesize?: number;
}): Promise<Row[]> {
return toRows(await fiestaGet('catalogue/getproducts', {
brand: opts.brand,
category: opts.category,
keyword: opts.keyword,
pageno: opts.pageno,
pagesize: opts.pagesize,
}));
}
/** GET /v1/web/catalogue/getproduct — Single product lookup by SKU */
export async function getGlobalProduct(opts: { brand: string; sku: string }): Promise<Row | null> {
return firstRow(await fiestaGet('catalogue/getproduct', { brand: opts.brand, sku: opts.sku }));
}

View File

@@ -166,3 +166,34 @@ export function orderRowToOrder(row: Row): CustomerOrder {
locationid: num(row.locationid),
};
}
/**
* Map a Global Catalogue product row (from /catalogue/getproducts) to ProductMatrixItem.
*/
export function globalRowToProduct(row: Row): ProductMatrixItem {
// Extract base price from price_range if possible, else 0
const priceRange = str(row.price_range) || '';
const priceMatch = priceRange.match(/\d+/);
const basePrice = priceMatch ? parseInt(priceMatch[0], 10) : 0;
return {
id: str(row.id),
name: str(row.product_name) || str(row.title) || 'Unnamed product',
sku: str(row.product_sku) || str(row.variant_key) || `SKU-${str(row.id)}`,
unitsSold: 0,
revenue: basePrice,
stockStatus: 'Healthy',
trend: 'flat',
image: (Array.isArray(row.images) && row.images.length > 0) ? str(row.images[0]) : str(row.image) || str(row.image_url) || (str(row.image_id) ? `https://fiesta.nearle.app/images/${str(row.image_id)}.jpg` : PLACEHOLDER_IMG),
category: str(row.category) || 'Uncategorized',
exposure: str(row.size) || '1 unit',
verified: true,
description: str(row.description) || undefined,
fssaiLicense: str(row.fssai_license) || undefined,
highlights: Array.isArray(row.highlights) ? row.highlights : [],
nutrients: Array.isArray(row.nutrients) ? row.nutrients : [],
providers: Array.isArray(row.providers) ? row.providers : [],
brand: str(row.brand) || undefined,
priceRange: str(row.price_range) || undefined,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,8 +17,8 @@
* Swap `read`/`write` for those calls when the API is ready; the hook API stays.
*/
import { useEffect, useState } from 'react';
import { createProductLocation, FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID } from './fiestaApi';
import { useFiestaProductLocations, useFiestaCreateProductLocation, useFiestaDeleteProductLocation } from './fiestaQueries';
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID } from './fiestaApi';
export interface StoreCatalogueItem {
productid: string;
@@ -30,71 +30,85 @@ export interface StoreCatalogueItem {
unit: string;
/** Quantity the admin intends to stock for this product. */
qty: number;
}
const KEY = 'nearledaily.storeCatalogue';
const EVENT = 'nearledaily:storeCatalogue';
function read(): StoreCatalogueItem[] {
try {
const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as StoreCatalogueItem[]) : [];
} catch {
return [];
}
}
function write(items: StoreCatalogueItem[]): void {
try {
localStorage.setItem(KEY, JSON.stringify(items));
} catch {
/* storage unavailable */
}
// Notify listeners in this tab (storage event only fires in OTHER tabs).
window.dispatchEvent(new Event(EVENT));
status: string;
}
/**
* Live view of the store catalogue + curation helpers. Re-renders whenever the
* catalogue changes (this tab or another).
* catalogue changes via React Query invalidation.
*/
export function useStoreCatalogue() {
const [items, setItems] = useState<StoreCatalogueItem[]>(read);
const q = useFiestaProductLocations({
tenantid: FIESTA_TENANT_ID,
locationid: FIESTA_PRIMARY_LOCATION_ID,
pagesize: 500,
});
useEffect(() => {
const sync = () => setItems(read());
window.addEventListener(EVENT, sync);
window.addEventListener('storage', sync);
return () => {
window.removeEventListener(EVENT, sync);
window.removeEventListener('storage', sync);
};
}, []);
const items: StoreCatalogueItem[] = (q.data || []).filter((r: any) => r.status !== 'Inactive').map((r: any) => ({
productid: String(r.productid),
name: String(r.name || r.productname || ''),
image: String(r.image || r.productimage || ''),
category: String(r.category || r.categoryname || 'General'),
sku: String(r.sku || ''),
price: Number(r.price || 0),
unit: String(r.unit || ''),
qty: Number(r.quantity ?? r.qty ?? 0),
status: String(r.status || 'Draft').charAt(0).toUpperCase() + String(r.status || 'Draft').slice(1).toLowerCase(),
}));
const mutation = useFiestaCreateProductLocation();
const deleteMutation = useFiestaDeleteProductLocation();
const has = (id: string) => items.some((i) => i.productid === id);
const getQty = (id: string) => items.find((i) => i.productid === id)?.qty ?? 0;
const add = (item: StoreCatalogueItem) => {
write([...read().filter((i) => i.productid !== item.productid), item]);
createProductLocation({
mutation.mutate({
tenantid: FIESTA_TENANT_ID,
locationid: FIESTA_PRIMARY_LOCATION_ID,
productid: Number(item.productid),
qty: item.qty,
status: 'Active'
}).catch(e => console.error('API createProductLocation failed:', e));
price: item.price,
status: item.status || 'Active'
});
};
const remove = (id: string) => write(read().filter((i) => i.productid !== id));
const remove = (id: string) => {
const item = items.find(i => i.productid === id);
if (!item) return;
deleteMutation.mutate({
tenantid: FIESTA_TENANT_ID,
locationid: FIESTA_PRIMARY_LOCATION_ID,
productid: Number(id)
});
};
const setQty = (id: string, qty: number) => {
const item = items.find(i => i.productid === id);
if (!item) return;
const safeQty = Math.max(1, Math.round(qty) || 1);
write(read().map((i) => (i.productid === id ? { ...i, qty: safeQty } : i)));
createProductLocation({
mutation.mutate({
tenantid: FIESTA_TENANT_ID,
locationid: FIESTA_PRIMARY_LOCATION_ID,
productid: Number(id),
qty: safeQty,
status: 'Active'
}).catch(e => console.error('API updateProductLocation failed:', e));
price: item.price,
status: item.status || 'Active'
});
};
const setPrice = (id: string, price: number) => {
const item = items.find(i => i.productid === id);
if (!item) return;
mutation.mutate({
tenantid: FIESTA_TENANT_ID,
locationid: FIESTA_PRIMARY_LOCATION_ID,
productid: Number(id),
qty: item.qty || 0,
price: price,
status: 'Active' // Setting a price activates it
});
};
return { items, has, getQty, add, remove, setQty };
return { items, has, getQty, add, remove, setQty, setPrice, isLoading: q.isLoading };
}