Skip to content

Merchant portal, what else is wrong (2026-08-28) โ€‹

  • Status: driven in a browser across all seven merchant routes plus the offer detail and the offer form, under BOTH logins. Ten items fixed, three reported and not fixed.
  • Scope: apps/admin/src/merchant, plus ONE file in apps/admin/src/shared the operator approved by name (see N7). Zero edits to apps/admin/src/admin.
  • Predecessor: merchant-defect-fixes.md closed M1 to M5 on 2026-08-27. All four of M1 to M4 were re-measured here and still hold. M5 is deliberately reversed, see footer-pin-988.md.
  • Test plan: merchant-portal-defects-2026-08-28.

What server was this driven on? โ€‹

  • Before measurements: http://localhost:3001, the operator's own stack, read only. Nothing restarted, rebuilt or killed.
  • After measurements: http://localhost:3013, a throwaway Vite admin portal this session stood up from the worktree, running fix/merchant-portal-988. It proxies to the same deployed dev APIs her stack does.
  • Both logins were driven: Agent Probe (an admin viewing a merchant) and the test merchant's own login, because the sidebar footer renders a different child in each and only one of them is what a real merchant sees.
  • One artifact of the throwaway port, not a defect: docs-api rejects localhost:3013 at CORS because only 3001 is in its allowlist. Those console errors do not appear on her stack and are not a finding.

What was fixed? โ€‹

N1. The offers list rendered the raw placement enum. โ€‹

  • A row read Lantern Alpha Venue ยท hero ยท Expires 9/4/2026. The word hero is the database value.
  • OffersList.jsx:177 interpolated {offer.placement} while already importing AUDIENCE_LABELS from constants/placements, the same module that exports PLACEMENT_LABELS with hero: 'Hero Rail'.
  • Now reads Hero Rail. Same failure family as survey items A1 and A7.

N2. OfferDetail.jsx kept its own copy of the placement labels, and the copy had drifted. โ€‹

  • Canonical: Hero Rail, Inline Card. The private copy in OfferDetail.jsx: Dashboard Hero Rail, Inline Venue Card.
  • So ONE offer's placement read three different ways depending on where you opened it: the raw enum in the list, Dashboard Hero Rail in the detail, Hero Rail in the form.
  • This is verbatim the failure the constants file's own comment records having already fixed once for audience labels. The copy is deleted and both screens import the canonical map.

N3. Account's "Member since" could never render a date, for any merchant. โ€‹

  • MyMerchantProfile.jsx called merchant?.createdAt?.toDate?.(), the Firestore Timestamp shape.
  • auth-api serializes it to an ISO string before it leaves the server (merchantHandlers.js:192, createdAt?.toDate?.()?.toISOString() || null). .toDate on a string is undefined, so the value silently fell through to the empty branch every single time.
  • The fallback half was dead too: the response carries no createdAt for an owner at all, so primaryOwner.createdAt?.toDate?.() could never resolve either.
  • Now accepts a Timestamp, an ISO string or epoch millis. Reads Aug 2026 on the test merchant.

N4. An empty detail field painted a bare hyphen with nothing behind it. โ€‹

  • DetailField in MerchantDetailFields.jsx rendered a literal -, which a screen reader announces as nothing useful. apps/admin/src/shared/components/EmptyValue.jsx exists precisely to add the words a lone dash cannot carry, and OfferDetail.jsx two folders away already uses it.
  • MyMerchantProfile also passed the literal string 'Not set', which is truthy, so the empty branch was unreachable from that field even in principle.
  • Both fixed. Exercised live by nulling createdAt in the response inside the browser: the field renders the glyph plus an sr-only "Not set". Nothing was written to shared dev data to do it.

N5. Two hand-typed en dashes in Overview.jsx where the module exports a constant for exactly that. โ€‹

  • Not visible: identical pixels. Recorded because packages/shared/format/emptyValue.js says in its own header that the constants exist "for the formatter functions that must return one", and a hand-typed dash literal is one keystroke from the banned em dash in a repo with a dash gate.
  • Overview.jsx:156 and line 236 now use UNAVAILABLE.

N7. The merchant switcher read "VIEWING Merchant" instead of the business name. โ€‹

  • #980, and the fix is one line. The name effect carried a local cancelled flag AND a hook-level in-flight ref. A ref survives StrictMode's simulated remount; the flag does not. So the second mount found the id still claimed, started no replacement fetch, and the first fetch resolved into a cancelled only IT could see and threw away the name it had just retrieved. Nothing was left to write it.
  • The cleanup now releases the in-flight marker as well as setting the flag, so a remount can re-fetch. The ref keeps doing its actual job, deduping concurrent fetches within one mount.
  • This is in apps/admin/src/shared/components/, and the operator gave a direct yes for this specific file on 2026-08-28 after being shown that MerchantShell.jsx:136 is its only runtime call site, so the change cannot reach the admin portal. It is the one shared edit in this session and it was not made on the session's own judgement.
  • The suite could not see it, and now it can. No case in MerchantSwitcher.test.jsx rendered under StrictMode, which is why every test stayed green through the whole defect. A strict option was added to the file's render helper and one case uses it. Checked against the original code: that one case fails, the other four still pass.
  • Reads VIEWING Round Trip Cafe 15890656 live, on two routes and after a hard reload.

What was found and NOT fixed? โ€‹

N6. A venue address renders with no commas, and the cause is the DATA. โ€‹

  • Venues shows East Market Street San Diego California 92101. No commas, no street number.
  • Venues.jsx:140 renders v.address verbatim, and auth-api passes address: v.address straight through from the venue document (merchantHandlers.js:161).
  • Not fixed because the string is wrong in Firestore, not in the merchant portal. Formatting around bad data would hide it: this one screen would look right while every other consumer of venue.address kept the broken string.
  • Filed as seeded venue addresses have no commas, so the merchant Venues page reads as one run-on string (#993), and since established as the importer, in two places that agree with each other: nominatim.service.js:84 and osm.service.js:228 both do [houseNumber, street, city, state, postcode].filter(Boolean).join(' '). The missing street number is the same line, dropped by filter(Boolean) when the source has none.
  • addressComponents is stored correctly beside the string, so a backfill can rebuild address without re-fetching anything. Recorded on the issue. Stopped there: services/api/venues is not merchant territory.

N8. The offers list falls back to a raw venue id when the venue lookup misses. โ€‹

  • {venue?.name || offer.venueId}, same family as N1 and survey A1.
  • Not fixed because it did not fire in this run, so there is nothing to verify a change against, and what it should say instead ("Venue unavailable"? the id? nothing?) is a copy decision rather than a defect with one right answer.

N9. The offer detail's Archive button is the loudest thing on the screen. โ€‹

  • Full-fill danger red, heavier than the page's own primary action, for an operation that is reversible.
  • FIXED 2026-08-28 on a relayed decision. Danger weight stays on Delete, which destroys a draft; Archive drops to secondary because it is reversible and is not what a merchant opened the page to do. Same button, two states, and they were sharing one weight.

N10. The offer detail header prints the title twice. โ€‹

  • HALF PRICE COLD BREW in the page header, then Half price cold brew again as the first line of the body.
  • FIXED 2026-08-28 on a relayed decision. The PageHeader keeps it, because that is what every other merchant page uses, and it gained the status badge the body row was carrying. A test now asserts the detail does NOT render the title, so the duplicate cannot come back green.

N11. Overview's Active Offers card has no row for a rejected offer. โ€‹

  • The card breaks out Draft 0 and Expired 0, while the Offers list shows one offer sitting at Rejected. A merchant whose offer was rejected sees no sign of it on the dashboard they land on.
  • NOT FIXED, and this is a decision rather than a deferral. A rejected offer is not active, so putting it on a card named Active Offers would fix a visibility gap by breaking the card's meaning. The real gap is that rejected offers have no home anywhere, which is now recorded in the design brief as a named gap rather than patched into a card.

N12. The Notes page's empty state is a bare sentence. โ€‹

  • No notes. with no icon, no action, unlike the Photos page's proper empty state on the same shell.
  • FIXED 2026-08-28 on a relayed decision. Photos, the sibling empty surface on the same shell, gets an icon and a sentence; Notes got neither. It now matches, and says different sentences to the two readers because only an admin can write a note.

N13. The offer form's Overview subtitle explains our implementation. โ€‹

  • "These defaults apply to every selected placement. Each placement can override on its sub-page." A merchant does not have sub-pages, the form does.
  • FIXED 2026-08-28 on a relayed decision, and there was more of it than the one string. "Sub-page" appeared twice, and five preview-variant descriptions were written in our styling vocabulary (genre tint, genre neon, pill, rim, runtime signal) and render to the merchant as a tooltip and a caption. Eight strings rewritten. Left alone: "geofence radius", which is the field's own label, so changing it is a bigger call than a hint rewrite.

Where did a fix want to reach into apps/admin/src/shared? โ€‹

Twice, and both times the answer was to report it. โ€‹

WantedWhy it stoppedWhat happened instead
MerchantSwitcher.jsx, to fix #980Lives in shared/components/, which this session may only report onStopped, showed the operator that its only call site is merchant code, and asked. She said yes for that file. Fixed as N7
EmptyValue.jsx, to reach the NotSet primitiveNothing needed changing thereImported it from merchant code instead, which is what it is for
  • The second one is worth naming because it is the shape that is FINE: importing a shared primitive from merchant code is not editing shared. The rule is about what lands on the admin portal, and an import lands nowhere.
  • The first one is worth naming for the opposite reason. The rule's premise did not hold for that file, and being right about that is still not authority to act on it. The reportable condition is "a guard blocked me and I reasoned about its scope", so it went to her and she decided.

Built with VitePress