health score toggle in same drawer

This commit is contained in:
2026-10-05 13:05:27 +05:30
parent 1eea29cf23
commit 7f2110d51e
4 changed files with 126 additions and 250 deletions

View File

@@ -28,7 +28,6 @@ import {
import { CatalogueCard } from './CatalogueCard'; import { CatalogueCard } from './CatalogueCard';
import { CatalogueSidebar } from './CatalogueSidebar'; import { CatalogueSidebar } from './CatalogueSidebar';
import { CatalogueDetailDrawer } from './CatalogueDetailDrawer'; import { CatalogueDetailDrawer } from './CatalogueDetailDrawer';
import { ImportConfirmDrawer } from './ImportConfirmDrawer';
const PAGE_SIZE = 24; const PAGE_SIZE = 24;
@@ -94,15 +93,6 @@ export function CatalogueBrowser({
const [debounced, setDebounced] = useState(''); const [debounced, setDebounced] = useState('');
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [justImported, setJustImported] = useState<Set<string>>(new Set()); 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 [open, setOpen] = useState<CatalogueProduct | null>(null);
const [category, setCategory] = useState(''); const [category, setCategory] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -343,8 +333,17 @@ export function CatalogueBrowser({
async function importDirect(product: CatalogueProduct) { async function importDirect(product: CatalogueProduct) {
if (!tenantid || !locationid) return; if (!tenantid || !locationid) return;
// Asks first. The write itself is unchanged — see confirmImport.
setPending([product]); /*
Opens the product rather than importing on the spot.
The drawer already shows this product's health score and how confident
the match is, and the import now carries a decision about exactly that —
so the question is asked where the answer is visible. A separate
confirmation would be a second panel repeating what this one already
renders.
*/
setOpen(product);
} }
/* /*
@@ -354,34 +353,30 @@ export function CatalogueBrowser({
different rows — the single-product button taking a different route from the different rows — the single-product button taking a different route from the
bulk action is how a selection quietly imports under a different category. bulk action is how a selection quietly imports under a different category.
*/ */
async function confirmImport(showHealthScore: boolean | undefined) { /*
const products = pending; The import, once the shopkeeper has answered.
if (!products || products.length === 0) return;
if (products.length === 1 && products[0]) { `showHealthScore` is undefined for a product with no score — the import then
const product = products[0]; says nothing about it rather than sending a choice nobody was offered, and
const key = catalogueKey(product); the backend reads an absent field as yes.
setBusy(key); */
try { async function confirmImport(product: CatalogueProduct, showHealthScore: boolean | undefined) {
await importOne.mutateAsync( if (!tenantid || !locationid) return;
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 { try {
await importMany.mutateAsync({ products, showHealthScore }); await importOne.mutateAsync(
importRowFor(
product,
aisleIdForCategory(categoryNameFor(product), await aisleIds()),
showHealthScore,
),
);
setJustImported((set) => new Set(set).add(key));
setOpen(null);
} finally { } finally {
setPending(null); setBusy(null);
} }
} }
@@ -566,8 +561,16 @@ export function CatalogueBrowser({
size="sm" size="sm"
isLoading={importMany.isPending} isLoading={importMany.isPending}
isDisabled={importMany.isPending} isDisabled={importMany.isPending}
/* No per-product question here: a toggle asked once
for forty products is a question nobody answers
honestly. A batch imports showing the score, which
is the default, and any of them can be changed
afterwards from the product. */
onClick={() => onClick={() =>
setPending(rows.filter((product) => selection.has(product.id))) importMany.mutate({
products: rows.filter((product) => selection.has(product.id)),
showHealthScore: undefined,
})
} }
/> />
</HStack> </HStack>
@@ -619,19 +622,6 @@ export function CatalogueBrowser({
</VStack> </VStack>
</div> </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 ? ( {open ? (
<CatalogueDetailDrawer <CatalogueDetailDrawer
product={open} product={open}
@@ -640,10 +630,17 @@ export function CatalogueBrowser({
actionLabel={actionLabel} actionLabel={actionLabel}
{...(canImport && !isReadOnly {...(canImport && !isReadOnly
? { ? {
onImport: () => { onImport: (showHealthScore: boolean | undefined) => {
const product = open; const product = open;
if (onImport) setOpen(null); // A caller that owns the import — the shelving flow — takes
run(product); // over here and the drawer closes. Otherwise this writes the
// row itself, carrying the shopkeeper's answer.
if (onImport) {
setOpen(null);
onImport(product);
return;
}
void confirmImport(product, showHealthScore);
}, },
} }
: {})} : {})}

View File

@@ -44,7 +44,14 @@ export interface CatalogueDetailDrawerProps {
isBusy?: boolean; isBusy?: boolean;
actionLabel?: string; actionLabel?: string;
/** Absent when the caller has nowhere to import to yet. */ /** Absent when the caller has nowhere to import to yet. */
onImport?: () => void; /**
* Import this product.
*
* `showHealthScore` is the shopkeeper's answer to the toggle below the score,
* and `undefined` when this product has none — the import then says nothing
* about it rather than sending a choice nobody was offered.
*/
onImport?: (showHealthScore: boolean | undefined) => void;
/** Shown in place of the action when importing is unavailable. */ /** Shown in place of the action when importing is unavailable. */
blockedReason?: string; blockedReason?: string;
onClose: () => void; onClose: () => void;
@@ -76,6 +83,19 @@ export function CatalogueDetailDrawer({
const aisle = aisleForCategory(suggested.category); const aisle = aisleForCategory(suggested.category);
const images = product.images ?? []; const images = product.images ?? [];
/*
Whether this shop will show the product's health score, and whether there is
one to show.
`hasScore` is reported up by the panel rather than looked up again here: the
panel already makes that call, already caches it, and already knows the
three ways a product can have no score — not food, not known to the service,
known but unscored. Asking a second time would be a second request and a
second opinion.
*/
const [showScore, setShowScore] = useState(true);
const [hasScore, setHasScore] = useState(false);
const [heroAt, setHeroAt] = useState(0); const [heroAt, setHeroAt] = useState(0);
const [isZoomed, setIsZoomed] = useState(false); const [isZoomed, setIsZoomed] = useState(false);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
@@ -115,7 +135,7 @@ export function CatalogueDetailDrawer({
variant="primary" variant="primary"
icon={<DownloadCloud size={15} />} icon={<DownloadCloud size={15} />}
isDisabled={Boolean(isBusy)} isDisabled={Boolean(isBusy)}
onClick={onImport} onClick={() => onImport(hasScore ? showScore : undefined)}
/> />
), ),
} }
@@ -197,9 +217,19 @@ export function CatalogueDetailDrawer({
`brand` and `image_id` come straight off the catalogue row, so no `brand` and `image_id` come straight off the catalogue row, so no
lookup is needed to find the key. */} lookup is needed to find the key. */}
{/* The decision and the thing being decided about, in one place.
"Show health score?" against a product name is unanswerable; the same
question directly beneath "22/100 — Less healthy, matched at 47%
confidence" answers itself. That is the whole reason the toggle lives
here rather than in a confirmation of its own. */}
<HealthScorePanel <HealthScorePanel
product={{ productid: 0, productbrand: product.brand, imageid: product.image_id }} product={{ productid: 0, productbrand: product.brand, imageid: product.image_id }}
category={product.category} category={product.category}
onScoreResolved={setHasScore}
{...(hasScore && !isImported && onImport
? { showToggle: { isShown: showScore, onChange: setShowScore } }
: {})}
/> />
{product.highlights?.length || product.nutrients?.length ? ( {product.highlights?.length || product.nutrients?.length ? (

View File

@@ -1,190 +0,0 @@
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

@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { AlertTriangle, Check, ExternalLink, Leaf } from 'lucide-react'; import { AlertTriangle, Check, ExternalLink, Leaf } from 'lucide-react';
import { nutritionApi } from '@/api/nutrition'; import { nutritionApi } from '@/api/nutrition';
@@ -30,15 +31,35 @@ export function HealthScorePanel({
product, product,
category, category,
onToggleShown, onToggleShown,
showToggle,
onScoreResolved,
}: { }: {
product: Product; product: Product;
/** /**
* Turn the score on or off for this shop. Omitted where the viewer does not * Turn the score on or off for a product ALREADY on the shelf. Omitted where
* get to decide — the Nearle staff console passes nothing, because the * the viewer does not get to decide — the Nearle staff console passes
* decision being made there is whether the data is fit to publish at all, * nothing, because the decision being made there is whether the data is fit
* not whether one shopkeeper wants it on their shelf. * to publish at all, not whether one shopkeeper wants it on their shelf.
*/ */
onToggleShown?: (show: boolean) => void; onToggleShown?: (show: boolean) => void;
/**
* The same control, for a product being imported — where the answer is not
* saved on each click but carried into the import.
*
* Separate from `onToggleShown` because the two are different moments with
* different state: one writes immediately, the other is a form field until
* the import button is pressed.
*/
showToggle?: { isShown: boolean; onChange: (show: boolean) => void };
/**
* Reports whether this product has a score at all.
*
* The caller needs to know — to decide whether to offer the toggle, and what
* to send on import — and this panel is already the thing that finds out. A
* second lookup in the drawer would be a second request and a second opinion
* about the three different ways a product can have no score.
*/
onScoreResolved?: (hasScore: boolean) => void;
/** /**
* The product's category, used to decide whether a nutrition score means * 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 * anything for it at all. Passed in because the two callers hold it in
@@ -127,6 +148,20 @@ export function HealthScorePanel({
const shown = present(query.data ?? null); const shown = present(query.data ?? null);
/*
Tell the caller whether there is a score, once the lookup has settled.
In an effect rather than during render: this sets state in a parent, and
doing that while rendering is the React warning about updating one
component from inside another. Guarded on `isLoading` so a drawer does not
briefly see "no score" for a product that has one and hide its toggle.
*/
const hasScore = !shown.isEmpty && !shown.isPending && shown.score !== null;
useEffect(() => {
if (query.isLoading) return;
onScoreResolved?.(hasScore);
}, [query.isLoading, hasScore, onScoreResolved]);
/* /*
This shop has turned the score off. This shop has turned the score off.
@@ -269,7 +304,11 @@ export function HealthScorePanel({
{/* Beneath the score, deliberately. The decision is about the thing above {/* Beneath the score, deliberately. The decision is about the thing above
it, and a shopkeeper weighing "is this rating fair to my product" it, and a shopkeeper weighing "is this rating fair to my product"
should be reading the rating and the confidence while they decide. */} should be reading the rating and the confidence while they decide. */}
{onToggleShown ? <ShowScoreToggle isShown onChange={onToggleShown} /> : null} {showToggle ? (
<ShowScoreToggle isShown={showToggle.isShown} onChange={showToggle.onChange} />
) : onToggleShown ? (
<ShowScoreToggle isShown onChange={onToggleShown} />
) : null}
</Section> </Section>
); );
} }