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 inapps/admin/src/sharedthe operator approved by name (see N7). Zero edits toapps/admin/src/admin. - Predecessor:
merchant-defect-fixes.mdclosed M1 to M5 on 2026-08-27. All four of M1 to M4 were re-measured here and still hold. M5 is deliberately reversed, seefooter-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, runningfix/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-apirejectslocalhost:3013at CORS because only3001is 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 wordherois the database value. OffersList.jsx:177interpolated{offer.placement}while already importingAUDIENCE_LABELSfromconstants/placements, the same module that exportsPLACEMENT_LABELSwithhero: '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 inOfferDetail.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 Railin the detail,Hero Railin 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.jsxcalledmerchant?.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)..toDateon a string isundefined, so the value silently fell through to the empty branch every single time. - The fallback half was dead too: the response carries no
createdAtfor an owner at all, soprimaryOwner.createdAt?.toDate?.()could never resolve either. - Now accepts a Timestamp, an ISO string or epoch millis. Reads
Aug 2026on the test merchant.
N4. An empty detail field painted a bare hyphen with nothing behind it. โ
DetailFieldinMerchantDetailFields.jsxrendered a literal-, which a screen reader announces as nothing useful.apps/admin/src/shared/components/EmptyValue.jsxexists precisely to add the words a lone dash cannot carry, andOfferDetail.jsxtwo folders away already uses it.MyMerchantProfilealso 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
createdAtin the response inside the browser: the field renders the glyph plus ansr-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.jssays 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:156and line 236 now useUNAVAILABLE.
N7. The merchant switcher read "VIEWING Merchant" instead of the business name. โ
#980, and the fix is one line. The name effect carried a localcancelledflag 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 acancelledonly 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 thatMerchantShell.jsx:136is 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.jsxrendered underStrictMode, which is why every test stayed green through the whole defect. Astrictoption 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 15890656live, 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:140rendersv.addressverbatim, and auth-api passesaddress: v.addressstraight 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.addresskept 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:84andosm.service.js:228both do[houseNumber, street, city, state, postcode].filter(Boolean).join(' '). The missing street number is the same line, dropped byfilter(Boolean)when the source has none. addressComponentsis stored correctly beside the string, so a backfill can rebuildaddresswithout re-fetching anything. Recorded on the issue. Stopped there:services/api/venuesis 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 BREWin the page header, thenHalf price cold brewagain 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 0andExpired 0, while the Offers list shows one offer sitting atRejected. 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. โ
| Wanted | Why it stopped | What happened instead |
|---|---|---|
MerchantSwitcher.jsx, to fix #980 | Lives in shared/components/, which this session may only report on | Stopped, 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 primitive | Nothing needed changing there | Imported 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.
What is related? โ
footer-pin-988.md, the #988 cause and its numbers.design-survey.md, the catalogue these extend.merchant-defect-fixes.md, yesterday's M1 to M5.