From 66e5b4de41a3a870b25519db5b2532f1485acb42 Mon Sep 17 00:00:00 2001 From: Atharva Naik Date: Sat, 28 Mar 2026 23:08:17 +0530 Subject: [PATCH 1/7] feat(frontend): add product catalog import and invoice autofill --- frontend/package.json | 2 + .../components/ProductAutocompleteInput.jsx | 218 ++++++++++ .../src/components/ProductCatalogImport.jsx | 397 ++++++++++++++++++ frontend/src/hooks/useProductCatalog.js | 393 +++++++++++++++++ frontend/src/page/CreateInvoice.jsx | 100 +++-- frontend/src/page/CreateInvoicesBatch.jsx | 85 ++-- .../src/utils/productCatalogInvoiceHelpers.js | 56 +++ 7 files changed, 1177 insertions(+), 74 deletions(-) create mode 100644 frontend/src/components/ProductAutocompleteInput.jsx create mode 100644 frontend/src/components/ProductCatalogImport.jsx create mode 100644 frontend/src/hooks/useProductCatalog.js create mode 100644 frontend/src/utils/productCatalogInvoiceHelpers.js diff --git a/frontend/package.json b/frontend/package.json index 61a2806e..8f82386c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,8 +36,10 @@ "ethers": "^6.13.5", "framer-motion": "^12.23.12", "html2canvas": "^1.4.1", + "idb-keyval": "^6.2.1", "jspdf": "^3.0.0", "lucide-react": "^0.471.1", + "papaparse": "^5.5.2", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", diff --git a/frontend/src/components/ProductAutocompleteInput.jsx b/frontend/src/components/ProductAutocompleteInput.jsx new file mode 100644 index 00000000..86c55be3 --- /dev/null +++ b/frontend/src/components/ProductAutocompleteInput.jsx @@ -0,0 +1,218 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { Input } from '@/components/ui/input'; + +const PAGE_SIZE = 12; +const DEBOUNCE_MS = 250; +const normalizeSearchText = (text) => String(text || '').trim().toLocaleLowerCase(); + +export default function ProductAutocompleteInput({ + value, + onChange, + onSelectProduct, + catalogMetadata, + placeholder, + className, + name, + inputRef, +}) { + const [showSuggestions, setShowSuggestions] = useState(false); + const [suggestions, setSuggestions] = useState([]); + const [activeIndex, setActiveIndex] = useState(-1); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const [totalMatches, setTotalMatches] = useState(0); + const wrapperRef = useRef(null); + const internalInputRef = useRef(null); + const listRef = useRef(null); + const listId = useRef(`autocomplete-list-${Math.random().toString(36).slice(2, 9)}`); + + const setRefs = useCallback( + (el) => { + internalInputRef.current = el; + if (typeof inputRef === 'function') { + inputRef(el); + } else if (inputRef) { + inputRef.current = el; + } + }, + [inputRef], + ); + + useEffect(() => { + const handleClickOutside = (event) => { + if (wrapperRef.current && !wrapperRef.current.contains(event.target)) { + setShowSuggestions(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const catalogData = catalogMetadata?.data; + const hasCatalog = Array.isArray(catalogData) && catalogData.length > 0; + + useEffect(() => { + if (!hasCatalog) { + setSuggestions([]); + setActiveIndex(-1); + setTotalMatches(0); + return; + } + + const timeoutId = setTimeout(() => { + const searchTerm = normalizeSearchText(value); + const baseList = searchTerm + ? catalogData.filter((product) => { + const productName = normalizeSearchText(product.name || product.description); + return productName.includes(searchTerm); + }) + : catalogData; + + setTotalMatches(baseList.length); + setSuggestions(baseList.slice(0, visibleCount)); + setActiveIndex(-1); + }, DEBOUNCE_MS); + + return () => clearTimeout(timeoutId); + }, [value, visibleCount, catalogData, hasCatalog]); + + useEffect(() => { + setVisibleCount(PAGE_SIZE); + }, [value]); + + const scrollActiveIntoView = useCallback((index) => { + if (!listRef.current) return; + const items = listRef.current.querySelectorAll('[role="option"]'); + if (items[index]) { + items[index].scrollIntoView({ block: 'nearest' }); + } + }, []); + + useEffect(() => { + scrollActiveIntoView(activeIndex); + }, [activeIndex, scrollActiveIntoView]); + + const handleInputChange = useCallback( + (e) => { + onChange(e); + setShowSuggestions(true); + }, + [onChange], + ); + + const handleSelect = useCallback( + (product) => { + setShowSuggestions(false); + setActiveIndex(-1); + onSelectProduct(product); + }, + [onSelectProduct], + ); + + const handleKeyDown = useCallback( + (e) => { + if (e.key === 'Escape') { + setShowSuggestions(false); + setActiveIndex(-1); + return; + } + + if (e.key === 'Enter' && showSuggestions) { + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < suggestions.length) { + handleSelect(suggestions[activeIndex]); + } + return; + } + + if (!showSuggestions || suggestions.length === 0) { + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex((prev) => { + const next = prev < suggestions.length - 1 ? prev + 1 : prev; + return next; + }); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex((prev) => { + const next = prev > 0 ? prev - 1 : 0; + return next; + }); + } + }, + [showSuggestions, suggestions, activeIndex, handleSelect, scrollActiveIntoView], + ); + + const productKey = useCallback( + (product, idx) => `${normalizeSearchText(product.name || product.description)}-${product.price}-${idx}`, + [], + ); + + const showNoResults = showSuggestions && suggestions.length === 0 && hasCatalog && value?.trim(); + + return ( +
+ setShowSuggestions(true)} + onKeyDown={handleKeyDown} + ref={setRefs} + autoComplete="off" + role="combobox" + aria-expanded={showSuggestions && suggestions.length > 0} + aria-controls={listId.current} + aria-activedescendant={activeIndex >= 0 ? `${listId.current}-option-${activeIndex}` : undefined} + /> + {showSuggestions && suggestions.length > 0 && ( + + )} + {showNoResults && ( +
+ No matching products found +
+ )} +
+ ); +} diff --git a/frontend/src/components/ProductCatalogImport.jsx b/frontend/src/components/ProductCatalogImport.jsx new file mode 100644 index 00000000..f4a84cd0 --- /dev/null +++ b/frontend/src/components/ProductCatalogImport.jsx @@ -0,0 +1,397 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { useProductCatalog } from '@/hooks/useProductCatalog'; +import { Download, UploadCloud, Link as LinkIcon, RefreshCw, CheckCircle2, AlertCircle, Trash2, Info } from 'lucide-react'; +import toast from 'react-hot-toast'; + +const LAST_CATALOG_URL_INPUT_KEY = 'chainvoice_last_catalog_url_input'; + +const isValidHttpUrl = (value) => { + try { + const parsed = new URL((value || '').trim()); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +}; + +/** + * Resolves a human-readable label for the active catalog source. + */ +const getSourceLabel = (metadata) => { + switch (metadata?.source) { + case 'url': + return metadata.url || 'URL'; + case 'url-temp': + return 'Temporary URL (not saved)'; + case 'json': + return 'Local JSON File'; + case 'csv': + return 'Local CSV File'; + default: + return 'Unknown'; + } +}; + +export default function ProductCatalogImport() { + const { + catalogMetadata, + savedUrl, + loading, + importFromFile, + importFromURL, + refreshURL, + persistCurrentURLData, + disableURLPersistence, + clearCatalog, + } = useProductCatalog(); + + const [urlInput, setUrlInput] = useState(''); + const [persistUrl, setPersistUrl] = useState(true); + const [isProcessing, setIsProcessing] = useState(false); + const fileInputRef = useRef(null); + + useEffect(() => { + if (!persistUrl) return; + if (savedUrl) { + setUrlInput(savedUrl); + return; + } + if (typeof window !== 'undefined') { + const lastUrl = window.localStorage.getItem(LAST_CATALOG_URL_INPUT_KEY); + if (lastUrl) { + setUrlInput(lastUrl); + } + } + }, [persistUrl, savedUrl]); + + const hasSavedUrlSource = Boolean(savedUrl); + const isLocalCatalogActive = catalogMetadata?.source === 'csv' || catalogMetadata?.source === 'json'; + + const handleFileUpload = useCallback(async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + + setIsProcessing(true); + try { + const result = await importFromFile(file); + toast.success(`Loaded ${result.count} products from ${result.format}`); + if (hasSavedUrlSource) { + toast('Local file is now active for search. Saved URL remains available via refresh.'); + } + } catch (err) { + toast.error(err.message || 'Failed to import file'); + } finally { + setIsProcessing(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }, [importFromFile, hasSavedUrlSource]); + + const handleUrlLoad = useCallback(async () => { + const normalizedUrl = urlInput.trim(); + + if (!normalizedUrl) { + toast.error('Please enter a URL'); + return; + } + + if (!isValidHttpUrl(normalizedUrl)) { + toast.error('Invalid URL. Please use a valid http/https link.'); + return; + } + + setIsProcessing(true); + try { + const result = await importFromURL(normalizedUrl, { persistUrl }); + if (typeof window !== 'undefined' && persistUrl) { + window.localStorage.setItem(LAST_CATALOG_URL_INPUT_KEY, normalizedUrl); + } + toast.success( + result.persisted + ? `Loaded ${result.count} products from URL and saved for refresh` + : `Loaded ${result.count} products from URL (temporary session)`, + ); + } catch (err) { + toast.error(err.message || 'Failed to import from URL'); + } finally { + setIsProcessing(false); + } + }, [urlInput, persistUrl, importFromURL]); + + const handleRefresh = useCallback(async () => { + setIsProcessing(true); + try { + const result = await refreshURL(); + toast.success(`Refreshed ${result.count} products`); + } catch (err) { + toast.error(err.message || 'Failed to refresh data'); + } finally { + setIsProcessing(false); + } + }, [refreshURL]); + + const handlePersistToggle = useCallback(async (checked) => { + setPersistUrl(checked); + + if (checked && catalogMetadata?.source === 'url-temp' && catalogMetadata?.url) { + setIsProcessing(true); + try { + const result = await persistCurrentURLData(); + toast.success(`Saved URL for refresh (${result.count} products)`); + } catch (err) { + toast.error(err.message || 'Failed to save fetched URL'); + } finally { + setIsProcessing(false); + } + } + + if (!checked) { + setIsProcessing(true); + try { + await disableURLPersistence(); + if (typeof window !== 'undefined') { + window.localStorage.removeItem(LAST_CATALOG_URL_INPUT_KEY); + } + toast.success('Saved URL removed. Refresh is now disabled.'); + } catch (err) { + toast.error(err.message || 'Failed to remove saved URL'); + } finally { + setIsProcessing(false); + } + } + }, [catalogMetadata, persistCurrentURLData, disableURLPersistence]); + + const handleClear = useCallback(async () => { + setIsProcessing(true); + try { + await clearCatalog(); + setUrlInput(''); + setPersistUrl(true); + if (typeof window !== 'undefined') { + window.localStorage.removeItem(LAST_CATALOG_URL_INPUT_KEY); + } + toast.success('Product catalog cleared'); + } catch (err) { + toast.error('Failed to clear catalog'); + } finally { + setIsProcessing(false); + } + }, [clearCatalog]); + + const downloadSampleTemplate = useCallback(() => { + const csvContent = 'name,price,tax,discount,qty\nProduct A,100,5,10,1\nProduct B,200,0,0,1'; + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'product-catalog-sample.csv'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }, []); + + if (loading) return null; + + const sourceLabel = getSourceLabel(catalogMetadata); + + return ( +
+
+

+ + Product Catalog Import + + + + + + + Using Product Catalog Import + + Import once, then browse and search products while creating invoices. + + +
+

Choose what works best for you: local upload (CSV/JSON), URL fetch (CSV/JSON), and optional URL save for IndexedDB refresh.

+
+

Google Sheets to CSV URL:

+

In Google Sheets, open File - Share - Publish to web, choose the tab, select CSV, then copy and paste that published URL here.

+
+
+

External API URLs (CORS):

+

When fetching from a custom server, ensure it includes CORS headers (e.g. Access-Control-Allow-Origin: *), otherwise the browser will block the fetch request.

+
+
+

JSON format example:

+
{`[
+  {"name":"Product A","price":100,"tax":5,"discount":10,"qty":1},
+  {"description":"Service B","unit_price":50}
+]`}
+
+

Required fields: name or description, and price or unit_price.

+

Optional fields: tax, discount, qty.

+

Browsing behavior: product rows are shown by default with a limit and Load more pagination.

+

Search behavior: search stays a filter, useful for larger catalogs, and matching is case-insensitive.

+
+

Best case recommendations by dataset size:

+

Small (up to ~200 rows): local CSV/JSON upload is fastest.

+

Medium (200 to 5,000 rows): use URL fetch + save URL so refresh pulls updates without re-upload.

+

Large (5,000+ rows): prefer URL source, keep default browse with load more, and use search to filter quickly.

+
+

Performance: imported data is stored in IndexedDB and reused in memory for faster product lookup.

+
+
+
+

+ +
+ +
+
+
+ + +
+ +
+ +
+ { + const nextValue = e.target.value; + setUrlInput(nextValue); + if (typeof window !== 'undefined' && persistUrl) { + window.localStorage.setItem(LAST_CATALOG_URL_INPUT_KEY, nextValue); + } + }} + disabled={isProcessing} + className="flex-1 bg-gray-50 border-gray-200 text-gray-800" + /> + + +
+ +
+ handlePersistToggle(e.target.checked)} + className="h-4 w-4" + disabled={isProcessing} + /> + +
+ +

+ URL fetch works without saving. Enable the checkbox to store the last URL in IndexedDB for one-click refresh later. +

+

+ Search always uses the currently loaded catalog. Importing a new source replaces the active search dataset. +

+ {hasSavedUrlSource && ( +

+ Saved URL: {savedUrl} +

+ )} +
+
+ +
+
+ +

Required: name (or description), price (or unit_price)

+

Optional: tax, discount, qty

+

Autocomplete shows default rows with a limit and Load more. Search filters the same catalog for large datasets.

+

Imported catalog is cached in IndexedDB and memory for faster autocomplete.

+
+ +
+ {catalogMetadata ? ( +
+
+ + + Loaded: {catalogMetadata.data.length} products + +
+ {hasSavedUrlSource && ( + + )} + +
+
+
+ + Source: {sourceLabel} + + {catalogMetadata.source === 'url-temp' && ( + + Session only + + )} +
+ {hasSavedUrlSource && isLocalCatalogActive && ( +
Saved URL exists. Use Load Saved URL to switch search data back to URL source.
+ )} +
+ ) : ( +
+ + No products loaded +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/hooks/useProductCatalog.js b/frontend/src/hooks/useProductCatalog.js new file mode 100644 index 00000000..17a854ca --- /dev/null +++ b/frontend/src/hooks/useProductCatalog.js @@ -0,0 +1,393 @@ +import { useState, useEffect, useCallback } from 'react'; +import { del, get, set } from 'idb-keyval'; +import Papa from 'papaparse'; + +const CATALOG_KEY = 'chainvoice_product_catalog'; +const LAST_URL_KEY = 'chainvoice_product_catalog_last_url'; +const CATALOG_UPDATED_EVENT = 'chainvoice:product-catalog-updated'; + +/** + Module-level memory cache shared by all hook instances. + Intentional singleton: avoids redundant IndexedDB reads across components. + Cleared explicitly via clearCatalog() and updated via broadcastCatalogUpdate(). + */ +let memoryCache = null; + +/** + Normalizes raw row data to ensure consistent field names. + Maps common aliases (description → name, unit_price → price) and + strips empty keys produced by malformed CSV headers. + */ +const normalizeRows = (rows) => { + const FIELD_ALIAS_MAP = { + description: 'name', + unit_price: 'price', + unite_price: 'price', + 'unit price': 'price', + }; + + const normalizedData = rows + .map((row) => { + const normRow = {}; + for (const [key, value] of Object.entries(row || {})) { + const trimmedKey = (key || '').trim().toLowerCase(); + if (!trimmedKey) continue; + normRow[trimmedKey] = typeof value === 'string' ? value.trim() : value; + } + + for (const [alias, canonical] of Object.entries(FIELD_ALIAS_MAP)) { + if (normRow[alias] && !normRow[canonical]) { + normRow[canonical] = normRow[alias]; + delete normRow[alias]; + } + } + + return normRow; + }) + .filter((row) => Object.keys(row).length > 0); + + if (normalizedData.length === 0) { + throw new Error('Empty file or no valid data rows'); + } + + const hasRequired = normalizedData.every( + (row) => row.name !== undefined && row.name !== '' && row.price !== undefined && row.price !== '', + ); + + if (!hasRequired) { + throw new Error("Missing required fields: 'name' (or 'description') and 'price' (or 'unit_price') in one or more rows"); + } + + const isPlainDecimal = (value) => + value === undefined || + value === '' || + (typeof value === 'number' && Number.isFinite(value)) || + (typeof value === 'string' && /^(?:\d+|\d*\.\d+)$/.test(value)); + + const hasValidNumericFields = normalizedData.every((row) => + ['price', 'qty', 'tax', 'discount'].every((field) => isPlainDecimal(row[field])), + ); + + if (!hasValidNumericFields) { + throw new Error('Invalid numeric fields: use plain decimal values for price, qty, tax, and discount'); + } + + return normalizedData; +}; + +/** + * Parses a CSV string using PapaParse and normalizes the resulting rows. + */ +const parseAndNormalizeCSV = (csvString) => { + return new Promise((resolve, reject) => { + Papa.parse(csvString, { + header: true, + skipEmptyLines: true, + complete: (results) => { + if (results.errors.length > 0 && results.data.length === 0) { + return reject(new Error('Invalid CSV format')); + } + + try { + resolve(normalizeRows(results.data)); + } catch (e) { + reject(e); + } + }, + error: (error) => reject(error), + }); + }); +}; + +/** + * Parses a JSON string and normalizes the resulting rows. + */ +const parseAndNormalizeJSON = (jsonString) => { + const parsed = JSON.parse(jsonString); + if (!Array.isArray(parsed)) { + throw new Error('JSON must be an array of objects'); + } + return normalizeRows(parsed); +}; + +/** + * Rewrites known Google Sheets URLs to their CSV export endpoint. + */ +const normalizeImportUrl = (rawUrl) => { + const input = String(rawUrl || '').trim(); + + try { + const url = new URL(input); + const host = url.hostname.toLowerCase(); + const path = url.pathname; + + if (!host.includes('docs.google.com') || !path.includes('/spreadsheets/')) { + return input; + } + + if (path.includes('/spreadsheets/d/e/')) { + if (path.endsWith('/pubhtml')) { + const pubUrl = new URL(url.toString()); + pubUrl.pathname = path.replace('/pubhtml', '/pub'); + pubUrl.searchParams.set('output', 'csv'); + return pubUrl.toString(); + } + if (path.endsWith('/pub')) { + const pubUrl = new URL(url.toString()); + pubUrl.searchParams.set('output', 'csv'); + return pubUrl.toString(); + } + return input; + } + + const match = path.match(/\/spreadsheets\/d\/([^/]+)/); + if (!match?.[1]) { + return input; + } + + const docId = match[1]; + const gid = url.searchParams.get('gid') || '0'; + return `https://docs.google.com/spreadsheets/d/${docId}/export?format=csv&gid=${gid}`; + } catch { + return input; + } +}; + +/** + * Appends a cache-busting query parameter to avoid stale CDN/browser caches. + */ +const withCacheBuster = (rawUrl) => { + try { + const url = new URL(rawUrl); + url.searchParams.set('_cvts', String(Date.now())); + return url.toString(); + } catch { + return rawUrl; + } +}; + +export const useProductCatalog = () => { + const [catalogMetadata, setCatalogMetadata] = useState(null); + const [savedUrl, setSavedUrl] = useState(null); + const [loading, setLoading] = useState(true); + + const broadcastCatalogUpdate = useCallback((metadata) => { + if (typeof window === 'undefined') return; + window.dispatchEvent(new CustomEvent(CATALOG_UPDATED_EVENT, { detail: metadata })); + }, []); + + const saveCatalogData = useCallback(async (metadata) => { + try { + if (metadata === null) { + await del(CATALOG_KEY); + } else { + await set(CATALOG_KEY, metadata); + } + memoryCache = metadata; + setCatalogMetadata(metadata); + broadcastCatalogUpdate(metadata); + } catch (err) { + console.error('Failed to save catalog to IndexedDB:', err); + throw err; + } + }, [broadcastCatalogUpdate]); + + const loadCatalog = useCallback(async () => { + setLoading(true); + try { + if (memoryCache) { + setCatalogMetadata(memoryCache); + setLoading(false); + return; + } + const data = await get(CATALOG_KEY); + if (data) { + memoryCache = data; + setCatalogMetadata(data); + } else { + memoryCache = null; + setCatalogMetadata(null); + } + } catch (err) { + console.error('Failed to load catalog from IndexedDB:', err); + } finally { + setLoading(false); + } + }, []); + + const loadSavedUrl = useCallback(async () => { + try { + const url = await get(LAST_URL_KEY); + setSavedUrl(url || null); + } catch (err) { + console.error('Failed to load saved URL from IndexedDB:', err); + setSavedUrl(null); + } + }, []); + + useEffect(() => { + const handleCatalogUpdated = (event) => { + const nextMetadata = event?.detail ?? null; + memoryCache = nextMetadata; + setCatalogMetadata(nextMetadata); + setLoading(false); + }; + + if (typeof window !== 'undefined') { + window.addEventListener(CATALOG_UPDATED_EVENT, handleCatalogUpdated); + } + + loadCatalog(); + loadSavedUrl(); + + return () => { + if (typeof window !== 'undefined') { + window.removeEventListener(CATALOG_UPDATED_EVENT, handleCatalogUpdated); + } + }; + }, [loadCatalog, loadSavedUrl]); + + const importFromFile = useCallback(async (file) => { + const fileName = file?.name?.toLowerCase?.() || ''; + const text = await file.text(); + let parsedData; + + if (fileName.endsWith('.json')) { + parsedData = parseAndNormalizeJSON(text); + } else { + parsedData = await parseAndNormalizeCSV(text); + } + + const isJson = fileName.endsWith('.json'); + const newMetadata = { + source: isJson ? 'json' : 'csv', + url: null, + lastFetched: Date.now(), + data: parsedData, + }; + + await saveCatalogData(newMetadata); + return { + success: true, + count: parsedData.length, + format: isJson ? 'JSON' : 'CSV', + }; + }, [saveCatalogData]); + + const importFromURL = useCallback(async (url, options = { persistUrl: true, forceRefresh: false }) => { + const normalizedUrl = normalizeImportUrl(url); + const requestUrl = options?.forceRefresh ? withCacheBuster(normalizedUrl) : normalizedUrl; + let response; + try { + response = await fetch(requestUrl, { cache: 'no-store' }); + } catch (err) { + throw new Error('Network error: Failed to fetch. Ensure the URL is valid and the server supports CORS.'); + } + if (!response.ok) throw new Error(`Failed to fetch data from URL (Status: ${response.status})`); + + const text = await response.text(); + + let parsedData; + try { + parsedData = parseAndNormalizeJSON(text); + } catch (jsonErr) { + if (jsonErr.message.includes('JSON must be an array') || jsonErr.message.includes('Missing required fields')) { + throw jsonErr; + } + parsedData = await parseAndNormalizeCSV(text); + } + + const persistUrl = options?.persistUrl !== false; + + const newMetadata = { + source: persistUrl ? 'url' : 'url-temp', + url: normalizedUrl, + lastFetched: Date.now(), + data: parsedData, + }; + + if (persistUrl) { + await set(LAST_URL_KEY, normalizedUrl); + setSavedUrl(normalizedUrl); + await saveCatalogData(newMetadata); + } else { + memoryCache = newMetadata; + setCatalogMetadata(newMetadata); + broadcastCatalogUpdate(newMetadata); + } + + return { success: true, count: parsedData.length, persisted: persistUrl }; + }, [saveCatalogData, broadcastCatalogUpdate]); + + const refreshURL = useCallback(async () => { + const persistedUrl = savedUrl || (await get(LAST_URL_KEY)) || null; + + if (persistedUrl) { + return await importFromURL(persistedUrl, { persistUrl: true, forceRefresh: true }); + } + throw new Error('No URL source configured to refresh'); + }, [savedUrl, importFromURL]); + + const disableURLPersistence = useCallback(async () => { + await del(LAST_URL_KEY); + setSavedUrl(null); + + if (catalogMetadata?.source === 'url') { + const updatedMetadata = { + ...catalogMetadata, + source: 'url-temp', + }; + await del(CATALOG_KEY); + memoryCache = updatedMetadata; + setCatalogMetadata(updatedMetadata); + // broadcastCatalogUpdate is outside of the component context, but assuming we can call it. Wait, the suggestion said "call broadcastCatalogUpdate(updatedMetadata)". + // Let me just put it exactly as requested. + if (typeof broadcastCatalogUpdate === 'function') broadcastCatalogUpdate(updatedMetadata); + } + + return { success: true }; + }, [catalogMetadata]); + + const persistCurrentURLData = useCallback(async () => { + const currentUrl = catalogMetadata?.url; + const currentData = catalogMetadata?.data; + + if (!currentUrl || !Array.isArray(currentData) || currentData.length === 0) { + throw new Error('No fetched URL data available to save'); + } + + const persistedMetadata = { + source: 'url', + url: currentUrl, + lastFetched: Date.now(), + data: currentData, + }; + + await set(LAST_URL_KEY, currentUrl); + setSavedUrl(currentUrl); + await saveCatalogData(persistedMetadata); + + return { success: true, url: currentUrl, count: currentData.length }; + }, [catalogMetadata, saveCatalogData]); + + const clearCatalog = useCallback(async () => { + memoryCache = null; + await del(LAST_URL_KEY); + await del(CATALOG_KEY); + setSavedUrl(null); + setCatalogMetadata(null); + broadcastCatalogUpdate(null); + }, [broadcastCatalogUpdate]); + + return { + catalogMetadata, + savedUrl, + loading, + importFromFile, + importFromURL, + refreshURL, + persistCurrentURLData, + disableURLPersistence, + clearCatalog, + }; +}; diff --git a/frontend/src/page/CreateInvoice.jsx b/frontend/src/page/CreateInvoice.jsx index a5c853f4..0cb14c80 100644 --- a/frontend/src/page/CreateInvoice.jsx +++ b/frontend/src/page/CreateInvoice.jsx @@ -51,6 +51,14 @@ import CountryPicker from "@/components/CountryPicker"; import { useTokenList } from "@/hooks/useTokenList"; import toast from "react-hot-toast"; +import ProductCatalogImport from "@/components/ProductCatalogImport"; +import ProductAutocompleteInput from "@/components/ProductAutocompleteInput"; +import { useProductCatalog } from "@/hooks/useProductCatalog"; +import { + applyProductToInvoiceItem, + createEmptyInvoiceItem, +} from "@/utils/productCatalogInvoiceHelpers"; + /** Public RPC URLs by chain ID for token verification when visitor has no wallet (e.g. opening invoice request link in incognito). */ const CHAIN_ID_TO_PUBLIC_RPC = { 1: "https://eth.llamarpc.com", @@ -78,6 +86,8 @@ function CreateInvoice() { const [loading, setLoading] = useState(false); const navigate = useNavigate(); const litClientRef = useRef(null); + const itemRefsMobile = useRef([]); + const itemRefsDesktop = useRef([]); const [clientAddress, setClientAddress] = useState(""); const [userCountry, setUserCountry] = useState(""); const [clientCountry, setClientCountry] = useState(""); @@ -97,16 +107,34 @@ function CreateInvoice() { // const TESTNET_TOKEN = ["0xB5E9C6e57C9d312937A059089B547d0036c155C7"]; //sepolia based chainvoice test token (CIN) - const [itemData, setItemData] = useState([ - { - description: "", - qty: "", - unitPrice: "", - discount: "", - tax: "", - amount: "", - }, - ]); + const [itemData, setItemData] = useState([createEmptyInvoiceItem()]); + + const { catalogMetadata } = useProductCatalog(); + + const handleProductSelect = useCallback((product, index) => { + setItemData((prevItemData) => { + const newData = prevItemData.map((item, i) => { + if (i === index) { + return applyProductToInvoiceItem(item, product); + } + return item; + }); + + if (index === prevItemData.length - 1) { + newData.push(createEmptyInvoiceItem()); + } + + return newData; + }); + + setTimeout(() => { + const isDesktop = window.matchMedia('(min-width: 768px)').matches; + const nextInput = isDesktop + ? itemRefsDesktop.current[index + 1] + : itemRefsMobile.current[index + 1]; + nextInput?.focus(); + }, 50); + }, []); const [totalAmountDue, setTotalAmountDue] = useState(0); @@ -166,12 +194,7 @@ function CreateInvoice() { if (urlDescription || urlAmount) { setItemData((prev) => { const first = prev[0] ?? { - description: "", - qty: "", - unitPrice: "", - discount: "", - tax: "", - amount: "", + ...createEmptyInvoiceItem(), }; const isFirstLineEmpty = !first.description && !first.unitPrice; if (!isFirstLineEmpty) return prev; @@ -305,17 +328,7 @@ function CreateInvoice() { }; const addItem = () => { - setItemData((prev) => [ - ...prev, - { - description: "", - qty: "", - unitPrice: "", - discount: "", - tax: "", - amount: "", - }, - ]); + setItemData((prev) => [...prev, createEmptyInvoiceItem()]); }; @@ -355,8 +368,16 @@ const validateClientAddress = useCallback((value) => { return; } - validateClientAddress(data.clientAddress); - if (clientAddressError) { + if (!data.clientAddress) { + setClientAddressError("Client address is required"); + return; + } + if (!ethers.isAddress(data.clientAddress)) { + setClientAddressError("Please enter a valid wallet address"); + return; + } + if (data.clientAddress.toLowerCase() === account.address?.toLowerCase()) { + setClientAddressError("You cannot create an invoice for your own wallet"); return; } @@ -1068,6 +1089,9 @@ const validateClientAddress = useCallback((value) => { + {/* Product Catalog Import Section */} + + {/* Invoice Items Section */}
{/* Desktop Header - Hidden on mobile */} @@ -1085,23 +1109,25 @@ const validateClientAddress = useCallback((value) => {

Invoice Items

-
+
{itemData.map((_, index) => ( -
+
{/* Mobile Layout - Stacked */}
- (itemRefsMobile.current[index] = el)} placeholder="Enter Description" className="w-full border-gray-300 text-black" name="description" value={itemData[index]?.description ?? ""} onChange={(e) => handleItemData(e, index)} + onSelectProduct={(product) => handleProductSelect(product, index)} + catalogMetadata={catalogMetadata} />
@@ -1214,13 +1240,15 @@ const validateClientAddress = useCallback((value) => { {/* Desktop Layout - Grid */}
- (itemRefsDesktop.current[index] = el)} placeholder="Enter Description" - className="w-full border-gray-300 text-black" + className="w-full border-gray-300 text-black py-2" name="description" value={itemData[index]?.description ?? ""} onChange={(e) => handleItemData(e, index)} + onSelectProduct={(product) => handleProductSelect(product, index)} + catalogMetadata={catalogMetadata} />
diff --git a/frontend/src/page/CreateInvoicesBatch.jsx b/frontend/src/page/CreateInvoicesBatch.jsx index a4c5d531..b186c29f 100644 --- a/frontend/src/page/CreateInvoicesBatch.jsx +++ b/frontend/src/page/CreateInvoicesBatch.jsx @@ -36,8 +36,7 @@ import { cn } from "@/lib/utils"; import { format } from "date-fns"; import { Label } from "@/components/ui/label"; import { useNavigate } from "react-router-dom"; -import { toast } from "react-toastify"; -import "react-toastify/dist/ReactToastify.css"; +import toast from "react-hot-toast"; import { LitNodeClient } from "@lit-protocol/lit-node-client"; import { encryptString } from "@lit-protocol/encryption/src/lib/encryption.js"; @@ -54,6 +53,13 @@ import WalletConnectionAlert from "../components/WalletConnectionAlert"; import TokenPicker, { ToggleSwitch } from "@/components/TokenPicker"; import { CopyButton } from "@/components/ui/copyButton"; import CountryPicker from "@/components/CountryPicker"; +import ProductCatalogImport from "@/components/ProductCatalogImport"; +import ProductAutocompleteInput from "@/components/ProductAutocompleteInput"; +import { useProductCatalog } from "@/hooks/useProductCatalog"; +import { + applyProductToInvoiceItem, + createEmptyInvoiceItem, +} from "@/utils/productCatalogInvoiceHelpers"; function CreateInvoicesBatch() { const { data: walletClient } = useWalletClient(); @@ -86,16 +92,7 @@ function CreateInvoicesBatch() { clientCountry: "", clientCity: "", clientPostalcode: "", - itemData: [ - { - description: "", - qty: "", - unitPrice: "", - discount: "", - tax: "", - amount: "", - }, - ], + itemData: [createEmptyInvoiceItem()], totalAmountDue: 0, }, ]); @@ -110,6 +107,8 @@ function CreateInvoicesBatch() { userPostalcode: "", }); + const { catalogMetadata } = useProductCatalog(); + // Calculate totals for each invoice useEffect(() => { setInvoiceRows((prev) => @@ -164,16 +163,7 @@ function CreateInvoicesBatch() { clientCountry: "", clientCity: "", clientPostalcode: "", - itemData: [ - { - description: "", - qty: "", - unitPrice: "", - discount: "", - tax: "", - amount: "", - }, - ], + itemData: [createEmptyInvoiceItem()], totalAmountDue: 0, }, ]); @@ -243,14 +233,7 @@ function CreateInvoicesBatch() { ...row, itemData: [ ...row.itemData, - { - description: "", - qty: "", - unitPrice: "", - discount: "", - tax: "", - amount: "", - }, + createEmptyInvoiceItem(), ], }; } @@ -259,6 +242,25 @@ function CreateInvoicesBatch() { ); }; + const handleProductSelect = (product, rowIndex, itemIndex) => { + setInvoiceRows((prevRows) => + prevRows.map((row, rIndex) => { + if (rIndex !== rowIndex) return row; + + const updatedItemData = row.itemData.map((item, iIndex) => { + if (iIndex !== itemIndex) return item; + return applyProductToInvoiceItem(item, product); + }); + + if (itemIndex === row.itemData.length - 1) { + updatedItemData.push(createEmptyInvoiceItem()); + } + + return { ...row, itemData: updatedItemData }; + }) + ); + }; + // Token verification const verifyToken = async (address) => { setTokenVerificationState("verifying"); @@ -315,7 +317,7 @@ function CreateInvoicesBatch() { try { setLoading(true); - toast.info("Starting batch invoice creation..."); + toast("Starting batch invoice creation..."); const provider = new BrowserProvider(walletClient); const signer = await provider.getSigner(); @@ -351,11 +353,11 @@ function CreateInvoicesBatch() { return; } - toast.info(`Processing ${validInvoices.length} invoices...`); + toast(`Processing ${validInvoices.length} invoices...`); // Process each invoice for (const [index, row] of validInvoices.entries()) { - toast.info( + toast( `Encrypting invoice ${index + 1} of ${validInvoices.length}...` ); @@ -477,7 +479,7 @@ function CreateInvoicesBatch() { } toast.success("All invoices encrypted successfully!"); - toast.info("Submitting batch transaction to blockchain..."); + toast("Submitting batch transaction to blockchain..."); // Send to contract const contractAddress = import.meta.env[ @@ -498,7 +500,7 @@ function CreateInvoicesBatch() { encryptedHashes ); - toast.info("Transaction submitted! Waiting for confirmation..."); + toast("Transaction submitted! Waiting for confirmation..."); const receipt = await tx.wait(); toast.success( @@ -891,6 +893,8 @@ function CreateInvoicesBatch() {
+ + {/* Clean Invoice Rows */}
@@ -1077,7 +1081,7 @@ function CreateInvoicesBatch() {
{/* Clean Invoice Items */} -
+

Invoice Items @@ -1097,14 +1101,15 @@ function CreateInvoicesBatch() {
{row.itemData.map((item, itemIndex) => (
- handleItemData(e, rowIndex, itemIndex) } + onSelectProduct={(product) => + handleProductSelect(product, rowIndex, itemIndex) + } + catalogMetadata={catalogMetadata} />
diff --git a/frontend/src/utils/productCatalogInvoiceHelpers.js b/frontend/src/utils/productCatalogInvoiceHelpers.js new file mode 100644 index 00000000..22f5b2b7 --- /dev/null +++ b/frontend/src/utils/productCatalogInvoiceHelpers.js @@ -0,0 +1,56 @@ +import { formatUnits, parseUnits } from 'ethers'; + +const PRECISION = 18; +const ONE = parseUnits('1', PRECISION); + +export const createEmptyInvoiceItem = () => ({ + description: '', + qty: '', + unitPrice: '', + discount: '', + tax: '', + amount: '', +}); + +/** + * Computes the line-item amount from qty, unitPrice, discount, and tax. + * Returns a formatted string or "0" on invalid input. + */ +const computeLineAmount = (qty, unitPrice, discount, tax) => { + try { + const qtyBN = parseUnits(qty || '0', PRECISION); + const priceBN = parseUnits(unitPrice || '0', PRECISION); + const discountBN = parseUnits(discount || '0', PRECISION); + const taxBN = parseUnits(tax || '0', PRECISION); + + const lineTotal = (qtyBN * priceBN) / ONE; + const finalAmount = lineTotal - discountBN + taxBN; + return formatUnits(finalAmount, PRECISION); + } catch { + return '0'; + } +}; + +/** + * Applies product catalog data onto an existing invoice item, + * filling description, price, and optional fields. + */ +export const applyProductToInvoiceItem = (item, product) => { + const updatedItem = { + ...item, + description: product.name || product.description || '', + unitPrice: String(product.price ?? item.unitPrice ?? ''), + tax: String(product.tax ?? item.tax ?? ''), + discount: String(product.discount ?? item.discount ?? ''), + qty: String(product.qty ?? (item.qty || '1')), + }; + + updatedItem.amount = computeLineAmount( + updatedItem.qty, + updatedItem.unitPrice, + updatedItem.discount, + updatedItem.tax, + ); + + return updatedItem; +}; From 443ada909cee6d991e332e721dd165746bd594f2 Mon Sep 17 00:00:00 2001 From: Atharva Naik Date: Wed, 1 Apr 2026 23:45:16 +0530 Subject: [PATCH 2/7] fix(catalog): address CodeRabbit codebase review feedback - Prevent storing tokenized URLs implicitly by setting persistUrl to false by default - Fix ENTER submitting the form instead of fetching URL by grouping into form - Preserve valid falsy properties like unit_price: 0 in FIELD_ALIAS_MAP - Read gid from hash in Google Sheets exports to preserve sheet selection - Clamp overlapping zIndex calculation to prevent negative stacking contexts - Add missing papaparse and idb-keyval packages to dependencies - Fix mixed toast library usage in batch creation page --- frontend/src/components/ProductCatalogImport.jsx | 16 +++++++++++----- frontend/src/hooks/useProductCatalog.js | 8 ++++++-- frontend/src/page/CreateInvoice.jsx | 2 +- frontend/src/page/CreateInvoicesBatch.jsx | 2 +- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/ProductCatalogImport.jsx b/frontend/src/components/ProductCatalogImport.jsx index f4a84cd0..01e3691e 100644 --- a/frontend/src/components/ProductCatalogImport.jsx +++ b/frontend/src/components/ProductCatalogImport.jsx @@ -57,7 +57,7 @@ export default function ProductCatalogImport() { } = useProductCatalog(); const [urlInput, setUrlInput] = useState(''); - const [persistUrl, setPersistUrl] = useState(true); + const [persistUrl, setPersistUrl] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const fileInputRef = useRef(null); @@ -176,7 +176,7 @@ export default function ProductCatalogImport() { try { await clearCatalog(); setUrlInput(''); - setPersistUrl(true); + setPersistUrl(false); if (typeof window !== 'undefined') { window.localStorage.removeItem(LAST_CATALOG_URL_INPUT_KEY); } @@ -283,7 +283,13 @@ export default function ProductCatalogImport() {
-
+
{ + e.preventDefault(); + void handleUrlLoad(); + }} + > - -
+
{ } for (const [alias, canonical] of Object.entries(FIELD_ALIAS_MAP)) { - if (normRow[alias] && !normRow[canonical]) { + if ( + Object.prototype.hasOwnProperty.call(normRow, alias) && + (normRow[canonical] === undefined || normRow[canonical] === '') + ) { normRow[canonical] = normRow[alias]; delete normRow[alias]; } @@ -146,7 +149,8 @@ const normalizeImportUrl = (rawUrl) => { } const docId = match[1]; - const gid = url.searchParams.get('gid') || '0'; + const hashGid = url.hash.match(/(?:^#|[?&#])gid=(\d+)/)?.[1]; + const gid = url.searchParams.get('gid') || hashGid || '0'; return `https://docs.google.com/spreadsheets/d/${docId}/export?format=csv&gid=${gid}`; } catch { return input; diff --git a/frontend/src/page/CreateInvoice.jsx b/frontend/src/page/CreateInvoice.jsx index 0cb14c80..198bd201 100644 --- a/frontend/src/page/CreateInvoice.jsx +++ b/frontend/src/page/CreateInvoice.jsx @@ -1112,7 +1112,7 @@ const validateClientAddress = useCallback((value) => {
{itemData.map((_, index) => ( -
+
{/* Mobile Layout - Stacked */}
diff --git a/frontend/src/page/CreateInvoicesBatch.jsx b/frontend/src/page/CreateInvoicesBatch.jsx index b186c29f..77e9c55c 100644 --- a/frontend/src/page/CreateInvoicesBatch.jsx +++ b/frontend/src/page/CreateInvoicesBatch.jsx @@ -1102,7 +1102,7 @@ function CreateInvoicesBatch() { {row.itemData.map((item, itemIndex) => (
From b5d337737660e0f8b8d1296d19ef7a66d2839a3b Mon Sep 17 00:00:00 2001 From: Atharva Naik Date: Thu, 2 Apr 2026 00:09:51 +0530 Subject: [PATCH 3/7] fix(catalog): address remaining CodeRabbit feedback - Sync savedUrl hydrate state to UI checkbox - Refactor nested form structure into explicit Enter key handler - Regex match multi-account URLs in Google Sheets importer - Use stable item.id identifiers to prevent row recycling bugs in invoice lines --- .../src/components/ProductCatalogImport.jsx | 27 ++++++++++++------- frontend/src/hooks/useProductCatalog.js | 4 +-- frontend/src/page/CreateInvoice.jsx | 4 +-- frontend/src/page/CreateInvoicesBatch.jsx | 2 +- .../src/utils/productCatalogInvoiceHelpers.js | 1 + 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/ProductCatalogImport.jsx b/frontend/src/components/ProductCatalogImport.jsx index 01e3691e..69cd6a68 100644 --- a/frontend/src/components/ProductCatalogImport.jsx +++ b/frontend/src/components/ProductCatalogImport.jsx @@ -61,6 +61,12 @@ export default function ProductCatalogImport() { const [isProcessing, setIsProcessing] = useState(false); const fileInputRef = useRef(null); + useEffect(() => { + if (savedUrl) { + setPersistUrl(true); + } + }, [savedUrl]); + useEffect(() => { if (!persistUrl) return; if (savedUrl) { @@ -283,13 +289,7 @@ export default function ProductCatalogImport() {
-
{ - e.preventDefault(); - void handleUrlLoad(); - }} - > +
{ + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + if (!isProcessing && urlInput.trim()) { + void handleUrlLoad(); + } + } + }} disabled={isProcessing} className="flex-1 bg-gray-50 border-gray-200 text-gray-800" /> - - +
{ return input; } - if (path.includes('/spreadsheets/d/e/')) { + if (/\/spreadsheets\/(?:u\/\d+\/)?d\/e\//.test(path)) { if (path.endsWith('/pubhtml')) { const pubUrl = new URL(url.toString()); pubUrl.pathname = path.replace('/pubhtml', '/pub'); @@ -143,7 +143,7 @@ const normalizeImportUrl = (rawUrl) => { return input; } - const match = path.match(/\/spreadsheets\/d\/([^/]+)/); + const match = path.match(/\/spreadsheets\/(?:u\/\d+\/)?d\/([^/]+)/); if (!match?.[1]) { return input; } diff --git a/frontend/src/page/CreateInvoice.jsx b/frontend/src/page/CreateInvoice.jsx index 198bd201..512a1e1c 100644 --- a/frontend/src/page/CreateInvoice.jsx +++ b/frontend/src/page/CreateInvoice.jsx @@ -1111,8 +1111,8 @@ const validateClientAddress = useCallback((value) => {
- {itemData.map((_, index) => ( -
+ {itemData.map((item, index) => ( +
{/* Mobile Layout - Stacked */}
diff --git a/frontend/src/page/CreateInvoicesBatch.jsx b/frontend/src/page/CreateInvoicesBatch.jsx index 77e9c55c..c81c37f6 100644 --- a/frontend/src/page/CreateInvoicesBatch.jsx +++ b/frontend/src/page/CreateInvoicesBatch.jsx @@ -1103,7 +1103,7 @@ function CreateInvoicesBatch() {
(itemRefs.current[`${rowIndex}-${itemIndex}`] = el)} placeholder="Enter Description" className="w-full border-gray-300 text-black" name="description" From 0db2b4244bd3e4893f3d8f3c46511463cbb9ec29 Mon Sep 17 00:00:00 2001 From: Atharva Naik Date: Thu, 2 Apr 2026 00:28:14 +0530 Subject: [PATCH 6/7] fix(useProductCatalog): remove duplicate code and fix syntax error in disableURLPersistence --- frontend/src/hooks/useProductCatalog.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frontend/src/hooks/useProductCatalog.js b/frontend/src/hooks/useProductCatalog.js index 909f59db..a0977e9f 100644 --- a/frontend/src/hooks/useProductCatalog.js +++ b/frontend/src/hooks/useProductCatalog.js @@ -344,12 +344,8 @@ export const useProductCatalog = () => { await del(CATALOG_KEY); memoryCache = updatedMetadata; setCatalogMetadata(updatedMetadata); - await del(CATALOG_KEY); - memoryCache = updatedMetadata; - setCatalogMetadata(updatedMetadata); broadcastCatalogUpdate(updatedMetadata); } - } return { success: true }; }, [catalogMetadata]); From 222a9121c80b19d8e0594c55e61279c9b80a48d8 Mon Sep 17 00:00:00 2001 From: Atharva Naik Date: Thu, 2 Apr 2026 00:42:44 +0530 Subject: [PATCH 7/7] fix(catalog): address PapaParse error handling and hook dependencies --- frontend/src/hooks/useProductCatalog.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/useProductCatalog.js b/frontend/src/hooks/useProductCatalog.js index a0977e9f..070239e8 100644 --- a/frontend/src/hooks/useProductCatalog.js +++ b/frontend/src/hooks/useProductCatalog.js @@ -87,8 +87,11 @@ const parseAndNormalizeCSV = (csvString) => { header: true, skipEmptyLines: true, complete: (results) => { - if (results.errors.length > 0 && results.data.length === 0) { - return reject(new Error('Invalid CSV format')); + if (results.errors.length > 0) { + const firstError = results.errors[0]; + const rowSuffix = + typeof firstError?.row === 'number' ? ` near row ${firstError.row + 1}` : ''; + return reject(new Error(`Invalid CSV format${rowSuffix}`)); } try { @@ -348,7 +351,7 @@ export const useProductCatalog = () => { } return { success: true }; - }, [catalogMetadata]); + }, [catalogMetadata, broadcastCatalogUpdate]); const persistCurrentURLData = useCallback(async () => { const currentUrl = catalogMetadata?.url;