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,
+ },
+ }
: {})}
/>