Skip to main content

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) so node --test type-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:110 cancel-full, or any Auth success toast.
  • Migration gate: catalog logic is verified by npm test; call-site migrations (hooks/components) are verified by npx 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 } | null
    • resolveToastIcon(type: ToastTone, icon?: ToastIcon): ToastIcon — returns icon when 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 local ERROR_MESSAGES + inline status switch with catalog calls)

Interfaces:

  • Produces:

    • errors.byCode: Record<string, string> (verbatim from current ERROR_MESSAGES)
    • errors.byHttpStatus: Record<number, string>
    • resolveApiErrorMessage(code: string | null, httpStatus: number | null, apiMessage: string | null): string — pure. Precedence mirrors current parseApiError: a byCode entry (except VALIDATION_ERROR) wins; else apiMessage; else byHttpStatus; 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.ts parseApiError to 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 (add icon to Notification, add toast.show)

Interfaces:

  • Consumes: ToastDescriptor, ToastIcon from Task 1.

  • Produces: toast.show(d: ToastDescriptor): string | void — returns addNotification id when d is non-null, no-ops (returns undefined) when d is null. Notification gains icon?: ToastIcon.

  • Step 1: Add icon to the Notification interface

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.show to the toast object

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, ToastIcon from Task 1; Notification.icon from Task 3.

  • Step 1: Replace the type-keyed iconMap with 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: ToastDescriptor from 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: ToastDescriptor from 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 / msg locals 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: ToastDescriptor from Task 1.

  • Produces:

    • lifecycle.serverNotifDeclined: ToastDescriptor = { type:'error', title:'Could Not Place Bet', message:'Unexpected Error' }
    • lifecycle.serverNotifAccepted: ToastDescriptor = null
    • lifecycle.serverNotifOther: ToastDescriptor = null
    • lifecycle.betfairResettlement: ToastDescriptor = null
    • lifecycle.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 restrict notification: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: ToastDescriptor from 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, errors from 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:status WS event (OrderStatusUpdate, useWebSocket.ts:77-84) carries only status/bookmaker/reason/settlementOutcome — no selection, side, matched odds, or matched %. classifyOrderStatusToast (orderStatus.ts:120) therefore cannot render Back/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:status publish payload in backend/src/exchanges/adapters/bifrost/BifrostBetConsumer.ts (lines 166/185/205/640/1754) and any other emitters, adding selectionName, side (Back/Lay), matchedOdds, matchedPct.
  • Frontend: widen OrderStatusUpdate; change classifyOrderStatusToast to accept and return the enriched matched/partial bodies; add lifecycle.betMatched(p) (success, title Bet Matched) and lifecycle.betPartiallyMatched(p) (warning + icon:'check', title Bet Partially Matched (<pct>%)), both bodied Back/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/ToastIcon defined in T1 and consumed unchanged in T2-T9; resolveToastIcon signature stable; toast.show signature stable; messages barrel shape matches all domain exports.