From 1eea29cf233a47ba23ab923b0dfee922db6f46db Mon Sep 17 00:00:00 2001 From: abhishek Date: Mon, 5 Oct 2026 12:03:35 +0530 Subject: [PATCH] health score toggle --- src/api/products.ts | 17 ++ src/api/types.ts | 23 +++ src/features/catalogue/CatalogueBrowser.tsx | 86 ++++++-- .../catalogue/ImportConfirmDrawer.tsx | 190 ++++++++++++++++++ src/features/store-admin/HealthScorePanel.tsx | 82 ++++++++ src/features/store-admin/ProductDrawer.tsx | 19 ++ 6 files changed, 404 insertions(+), 13 deletions(-) create mode 100644 src/features/catalogue/ImportConfirmDrawer.tsx diff --git a/src/api/products.ts b/src/api/products.ts index 7eb1f38..c2d5b99 100644 --- a/src/api/products.ts +++ b/src/api/products.ts @@ -98,6 +98,23 @@ export const productsApi = { importFromCatalogue: (rows: ImportCatalogueProductRequest[]) => api.post(`${WEB}/products/importcatalogueproduct`, rows), + /** + * Turn one product's health score on or off for this shop. + * + * The merchant's call, not Nearle's. The score comes from a third party + * matching a reference product by name — often under 60% confidence — and a + * shopkeeper holding the packet is better placed than that matcher to say + * whether the rating describes what they sell. + * + * Hides the SCORE only. The nutrition table is unaffected. + */ + setShowHealthScore: (tenantid: number, productid: number, showhealthscore: boolean) => + api.put(`${WEB}/products/showhealthscore`, { + tenantid, + productid, + showhealthscore, + }), + /** * Single product only — the backend parses one object, not an array. * diff --git a/src/api/types.ts b/src/api/types.ts index 3de916a..7a5a950 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -297,6 +297,18 @@ export interface Product { /** Capitalised on the wire. */ Subcategoryname?: string; catalogueid?: number; + /** + * Whether this shop shows the product's health score. + * + * The merchant's decision, made at import and changeable from the product. + * Optional because a backend that predates the column sends nothing — and an + * absent value means YES, which is what every product showed before this + * existed. + * + * Gates the SCORE only. The nutrition figures are shown either way: they are + * what the label states, while the score is a judgement of them. + */ + showhealthscore?: boolean; productname?: string; productimage?: string; /** A JSON-encoded array of URLs, held as a string. Parse before use. */ @@ -405,6 +417,17 @@ export interface ImportCatalogueProductRequest { retailprice: number; productcost: number; taxpercent: number; + /** + * Whether this shop will show the product's health score. + * + * Omitted means yes. The backend reads an absent field and an explicit + * `false` as different answers on purpose, so a caller that does not know + * about this does not have its imports read as a deliberate no. + * + * Only the score. The nutrition figures are sent either way — they are what + * the label states, while the score is a judgement of them. + */ + showhealthscore?: boolean; } /** Body of POST /web/products/createproductlocation — send an ARRAY. Upserts. */ diff --git a/src/features/catalogue/CatalogueBrowser.tsx b/src/features/catalogue/CatalogueBrowser.tsx index 476fd55..b67f810 100644 --- a/src/features/catalogue/CatalogueBrowser.tsx +++ b/src/features/catalogue/CatalogueBrowser.tsx @@ -28,6 +28,7 @@ import { import { CatalogueCard } from './CatalogueCard'; import { CatalogueSidebar } from './CatalogueSidebar'; import { CatalogueDetailDrawer } from './CatalogueDetailDrawer'; +import { ImportConfirmDrawer } from './ImportConfirmDrawer'; const PAGE_SIZE = 24; @@ -93,6 +94,15 @@ export function CatalogueBrowser({ const [debounced, setDebounced] = useState(''); const [busy, setBusy] = useState(null); const [justImported, setJustImported] = useState>(new Set()); + /* + The import waiting on a confirmation. + + Importing used to be one click. It still writes the same row — the step + added in front of it asks whether this shelf shows the product's health + score, which is a question the shopkeeper is the right person to answer and + nobody was asking. See ImportConfirmDrawer. + */ + const [pending, setPending] = useState(null); const [open, setOpen] = useState(null); const [category, setCategory] = useState(''); const [page, setPage] = useState(1); @@ -196,15 +206,21 @@ export function CatalogueBrowser({ one-argument builder and stayed valid the moment the second argument arrived. */ - mutationFn: async (products: CatalogueProduct[]) => { + mutationFn: async ({ + products, + showHealthScore, + }: { + products: CatalogueProduct[]; + showHealthScore: boolean | undefined; + }) => { const ids = await aisleIds(); return productsApi.importFromCatalogue( products.map((product) => - importRowFor(product, aisleIdForCategory(categoryNameFor(product), ids)), + importRowFor(product, aisleIdForCategory(categoryNameFor(product), ids), showHealthScore), ), ); }, - onSuccess: async (_result, products) => { + onSuccess: async (_result, { products }) => { // Ticked locally as well as refetched: the imported list is a separate // query and the grid would otherwise show them as un-imported until it // came back, tempting a second click. @@ -296,6 +312,7 @@ export function CatalogueBrowser({ function importRowFor( product: CatalogueProduct, subcategoryid: number, + showHealthScore: boolean | undefined, ): ImportCatalogueProductRequest { return { tenantid: tenantid as number, @@ -318,21 +335,53 @@ export function CatalogueBrowser({ retailprice: 0, productcost: 0, taxpercent: 0, + /* Omitted when the product has no score, so the import says nothing about + it rather than sending a choice nobody was offered. */ + ...(showHealthScore === undefined ? {} : { showhealthscore: showHealthScore }), }; } async function importDirect(product: CatalogueProduct) { if (!tenantid || !locationid) return; + // Asks first. The write itself is unchanged — see confirmImport. + setPending([product]); + } + + /* + The import, once the shopkeeper has answered. + + One path for a single card and for a batch, so the two cannot write + different rows — the single-product button taking a different route from the + bulk action is how a selection quietly imports under a different category. + */ + async function confirmImport(showHealthScore: boolean | undefined) { + const products = pending; + if (!products || products.length === 0) return; + + if (products.length === 1 && products[0]) { + const product = products[0]; + const key = catalogueKey(product); + setBusy(key); + try { + await importOne.mutateAsync( + importRowFor( + product, + aisleIdForCategory(categoryNameFor(product), await aisleIds()), + showHealthScore, + ), + ); + setJustImported((set) => new Set(set).add(key)); + } finally { + setBusy(null); + setPending(null); + } + return; + } - const key = catalogueKey(product); - setBusy(key); try { - await importOne.mutateAsync( - importRowFor(product, aisleIdForCategory(categoryNameFor(product), await aisleIds())), - ); - setJustImported((set) => new Set(set).add(key)); + await importMany.mutateAsync({ products, showHealthScore }); } finally { - setBusy(null); + setPending(null); } } @@ -518,9 +567,7 @@ export function CatalogueBrowser({ isLoading={importMany.isPending} isDisabled={importMany.isPending} onClick={() => - importMany.mutate( - rows.filter((product) => selection.has(product.id)), - ) + setPending(rows.filter((product) => selection.has(product.id))) } /> @@ -572,6 +619,19 @@ export function CatalogueBrowser({ + {/* Asked before anything is written. One drawer for a single card and for + a batch, so the question and the answer cannot diverge between the + two paths. */} + {pending && pending[0] ? ( + setPending(null)} + onConfirm={(showHealthScore) => void confirmImport(showHealthScore)} + /> + ) : null} + {open ? ( void; + /** `showHealthScore` is undefined when the product has no score to show. */ + onConfirm: (showHealthScore: boolean | undefined) => void; +}) { + const [showScore, setShowScore] = useState(true); + + const brand = (product.brand ?? '').trim(); + const imageId = (product.image_id ?? '').trim(); + + /* + The same lookup the product drawer makes, and the same guard in front of it. + + `enabled` keeps a non-food product from asking at all — there is nothing to + ask about — and a batch asks only about the one product on screen, because + forty lookups to populate one toggle is forty requests to a third party for + a question that has one answer. + */ + const isFood = isEdible(product.category); + const query = useQuery({ + queryKey: ['nutrition', brand, imageId], + queryFn: () => nutritionApi.forProduct(brand, imageId), + enabled: Boolean(brand && imageId && isFood), + staleTime: 60 * 60_000, + refetchOnWindowFocus: false, + retry: false, + }); + + const shown = present(query.data ?? null); + const hasScore = isFood && !shown.isEmpty && !shown.isPending && shown.score !== null; + const isChecking = isFood && Boolean(brand && imageId) && query.isLoading; + + const label = count === 1 ? product.product_name : `${count} products`; + + return ( + + + onConfirm(hasScore ? showScore : undefined)} + /> + + } + > + {isChecking ? ( +
+ Checking whether this product has one… +
+ ) : hasScore ? ( +
+
+ + {shown.display} + +
+ + {shown.band ? BAND_LABEL[shown.band] : ''} + + out of 100 +
+
+ + {/* The confidence, where it is weak. This is the single most useful + thing on this screen: it is the reason the question is being asked + at all, and most matches are below the line. */} + {shown.caveat ? {shown.caveat} : null} + + +
+ ) : ( +
+ + {isFood + ? 'No health score for this product yet, so there is nothing to show or hide.' + : 'Health scores are only for food and drink, so this product will not have one.'} + +
+ )} + + {count > 1 ? ( + }> + {`This choice applies to all ${count} products in this batch.`} + + ) : null} +
+ ); +} diff --git a/src/features/store-admin/HealthScorePanel.tsx b/src/features/store-admin/HealthScorePanel.tsx index f05634a..9be6cea 100644 --- a/src/features/store-admin/HealthScorePanel.tsx +++ b/src/features/store-admin/HealthScorePanel.tsx @@ -29,8 +29,16 @@ import './pages/deliveries.css'; export function HealthScorePanel({ product, category, + onToggleShown, }: { product: Product; + /** + * Turn the score on or off for this shop. Omitted where the viewer does not + * get to decide — the Nearle staff console passes nothing, because the + * decision being made there is whether the data is fit to publish at all, + * not whether one shopkeeper wants it on their shelf. + */ + onToggleShown?: (show: boolean) => void; /** * The product's category, used to decide whether a nutrition score means * anything for it at all. Passed in because the two callers hold it in @@ -119,6 +127,30 @@ export function HealthScorePanel({ const shown = present(query.data ?? null); + /* + This shop has turned the score off. + + Shown to the SHOPKEEPER as a switched-off control, not hidden: they are the + one who turned it off and they need a way back. A blank space where the + panel was reads as the feature having broken. + + The customer never reaches this branch — the app is served by + `getproductbyvariant`, which omits `healthscore` entirely for a product in + this state. + */ + if (product.showhealthscore === false) { + return ( +
+ + Hidden from customers for this product. The nutrition figures are still shown. + + {onToggleShown ? ( + + ) : null} +
+ ); + } + // The service has never heard of this product — a different answer from // "known but unscored", and said differently, for the same reason as above. if (shown.isEmpty) { @@ -233,6 +265,56 @@ export function HealthScorePanel({ ) : null} + + {/* Beneath the score, deliberately. The decision is about the thing above + it, and a shopkeeper weighing "is this rating fair to my product" + should be reading the rating and the confidence while they decide. */} + {onToggleShown ? : null} ); } + + +/** + * Turning the score on or off for this shop. + * + * A plain checkbox rather than a switch, because it sits inside a panel of + * facts and a switch reads as a setting screen. The wording says what a + * customer sees, not what a column holds — "show to customers" is the decision; + * `showhealthscore` is the implementation. + */ +function ShowScoreToggle({ + isShown, + onChange, +}: { + isShown: boolean; + onChange: (show: boolean) => void; +}) { + return ( + + ); +} diff --git a/src/features/store-admin/ProductDrawer.tsx b/src/features/store-admin/ProductDrawer.tsx index faaa2fa..62c89bf 100644 --- a/src/features/store-admin/ProductDrawer.tsx +++ b/src/features/store-admin/ProductDrawer.tsx @@ -133,6 +133,22 @@ export function ProductDrawer({ const reason = blockedReason(product); const branch = current ?? scoped[0]; + /* + Turning this product's health score on or off for this shop. + + The list is invalidated rather than the drawer patched, because the chip the + catalogue row shows is read from the same product record — leaving the row + stale would mean the drawer and the line behind it disagreeing about what a + customer sees. + */ + const showScore = useMutation({ + mutationFn: (show: boolean) => + productsApi.setShowHealthScore(tenantid as number, product.productid, show), + onSuccess: () => { + void client.invalidateQueries({ queryKey: queryKeys.products.all }); + }, + }); + const unpublish = useMutation({ mutationFn: () => productsApi.unpublish({ tenantid: tenantid as number, productid: product.productid }), @@ -247,6 +263,9 @@ export function ProductDrawer({ showScore.mutate(show) } + : {})} /> {product.productdesc ? (