Message Catalog Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Centralize every user-facing toast/inline message (backend-mapped and frontend-only) into one referenced catalog under strykr-fe/src/lib/messages/, replacing inline hardcoded strings.
Architecture: A pure, dependency-free catalog of descriptors and factory functions (mirrors orderStatus.ts / ERROR_MESSAGES). Static entries are ToastDescriptor objects; dynamic ones are factories returning descriptors; null means "no toast"; inline validation entries are (params) => string. A new toast.show(descriptor) dispatch no-ops on null. Toast.tsx gains an optional icon override so a green tick can sit on a yellow toast.
Tech Stack: Next.js, React, Zustand (notificationStore), TypeScript, node --test (.test.mjs, Node 24 native type-stripping — modules under test MUST be pure/dependency-free).
Global Constraints
- Copy is verbatim from the Excel. Titles/messages must match exactly, including punctuation and capitalization.
- Catalog modules must be pure and dependency-free (no
@/imports, no runtime imports) sonode --testtype-stripping can load them directly. Type-only imports are fine. - Never fabricate financial/domain values (odds, selection, matched %). If a payload lacks a field, the message cannot render it — that gap is a backend task (Phase 1b), not a frontend default. (Ref:
.claude/rules/financial-security.md§1.) - NC rows stay untouched. Do not change
BetSlipFooter.tsx, one-click grids,useAuth.ts:303(already correct),useOpenBets.ts:110cancel-full, or any Auth success toast. - Migration gate: catalog logic is verified by
npm test; call-site migrations (hooks/components) are verified bynpx tsc --noEmit(no component-test framework exists). - Q1/Q2/Q3 resolutions: inline stake errors + inline placement failure render as red caption text (no toast); settled toasts removed (Notification Center entry kept — it reads the backend
['notifications']query, a separate source); declined/lapsed/cancelled left unchanged.
File Structure
strykr-fe/src/lib/messages/
├── types.ts # ToastTone, ToastIcon, ToastDescriptor, resolveToastIcon() [PURE]
├── errors.ts # byCode, byHttpStatus, resolveApiErrorMessage() [PURE]
├── auth.ts # auth.* [PURE]
├── placement.ts # placement.* (stakeError inline strings + placement toasts) [PURE]
├── lifecycle.ts # lifecycle.* (settled null, serverNotif*, resettlement null) [PURE]
├── cancel.ts # cancel.* [PURE]
└── index.ts # export const messages = { auth, placement, lifecycle, cancel, errors } [PURE]
Migrated call sites: src/store/notificationStore.ts, src/components/ui/Toast.tsx, src/lib/api.ts, src/hooks/useAuth.ts, src/components/betting/InlineBetSlip.tsx, src/hooks/useWebSocket.ts, src/hooks/useOpenBets.ts.
Task 1: Core types + icon resolver
Files:
- Create:
strykr-fe/src/lib/messages/types.ts - Test:
strykr-fe/src/lib/messages/types.test.mjs
Interfaces:
-
Produces:
type ToastTone = 'success' | 'error' | 'warning' | 'info'type ToastIcon = 'check' | 'alert' | 'triangle' | 'info' | 'cross'type ToastDescriptor = { type: ToastTone; title: string; message?: string; duration?: number; icon?: ToastIcon } | nullresolveToastIcon(type: ToastTone, icon?: ToastIcon): ToastIcon— returnsiconwhen set, else the tone's default (success→check,error→cross,warning→triangle,info→info).
-
Step 1: Write the failing test
// strykr-fe/src/lib/messages/types.test.mjs
import assert from 'node:assert/strict';
import test from 'node:test';
const { resolveToastIcon } = await import('./types.ts');
test('resolveToastIcon falls back to the tone default', () => {
assert.equal(resolveToastIcon('success'), 'check');
assert.equal(resolveToastIcon('error'), 'cross');
assert.equal(resolveToastIcon('warning'), 'triangle');
assert.equal(resolveToastIcon('info'), 'info');
});
test('resolveToastIcon honours an explicit override', () => {
// the Tick+Yellow case: warning tone, check icon
assert.equal(resolveToastIcon('warning', 'check'), 'check');
});
- Step 2: Run test to verify it fails
Run: cd strykr-fe && node --test src/lib/messages/types.test.mjs
Expected: FAIL — cannot find module ./types.ts.
- Step 3: Write minimal implementation
// strykr-fe/src/lib/messages/types.ts
export type ToastTone = 'success' | 'error' | 'warning' | 'info';
export type ToastIcon = 'check' | 'alert' | 'triangle' | 'info' | 'cross';
export type ToastDescriptor = {
type: ToastTone;
title: string;
message?: string;
duration?: number;
icon?: ToastIcon;
} | null;
const DEFAULT_ICON: Record<ToastTone, ToastIcon> = {
success: 'check',
error: 'cross',
warning: 'triangle',
info: 'info',
};
export function resolveToastIcon(type: ToastTone, icon?: ToastIcon): ToastIcon {
return icon ?? DEFAULT_ICON[type];
}
- Step 4: Run test to verify it passes
Run: cd strykr-fe && node --test src/lib/messages/types.test.mjs
Expected: PASS (2 tests).
- Step 5: Commit
git add strykr-fe/src/lib/messages/types.ts strykr-fe/src/lib/messages/types.test.mjs
git commit -m "feat(messages): add ToastDescriptor types and icon resolver"
Task 2: Backend error catalog + resolver
Files:
- Create:
strykr-fe/src/lib/messages/errors.ts - Test:
strykr-fe/src/lib/messages/errors.test.mjs - Modify:
strykr-fe/src/lib/api.ts:242-295(replace localERROR_MESSAGES+ inline status switch with catalog calls)
Interfaces:
-
Produces:
errors.byCode: Record<string, string>(verbatim from currentERROR_MESSAGES)errors.byHttpStatus: Record<number, string>resolveApiErrorMessage(code: string | null, httpStatus: number | null, apiMessage: string | null): string— pure. Precedence mirrors currentparseApiError: abyCodeentry (exceptVALIDATION_ERROR) wins; elseapiMessage; elsebyHttpStatus; else'An unexpected error occurred.'.
-
Step 1: Write the failing test
// strykr-fe/src/lib/messages/errors.test.mjs
import assert from 'node:assert/strict';
import test from 'node:test';
const { errors, resolveApiErrorMessage } = await import('./errors.ts');
test('known code (non-validation) maps to friendly copy', () => {
assert.equal(resolveApiErrorMessage('INSUFFICIENT_BALANCE', 400, 'raw'),
'Insufficient balance to complete this action.');
});
test('VALIDATION_ERROR prefers the API message over the generic code copy', () => {
assert.equal(resolveApiErrorMessage('VALIDATION_ERROR', 400, 'Stake too low'), 'Stake too low');
});
test('unknown code with no api message falls back to http status', () => {
assert.equal(resolveApiErrorMessage('SOMETHING_NEW', 503, null), errors.byHttpStatus[503]);
});
test('nothing known falls back to the generic message', () => {
assert.equal(resolveApiErrorMessage(null, null, null), 'An unexpected error occurred.');
});
- Step 2: Run test to verify it fails
Run: cd strykr-fe && node --test src/lib/messages/errors.test.mjs
Expected: FAIL — cannot find module ./errors.ts.
- Step 3: Write minimal implementation
// strykr-fe/src/lib/messages/errors.ts
export const errors = {
byCode: {
VALIDATION_ERROR: 'Please check your input and try again.',
AUTHENTICATION_ERROR: 'Please sign in to continue.',
AUTHORIZATION_ERROR: "You don't have permission to perform this action.",
NOT_FOUND: 'The requested resource was not found.',
CONFLICT: 'This action conflicts with existing data.',
RATE_LIMIT_EXCEEDED: 'Too many requests. Please wait a moment and try again.',
EXTERNAL_SERVICE_ERROR: 'A service is temporarily unavailable. Please try again later.',
INSUFFICIENT_BALANCE: 'Insufficient balance to complete this action.',
ORDER_LIMIT_EXCEEDED: 'Order exceeds allowed limits.',
INTERNAL_ERROR: 'Something went wrong. Please try again later.',
} as Record<string, string>,
byHttpStatus: {
400: 'Invalid request. Please check your input.',
401: 'Please sign in to continue.',
403: "You don't have permission to perform this action.",
404: 'The requested resource was not found.',
429: 'Too many requests. Please wait a moment.',
500: 'Server error. Please try again later.',
502: 'Service temporarily unavailable.',
503: 'Service is under maintenance.',
} as Record<number, string>,
};
export function resolveApiErrorMessage(
code: string | null,
httpStatus: number | null,
apiMessage: string | null,
): string {
if (code) {
const friendly = errors.byCode[code];
if (friendly && code !== 'VALIDATION_ERROR') return friendly;
}
if (apiMessage) return apiMessage;
if (httpStatus != null && errors.byHttpStatus[httpStatus]) return errors.byHttpStatus[httpStatus];
return 'An unexpected error occurred.';
}
- Step 4: Run test to verify it passes
Run: cd strykr-fe && node --test src/lib/messages/errors.test.mjs
Expected: PASS (4 tests).
- Step 5: Refactor
api.tsparseApiErrorto use the catalog
Replace the ERROR_MESSAGES const (lines ~242-253) and the body of parseApiError (lines ~256-295). Delete the local ERROR_MESSAGES. New parseApiError:
import { resolveApiErrorMessage } from './messages/errors';
export function parseApiError(error: unknown): string {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<ApiError>;
if (!axiosError.response) {
return 'Unable to connect to the server. Please check your internet connection.';
}
const apiError = axiosError.response.data?.error;
return resolveApiErrorMessage(
apiError?.code ?? null,
axiosError.response.status ?? null,
apiError?.message ?? null,
);
}
if (error instanceof Error) return error.message;
return 'An unexpected error occurred.';
}
Note: the network-error early return and the Error fallback are preserved. The status-switch default ('An unexpected error occurred.') is now the resolver's final fallback.
- Step 6: Verify typecheck passes
Run: cd strykr-fe && npx tsc --noEmit
Expected: no errors.
- Step 7: Commit
git add strykr-fe/src/lib/messages/errors.ts strykr-fe/src/lib/messages/errors.test.mjs strykr-fe/src/lib/api.ts
git commit -m "feat(messages): absorb backend error map into catalog; parseApiError reads from it"
Task 3: toast.show dispatch + icon field
Files:
- Modify:
strykr-fe/src/store/notificationStore.ts(addicontoNotification, addtoast.show)
Interfaces:
-
Consumes:
ToastDescriptor,ToastIconfrom Task 1. -
Produces:
toast.show(d: ToastDescriptor): string | void— returnsaddNotificationid whendis non-null, no-ops (returnsundefined) whendisnull.Notificationgainsicon?: ToastIcon. -
Step 1: Add
iconto theNotificationinterface
In notificationStore.ts, import the icon type and extend the interface:
import type { ToastDescriptor, ToastIcon } from '@/lib/messages/types';
export interface Notification {
id: string;
type: NotificationType;
title: string;
message?: string;
duration?: number;
icon?: ToastIcon; // NEW — optional icon override
action?: { label: string; onClick: () => void };
}
- Step 2: Add
toast.showto thetoastobject
Append inside the export const toast = { ... } object (alongside success/error/...):
/** Dispatch a catalog descriptor. `null` = no toast (no-op). */
show: (d: ToastDescriptor) => {
if (!d) return;
return useNotificationStore.getState().addNotification({
type: d.type,
title: d.title,
message: d.message,
duration: d.duration,
icon: d.icon,
});
},
- Step 3: Verify typecheck passes
Run: cd strykr-fe && npx tsc --noEmit
Expected: no errors. (NotificationType and ToastTone are the same four-member union — assignable.)
- Step 4: Commit
git add strykr-fe/src/store/notificationStore.ts
git commit -m "feat(messages): add toast.show(descriptor) dispatch and icon field"
Task 4: Toast.tsx icon override
Files:
- Modify:
strykr-fe/src/components/ui/Toast.tsx
Interfaces:
-
Consumes:
resolveToastIcon,ToastIconfrom Task 1;Notification.iconfrom Task 3. -
Step 1: Replace the type-keyed
iconMapwith an icon-keyed map + resolver
import { X, CheckCircle, AlertCircle, AlertTriangle, Info, XCircle } from 'lucide-react';
import { resolveToastIcon, ToastIcon } from '@/lib/messages/types';
const iconMap: Record<ToastIcon, React.ReactNode> = {
check: <CheckCircle className="w-5 h-5 text-green-400" />,
cross: <XCircle className="w-5 h-5 text-red-400" />,
alert: <AlertCircle className="w-5 h-5 text-red-400" />,
triangle: <AlertTriangle className="w-5 h-5 text-yellow-400" />,
info: <Info className="w-5 h-5 text-blue-400" />,
};
- Step 2: Use the resolver at the render site
Replace {iconMap[notification.type]} (line ~49) with:
{iconMap[resolveToastIcon(notification.type, notification.icon)]}
bgColorMap[notification.type] stays unchanged — colour still comes from type. Result: a warning (yellow) toast with icon: 'check' renders a green tick on a yellow background.
- Step 3: Verify typecheck passes
Run: cd strykr-fe && npx tsc --noEmit
Expected: no errors.
- Step 4: Commit
git add strykr-fe/src/components/ui/Toast.tsx
git commit -m "feat(messages): decouple toast icon from colour via icon override"
Task 5: auth catalog + migrate useAuth
Files:
- Create:
strykr-fe/src/lib/messages/auth.ts - Test:
strykr-fe/src/lib/messages/auth.test.mjs - Modify:
strykr-fe/src/hooks/useAuth.ts:213,:298
Interfaces:
-
Consumes:
ToastDescriptorfrom Task 1. -
Produces:
auth.signInFailedGeneric: ToastDescriptor={ type:'error', title:'Sign In Failed', message:'An unexpected error occurred. Please try again.' }. -
Step 1: Write the failing test
// strykr-fe/src/lib/messages/auth.test.mjs
import assert from 'node:assert/strict';
import test from 'node:test';
const { auth } = await import('./auth.ts');
test('signInFailedGeneric matches the Excel copy', () => {
assert.deepEqual(auth.signInFailedGeneric, {
type: 'error',
title: 'Sign In Failed',
message: 'An unexpected error occurred. Please try again.',
});
});
- Step 2: Run test to verify it fails
Run: cd strykr-fe && node --test src/lib/messages/auth.test.mjs
Expected: FAIL — cannot find module ./auth.ts.
- Step 3: Write minimal implementation
// strykr-fe/src/lib/messages/auth.ts
import type { ToastDescriptor } from './types';
export const auth = {
signInFailedGeneric: {
type: 'error',
title: 'Sign In Failed',
message: 'An unexpected error occurred. Please try again.',
} satisfies ToastDescriptor,
};
- Step 4: Run test to verify it passes
Run: cd strykr-fe && node --test src/lib/messages/auth.test.mjs
Expected: PASS.
- Step 5: Migrate the two call sites
In useAuth.ts, add import { messages } from '@/lib/messages'; (top of file, near other lib imports).
At line 213 (auto-auth, "400 other error" branch), replace:
toast.error('Sign In Failed', backendErrorMessage);
with:
toast.show(messages.auth.signInFailedGeneric);
At line 298 (manual-retry authenticateWallet, non-invite 400 branch), replace:
toast.error('Sign In Failed', backendErrorMessage);
with:
toast.show(messages.auth.signInFailedGeneric);
Leave lines 207-211 (Invite Required) and 290-294 unchanged (NC). Leave line 303 unchanged (already the generic copy). backendErrorMessage remains used by the invite-detection logic, so do not delete it.
- Step 6: Verify typecheck passes
Run: cd strykr-fe && npx tsc --noEmit
Expected: no errors.
- Step 7: Commit
git add strykr-fe/src/lib/messages/auth.ts strykr-fe/src/lib/messages/auth.test.mjs strykr-fe/src/hooks/useAuth.ts
git commit -m "feat(messages): auth catalog + migrate Sign In Failed generic copy"
Task 6: placement catalog + migrate InlineBetSlip
Files:
- Create:
strykr-fe/src/lib/messages/placement.ts - Test:
strykr-fe/src/lib/messages/placement.test.mjs - Modify:
strykr-fe/src/components/betting/InlineBetSlip.tsx(stake-error caption ~67/231-237/391; placement result 284/286/293/300/305)
Interfaces:
-
Consumes:
ToastDescriptorfrom Task 1. -
Produces:
placement.stakeError.min(limit: string): string→`Min stake is ${limit}`placement.stakeError.maxPerBet(limit: string): string→`Max stake per bet is ${limit}`placement.stakeError.maxMarket(): string→'Market limit exceeded - try a lower stake'placement.stakeError.other(): string→'Invalid Stake'placement.inlineFailure: string→'Unexpected Error'placement.betAccepted(p: { side: 'Back'|'Lay'; selection: string; odds: string }): ToastDescriptor→{ type:'warning', title:'Bet Accepted - Awaiting Match', message:${p.side} "${p.selection}" @ "${p.odds}"}placement.betPlacedFancy(p: { side: 'Back'|'Lay'; selection: string; odds: string }): ToastDescriptor→{ type:'success', title:'Bet Placed Successfully!', message:${p.side} "${p.selection}" @ "${p.odds}"}placement.partial: ToastDescriptor=null
-
Step 1: Write the failing test
// strykr-fe/src/lib/messages/placement.test.mjs
import assert from 'node:assert/strict';
import test from 'node:test';
const { placement } = await import('./placement.ts');
test('stake error strings match the Excel', () => {
assert.equal(placement.stakeError.min('$20'), 'Min stake is $20');
assert.equal(placement.stakeError.maxPerBet('$500'), 'Max stake per bet is $500');
assert.equal(placement.stakeError.maxMarket(), 'Market limit exceeded - try a lower stake');
assert.equal(placement.stakeError.other(), 'Invalid Stake');
assert.equal(placement.inlineFailure, 'Unexpected Error');
});
test('betAccepted (BF) is a yellow awaiting-match toast', () => {
assert.deepEqual(placement.betAccepted({ side: 'Back', selection: 'Team A', odds: '2.00' }), {
type: 'warning',
title: 'Bet Accepted - Awaiting Match',
message: 'Back "Team A" @ "2.00"',
});
});
test('betPlacedFancy (BM) is a green success toast', () => {
assert.deepEqual(placement.betPlacedFancy({ side: 'Lay', selection: 'Over 2.5', odds: '1.90' }), {
type: 'success',
title: 'Bet Placed Successfully!',
message: 'Lay "Over 2.5" @ "1.90"',
});
});
test('partial placement produces no toast', () => {
assert.equal(placement.partial, null);
});
- Step 2: Run test to verify it fails
Run: cd strykr-fe && node --test src/lib/messages/placement.test.mjs
Expected: FAIL — cannot find module ./placement.ts.
- Step 3: Write minimal implementation
// strykr-fe/src/lib/messages/placement.ts
import type { ToastDescriptor } from './types';
type Placed = { side: 'Back' | 'Lay'; selection: string; odds: string };
const placedLine = (p: Placed) => `${p.side} "${p.selection}" @ "${p.odds}"`;
export const placement = {
stakeError: {
min: (limit: string) => `Min stake is ${limit}`,
maxPerBet: (limit: string) => `Max stake per bet is ${limit}`,
maxMarket: () => 'Market limit exceeded - try a lower stake',
other: () => 'Invalid Stake',
},
inlineFailure: 'Unexpected Error',
betAccepted: (p: Placed): ToastDescriptor => ({
type: 'warning',
title: 'Bet Accepted - Awaiting Match',
message: placedLine(p),
}),
betPlacedFancy: (p: Placed): ToastDescriptor => ({
type: 'success',
title: 'Bet Placed Successfully!',
message: placedLine(p),
}),
partial: null as ToastDescriptor,
};
- Step 4: Run test to verify it passes
Run: cd strykr-fe && node --test src/lib/messages/placement.test.mjs
Expected: PASS (4 tests).
- Step 5: Migrate the stake-error caption in
InlineBetSlip.tsx
Add import { messages } from '@/lib/messages'; near the other lib imports.
The min violation already shows via the minError caption (line ~67, rendered ~391). Per Q1, the per-bet / market / other violations must ALSO become caption text, not a toast. Introduce a single caption source.
5a. Replace the min-only minError (line ~67) with a stakeCaption that covers all kinds. Current minError derives from validateStake(...).kind === 'min'. Change it to surface the message for ALL kinds using the catalog. Replace the minError definition with:
// All stake validation surfaces as red caption text (no toast). validateStake
// returns { error, kind }; map kind → catalog copy. `limits` may be null pre-load.
const stakeCaption: string | null = (() => {
if (isDemo) return null;
const v = validateStake({ stake: item.stake, betType: item.betType, odds: dOdds }, resolveBetLimits(limits));
if (!v.error) return null;
const lim = resolveBetLimits(limits);
switch (v.kind) {
case 'min': return messages.placement.stakeError.min(formatLimitAmount(lim.min, lim.currency));
case 'maxPerBet': return messages.placement.stakeError.maxPerBet(formatLimitAmount(lim.maxPerBet, lim.currency));
case 'maxMarket': return messages.placement.stakeError.maxMarket();
default: return messages.placement.stakeError.other();
}
})();
(Import formatLimitAmount and resolveBetLimits from @/lib/betLimits if not already imported — check the existing import line for validateStake.)
5b. Update the caption render (line ~390-392) to use stakeCaption instead of minError:
{stakeCaption && (
<span className="mt-[4px] text-[12px] text-danger">{stakeCaption}</span>
5c. Remove the toast branch in the placement handler (lines ~232-237). Replace:
const v = validateStake({ stake: item.stake, betType: item.betType, odds: dOdds }, resolveBetLimits(limits));
if (v.error) {
if (v.kind !== 'min') toast.error('Stake not allowed', v.error);
return;
}
with (block placement on ANY error; the caption already shows why — no toast):
const v = validateStake({ stake: item.stake, betType: item.betType, odds: dOdds }, resolveBetLimits(limits));
if (v.error) {
return; // caption (stakeCaption) shows the reason; no toast per Q1
}
- Step 6: Migrate the placement-result toasts in
InlineBetSlip.tsx
Compute the placed-line params once (side from isBack, selection from item.selectionName, odds from the displayed item.odds). In the success block (lines ~279-289), replace:
if (result.submitted > 0 && result.failed === 0) {
haptics.betPlaced();
const isFancy = item.isFancy;
if (isFancy) {
toast.warning('Submitted', `${item.selectionName} submitted successfully`);
} else {
toast.success('Bet Placed', `${item.selectionName} @ ${formatOdds(item.odds, item.oddsType)}`);
}
removeBet(item.id);
setActiveInlineId(null);
} else if (result.submitted > 0 && result.failed > 0) {
with:
if (result.submitted > 0 && result.failed === 0) {
haptics.betPlaced();
const placed = {
side: (isBack ? 'Back' : 'Lay') as 'Back' | 'Lay',
selection: item.selectionName,
odds: formatOdds(item.odds, item.oddsType),
};
// Fancy (bookmaker/BM) → green success; exchange (BF) → yellow awaiting-match.
toast.show(item.isFancy ? messages.placement.betPlacedFancy(placed) : messages.placement.betAccepted(placed));
removeBet(item.id);
setActiveInlineId(null);
} else if (result.submitted > 0 && result.failed > 0) {
In the partial block (lines ~290-295), remove the toast (Q1/Excel: no toast), keep cleanup:
} else if (result.submitted > 0 && result.failed > 0) {
// Partial — no toast (Excel). Clean up as before.
haptics.warning();
removeBet(item.id);
setActiveInlineId(null);
} else {
In the all-failed block (lines ~296-301) and the catch (lines ~302-305), replace the backend-message toast with the fixed inline-failure copy:
} else {
haptics.error();
toast.show({ type: 'error', title: messages.placement.inlineFailure, message: undefined });
}
} catch (error: any) {
haptics.error();
toast.show({ type: 'error', title: messages.placement.inlineFailure, message: undefined });
} finally {
Note: the Excel gives inline failure
NewMessage = 'Unexpected Error'with NA title. A toast needs a title; here the title carries the copy and message is omitted. If, on review, you prefer this as a caption instead of a toast, revisit — but placement failure is not a stake-validation caption, so a minimal error toast titled "Unexpected Error" is the chosen rendering.firstError/msglocals become unused — delete their now-dead assignment lines to satisfy lint.
- Step 7: Verify typecheck + tests
Run: cd strykr-fe && npx tsc --noEmit && npm test
Expected: typecheck clean; all .test.mjs pass.
- Step 8: Commit
git add strykr-fe/src/lib/messages/placement.ts strykr-fe/src/lib/messages/placement.test.mjs strykr-fe/src/components/betting/InlineBetSlip.tsx
git commit -m "feat(messages): placement catalog; inline stake errors as caption, new placement toasts"
Task 7: lifecycle catalog + migrate useWebSocket
Files:
- Create:
strykr-fe/src/lib/messages/lifecycle.ts - Test:
strykr-fe/src/lib/messages/lifecycle.test.mjs - Modify:
strykr-fe/src/hooks/useWebSocket.ts:234(remove settled toast),:252-255(remove resettlement toast),:271-276(restrict notification:new)
Interfaces:
-
Consumes:
ToastDescriptorfrom Task 1. -
Produces:
lifecycle.serverNotifDeclined: ToastDescriptor={ type:'error', title:'Could Not Place Bet', message:'Unexpected Error' }lifecycle.serverNotifAccepted: ToastDescriptor=nulllifecycle.serverNotifOther: ToastDescriptor=nulllifecycle.betfairResettlement: ToastDescriptor=nulllifecycle.settled: ToastDescriptor=null
-
Step 1: Write the failing test
// strykr-fe/src/lib/messages/lifecycle.test.mjs
import assert from 'node:assert/strict';
import test from 'node:test';
const { lifecycle } = await import('./lifecycle.ts');
test('declined server notification maps to fixed copy', () => {
assert.deepEqual(lifecycle.serverNotifDeclined, {
type: 'error', title: 'Could Not Place Bet', message: 'Unexpected Error',
});
});
test('accepted / other / resettlement / settled produce no toast', () => {
assert.equal(lifecycle.serverNotifAccepted, null);
assert.equal(lifecycle.serverNotifOther, null);
assert.equal(lifecycle.betfairResettlement, null);
assert.equal(lifecycle.settled, null);
});
- Step 2: Run test to verify it fails
Run: cd strykr-fe && node --test src/lib/messages/lifecycle.test.mjs
Expected: FAIL — cannot find module ./lifecycle.ts.
- Step 3: Write minimal implementation
// strykr-fe/src/lib/messages/lifecycle.ts
import type { ToastDescriptor } from './types';
export const lifecycle = {
serverNotifDeclined: {
type: 'error',
title: 'Could Not Place Bet',
message: 'Unexpected Error',
} satisfies ToastDescriptor,
serverNotifAccepted: null as ToastDescriptor,
serverNotifOther: null as ToastDescriptor,
betfairResettlement: null as ToastDescriptor,
settled: null as ToastDescriptor,
};
- Step 4: Run test to verify it passes
Run: cd strykr-fe && node --test src/lib/messages/lifecycle.test.mjs
Expected: PASS (2 tests).
- Step 5: Remove the settled toast (
useWebSocket.ts:234)
Add import { messages } from '@/lib/messages'; near the other imports (if not present via Task cross-imports). In the settlement:update handler, delete:
toast.betSettled(update.outcome, Math.abs(update.profitLoss));
Keep the surrounding queryClient.invalidateQueries calls and haptics untouched (the Notification Center entry is driven by the backend notification:new / ['notifications'] query, not this toast — Q2). Leave toast.betSettled defined in notificationStore.ts (may be used elsewhere; removing the definition is out of scope — verify with a grep in Step 7).
- Step 6: Remove the Betfair re-settlement toast (
useWebSocket.ts:252-255) and restrictnotification:new(:271-276)
In settlement:resettlement, delete the toast.info('Bet Re-settled', ...) call (lines ~252-255); keep the three invalidateQueries.
In notification:new (lines ~271-276), replace:
if (notification.type !== 'bet_settled') {
const notificationType = notification.type === 'bet_accepted' ? 'success'
: notification.type === 'bet_declined' ? 'error'
: 'info';
toast[notificationType](notification.title, notification.message);
}
with (only declined toasts, with fixed catalog copy — accepted/settled/other are silent per Excel):
if (notification.type === 'bet_declined') {
toast.show(messages.lifecycle.serverNotifDeclined);
}
- Step 7: Verify typecheck, tests, and no orphaned toast usage
Run: cd strykr-fe && npx tsc --noEmit && npm test
Expected: clean + pass.
Run: grep -rn "toast.betSettled\|toast.info('Bet Re-settled" src
Expected: only the notificationStore.ts definition of betSettled remains; no live callers in useWebSocket.ts.
- Step 8: Commit
git add strykr-fe/src/lib/messages/lifecycle.ts strykr-fe/src/lib/messages/lifecycle.test.mjs strykr-fe/src/hooks/useWebSocket.ts
git commit -m "feat(messages): lifecycle catalog; silence settled/resettlement/accepted toasts, fix declined copy"
Task 8: cancel catalog + migrate useOpenBets
Files:
- Create:
strykr-fe/src/lib/messages/cancel.ts - Test:
strykr-fe/src/lib/messages/cancel.test.mjs - Modify:
strykr-fe/src/hooks/useOpenBets.ts:108,:118
Interfaces:
-
Consumes:
ToastDescriptorfrom Task 1. -
Produces:
cancel.partial: ToastDescriptor={ type:'warning', title:'Partially Cancelled', message:'Bet could not be fully cancelled' }cancel.failed: ToastDescriptor={ type:'error', title:'Cancel Failed', message:'Please try again later.' }
-
Step 1: Write the failing test
// strykr-fe/src/lib/messages/cancel.test.mjs
import assert from 'node:assert/strict';
import test from 'node:test';
const { cancel } = await import('./cancel.ts');
test('cancel copy matches the Excel', () => {
assert.deepEqual(cancel.partial, {
type: 'warning', title: 'Partially Cancelled', message: 'Bet could not be fully cancelled',
});
assert.deepEqual(cancel.failed, {
type: 'error', title: 'Cancel Failed', message: 'Please try again later.',
});
});
- Step 2: Run test to verify it fails
Run: cd strykr-fe && node --test src/lib/messages/cancel.test.mjs
Expected: FAIL — cannot find module ./cancel.ts.
- Step 3: Write minimal implementation
// strykr-fe/src/lib/messages/cancel.ts
import type { ToastDescriptor } from './types';
export const cancel = {
partial: {
type: 'warning', title: 'Partially Cancelled', message: 'Bet could not be fully cancelled',
} satisfies ToastDescriptor,
failed: {
type: 'error', title: 'Cancel Failed', message: 'Please try again later.',
} satisfies ToastDescriptor,
};
- Step 4: Run test to verify it passes
Run: cd strykr-fe && node --test src/lib/messages/cancel.test.mjs
Expected: PASS.
- Step 5: Migrate
useOpenBets.ts
Add import { messages } from '@/lib/messages';. In useCancelOrder:
Line ~108 (partial branch), replace:
toast.warning('Partially Cancelled', data.message);
with:
toast.show(messages.cancel.partial);
Line ~118 (onError), replace:
toast.error('Cancel Failed', parseApiError(error));
with:
toast.show(messages.cancel.failed);
Leave line ~110 toast.success('Bet Cancelled') unchanged (NC). parseApiError import becomes unused here — remove it from this file's imports if nothing else uses it (grep the file first).
- Step 6: Verify typecheck + tests
Run: cd strykr-fe && npx tsc --noEmit && npm test
Expected: clean + pass.
- Step 7: Commit
git add strykr-fe/src/lib/messages/cancel.ts strykr-fe/src/lib/messages/cancel.test.mjs strykr-fe/src/hooks/useOpenBets.ts
git commit -m "feat(messages): cancel catalog + migrate open-bet cancel toasts"
Task 9: Barrel index
Files:
- Create:
strykr-fe/src/lib/messages/index.ts - Test:
strykr-fe/src/lib/messages/index.test.mjs
Interfaces:
-
Consumes:
auth,placement,lifecycle,cancel,errorsfrom Tasks 2/5/6/7/8. -
Produces:
export const messages = { auth, placement, lifecycle, cancel, errors }. (Referenced by every migrated call site — those imports resolve once this exists. To keep earlier tasks compiling, create this file at the start of Task 5 with only the domains that exist yet, then extend it in each subsequent task. The final shape is below.) -
Step 1: Write the failing test
// strykr-fe/src/lib/messages/index.test.mjs
import assert from 'node:assert/strict';
import test from 'node:test';
const { messages } = await import('./index.ts');
test('catalog exposes all domains', () => {
for (const k of ['auth', 'placement', 'lifecycle', 'cancel', 'errors']) {
assert.ok(messages[k], `missing domain: ${k}`);
}
});
- Step 2: Run test to verify it fails
Run: cd strykr-fe && node --test src/lib/messages/index.test.mjs
Expected: FAIL until index.ts exports all five.
- Step 3: Write implementation
// strykr-fe/src/lib/messages/index.ts
import { auth } from './auth';
import { placement } from './placement';
import { lifecycle } from './lifecycle';
import { cancel } from './cancel';
import { errors } from './errors';
export const messages = { auth, placement, lifecycle, cancel, errors };
export type { ToastDescriptor, ToastTone, ToastIcon } from './types';
- Step 4: Run test + full suite + typecheck
Run: cd strykr-fe && node --test src/lib/messages/index.test.mjs && npm test && npx tsc --noEmit
Expected: all pass, clean typecheck.
- Step 5: Commit
git add strykr-fe/src/lib/messages/index.ts strykr-fe/src/lib/messages/index.test.mjs
git commit -m "feat(messages): barrel export for the message catalog"
Task 10 (Phase 1b — needs backend): lifecycle matched toasts
Deferred behind a backend payload change. The
order:statusWS event (OrderStatusUpdate,useWebSocket.ts:77-84) carries onlystatus/bookmaker/reason/settlementOutcome— no selection, side, matched odds, or matched %.classifyOrderStatusToast(orderStatus.ts:120) therefore cannot renderBack/Lay "selection" @ "matched_odds"or the partial %. Per the no-fabrication rule, the frontend must NOT synthesize these.
Files (when scheduled):
- Backend: extend the
order:statuspublish payload inbackend/src/exchanges/adapters/bifrost/BifrostBetConsumer.ts(lines 166/185/205/640/1754) and any other emitters, addingselectionName,side(Back/Lay),matchedOdds,matchedPct. - Frontend: widen
OrderStatusUpdate; changeclassifyOrderStatusToastto accept and return the enriched matched/partial bodies; addlifecycle.betMatched(p)(success, titleBet Matched) andlifecycle.betPartiallyMatched(p)(warning +icon:'check', titleBet Partially Matched (<pct>%)), both bodiedBack/Lay "selection" @ "matched_odds".
Status: NOT part of Phase 1a. Requires its own spec/plan slice once the backend payload owner confirms the fields. Tracked as a follow-up.
Phase 2 (follow-up spec): app-wide sweep
Migrate remaining hardcoded toast.* call sites onto the catalog:
BetSlipFooter.tsx, FixtureCard.tsx, ExchangeGrid.tsx, SportsbookGrid.tsx,
FancyMarketGrid.tsx, and any others found via
grep -rn "toast\.\(success\|error\|warning\|info\)(" strykr-fe/src. Out of
scope for this plan; the Excel marks these NC (legacy) for now.
Self-Review
- Spec coverage: every non-NC Excel row maps to a task — auth generic (T5), inline stake errors + placement toasts (T6), settled/resettlement/server-notif (T7), cancel partial/failed (T8), backend error map (T2), Toast icon override for Tick+Yellow (T4), lifecycle matched (T10, deferred with reason). ✅
- Placeholders: none — all code and commands are concrete. The only deferred content (T10) is explicitly gated on a backend dependency, not a vague TODO.
- Type consistency:
ToastDescriptor/ToastTone/ToastIcondefined in T1 and consumed unchanged in T2-T9;resolveToastIconsignature stable;toast.showsignature stable;messagesbarrel shape matches all domain exports.