+ {/* 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. */}
+
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 */}
-

+ {/* 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 */}
-
-

-
+ {/* 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"