health score toggle in same drawer
This commit is contained in:
@@ -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);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user