diff --git a/src/App.tsx b/src/App.tsx index 1c8b415..1fbb9ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -71,7 +71,11 @@ function loadStoredUser(): AuthUser | null { const raw = localStorage.getItem(AUTH_STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as AuthUser; - return parsed && typeof parsed === 'object' && (parsed.role === 'admin' || parsed.role === 'user') + // super_admin included: omitting it logged a platform operator out on + // every page refresh, because the stored session failed this check and was + // discarded rather than rehydrated. + return parsed && typeof parsed === 'object' && + (parsed.role === 'admin' || parsed.role === 'user' || parsed.role === 'super_admin') ? parsed : null; } catch { diff --git a/src/components/BulkCartDrawer.tsx b/src/components/BulkCartDrawer.tsx index 339a3f3..1caf0cd 100644 --- a/src/components/BulkCartDrawer.tsx +++ b/src/components/BulkCartDrawer.tsx @@ -19,15 +19,22 @@ export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: Bu let addedCount = 0; selectedProducts.forEach(prod => { if (prod.verified && !storeCat.has(prod.id)) { - storeCat.add({ - productid: String(prod.id), - name: prod.name, - image: prod.image, - category: prod.category, - sku: prod.sku, - price: prod.price, - unit: 'All Outlets', - qty: 1 + storeCat.add({ + productid: String(prod.id), + name: prod.name, + image: prod.image, + // The source here is a ProductMatrixItem, which carries one image and + // no lifecycle status. Both are stated explicitly rather than left + // off: this call has been failing typecheck on the missing `status` + // for some time, and silently widening the type to accommodate it + // would hide the same gap at every other call site. + images: prod.image ? [prod.image] : [], + status: 'Draft', + category: prod.category, + sku: prod.sku, + price: prod.price, + unit: 'All Outlets', + qty: 1, }); addedCount++; } diff --git a/src/components/CatalogueBrowser.tsx b/src/components/CatalogueBrowser.tsx index 6916655..1ee9a84 100644 --- a/src/components/CatalogueBrowser.tsx +++ b/src/components/CatalogueBrowser.tsx @@ -12,6 +12,7 @@ import { useStoreCatalogue, usePriceEverywhere } from '../services/storeCatalogu import { useFiestaTenantLocations, useFiestaProductLocations } from '../services/fiestaQueries'; import { num as fnum } from '../services/fiestaApi'; import ImportProductModal from './ImportProductModal'; +import { CardCarousel } from './CatalogueGallery'; interface CatalogueBrowserProps { tenantid: number; @@ -223,15 +224,11 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-none flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden cursor-pointer" >
- {p.images && p.images.length > 0 ? ( - {p.product_name} - ) : ( - - )} + {/* Was `images[0]` and nothing else — 90 of nestle's 123 + products have more than one photo, up to ten, and + this is the screen where somebody decides what to + stock. */} +
@@ -258,28 +255,18 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata Imported ) : ( - + + +
+ {list.slice(0, 8).map((_, n) => ( + + ))} + {list.length > 8 && ( + + +{list.length - 8} + + )} +
+ + + {i + 1}/{list.length} + + + )} + + ); +} + +/** + * Full-size viewer. + * + * Arrow keys and Escape, because a person comparing five photos of a packet + * should not have to aim at a button five times. Rendered at the top of the + * stacking order so it sits above the import modal that opens it. + */ +export function Lightbox({ + images, + index, + alt, + onClose, + onIndex, +}: { + images: string[]; + index: number; + alt: string; + onClose: () => void; + onIndex: (n: number) => void; +}) { + const move = useCallback( + (by: number) => onIndex((index + by + images.length) % images.length), + [index, images.length, onIndex], + ); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + else if (e.key === 'ArrowRight') move(1); + else if (e.key === 'ArrowLeft') move(-1); + }; + window.addEventListener('keydown', onKey); + // Restored rather than cleared: the import modal beneath this one also + // locks the body, and clearing outright would let the page behind it + // scroll once this closes. + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + window.removeEventListener('keydown', onKey); + document.body.style.overflow = prev; + }; + }, [move, onClose]); + + if (!images.length) return null; + + return ( +
+
+ + + +
+ {alt} + + {images.length > 1 && ( + <> +
+ + {index + 1} of {images.length} + +
+ +
+ {images.map((src, n) => ( + onIndex(n)} + className="h-12 w-12 object-contain bg-white rounded-lg shrink-0 cursor-pointer transition-all" + style={{ + opacity: n === index ? 1 : 0.45, + outline: n === index ? '2px solid #fff' : 'none', + }} + /> + ))} +
+ + )} +
+
+ ); +} + +/** + * Hero image plus thumbnail strip, for a product detail panel. + * + * The third shape this file needs: a card hints, a lightbox magnifies, and a + * detail panel wants both at once — a large image you can page through without + * leaving the page, and a way into full size when a label is too small to read. + * + * Degrades to a plain image for a single photo. Most tenant products still have + * one, because the import kept only `Images[0]` until recently and the backfill + * for the rest is a separate step — so this is the common case, not the edge. + */ +export function ProductGallery({ + images, + alt, + height = 'h-64', +}: { + images: string[]; + alt: string; + height?: string; +}) { + const [at, setAt] = useState(0); + const [zoom, setZoom] = useState(null); + + const list = (images ?? []).filter(Boolean); + const i = Math.min(at, Math.max(list.length - 1, 0)); + + if (list.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {alt} setZoom(i)} + className="w-auto h-full max-w-full object-contain mix-blend-multiply cursor-zoom-in" + /> + {list.length > 1 && ( + + {i + 1}/{list.length} + + )} +
+ + {list.length > 1 && ( +
+ {list.map((src, n) => ( + (n === i ? setZoom(n) : setAt(n))} + className="h-14 w-14 object-contain bg-white rounded-lg border shrink-0 cursor-pointer transition-colors" + style={{ + opacity: n === i ? 1 : 0.55, + borderColor: n === i ? '#662582' : '#e2e8f0', + }} + /> + ))} +
+ )} + + {zoom !== null && ( + setZoom(null)} onIndex={setZoom} /> + )} +
+ ); +} diff --git a/src/components/ImportProductModal.tsx b/src/components/ImportProductModal.tsx index 67d0bf1..a4daadb 100644 --- a/src/components/ImportProductModal.tsx +++ b/src/components/ImportProductModal.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { X, Save, AlertCircle, CheckCircle, DownloadCloud, Plus, Box } from 'lucide-react'; import { CatalogueProduct, ImportCatalogueProductRequest } from '../services/catalogueApi'; import { useProductSubcategories, useTenantCategories } from '../hooks/useCatalogueImport'; +import { Lightbox } from './CatalogueGallery'; import { SlideDrawer } from './consoleUi'; import FMCGHoverOverlay from './FMCGHoverOverlay'; @@ -24,13 +25,74 @@ export default function ImportProductModal({ }: ImportProductModalProps) { const [categoryId, setCategoryId] = useState(''); const [subcategoryId, setSubcategoryId] = useState(''); - const [retailPrice, setRetailPrice] = useState(''); + + /** + * Seed the price from the catalogue's own market range. + * + * `price_range` arrives as "₹142-158" and was rendered on the browse card and + * then discarded — the admin typed a price with no anchor, or more often left + * it at zero. The lower bound is used rather than the midpoint: it is the + * figure a shop is most likely to actually undercut, and a price that is too + * low is visible on the shelf while one that is too high quietly stops + * selling. + * + * Only a starting point. The field stays editable and nothing is imported + * until somebody presses the button. + */ + const suggestedPrice = (() => { + const m = String(product.price_range ?? '').match(/\d+(?:\.\d+)?/); + return m ? m[0] : ''; + })(); + + const [retailPrice, setRetailPrice] = useState(suggestedPrice); const [productCost, setProductCost] = useState(''); const [taxPercent, setTaxPercent] = useState('0'); + /** Which photo the full-size viewer is showing, or null when it is closed. */ + const [lightboxAt, setLightboxAt] = useState(null); + + /** + * Which photo the large image at the top is showing. + * + * Separate from the lightbox so a thumbnail behaves the way people expect of + * a product gallery: the first click swaps the main image, and clicking the + * one already shown opens it full size. Going straight to full size on every + * thumbnail makes browsing five photos five modal dismissals. + */ + const [heroAt, setHeroAt] = useState(0); + // Categories this tenant's own products actually use const { data: categories = [], isLoading: isLoadingCategories } = useTenantCategories(tenantid); + /** + * Preselect the tenant category that matches the catalogue's own. + * + * The catalogue knows this product is "Noodles & Instant Food"; the tenant + * has its own category list. Matched case-insensitively on the name, and + * loosely — a tenant's "Noodles" should catch the catalogue's "Noodles & + * Instant Food" rather than leaving the admin to classify by hand, which is + * how everything imported so far ended up in whichever category happened to + * be first. + * + * No match leaves the picker empty and the form unsubmittable, which is the + * right outcome: guessing a category is what this replaces. + */ + React.useEffect(() => { + if (categoryId || !categories.length) return; + const want = String(product.category ?? '').toLowerCase().trim(); + if (!want) return; + + const norm = (s: string) => s.toLowerCase().trim(); + const hit = + categories.find((c) => norm(String(c.categoryname)) === want) ?? + categories.find((c) => { + const n = norm(String(c.categoryname)); + return n.length > 2 && (want.includes(n) || n.includes(want)); + }); + + if (hit) setCategoryId(String(hit.categoryid)); + }, [categories, product.category, categoryId]); + // Subcategories scoped to selected category const { data: subcategories = [], isLoading: isLoadingSubcats } = useProductSubcategories( tenantid, @@ -78,7 +140,12 @@ export default function ImportProductModal({ {/* Clean Image Container */}
{product.images && product.images.length > 0 ? ( - {product.product_name} + {product.product_name} setLightboxAt(heroAt)} + className="w-auto h-full max-w-full object-contain mix-blend-multiply cursor-zoom-in" + /> ) : ( )} @@ -116,6 +183,100 @@ export default function ImportProductModal({ )}
+ {/* + Everything below is already in the API response and was being + discarded — the browser rendered five of nineteen fields, and the + import keeps seven. Rendering them here costs nothing (no extra call) + and is the difference between importing a name and importing a + product. The FSSAI licence in particular is a food-compliance figure + nobody could previously see anywhere in the platform. + */} + {product.description && ( +
+ Description +

{product.description}

+
+ )} + +
+ {Array.isArray(product.highlights) && product.highlights.length > 0 && ( +
+ Highlights +
    + {product.highlights.map((h: string, i: number) => ( +
  • • {h}
  • + ))} +
+
+ )} + {Array.isArray(product.nutrients) && product.nutrients.length > 0 && ( +
+ Nutrition +
    + {product.nutrients.map((n: string, i: number) => ( +
  • • {n}
  • + ))} +
+
+ )} +
+ +
+ {product.category && ( + + {product.category} + + )} + {product.product_sku && ( + + SKU {product.product_sku} + + )} + {product.fssai_license && ( + + FSSAI {product.fssai_license} + + )} + {Array.isArray(product.providers) && product.providers.length > 0 && ( + + Sold on {product.providers.join(', ')} + + )} +
+ + {/* Only the first image survives an import today — the rest exist in the + catalogue and have nowhere to land in `products`. Shown so an admin + at least knows what is being left behind. */} + {Array.isArray(product.images) && product.images.length > 1 && ( +
+ + {product.images.length} images — only the first is imported + +
+ {product.images.map((src: string, i: number) => ( + {`${product.product_name} (i === heroAt ? setLightboxAt(i) : setHeroAt(i))} + className="h-14 w-14 object-contain bg-white rounded-lg border border-slate-200 shrink-0 cursor-pointer hover:border-[#662582] transition-colors" + style={{ opacity: i === heroAt ? 1 : 0.55 }} + /> + ))} +
+
+ )} + + {lightboxAt !== null && Array.isArray(product.images) && ( + setLightboxAt(null)} + onIndex={setLightboxAt} + /> + )} + {/* Import Action Container */}
{isImported ? ( diff --git a/src/components/InventoryView.tsx b/src/components/InventoryView.tsx index 3583b9c..d336755 100644 --- a/src/components/InventoryView.tsx +++ b/src/components/InventoryView.tsx @@ -50,6 +50,7 @@ import { import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr } from '../services/fiestaApi'; import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers'; import { useStoreCatalogue, usePriceEverywhere, isPublishedItem } from '../services/storeCatalogue'; +import { CardCarousel, ProductGallery } from './CatalogueGallery'; import BulkCartDrawer from './BulkCartDrawer'; import AwaitingApi from './AwaitingApi'; import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi'; @@ -134,6 +135,21 @@ export default function InventoryView({ const [hoveredAdminProduct, setHoveredAdminProduct] = useState(null); const [localSearch, setLocalSearch] = useState(''); const storeCat = useStoreCatalogue(tenantId, primaryLocationId); + /** + * productid → every photo, for the catalogue cards. + * + * Built once rather than searching storeCat.items inside each card's render: + * the grid draws hundreds of cards and a linear scan per card is a scan per + * card per keystroke in the search box. + */ + const imagesByProduct = useMemo(() => { + const m = new Map(); + storeCat.items.forEach((i) => { + if (i.images?.length) m.set(i.productid, i.images); + }); + return m; + }, [storeCat.items]); + const { priceEverywhere, isPending: isPricing } = usePriceEverywhere(tenantId); const [importPrice, setImportPrice] = useState(''); const [isSettingPrice, setIsSettingPrice] = useState(false); @@ -589,11 +605,13 @@ export default function InventoryView({
setSelectedAdminProduct(prod)} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-none flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden cursor-pointer"> {/* Image Section - Top */}
- {prod.name}
{prod.isSample && ( @@ -906,10 +924,20 @@ export default function InventoryView({ return (
- {/* Clean Image Container */} -
- {selectedAdminProduct.name} -
+ {/* Gallery. A product carries up to ten photos in the global + catalogue and only the first was ever stored, so most rows still + have one until the backfill runs — ProductGallery renders a + plain image in that case rather than empty chrome. */} + {/* Title & Basics */}
diff --git a/src/components/StoreCatalogView.tsx b/src/components/StoreCatalogView.tsx index 0f3e741..1723e2d 100644 --- a/src/components/StoreCatalogView.tsx +++ b/src/components/StoreCatalogView.tsx @@ -25,10 +25,11 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity, Info, Inbox, UploadCloud } from 'lucide-react'; import { useFiestaStockStatement, useFiestaCreateStockRequest, useFiestaGetStockRequests, useFiestaUpdateStockRequest, useFiestaCreateProductLocation, FIESTA_TENANT_ID } from '../services/fiestaQueries'; -import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; +import { num as fnum, productImages, str as fstr, type Row } from '../services/fiestaApi'; import { useStoreCatalogue } from '../services/storeCatalogue'; import AwaitingApi from './AwaitingApi'; import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi'; +import { CardCarousel, ProductGallery } from './CatalogueGallery'; import FMCGHoverOverlay from './FMCGHoverOverlay'; import OfflineSalesUpload from './OfflineSalesUpload'; @@ -114,6 +115,10 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', name: it.name, sku: it.sku || `SKU-${it.productid}`, image: it.image || PLACEHOLDER, + // Carried through so the detail drawer can show the gallery. Empty + // for anything imported before productimages existed, which the + // gallery falls back from to `image` above. + images: it.images ?? [], category: it.category || 'General', price: it.price, unit: it.unit, @@ -272,6 +277,10 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', name: fstr(r.productname) || catItem?.name || 'Unnamed product', sku: catItem?.sku || `SKU-${productid}`, image: fstr(r.productimage) || catItem?.image || PLACEHOLDER, + // Gallery from the catalogue row, then the stock row's single image. + // Products imported before productimages existed have one until the + // backfill runs, which the gallery renders as a plain image. + images: catItem?.images?.length ? catItem.images : productImages(r), category: fstr(r.categoryname) || catItem?.category || 'General', unit: fstr(r.productunit) || catItem?.unit || '', closing, @@ -536,7 +545,9 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
setSelectedProduct(p)} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-none flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden cursor-pointer"> {/* Image Section - Top */}
- {p.name} + {/* Every photo, not just the first. Controls stop + propagation so paging does not also open the drawer. */} +
@@ -925,10 +936,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', return (
- {/* Clean Image Container */} -
- {selectedProduct.name} -
+ {/* A store user deciding whether to request stock benefits from + the packaging shots more than anyone — this is the screen + where "is this the 1kg or the 500g" gets settled. */} + {/* Title & Basics */}
diff --git a/src/services/catalogueApi.ts b/src/services/catalogueApi.ts index 87c6291..241d174 100644 --- a/src/services/catalogueApi.ts +++ b/src/services/catalogueApi.ts @@ -5,15 +5,35 @@ const API_BASE = import.meta.env.VITE_FIESTA_URL || "https://fiesta.nearle.app/live/api/v1/web"; +/** + * A global catalogue row. + * + * The API returns nineteen fields and this described eight of them, so the + * other eleven were arriving on every browse and being silently discarded — + * including the nutrition panel, the marketing highlights and the FSSAI food + * licence, none of which existed anywhere in the product as a result. + */ export interface CatalogueProduct { id: number; brand: string; product_name: string; + title?: string; + description?: string; category?: string; + image_id?: string; images?: string[]; size?: string; + variant_key?: string; product_sku?: string; + sku_source?: string; price_range?: string; // display only — never an exact price + providers?: string[]; + fssai_license?: string; + highlights?: string[]; + nutrients?: string[]; + search_query?: string; + created_at?: string; + updated_at?: string; } export interface ImportedRef { diff --git a/src/services/fiestaApi.ts b/src/services/fiestaApi.ts index 9fa7b16..eac89e9 100644 --- a/src/services/fiestaApi.ts +++ b/src/services/fiestaApi.ts @@ -1928,3 +1928,43 @@ export async function deactivatePosStaff(opts: { } return json as Row; } + +/** + * Every photo a tenant product has, newest schema first. + * + * `products.productimages` is a jsonb array added after the fact; `productimage` + * is the single URL every existing reader already uses and is kept as the first + * of them. So this prefers the array, falls back to the single, and returns an + * empty list when there is neither. + * + * The jsonb column arrives as a JSON *string* through this API rather than a + * parsed array — it is selected as `productimages::text` so GORM's raw + * scan-into-struct does not drop it, which it does for slice-kind fields. Both + * shapes are accepted anyway, so a future change to send it parsed needs no + * change here. + */ +export function productImages(row: Row): string[] { + const raw = (row as { productimages?: unknown }).productimages; + + let list: string[] = []; + if (Array.isArray(raw)) { + list = raw.map(String); + } else if (typeof raw === 'string' && raw.trim() !== '' && raw.trim() !== 'null') { + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) list = parsed.map(String); + } catch { + /* a malformed column is not a reason to render nothing — fall through */ + } + } + + const single = str((row as { productimage?: unknown }).productimage).trim(); + if (list.length === 0) return single ? [single] : []; + + // Guard against the two disagreeing: the single column is the canonical + // first image, so it leads even if the array was written in another order. + if (single && list[0] !== single) { + return [single, ...list.filter((u) => u !== single)]; + } + return list; +} diff --git a/src/services/storeCatalogue.ts b/src/services/storeCatalogue.ts index c1f489c..2859745 100644 --- a/src/services/storeCatalogue.ts +++ b/src/services/storeCatalogue.ts @@ -27,12 +27,22 @@ import { useFiestaCreateProductLocations, useFiestaDeleteProductLocation, } from './fiestaQueries'; -import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID } from './fiestaApi'; +import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, productImages } from './fiestaApi'; export interface StoreCatalogueItem { productid: string; name: string; + /** The primary photo. Stays the single URL every existing reader uses. */ image: string; + /** + * Every photo the product has, primary first. + * + * Populated from `products.productimages`, the jsonb column the catalogue + * import now fills. Products imported before it existed have one entry until + * the backfill runs, so a caller must handle a list of one without treating + * it as missing data. + */ + images: string[]; category: string; sku?: string; price: number; @@ -128,6 +138,7 @@ export function useStoreCatalogue(tenantid: number = FIESTA_TENANT_ID, locationi productid: String(r.productid), name: String(r.name || r.productname || ''), image: String(r.image || r.productimage || ''), + images: productImages(r), category: String(r.category || r.categoryname || 'General'), // The row's columns are `productsku` / `productunit` — the bare `sku` / // `unit` names don't exist on it, so a real SKU "PEPSIC-CHE-100-002"