health score toggle
This commit is contained in:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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())),
|
||||
importRowFor(
|
||||
product,
|
||||
aisleIdForCategory(categoryNameFor(product), await aisleIds()),
|
||||
showHealthScore,
|
||||
),
|
||||
);
|
||||
setJustImported((set) => new Set(set).add(key));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
setPending(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await importMany.mutateAsync({ products, showHealthScore });
|
||||
} finally {
|
||||
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}
|
||||
|
||||
190
src/features/catalogue/ImportConfirmDrawer.tsx
Normal file
190
src/features/catalogue/ImportConfirmDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user