health score toggle in same drawer
This commit is contained in:
@@ -28,7 +28,6 @@ import {
|
||||
import { CatalogueCard } from './CatalogueCard';
|
||||
import { CatalogueSidebar } from './CatalogueSidebar';
|
||||
import { CatalogueDetailDrawer } from './CatalogueDetailDrawer';
|
||||
import { ImportConfirmDrawer } from './ImportConfirmDrawer';
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
@@ -94,15 +93,6 @@ 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);
|
||||
@@ -343,8 +333,17 @@ export function CatalogueBrowser({
|
||||
|
||||
async function importDirect(product: CatalogueProduct) {
|
||||
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
|
||||
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;
|
||||
/*
|
||||
The import, once the shopkeeper has answered.
|
||||
|
||||
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;
|
||||
}
|
||||
`showHealthScore` is undefined for a product with no score — the import then
|
||||
says nothing about it rather than sending a choice nobody was offered, and
|
||||
the backend reads an absent field as yes.
|
||||
*/
|
||||
async function confirmImport(product: CatalogueProduct, showHealthScore: boolean | undefined) {
|
||||
if (!tenantid || !locationid) return;
|
||||
|
||||
const key = catalogueKey(product);
|
||||
setBusy(key);
|
||||
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 {
|
||||
setPending(null);
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,8 +561,16 @@ export function CatalogueBrowser({
|
||||
size="sm"
|
||||
isLoading={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={() =>
|
||||
setPending(rows.filter((product) => selection.has(product.id)))
|
||||
importMany.mutate({
|
||||
products: rows.filter((product) => selection.has(product.id)),
|
||||
showHealthScore: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</HStack>
|
||||
@@ -619,19 +622,6 @@ 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}
|
||||
@@ -640,10 +630,17 @@ export function CatalogueBrowser({
|
||||
actionLabel={actionLabel}
|
||||
{...(canImport && !isReadOnly
|
||||
? {
|
||||
onImport: () => {
|
||||
onImport: (showHealthScore: boolean | undefined) => {
|
||||
const product = open;
|
||||
if (onImport) setOpen(null);
|
||||
run(product);
|
||||
// A caller that owns the import — the shelving flow — takes
|
||||
// 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);
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
|
||||
@@ -44,7 +44,14 @@ export interface CatalogueDetailDrawerProps {
|
||||
isBusy?: boolean;
|
||||
actionLabel?: string;
|
||||
/** 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. */
|
||||
blockedReason?: string;
|
||||
onClose: () => void;
|
||||
@@ -76,6 +83,19 @@ export function CatalogueDetailDrawer({
|
||||
const aisle = aisleForCategory(suggested.category);
|
||||
|
||||
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 [isZoomed, setIsZoomed] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
@@ -115,7 +135,7 @@ export function CatalogueDetailDrawer({
|
||||
variant="primary"
|
||||
icon={<DownloadCloud size={15} />}
|
||||
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
|
||||
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
|
||||
product={{ productid: 0, productbrand: product.brand, imageid: product.image_id }}
|
||||
category={product.category}
|
||||
onScoreResolved={setHasScore}
|
||||
{...(hasScore && !isImported && onImport
|
||||
? { showToggle: { isShown: showScore, onChange: setShowScore } }
|
||||
: {})}
|
||||
/>
|
||||
|
||||
{product.highlights?.length || product.nutrients?.length ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AlertTriangle, Check, ExternalLink, Leaf } from 'lucide-react';
|
||||
import { nutritionApi } from '@/api/nutrition';
|
||||
@@ -30,15 +31,35 @@ export function HealthScorePanel({
|
||||
product,
|
||||
category,
|
||||
onToggleShown,
|
||||
showToggle,
|
||||
onScoreResolved,
|
||||
}: {
|
||||
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.
|
||||
* Turn the score on or off for a product ALREADY on the shelf. 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 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
|
||||
* 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);
|
||||
|
||||
/*
|
||||
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.
|
||||
|
||||
@@ -269,7 +304,11 @@ export function HealthScorePanel({
|
||||
{/* 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}
|
||||
{showToggle ? (
|
||||
<ShowScoreToggle isShown={showToggle.isShown} onChange={showToggle.onChange} />
|
||||
) : onToggleShown ? (
|
||||
<ShowScoreToggle isShown onChange={onToggleShown} />
|
||||
) : null}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user