diff --git a/src/features/store-admin/HealthScorePanel.tsx b/src/features/store-admin/HealthScorePanel.tsx index a1f4daf..106c2e4 100644 --- a/src/features/store-admin/HealthScorePanel.tsx +++ b/src/features/store-admin/HealthScorePanel.tsx @@ -31,27 +31,30 @@ import './pages/deliveries.css'; export function HealthScorePanel({ product, category, - onToggleShown, showToggle, onScoreResolved, }: { product: Product; /** - * 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. + * The show/hide control, for both moments it appears in: a product being + * imported, where the answer is carried into the import, and one already on + * the shelf, where each click is a write. * - * 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. + * One prop rather than two, because the difference is entirely the caller's — + * the panel renders the same switch either way, and two props meant two code + * paths that could drift in what they looked like. + * + * Omitted where the viewer does not get to decide: the Nearle staff console + * passes nothing, and so does a branch user, since this writes per tenant. */ - showToggle?: { isShown: boolean; onChange: (show: boolean) => void }; + showToggle?: { + isShown: boolean; + onChange: (show: boolean) => void; + /** Shown beneath the switch. A write that fails silently is the bug this + panel already shipped once. */ + error?: string | undefined; + isBusy?: boolean | undefined; + }; /** * Reports whether this product has a score at all. * @@ -188,9 +191,7 @@ export function HealthScorePanel({ Hidden from customers for this product. The nutrition figures are still shown. - {onToggleShown ? ( - - ) : null} + {showToggle ? : null} ); } @@ -313,11 +314,7 @@ 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. */} - {showToggle ? ( - - ) : onToggleShown ? ( - - ) : null} + {showToggle ? : null} ); } @@ -338,9 +335,13 @@ export function HealthScorePanel({ function ShowScoreToggle({ isShown, onChange, + error, + isBusy, }: { isShown: boolean; onChange: (show: boolean) => void; + error?: string | undefined; + isBusy?: boolean | undefined; }) { return (
@@ -349,9 +350,18 @@ function ShowScoreToggle({ description="Appears on this product in your catalogue and in the customer app. The nutrition figures are shown either way." value={isShown} onChange={onChange} + isLoading={Boolean(isBusy)} size="sm" labelSpacing="spread" /> + {/* A write that fails has to say so. This one shipped without it: the + switch moved, nothing saved, and the only way to find out was to call + the API by hand. */} + {error ? ( +

+ {error} +

+ ) : null}
); } diff --git a/src/features/store-admin/ProductDrawer.tsx b/src/features/store-admin/ProductDrawer.tsx index 0047d30..10b0aec 100644 --- a/src/features/store-admin/ProductDrawer.tsx +++ b/src/features/store-admin/ProductDrawer.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { BookOpen, ClipboardList, EyeOff, Package, Ruler, Tag } from 'lucide-react'; +import { errorMessage } from '@/api/client'; import { productsApi } from '@/api/products'; import type { CatalogueProduct, Product, StockRequest } from '@/api/types'; /* A pure reading of a `StockRequest`, not a Store user component — the same @@ -141,9 +142,36 @@ export function ProductDrawer({ stale would mean the drawer and the line behind it disagreeing about what a customer sees. */ + /* + What this drawer believes the switch is set to. + + Local, because `product` is a prop captured when the drawer opened and does + not change when the list behind it refetches — so without this the switch + read the same value before and after a click, and a save that worked looked + exactly like one that did not. It shipped that way once. + + Seeded from the record and reconciled on failure, never on success: the + server has agreed by then, and snapping back to a stale prop would undo what + the shopkeeper just did on screen. + */ + const [scoreShown, setScoreShown] = useState(product.showhealthscore !== false); + const [scoreError, setScoreError] = useState(null); + const showScore = useMutation({ mutationFn: (show: boolean) => productsApi.setShowHealthScore(tenantid as number, product.productid, show), + onMutate: (show: boolean) => { + setScoreError(null); + setScoreShown(show); + return { previous: !show }; + }, + onError: (error, _show, context) => { + // Put the switch back where it was and say why. A write that fails + // silently is worse than one that fails loudly: the shopkeeper walks away + // believing a rating is hidden from their customers when it is not. + if (context) setScoreShown(context.previous); + setScoreError(errorMessage(error)); + }, onSuccess: () => { void client.invalidateQueries({ queryKey: queryKeys.products.all }); }, @@ -271,7 +299,14 @@ export function ProductDrawer({ product={product} category={product.categoryname || product.Subcategoryname} {...(canManage && product.tenantid && product.productid - ? { onToggleShown: (show: boolean) => showScore.mutate(show) } + ? { + showToggle: { + isShown: scoreShown, + onChange: (show: boolean) => showScore.mutate(show), + error: scoreError ?? undefined, + isBusy: showScore.isPending, + }, + } : {})} />