Message Catalog — Design Spec
Date: 2026-07-17
Branch: feat/message-refactor
Scope: strykr-fe (frontend)
Problem
User-facing toast/notification copy is hardcoded inline across many files
(useAuth.ts, InlineBetSlip.tsx, useWebSocket.ts, useOpenBets.ts,
notificationStore.ts, orderStatus.ts, api.ts). The same concerns are
scattered: what text to show, which type (icon + colour) to use, whether to
show a toast at all, and how backend error codes map to friendly strings. This
makes copy changes error-prone (the driving Excel has ~20 edits across stale
line numbers) and makes it impossible to see all user-facing messages in one
place.
Goals
- One catalog holding every user-facing message — both backend-mapped (error code → friendly string) and frontend-only (toasts, inline validation).
- Each entry carries not just text but the full presentation:
type(colour),title,message,duration, optionaliconoverride, and the ability to express "no toast". - Support dynamic messages (e.g.
Back/Lay "selection" @ "matched_odds",Min stake is <limit>,Bet Partially Matched (<pct>%)). - Call sites reference the catalog instead of hardcoding strings.
- No new runtime dependency; generalize patterns already trusted in the repo.
Non-Goals
- Multi-language / locale support (no i18n library — YAGNI for a single-locale app).
- Changing when/whether events fire on the backend (that is content, tracked separately — see Dependencies).
- Restyling toasts beyond the single icon-override change needed for Tick+Yellow.
Existing patterns this generalizes
ERROR_MESSAGES(src/lib/api.ts:242) — backendcode→ friendly string map, resolved byparseApiError(api.ts:256).classifyOrderStatusToast(src/lib/orderStatus.ts:120) — pure, unit-tested function mapping a status to{ kind, title, body }ornull(no toast).toast(src/store/notificationStore.ts) —success|error|warning|infoconvenience wrappers; rendered bysrc/components/ui/Toast.tsxwheretypedrives BOTH icon and colour (they are coupled today).
Architecture
Module layout — src/lib/messages/
src/lib/messages/
├── types.ts ToastDescriptor, ToastTone, ToastIcon
├── auth.ts auth.* entries
├── placement.ts inline + slip placement entries (incl. stakeError.*)
├── lifecycle.ts matched / settled / re-settle / server-notification entries
├── cancel.ts open-bet cancel entries
├── errors.ts byCode map (absorbs ERROR_MESSAGES) + http fallbacks
└── index.ts export const messages = { auth, placement, lifecycle, cancel, errors }
Split by domain because one file for ~40 entries becomes a "does too much" file. Each domain file is independently readable and unit-testable.
Core types — types.ts
export type ToastTone = 'success' | 'error' | 'warning' | 'info'; // → colour
export type ToastIcon = 'check' | 'alert' | 'triangle' | 'info' | 'cross';
export type ToastDescriptor = {
type: ToastTone;
title: string;
message?: string;
duration?: number;
icon?: ToastIcon; // optional override; decouples icon from colour (Tick+Yellow)
} | null; // null = "No Toast Message"
- Static entries are
ToastDescriptorobjects. - Dynamic entries are factories
(params) => ToastDescriptor. - Inline (non-toast) strings (e.g.
stakeError.min) are(params) => string.
Dispatch — notificationStore.ts
Add toast.show(d: ToastDescriptor):
d === null→ no-op (this is how "No Toast Message" rows resolve).- else forward
{ type, title, message, duration, icon }toaddNotification.
addNotification / Notification gains an optional icon?: ToastIcon.
Existing toast.success/error/warning/info remain for back-compat during migration.
Renderer — Toast.tsx
iconMapkeyed byToastIcon. When a notification has an expliciticon, render that; otherwise fall back to the currenttype-derived icon.- Colour continues to come from
type(bgColorMap). - This is the ONLY design-system change and enables Tick(check) + Yellow(warning).
Backend map absorbed — errors.ts + api.ts
- Move
ERROR_MESSAGESverbatim intomessages.errors.byCode. - Move the HTTP status fallbacks into
messages.errors.byHttpStatus. parseApiErrorimports both from the catalog; resolution logic unchanged.- Single source of truth for all user-facing copy.
Message inventory (Phase 1 — from the Excel)
NC rows are omitted. Each change maps to a catalog key + the current call site.
type column reflects Excel Icon/Colour: Tick+Green=success, !+Yellow=warning,
Cross+Red=error, blue=info.
auth.ts
| Key | Call site (current) | type | title | message |
|---|---|---|---|---|
signInFailedGeneric | useAuth.ts:213, :298 | error | Sign In Failed | An unexpected error occurred. Please try again. |
(Other auth rows are NC; useAuth.ts:303 already uses this string.)
placement.ts (InlineBetSlip.tsx)
| Key | Call site (current) | type | title | message |
|---|---|---|---|---|
stakeError.min(limit) | caption :67 / :391 (inline red text) | — | — | Min stake is <limit> |
stakeError.maxPerBet(limit) | :235 toast | error | (TBD title — see Q1) | Max stake per bet is <limit> |
stakeError.maxMarket() | :235 toast | error | (TBD title — see Q1) | Market limit exceeded - try a lower stake |
stakeError.other() | :235 toast | error | (TBD title — see Q1) | Invalid Stake |
betAccepted(p) non-fancy/BF | :286 toast.success('Bet Placed',…) | warning | Bet Accepted - Awaiting Match | <Back/Lay> "<selection>" @ "<matched_odds>" |
betPlacedFancy(p) BM | :284 toast.warning('Submitted',…) | success | Bet Placed Successfully! | <Back/Lay> "<selection>" @ "<matched_odds>" |
placementPartial | :293 toast.warning('Partial',…) | — | — | null (no toast) |
placementFailed | :300, :305 toast.error('Bet Failed',…) | error | (TBD title — see Q1) | Unexpected Error |
lifecycle.ts
| Key | Call site (current) | type | title | message |
|---|---|---|---|---|
betMatched(p) full | orderStatus.ts:137 | success | Bet Matched | <Back/Lay> "<selection>" @ "<matched_odds>" |
betPartiallyMatched(p) | NEW (classify branch) | warning + icon:'check' | Bet Partially Matched (<pct>%) | <Back/Lay> "<selection>" @ "<matched_odds>" |
serverNotifAccepted | useWebSocket.ts:271-276 | — | — | null (no toast) |
serverNotifDeclined | :275 | error | Could Not Place Bet | Unexpected Error |
serverNotifOther | :275 | — | — | null (no toast) |
betfairResettlement | :252-255 toast.info(…) | — | — | null (no toast) |
settled.win/lose/push/void | notificationStore.ts:115-131 via :234 | — | — | null (no toast) |
betDeclinedLapsedCancelled | orderStatus.ts classify | — | — | UNDECIDED (TJ: "study further") — leave current behaviour |
cancel.ts (useOpenBets.ts)
| Key | Call site (current) | type | title | message |
|---|---|---|---|---|
cancelPartial | :108 | warning | Partially Cancelled | Bet could not be fully cancelled |
cancelFailed | :118 | error | Cancel Failed | Please try again later. |
(cancelFull :110 is NC.)
Dependencies (gate content, not format)
Dynamic factories betMatched, betAccepted, betPlacedFancy,
betPartiallyMatched require: side (Back/Lay), selection, matched_odds,
and pct (partial %). These may not be present in the current
/orders/batch response or the order:status / WS payloads. Phase 1 includes
tracing those payloads (read-only). If a field is missing, the catalog key is
authored with the param it needs and the backend gap is filed separately — the
frontend does NOT synthesize odds/selection (financial-integrity: no fabricated
values).
Testing
One *.test.mjs per domain, mirroring orderStatus.test.mjs: assert each
factory/entry produces the exact type/title/message/icon/null the Excel
specifies. parseApiError tests updated to read from messages.errors.
Migration
- Phase 1 (this spec): build infra (
types,toast.show,Toast.tsxicon override,errorsabsorption) + populate the catalog for every Excel message + migrate those call sites. Verify no behavioural regressions on NC paths. - Phase 2 (follow-up spec): sweep remaining hardcoded
toast.*call sites (BetSlipFooter, one-click grids, and any others found by grep) onto the catalog so no user-facing copy remains inline.
Resolved questions
- Q1 — "NA" titles → inline red text. Inline stake errors (
min,maxPerBet,maxMarket,other) and the inline placement failure render as red text inside the bet slip (like the existing min-stake caption), NOT as toasts. They are(params) => stringentries, notToastDescriptor. This removes the:235and:300/:305toasts fromInlineBetSlip.tsxin favour of a caption. The "TBD title" cells above are therefore N/A (no title). - Q2 — settled notifications → remove toast only. Stop the transient toast for settled win/lose/push/void; KEEP the persistent Notification Center entry. Implementation must confirm the toast and the Notification Center read from different sources before removing (see Implementation note).
- Q3 —
betDeclinedLapsedCancelled→ unchanged. LeaveclassifyOrderStatusToast's current declined/lapsed/cancelled output as-is.
Implementation note (Q2): verify whether ToastContainer and
NotificationCenter both read useNotificationStore or whether the Center reads
the ['notifications'] backend query. If they share the store, removing the
toast necessarily removes the Center entry — reconcile before implementing.
Cross-system impact
- backend: possibly — only if matched_odds/selection/pct are not already in the WS/API payloads (tracked as a separate change).
- frontend: the catalog +
Toast.tsxicon override + call-site migrations. - services / infra: none.