health score toggle
This commit is contained in:
@@ -98,6 +98,23 @@ export const productsApi = {
|
|||||||
importFromCatalogue: (rows: ImportCatalogueProductRequest[]) =>
|
importFromCatalogue: (rows: ImportCatalogueProductRequest[]) =>
|
||||||
api.post<unknown>(`${WEB}/products/importcatalogueproduct`, rows),
|
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.
|
* Single product only — the backend parses one object, not an array.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -297,6 +297,18 @@ export interface Product {
|
|||||||
/** Capitalised on the wire. */
|
/** Capitalised on the wire. */
|
||||||
Subcategoryname?: string;
|
Subcategoryname?: string;
|
||||||
catalogueid?: number;
|
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;
|
productname?: string;
|
||||||
productimage?: string;
|
productimage?: string;
|
||||||
/** A JSON-encoded array of URLs, held as a string. Parse before use. */
|
/** A JSON-encoded array of URLs, held as a string. Parse before use. */
|
||||||
@@ -405,6 +417,17 @@ export interface ImportCatalogueProductRequest {
|
|||||||
retailprice: number;
|
retailprice: number;
|
||||||
productcost: number;
|
productcost: number;
|
||||||
taxpercent: 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. */
|
/** Body of POST /web/products/createproductlocation — send an ARRAY. Upserts. */
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ 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;
|
||||||
|
|
||||||
@@ -93,6 +94,15 @@ 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);
|
||||||
@@ -196,15 +206,21 @@ export function CatalogueBrowser({
|
|||||||
one-argument builder and stayed valid the moment the second argument
|
one-argument builder and stayed valid the moment the second argument
|
||||||
arrived.
|
arrived.
|
||||||
*/
|
*/
|
||||||
mutationFn: async (products: CatalogueProduct[]) => {
|
mutationFn: async ({
|
||||||
|
products,
|
||||||
|
showHealthScore,
|
||||||
|
}: {
|
||||||
|
products: CatalogueProduct[];
|
||||||
|
showHealthScore: boolean | undefined;
|
||||||
|
}) => {
|
||||||
const ids = await aisleIds();
|
const ids = await aisleIds();
|
||||||
return productsApi.importFromCatalogue(
|
return productsApi.importFromCatalogue(
|
||||||
products.map((product) =>
|
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
|
// 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
|
// query and the grid would otherwise show them as un-imported until it
|
||||||
// came back, tempting a second click.
|
// came back, tempting a second click.
|
||||||
@@ -296,6 +312,7 @@ export function CatalogueBrowser({
|
|||||||
function importRowFor(
|
function importRowFor(
|
||||||
product: CatalogueProduct,
|
product: CatalogueProduct,
|
||||||
subcategoryid: number,
|
subcategoryid: number,
|
||||||
|
showHealthScore: boolean | undefined,
|
||||||
): ImportCatalogueProductRequest {
|
): ImportCatalogueProductRequest {
|
||||||
return {
|
return {
|
||||||
tenantid: tenantid as number,
|
tenantid: tenantid as number,
|
||||||
@@ -318,21 +335,53 @@ export function CatalogueBrowser({
|
|||||||
retailprice: 0,
|
retailprice: 0,
|
||||||
productcost: 0,
|
productcost: 0,
|
||||||
taxpercent: 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) {
|
async function importDirect(product: CatalogueProduct) {
|
||||||
if (!tenantid || !locationid) return;
|
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 {
|
try {
|
||||||
await importOne.mutateAsync(
|
await importMany.mutateAsync({ products, showHealthScore });
|
||||||
importRowFor(product, aisleIdForCategory(categoryNameFor(product), await aisleIds())),
|
|
||||||
);
|
|
||||||
setJustImported((set) => new Set(set).add(key));
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setPending(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,9 +567,7 @@ export function CatalogueBrowser({
|
|||||||
isLoading={importMany.isPending}
|
isLoading={importMany.isPending}
|
||||||
isDisabled={importMany.isPending}
|
isDisabled={importMany.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
importMany.mutate(
|
setPending(rows.filter((product) => selection.has(product.id)))
|
||||||
rows.filter((product) => selection.has(product.id)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -572,6 +619,19 @@ 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}
|
||||||
|
|||||||
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({
|
export function HealthScorePanel({
|
||||||
product,
|
product,
|
||||||
category,
|
category,
|
||||||
|
onToggleShown,
|
||||||
}: {
|
}: {
|
||||||
product: Product;
|
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
|
* 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
|
||||||
@@ -119,6 +127,30 @@ export function HealthScorePanel({
|
|||||||
|
|
||||||
const shown = present(query.data ?? null);
|
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
|
// The service has never heard of this product — a different answer from
|
||||||
// "known but unscored", and said differently, for the same reason as above.
|
// "known but unscored", and said differently, for the same reason as above.
|
||||||
if (shown.isEmpty) {
|
if (shown.isEmpty) {
|
||||||
@@ -233,6 +265,56 @@ export function HealthScorePanel({
|
|||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
</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 reason = blockedReason(product);
|
||||||
const branch = current ?? scoped[0];
|
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({
|
const unpublish = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
productsApi.unpublish({ tenantid: tenantid as number, productid: product.productid }),
|
productsApi.unpublish({ tenantid: tenantid as number, productid: product.productid }),
|
||||||
@@ -247,6 +263,9 @@ export function ProductDrawer({
|
|||||||
<HealthScorePanel
|
<HealthScorePanel
|
||||||
product={product}
|
product={product}
|
||||||
category={product.categoryname || product.Subcategoryname}
|
category={product.categoryname || product.Subcategoryname}
|
||||||
|
{...(product.tenantid && product.productid
|
||||||
|
? { onToggleShown: (show: boolean) => showScore.mutate(show) }
|
||||||
|
: {})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{product.productdesc ? (
|
{product.productdesc ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user