product images added

This commit is contained in:
2026-08-11 15:04:21 +05:30
parent 1861480b67
commit e54e3c9290
10 changed files with 627 additions and 59 deletions

View File

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

View File

@@ -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++;
}

View File

@@ -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"
>
<div className="w-full h-48 bg-white p-4 relative overflow-hidden shrink-0 border-b border-slate-100 flex items-center justify-center">
{p.images && p.images.length > 0 ? (
<img
src={p.images[0]}
alt={p.product_name}
className="w-full h-full object-contain mix-blend-multiply group-hover:scale-110 transition-transform duration-700 ease-in-out"
/>
) : (
<Box className="w-16 h-16 text-slate-200" />
)}
{/* 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. */}
<CardCarousel images={p.images} alt={p.product_name} />
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
</div>
@@ -258,28 +255,18 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
Imported
</button>
) : (
<button
onClick={(e) => {
e.stopPropagation();
const validCategory = tenantCategories.find(c => c.categoryid > 0)?.categoryid || (tenantCategories[0]?.categoryid ?? 2);
handleImportSubmit({
tenantid,
locationid,
brand: p.brand,
catalogueid: p.id,
categoryid: validCategory,
subcategoryid: 0,
quantity: 0,
stocktype: "in",
// Draft: lands in the Admin Catalogue only. It only becomes
// visible to store users once the admin explicitly publishes
// it via "Add to Store Catalogue" (InventoryView).
status: "Draft",
retailprice: 0,
productcost: 0,
taxpercent: 0,
});
}}
<button
// Opens the same form the re-import path uses,
// rather than importing straight away.
//
// It used to submit immediately with retailprice 0,
// taxpercent 0 and whichever tenant category
// happened to be first — so every product arrived
// unpriced, untaxed and misfiled, and an unpriced
// product comes down to a till as `is_active:false`
// and cannot be rung up at all. The market price was
// rendered on this very card and thrown away.
onClick={(e) => { e.stopPropagation(); setImportingProduct(p); }}
className="w-full bg-[#f8fafc] hover:bg-[#662582] text-slate-600 hover:text-white border border-slate-200 hover:border-[#662582] px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
>
<DownloadCloud size={12} />

View File

@@ -0,0 +1,296 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Product photography for the global catalogue.
*
* The API has always returned every image a product has — they come from an S3
* listing of `daily/brands/{brand}/{image_id}/image_NNN.*` — and the browse card
* rendered `images[0]` and nothing else. For nestle alone that is **90 of 123
* products with more than one photo**, up to ten, invisible on the screen where
* somebody decides what to stock.
*
* Two pieces, because the card and the detail view want opposite things: a card
* needs to hint that more exist without stealing the click that opens the
* product, and a detail view needs to actually show them.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { Box, ChevronLeft, ChevronRight, X } from 'lucide-react';
/**
* The image on a browse card, with the rest reachable.
*
* Every control calls `stopPropagation`: the card itself is a button that opens
* the import form, and paging through photos must not also submit the product.
*
* Renders as a plain image when there is one photo or none, so a catalogue of
* single-image products gains no chrome it cannot use.
*/
export function CardCarousel({
images,
alt,
}: {
images?: string[];
alt: string;
}) {
const [i, setI] = useState(0);
const list = images ?? [];
if (list.length === 0) {
return <Box className="w-16 h-16 text-slate-200" />;
}
const step = (e: React.MouseEvent, by: number) => {
e.stopPropagation();
e.preventDefault();
setI((n) => (n + by + list.length) % list.length);
};
return (
<>
<img
src={list[Math.min(i, list.length - 1)]}
alt={alt}
className="w-full h-full object-contain mix-blend-multiply group-hover:scale-110 transition-transform duration-700 ease-in-out"
/>
{list.length > 1 && (
<>
{/* Faint at rest and solid on hover rather than hidden until hover:
a touch device never hovers, and controls that only exist on a
mouse are controls half the users never find. */}
<button
onClick={(e) => step(e, -1)}
aria-label="Previous image"
className="absolute left-1 top-1/2 -translate-y-1/2 z-10 h-7 w-7 rounded-full bg-white/70 hover:bg-white text-slate-700 flex items-center justify-center shadow-sm opacity-40 group-hover:opacity-100 transition-opacity cursor-pointer"
>
<ChevronLeft size={15} />
</button>
<button
onClick={(e) => step(e, 1)}
aria-label="Next image"
className="absolute right-1 top-1/2 -translate-y-1/2 z-10 h-7 w-7 rounded-full bg-white/70 hover:bg-white text-slate-700 flex items-center justify-center shadow-sm opacity-40 group-hover:opacity-100 transition-opacity cursor-pointer"
>
<ChevronRight size={15} />
</button>
<div className="absolute bottom-1.5 left-0 right-0 z-10 flex items-center justify-center gap-1 pointer-events-none">
{list.slice(0, 8).map((_, n) => (
<span
key={n}
className="rounded-full transition-all"
style={{
width: n === i ? 12 : 5,
height: 5,
background: n === i ? '#662582' : '#cbd5e1',
}}
/>
))}
{list.length > 8 && (
<span className="text-[9px] font-bold ml-0.5" style={{ color: '#94a3b8' }}>
+{list.length - 8}
</span>
)}
</div>
<span
className="absolute top-1.5 right-1.5 z-10 rounded-full px-1.5 py-0.5 text-[9.5px] font-extrabold pointer-events-none"
style={{ background: 'rgba(255,255,255,0.85)', color: '#475569' }}
>
{i + 1}/{list.length}
</span>
</>
)}
</>
);
}
/**
* 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 (
<div className="fixed inset-0 z-[400] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-950/85" onClick={onClose} />
<button
onClick={onClose}
aria-label="Close"
className="absolute top-4 right-4 z-10 h-9 w-9 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center cursor-pointer"
>
<X size={18} />
</button>
<div className="relative z-10 flex flex-col items-center gap-3 max-w-full">
<img
src={images[index]}
alt={alt}
className="max-h-[75vh] max-w-[90vw] object-contain bg-white rounded-xl"
/>
{images.length > 1 && (
<>
<div className="flex items-center gap-3 text-white/80 text-[12px] font-semibold">
<button
onClick={() => move(-1)}
aria-label="Previous image"
className="h-8 w-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center cursor-pointer"
>
<ChevronLeft size={16} />
</button>
{index + 1} of {images.length}
<button
onClick={() => move(1)}
aria-label="Next image"
className="h-8 w-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center cursor-pointer"
>
<ChevronRight size={16} />
</button>
</div>
<div className="flex gap-2 overflow-x-auto max-w-[90vw] pb-1">
{images.map((src, n) => (
<img
key={n}
src={src}
alt=""
onClick={() => 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',
}}
/>
))}
</div>
</>
)}
</div>
</div>
);
}
/**
* 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<number | null>(null);
const list = (images ?? []).filter(Boolean);
const i = Math.min(at, Math.max(list.length - 1, 0));
if (list.length === 0) {
return (
<div className={`w-full ${height} bg-white rounded-2xl border border-slate-200 flex items-center justify-center`}>
<Box size={48} className="text-slate-300" />
</div>
);
}
return (
<div className="flex flex-col gap-2">
<div className={`w-full ${height} bg-white rounded-2xl border border-slate-200 overflow-hidden relative flex items-center justify-center p-4`}>
<img
src={list[i]}
alt={alt}
onClick={() => setZoom(i)}
className="w-auto h-full max-w-full object-contain mix-blend-multiply cursor-zoom-in"
/>
{list.length > 1 && (
<span
className="absolute top-2 right-2 rounded-full px-2 py-0.5 text-[10px] font-extrabold"
style={{ background: 'rgba(255,255,255,0.85)', color: '#475569' }}
>
{i + 1}/{list.length}
</span>
)}
</div>
{list.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{list.map((src, n) => (
<img
key={n}
src={src}
alt=""
// First click selects, a second on the same one zooms — the same
// two-step the import drawer uses, so a person who has learned it
// in one place is not surprised in the other.
onClick={() => (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',
}}
/>
))}
</div>
)}
{zoom !== null && (
<Lightbox images={list} index={zoom} alt={alt} onClose={() => setZoom(null)} onIndex={setZoom} />
)}
</div>
);
}

View File

@@ -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<string>('');
const [subcategoryId, setSubcategoryId] = useState<string>('');
const [retailPrice, setRetailPrice] = useState<string>('');
/**
* 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<string>(suggestedPrice);
const [productCost, setProductCost] = useState<string>('');
const [taxPercent, setTaxPercent] = useState<string>('0');
/** Which photo the full-size viewer is showing, or null when it is closed. */
const [lightboxAt, setLightboxAt] = useState<number | null>(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 */}
<div className="w-full h-64 bg-white rounded-2xl border border-slate-200 overflow-hidden relative flex items-center justify-center p-4 shadow-xs">
{product.images && product.images.length > 0 ? (
<img src={product.images[0]} alt={product.product_name} className="w-auto h-full max-w-full object-contain mix-blend-multiply" />
<img
src={product.images[heroAt] ?? product.images[0]}
alt={product.product_name}
onClick={() => setLightboxAt(heroAt)}
className="w-auto h-full max-w-full object-contain mix-blend-multiply cursor-zoom-in"
/>
) : (
<Box size={48} className="text-slate-300" />
)}
@@ -116,6 +183,100 @@ export default function ImportProductModal({
)}
</div>
{/*
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 && (
<div className="mb-4">
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-widest block mb-1">Description</span>
<p className="text-[12.5px] text-slate-600 leading-relaxed">{product.description}</p>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
{Array.isArray(product.highlights) && product.highlights.length > 0 && (
<div>
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-widest block mb-1">Highlights</span>
<ul className="space-y-0.5">
{product.highlights.map((h: string, i: number) => (
<li key={i} className="text-[11.5px] text-slate-600 leading-snug">• {h}</li>
))}
</ul>
</div>
)}
{Array.isArray(product.nutrients) && product.nutrients.length > 0 && (
<div>
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-widest block mb-1">Nutrition</span>
<ul className="space-y-0.5">
{product.nutrients.map((n: string, i: number) => (
<li key={i} className="text-[11.5px] text-slate-600 leading-snug">• {n}</li>
))}
</ul>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2 mb-5">
{product.category && (
<span className="px-2.5 py-1 rounded-lg bg-white text-slate-600 text-[11px] font-bold border border-slate-200">
{product.category}
</span>
)}
{product.product_sku && (
<span className="px-2.5 py-1 rounded-lg bg-white text-slate-600 text-[11px] font-mono border border-slate-200">
SKU {product.product_sku}
</span>
)}
{product.fssai_license && (
<span className="px-2.5 py-1 rounded-lg bg-white text-slate-600 text-[11px] font-mono border border-slate-200">
FSSAI {product.fssai_license}
</span>
)}
{Array.isArray(product.providers) && product.providers.length > 0 && (
<span className="px-2.5 py-1 rounded-lg bg-white text-slate-500 text-[11px] font-medium border border-slate-200">
Sold on {product.providers.join(', ')}
</span>
)}
</div>
{/* 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 && (
<div className="mb-5">
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-widest block mb-1.5">
{product.images.length} images — only the first is imported
</span>
<div className="flex gap-2 overflow-x-auto pb-1">
{product.images.map((src: string, i: number) => (
<img
key={i}
src={src}
alt={`${product.product_name} — image ${i + 1}`}
onClick={() => (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 }}
/>
))}
</div>
</div>
)}
{lightboxAt !== null && Array.isArray(product.images) && (
<Lightbox
images={product.images}
index={lightboxAt}
alt={product.product_name}
onClose={() => setLightboxAt(null)}
onIndex={setLightboxAt}
/>
)}
{/* Import Action Container */}
<div className="bg-slate-50/80 rounded-2xl border border-slate-200 p-5 space-y-4 shadow-xs">
{isImported ? (

View File

@@ -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<ProductMatrixItem | null>(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<string, string[]>();
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<string>('');
const [isSettingPrice, setIsSettingPrice] = useState(false);
@@ -589,11 +605,13 @@ export default function InventoryView({
<div key={prod.id} onClick={() => 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 */}
<div className="w-full h-48 bg-white p-4 relative overflow-hidden shrink-0 border-b border-slate-100 flex items-center justify-center">
<img
src={prod.image}
alt={prod.name}
referrerPolicy="no-referrer"
className="w-full h-full object-contain mix-blend-multiply group-hover:scale-110 transition-transform duration-700 ease-in-out"
{/* Every photo the product has. ProductMatrixItem
carries one `image` and is built by two mappers,
so the gallery is looked up by id — see
imagesByProduct. */}
<CardCarousel
images={imagesByProduct.get(prod.id) ?? [prod.image]}
alt={prod.name}
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
{prod.isSample && (
@@ -906,10 +924,20 @@ export default function InventoryView({
return (
<div className="flex flex-col gap-8 pb-8">
{/* Clean Image Container */}
<div className="w-full h-64 bg-white rounded-2xl border border-slate-200 overflow-hidden relative flex items-center justify-center p-4">
<img src={selectedAdminProduct.image} alt={selectedAdminProduct.name} className="w-auto h-full max-w-full object-contain mix-blend-multiply" />
</div>
{/* 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. */}
<ProductGallery
// Resolved from the catalogue row rather than from
// ProductMatrixItem, which carries a single `image` and is built
// by two different mappers — teaching both about a gallery to
// reach one drawer is more surface than this earns. Falls back to
// that single image, which is all a product has until the
// productimages backfill runs.
images={imagesByProduct.get(selectedAdminProduct.id) ?? [selectedAdminProduct.image]}
alt={selectedAdminProduct.name}
/>
{/* Title & Basics */}
<div>

View File

@@ -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',
<div key={p.id} onClick={() => 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 */}
<div className="w-full h-48 bg-white p-4 relative overflow-hidden shrink-0 border-b border-slate-100 flex items-center justify-center">
<img src={p.image} alt={p.name} referrerPolicy="no-referrer" className="w-full h-full object-contain mix-blend-multiply group-hover:scale-110 transition-transform duration-700 ease-in-out" />
{/* Every photo, not just the first. Controls stop
propagation so paging does not also open the drawer. */}
<CardCarousel images={p.images?.length ? p.images : [p.image]} alt={p.name} />
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
</div>
@@ -925,10 +936,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
return (
<div className="flex flex-col gap-8 pb-8">
{/* Clean Image Container */}
<div className="w-full h-64 bg-white rounded-2xl border border-slate-200 overflow-hidden relative flex items-center justify-center p-4">
<img src={selectedProduct.image} alt={selectedProduct.name} className="w-auto h-full max-w-full object-contain mix-blend-multiply" />
</div>
{/* 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. */}
<ProductGallery
images={selectedProduct.images?.length ? selectedProduct.images : [selectedProduct.image]}
alt={selectedProduct.name}
/>
{/* Title & Basics */}
<div>

View File

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

View File

@@ -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;
}

View File

@@ -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"