product images added
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
296
src/components/CatalogueGallery.tsx
Normal file
296
src/components/CatalogueGallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user