health score toggle

This commit is contained in:
2026-10-05 12:03:35 +05:30
parent 0a828fbe4f
commit 1eea29cf23
6 changed files with 404 additions and 13 deletions

View File

@@ -98,6 +98,23 @@ export const productsApi = {
importFromCatalogue: (rows: ImportCatalogueProductRequest[]) =>
api.post<unknown>(`${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<unknown>(`${WEB}/products/showhealthscore`, {
tenantid,
productid,
showhealthscore,
}),
/**
* Single product only — the backend parses one object, not an array.
*

View File

@@ -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. */

View File

@@ -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<string | null>(null);
const [justImported, setJustImported] = useState<Set<string>>(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<CatalogueProduct[] | null>(null);
const [open, setOpen] = useState<CatalogueProduct | null>(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)))
}
/>
</HStack>
@@ -572,6 +619,19 @@ export function CatalogueBrowser({
</VStack>
</div>
{/* 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] ? (
<ImportConfirmDrawer
product={pending[0]}
count={pending.length}
isBusy={importOne.isPending || importMany.isPending}
onCancel={() => setPending(null)}
onConfirm={(showHealthScore) => void confirmImport(showHealthScore)}
/>
) : null}
{open ? (
<CatalogueDetailDrawer
product={open}

View File

@@ -0,0 +1,190 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Check } from 'lucide-react';
import { nutritionApi } from '@/api/nutrition';
import type { CatalogueProduct } from '@/api/types';
import { Drawer } from '@/features/store-admin/Drawer';
import { DrawerButton, Note, Section } from '@/features/store-admin/drawerKit';
import { BAND_COLOR, BAND_LABEL, isEdible, present } from '@/features/store-admin/healthScore';
/**
* Confirming an import, and deciding whether this shelf shows a health score.
*
* ── Why there is a step here at all ─────────────────────────────────────────
*
* Importing used to be one click. It still writes the same row; the only thing
* added is a question the shopkeeper is the right person to answer.
*
* The score comes from a third party that matches a reference product by name,
* and it publishes how sure it is — 47% and 61% are ordinary. One live record
* reports under 1mg of sodium per 100g for salted crisps, and the rating's own
* "low in sodium" praise is derived from that figure. A shopkeeper holding the
* packet can see that in a second; the matcher cannot.
*
* ── Why the score is shown and not just named ───────────────────────────────
*
* The toggle is useless without the thing it is deciding about. "Show health
* score?" against a product name is a question nobody can answer; the same
* question beside "22/100 — Less healthy, matched at 47% confidence" answers
* itself.
*
* ── Why the toggle disappears ───────────────────────────────────────────────
*
* No score, no question. Non-food never gets one — the service has rated
* insecticide 80/100, so an allowlist gates it — and most products are simply
* unscored. Showing a dead toggle on those invites somebody to set it and
* wonder later why nothing changed.
*/
export function ImportConfirmDrawer({
product,
count,
isBusy,
onCancel,
onConfirm,
}: {
/** The product being imported, or the first of a batch. */
product: CatalogueProduct;
/** How many products this confirms. 1 for a single card. */
count: number;
isBusy: boolean;
onCancel: () => 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 (
<Drawer
title="Add to your catalogue"
subtitle={label}
width={460}
onClose={onCancel}
isFooterSpread
footer={
<>
<DrawerButton label="Cancel" variant="ghost" onClick={onCancel} />
<DrawerButton
label={isBusy ? 'Adding…' : count === 1 ? 'Add product' : `Add ${count} products`}
variant="primary"
isDisabled={isBusy}
/* `undefined` when there is no score: the import then says nothing
about it rather than sending a choice nobody was offered. The
backend reads an absent field as yes, which is what a product
with no score shows anyway — nothing. */
onClick={() => onConfirm(hasScore ? showScore : undefined)}
/>
</>
}
>
{isChecking ? (
<Section title="Health score">
<Note>Checking whether this product has one…</Note>
</Section>
) : hasScore ? (
<Section title="Health score">
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 14px',
borderRadius: 10,
background: 'var(--color-surface-sunken, #f2f4f7)',
border: '1px solid var(--color-line, #e0e4ea)',
}}
>
<span
style={{
fontSize: 22,
fontWeight: 700,
fontVariantNumeric: 'tabular-nums',
color: shown.band ? BAND_COLOR[shown.band] : 'var(--color-ink-1)',
}}
>
{shown.display}
</span>
<div style={{ display: 'grid', gap: 2 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-ink-1)' }}>
{shown.band ? BAND_LABEL[shown.band] : ''}
</span>
<span style={{ fontSize: 11.5, color: 'var(--color-ink-4)' }}>out of 100</span>
</div>
</div>
{/* 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 ? <Note>{shown.caveat}</Note> : null}
<label
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 10,
marginTop: 12,
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={showScore}
onChange={(event) => setShowScore(event.target.checked)}
style={{ marginTop: 3, width: 16, height: 16, cursor: 'pointer' }}
/>
<span style={{ display: 'grid', gap: 3 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-ink-1)' }}>
Show this health score to customers
</span>
<span style={{ fontSize: 11.5, lineHeight: 1.5, color: 'var(--color-ink-4)' }}>
Appears on the product in your catalogue and in the customer app. The nutrition
figures are shown either way — this is only the rating. You can change it later
from the product.
</span>
</span>
</label>
</Section>
) : (
<Section title="Health score">
<Note>
{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.'}
</Note>
</Section>
)}
{count > 1 ? (
<Note icon={<Check size={15} />}>
{`This choice applies to all ${count} products in this batch.`}
</Note>
) : null}
</Drawer>
);
}

View File

@@ -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 (
<Section title="Health score">
<Note>
Hidden from customers for this product. The nutrition figures are still shown.
</Note>
{onToggleShown ? (
<ShowScoreToggle isShown={false} onChange={onToggleShown} />
) : null}
</Section>
);
}
// 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({
</a>
) : null}
</div>
{/* 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 ? <ShowScoreToggle isShown onChange={onToggleShown} /> : null}
</Section>
);
}
/**
* 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 (
<label
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 10,
marginTop: 10,
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={isShown}
onChange={(event) => onChange(event.target.checked)}
style={{ marginTop: 3, width: 16, height: 16, cursor: 'pointer' }}
/>
<span style={{ display: 'grid', gap: 3 }}>
<span style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--color-ink-1)' }}>
Show this health score to customers
</span>
<span style={{ fontSize: 11.5, lineHeight: 1.5, color: 'var(--color-ink-4)' }}>
Appears on this product in your catalogue and in the customer app. The nutrition
figures are shown either way.
</span>
</span>
</label>
);
}

View File

@@ -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({
<HealthScorePanel
product={product}
category={product.categoryname || product.Subcategoryname}
{...(product.tenantid && product.productid
? { onToggleShown: (show: boolean) => showScore.mutate(show) }
: {})}
/>
{product.productdesc ? (