From a99106b1f965f17594e7c975745468f3469a115d Mon Sep 17 00:00:00 2001 From: omoh5 Date: Tue, 28 Jul 2026 13:31:21 +0000 Subject: [PATCH] docs(error-handling): add error handling conventions doc and cross-link from state-management Adds docs/error-handling-conventions.md covering the five error display modes (toast, inline, section boundary, page boundary, transaction surfaces) plus the network mismatch banner overlay. Documents the ApiError.status cheat sheet, mutation patterns (network, server, validation, rejection), inline error state component usage, and how to extend the wallet/signature error classification (WALLET_ERROR_COPY) and the disabled-reason helper pattern (ClaimActionDisabledReasonKey). Cross-links from docs/state-management.md section 3 (Error Architecture Strategy) to the new conventions doc as required by issue #630 acceptance criteria. Closes #630 --- docs/error-handling-conventions.md | 789 +++++++++++++++++++++++++++++ docs/state-management.md | 2 + 2 files changed, 791 insertions(+) create mode 100644 docs/error-handling-conventions.md diff --git a/docs/error-handling-conventions.md b/docs/error-handling-conventions.md new file mode 100644 index 00000000..e8859e9a --- /dev/null +++ b/docs/error-handling-conventions.md @@ -0,0 +1,789 @@ +# Error Handling Conventions + +> Issue #630 — Establish the expected pattern for every error category +> (boundary, query state, mutation feedback, transaction surface, network +> warning) so the UI feels consistent regardless of which component rendered +> the error. + +This guide is the **single source of truth** for how errors are surfaced in +the Access Layer client. Pair it with +[Error Handling in React Query Hooks](./error-handling-in-hooks.md) for the +hook-side `ApiError` branching detail and +[State Management](./state-management.md) for the +[Error Architecture Strategy](./state-management.md#3-error-architecture-strategy-boundary-vs-inline-states) +section that motivates the boundary/inline split. + +--- + +## The Five Error Display Modes + +Every error in the client surfaces through **one of five modes**. Pick the +right one based on where the error came from and what the user still needs +to be able to do. + +| # | Mode | Trigger | Audience | Component (canonical) | +| --- | -------------------- | --------------------------------------- | --------------- | ----------------------------------------------------- | +| 1 | **Toast** | `useMutation.onError` (user action) | Glanceable | `showToast.error` from `@/utils/toast.util` | +| 2 | **Inline state** | `useQuery` `isError` (blocking content) | Focused | `CreatorProfileErrorState`, section-level cards | +| 3 | **Section boundary** | Render-time throw in a sub-component | Isolated retry | `SectionErrorBoundary` from `@/components/common` | +| 4 | **Page boundary** | Render-time throw on a whole page | Whole route | `CreatorPageErrorBoundary`, `AppErrorBoundary` | +| 5 | **Tx surface** | On-chain trade write failure | Detail-oriented | `TransactionRetryNotice` + `TransactionFailureDrawer` | + +**Plus one always-on overlay** for a precondition state: + +| # | Mode | Trigger | Audience | Component | +| --- | ------------------ | ----------------------------- | ----------------- | -------------------------------------------------- | +| 6 | **Network banner** | Connected wallet, wrong chain | Persistent notice | `NetworkMismatchBanner` from `@/components/common` | + +--- + +## Decision Flowchart + +Walk this top to bottom. The first matching branch wins. + +``` +Did the error come from a user-initiated write (buy, sell, enroll, claim, …)? +├── YES → Mode 1 (Toast) — see "Mode 1: Toasts" +│ Special: On-chain writes → also open Mode 5 drawer for detail +└── NO → Did it come from a useQuery that blocks the page? + ├── YES → Mode 2 (Inline state) — see "Mode 2: Inline" + └── NO → Did something throw while rendering JSX? + ├── YES inside a sub-component → + │ Mode 3 (SectionErrorBoundary) + ├── YES at the route level → + │ Mode 4 (Page boundary) + └── NO → Re-check the trigger; you may have an + unhandled case — default to Mode 4 (the last + line of defense is `AppErrorBoundary`). + +Network mismatch? → Mode 6 banner (always-on; renders nothing when healthy). +``` + +> **Cross-link.** The same boundary vs inline split is described from the +> loading-state angle in +> [State Management → 3. Error Architecture Strategy](./state-management.md#3-error-architecture-strategy-boundary-vs-inline-states). +> This document is the full convention; that section is the executive summary. + +--- + +## Mode 1 — Toasts for Mutation Errors + +**Use when:** + +- The failure came from a user-initiated write (buy, sell, course enroll, + profile update, share, copy). +- The page can still render usefully without retrying. +- One short line of feedback is enough — the user needs to know "this + didn't work" and either retry or change input. + +**Don't use when:** + +- The failure blocks the primary purpose of the screen (use Mode 2). +- The failure contains field-level validation detail that must attach to a + specific input (use Mode 2 inline errors with `apiError.response?.errors`). +- You need to retry automatically — toasts disappear, they don't retry. + +### Pattern: `useMutation.onError` + +Cast the error to `ApiError` and branch on `status`. The numeric scale is +documented in +[Error Handling in Hooks → Distinguishing Error Types](./error-handling-in-hooks.md#distinguishing-error-types); +here is the consolidated decision: + +```ts +// src/hooks/useBuyCreatorKey.ts +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { ApiError } from '@/services/api.service'; +import showToast from '@/utils/toast.util'; +import { queryKeys } from '@/lib/queryKeys'; +import { creatorKeysService } from '@/services/creatorKeys.service'; + +export function useBuyCreatorKey() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + creatorId, + amount, + }: { + creatorId: string; + amount: number; + }) => creatorKeysService.buyKey(creatorId, amount), + + onError: error => { + const apiError = error as ApiError; + + // 1. Network failure — user is offline / server unreachable + if (apiError.status === 0) { + showToast.error( + 'Network error. Check your connection and try again.' + ); + return; + } + + // 2. Server failure — not the user's fault; generic + retry + if (apiError.status >= 500) { + showToast.error( + 'The server ran into a problem. Please try again shortly.' + ); + return; + } + + // 3. Validation — surface the first field error if present + if (apiError.status === 422 && apiError.response?.errors?.length) { + showToast.error(apiError.response.errors[0].message); + return; + } + + // 4. Other 4xx — API message is safe for the user + showToast.error(apiError.message); + }, + + onSuccess: (_, { creatorId }) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.creators.detail(creatorId), + }); + queryClient.invalidateQueries({ queryKey: queryKeys.wallet.all }); + showToast.success('Key purchased successfully!'); + }, + }); +} +``` + +### Pattern: User rejection (wallet signature) + +Wallet signatures can be rejected by the user mid-flow. Detect this with +the shared helper and skip the noisy generic toast: + +```ts +// src/hooks/useWalletConnection.ts (excerpt) +import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; +import showToast from '@/utils/toast.util'; + +onError: error => { + showToast.error(getSignatureErrorMessage(error)); + // getSignatureErrorMessage returns either: + // - WALLET_ERROR_COPY.SIGNATURE_REJECTED (user clicked "Cancel") + // - WALLET_ERROR_COPY.SIGNATURE_FAILED (other wallet-side failure) +}; +``` + +`getSignatureErrorMessage` is implemented in +[`src/utils/errorHandling.utils.ts`](./../src/utils/errorHandling.utils.ts) +and detects the EIP-1193 code `4001`, ethers' `ACTION_REJECTED`, and the +common message fragments "user rejected" / "declined" / "cancelled". + +### Pattern: Off-chain mutation that should NOT use a drawer + +Copy-to-clipboard, share-to-Twitter, follow a creator — all of these are +writes that have no chain side effects. Just toast: + +```ts +// Excerpt from CreatorProfileHeader +const handleShare = async () => { + try { + await navigator.clipboard.writeText(profileUrl); + showToast.success('Profile link copied to clipboard!'); + } catch { + showToast.error('Could not copy the profile link. Please try again.'); + } +}; +``` + +--- + +## Mode 2 — Inline Error State for Blocking Query Failures + +**Use when:** + +- The query is the primary content of the page (e.g. creator profile header). +- Without the data, the screen is empty; there is nothing else for the user + to do here. +- You want the user to retry without leaving the page — wire `refetch` from + React Query. + +**Don't use when:** + +- The query is enriching a page that can render something else (use Mode 3). +- The failure came from a mutation (use Mode 1). + +### Pattern: Canonical shared component + +`CreatorProfileErrorState` is the canonical inline error component. It +renders a marketplace-styled card with an icon, a message, and an optional +retry button. + +```tsx +// In a creator profile section +import { useCreatorProfile } from '@/hooks/useCreatorProfile'; +import CreatorProfileErrorState from '@/components/common/CreatorProfileErrorState'; +import { ApiError } from '@/services/api.service'; + +function CreatorHeader({ creatorId }: { creatorId: string }) { + const { data, isLoading, isError, error, refetch } = + useCreatorProfile(creatorId); + + if (isLoading) return ; + + if (isError) { + const apiError = error as ApiError; + return ( + void refetch()} + isRetrying={ + false /* wire from your query's isFetching if you want */ + } + title="Unable to load this creator profile" + /> + ); + } + + return ; +} +``` + +Component contract (from `CreatorProfileErrorStateProps`): + +| Prop | Type | Notes | +| ------------ | ------------------------- | ---------------------------------------------------------- | +| `error` | `Error \| string \| null` | Drives the message; falls back to a generic copy when null | +| `onRetry` | `() => void` | When provided, renders a retry button | +| `isRetrying` | `boolean` | Spins the refresh icon and disables the button | +| `title` | `string` | Defaults to "Unable to load this creator profile" | +| `message` | `string` | When present, overrides the error message | + +### Pattern: Hand-rolled inline state + +For sections that aren't creator profiles (holdings table, transaction +history), inline state is a styled `
` with a refresh +button wired to `refetch`. Always include the retry control — fail without +one and the user has no escape hatch. + +```tsx +const { data, isError, error, refetch } = useHoldings(address); + +if (isError) { + const apiError = error as ApiError; + return ( +
+

+ {apiError.status >= 500 + ? 'Unable to load holdings. Please try again later.' + : apiError.message} +

+ +
+ ); +} +``` + +### Inline vs. throw-to-boundary — the rule + +> If the failure should leave the rest of the page interactive → render an +> **inline** state. If it means the route as a whole cannot be useful → throw +> the error and let the page-level boundary catch it. + +Concretely: + +- A creator list failing inside the marketplace page? **Inline.** The hero, + filters, and holdings are still useful. +- The creator profile header failing on `/creator/:id`? **Throw** → + `CreatorPageErrorBoundary` renders a "Creator not found" or + "could not load" page-level state and a back-link. + +--- + +## Mode 3 — Section Error Boundary (Render Falls Here) + +**Use when:** + +- A sub-component (tab body, sidebar, list section) can throw during render + — e.g. it consumes data that is undefined when the parent didn't gate it. +- A failure in **this section** should not crash the whole page. +- The user should be able to retry the section without a full reload. + +**Don't use when:** + +- The error is a query failure (React Query surfaces it via `isError` — + branch in the component, don't throw). +- The error is from an on-chain mutation (use Mode 5). + +### Pattern: Wrap any sub-tree that might throw + +```tsx +import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; + +// In LandingPage + + +; +``` + +The retry button on the fallback resets the boundary's internal error +state; it does **not** re-fire the underlying `useQuery`. If you need a +network retry, combine the boundary with a query that has `enabled` tied +to a "retry counter" `useState` and bump it inside the retry handler. + +### Pair with `throwOnError` + +If you want React Query to throw a render-time exception (so the boundary +catches it) instead of branching on `isError` inside the component, opt in +on the query: + +```ts +useQuery({ + queryKey: queryKeys.creators.detail(id), + queryFn: () => courseService.getById(id), + throwOnError: error => { + const api = error as ApiError; + // 4xx: render inline; 5xx: bubble to boundary + return api.status >= 500; + }, +}); +``` + +See [Error Handling in Hooks → SectionErrorBoundary](./error-handling-in-hooks.md#use-sectionerrorboundary-when) +for the full reasoning. + +--- + +## Mode 4 — Page-Level Error Boundaries + +There are two nested page boundaries; pick the **most specific** one that +fits the route. + +### `CreatorPageErrorBoundary` — creator routes + +Scoped to `/creator/:id` and any other creator detail route. When a creator +page throws, this catches it and renders a fallback with a "back to +creators" link. It special-cases `ApiError` with `status === 404` to render +a "Creator not found" message instead of a generic failure. + +```tsx +// src/pages/CreatorDetailPage.tsx +import CreatorPageErrorBoundary from '@/components/common/CreatorPageErrorBoundary'; + +export default function CreatorDetailPage() { + const { id } = useParams<{ id: string }>(); + const { data, isLoading, error } = useCreatorDetail(id ?? ''); + + if (!data && !isLoading) { + throw new ApiError('Creator not found', 404); + } + if (error) { + throw error; // → CreatorPageErrorBoundary + } + + return ; +} + +// Wrap in the route element (already done in src/routes.tsx): +{routes}; +``` + +### `AppErrorBoundary` — last line of defense + +Mounted once in [`src/App.tsx`](./../src/App.tsx) around the router. When +something throws that isn't caught by a more specific boundary, this is +the last chance before React would otherwise unmount the entire tree. The +fallback offers a **full page reload** rather than resetting local state — +the rationale is documented in the component's source comment. + +Reach: anything that escapes a `SectionErrorBoundary` or +`CreatorPageErrorBoundary` will land here. + +--- + +## Mode 5 — Transaction Failure Surfaces + +Buy / sell / claim trades have **two** surfaces: an inline +`TransactionRetryNotice` so the user sees a persistent recovery prompt, +and a modal `TransactionFailureDrawer` for full diagnostic detail. + +### When to use both together + +A failed trade should: + +1. Show a `TransactionRetryNotice` in place of the trade action area — + it stays visible until the user retries or dismisses. +2. Open a `TransactionFailureDrawer` so the user can copy the error code + or transaction hash for support. + +```tsx +// src/components/common/CreatorCard.tsx (excerpt) +import TransactionRetryNotice from '@/components/common/TransactionRetryNotice'; +import TransactionFailureDrawer from '@/components/common/TransactionFailureDrawer'; +import type { TransactionFailureDetails } from '@/components/common/TransactionFailureDrawer'; + +const [failure, setFailure] = useState(null); + +const handleTrade = async (amount: number) => { + try { + await buyKey({ creatorId, amount }); + } catch (err) { + const apiError = err as ApiError; + setFailure({ + errorMessage: apiError.message, + errorCode: apiError.response?.code, + txHash: undefined, // chain-specific; pass when available + timestamp: Date.now(), + developerDetails: apiError.response, + }); + } +}; + +return ( + <> + {failure && ( + <> + { + setFailure(null); + handleTrade(lastAmount); + }} + /> + { + setFailure(null); + handleTrade(lastAmount); + }} + onDismiss={() => setFailure(null)} + /> + + )} + +); +``` + +`TransactionFailureDetails`: + +| Field | Type | Purpose | +| ------------------ | -------------------------- | ---------------------------------------------- | +| `errorMessage` | `string` (required) | User-facing message displayed in the drawer | +| `txHash` | `string?` | On-chain hash for support; copy-to-clipboard | +| `errorCode` | `string?` | Machine code (e.g. `INSUFFICIENT_BALANCE`) | +| `timestamp` | `number?` | Unix ms; rendered via `formatTimestampTooltip` | +| `developerDetails` | `Record?` | Hidden behind a `
` toggle for devs | + +See [`src/components/common/TransactionFailureDrawer.tsx`](./../src/components/common/TransactionFailureDrawer.tsx) +for the component contract. + +### When to NOT use the drawer + +If the action is not on-chain (share, copy, follow) — toast only. The +drawer exists to expose blockchain-detail information; if there is no +chain context, it adds noise. + +--- + +## Mode 6 — Network Mismatch Banner + +`NetworkMismatchBanner` is **always-on**: it reads from +`useNetworkMismatch()` and returns `null` when the wallet is on the +correct network. Mount it once in any layout where users can initiate +trades; it renders nothing in the happy path. + +```tsx +// Wherever the user can trade +
+ + +
+``` + +This is **not** an error per se — it's a precondition warning. Do **not** +also toast "wrong network" on top of this banner; the banner is the +canonical surface. + +--- + +## Branching on `ApiError.status` — Consolidated Cheat Sheet + +The same numeric scale drives every mode. Source: +[`src/services/api.service.ts`](./../src/services/api.service.ts) → +[`BaseApiService.handleError`](./../src/services/api.service.ts). + +| `apiError.status` | Meaning | Toast copy | Inline copy | Boundary fallback | +| ----------------- | ------------------------ | ---------------------------------------------------------- | ------------------------------------------- | ----------------------------------------------- | +| `0` | No network response | "Network error. Check your connection and try again." | "Check your connection and try again." | `AppErrorBoundary` | +| `400` | Malformed request | `apiError.message` (safe) | `apiError.message` | `AppErrorBoundary` | +| `401` | Session expired | (interceptor handles — auto-refresh, redirect to `/login`) | n/a | n/a | +| `403` | Insufficient permissions | `apiError.message` | "You don't have access to this." | `AppErrorBoundary` | +| `404` | Resource not found | `apiError.message` | "Not found." | `CreatorPageErrorBoundary` (404 special-cases) | +| `422` | Validation failure | `apiError.response.errors[0].message` (first field error) | Map every `response.errors[i]` to its input | `AppErrorBoundary` | +| `429` | Rate limited | "Too many requests. Please wait a moment and retry." | "Rate limited. Try again shortly." | `AppErrorBoundary` | +| `>= 500` | Server error | "The server ran into a problem. Please try again shortly." | "Unable to load this. Try again later." | Section-level (skeleton→empty→relies on inline) | + +--- + +## Adding a New Error Type to the Classification System + +The codebase has **two** classification helpers that capture domain +specifics. Pick the one that matches your domain; do not invent a new +helper unless neither fits. + +### 1. Wallet & signature errors → extend `WALLET_ERROR_COPY` + +File: [`src/utils/errorHandling.utils.ts`](./../src/utils/errorHandling.utils.ts). + +Centralized map of wallet-and-signature messages, plus detection helpers: + +```ts +export const WALLET_ERROR_COPY = { + SIGNATURE_REJECTED: + "Signature request was declined. Please try again when you're ready to confirm.", + SIGNATURE_FAILED: + 'The signature request failed. Please ensure your wallet is unlocked and try again.', + GENERIC_TRANSACTION_FAILED: + 'Transaction failed. Please check your balance or connection and try again.', +}; + +export function isUserRejection(error: unknown): boolean { + /* … */ +} +export function getSignatureErrorMessage(error: unknown): string { + /* … */ +} +``` + +**To add a new wallet error type:** + +1. Append a new key to `WALLET_ERROR_COPY` with a sentence that states the + cause and the next step. +2. If the detector matters, extend `isUserRejection`-style recognition in + a new exported predicate (`isRateLimited(error)`, `isChainSwitchRequired(error)`). +3. Wire the predicate + key into the calling hook's `onError` — usually + by extending `getSignatureErrorMessage` or introducing a sibling + `getWalletErrorMessage` if the surface is broader than signatures. +4. Add a Vitest unit test under + `src/utils/__tests__/errorHandling.utils.test.ts` that pins the new + copy and predicate behavior. + +### 2. Pre-action disabled reasons → extend the typed helper + +File: [`src/utils/claimActionDisabledReason.ts`](./../src/utils/claimActionDisabledReason.ts). + +Pattern: a **closed union** of reason keys + a **single `Record`** that +maps each key to a standardized copy. This pattern is reused for other +disabled-reason surfaces (see `BuyActionHelperText` and +`ClaimActionHelperText` documentation). + +```ts +export type ClaimActionDisabledReasonKey = + | 'wallet_not_connected' + | 'no_claimable_rewards' + | 'claim_in_progress' + | 'network_mismatch' + | 'insufficient_gas' + | 'unknown'; + +const CLAIM_ACTION_DISABLED_REASON_TEXT: Record< + ClaimActionDisabledReasonKey, + string +> = { + // Every entry follows the same shape: + // "{Action} is unavailable because {cause}. {Next step}." + wallet_not_connected: + 'Claim is unavailable because your wallet is not connected. Connect your wallet to continue.', + // … +}; + +export const getClaimActionDisabledReasonText = ( + reason: ClaimActionDisabledReasonKey +): string => CLAIM_ACTION_DISABLED_REASON_TEXT[reason]; +``` + +**To add a new disabled reason:** + +1. Append the new key to the union — **don't** fall back to `unknown` + silently; force every caller to make a decision. +2. Add a matching entry to `CLAIM_ACTION_DISABLED_REASON_TEXT` using the + "{Action} is unavailable because {cause}. {Next step}." template so + tones stay consistent. +3. Compute the new key at the call site (the component that evaluates + pre-conditions) and pass it to `getClaimActionDisabledReasonText`. +4. Extend + `src/utils/__tests__/claimActionDisabledReason.test.ts` to cover the + new key. + +### 3. When neither helper fits + +If your error surface is genuinely new (e.g. diverging-chain detection, +on-chain simulation errors), create a new sibling helper in +`src/utils/ErrorHandling.utils.ts` following the same shape: + +1. **Closed key union** — TypeScript exhaustiveness forces callers to + pick a copy. +2. **One Record/Map** that owns _all_ English copy — no copy scattered + across components. +3. **Pure functions** — no React, no hooks, no side effects. Easy to + unit-test. +4. **Side-by-side tests** — colocated unit test in + `src/utils/__tests__/`. + +Do not duplicate copy inline in components; always go through a typed +helper so future copy edits stay coherent. + +--- + +## Shared Error State Components — Reference + +| Component | Mode | Purpose | Key props | +| -------------------------------------------------- | ---- | --------------------------------------------------- | ------------------------------------------------------------------------- | +| `showToast` (`@/utils/toast.util`) | 1 | Mutation feedback (success, error, loading, tx) | `showToast.{success,error,loading,transactionSuccess}(message, options?)` | +| `CreatorProfileErrorState` (`@/components/common`) | 2 | Canonical inline error card for creator profile | `error?, onRetry?, isRetrying?, title?, message?` | +| `SectionErrorBoundary` (`@/components/common`) | 3 | Catches sub-tree render throws with retry | `sectionName?, minHeight?, className?` | +| `CreatorPageErrorBoundary` (`@/components/common`) | 4 | Catches creator-route render throws | none (wraps ``) | +| `AppErrorBoundary` (`@/components/common`) | 4 | App-wide last-line-of-defense; full reload on retry | none (wraps ``) | +| `TransactionRetryNotice` (`@/components/common`) | 5 | Persistent inline retry banner for failed trades | `title?, message, onRetry, retryLabel?, disabled?, className?` | +| `TransactionFailureDrawer` (`@/components/common`) | 5 | Modal with error code, hash, and developer details | `open, onOpenChange?, failureDetails, onRetry?, onDismiss?` | +| `NetworkMismatchBanner` (`@/components/common`) | 6 | Persistent "wrong network" warning | `className?` | + +For accessible state components (skeletons, empty states), see +[Adding a Page and Data Fetching](./adding-page-and-data-fetching.md) +and [Shared Components](./shared-components.md). + +--- + +## Worked Examples + +### Example A — Marketplace search list (read failure → Mode 2) + +The creator list inside the marketplace is critical content, so a query +failure renders an inline state with retry. The rest of the page +(hero, holdings, footer) stays interactive. + +```tsx +// src/pages/LandingPage.tsx (excerpt) +const { data: creators, isError, error, refetch } = useCreatorList(); + +if (isError) { + const apiError = error as ApiError; + return ( +
+

Couldn't load the creator list

+

+ {apiError.status >= 500 + ? 'Something went wrong on our end. Please try again.' + : apiError.message} +

+ +
+ ); +} + +return ; +``` + +### Example B — Creator profile (read failure → Mode 4) + +The creator profile header IS the page. A query failure here becomes a +page-level error: + +```tsx +// src/pages/CreatorDetailPage.tsx (excerpt) +function CreatorDetailPageContent() { + const { id } = useParams<{ id: string }>(); + const { data, isLoading, error } = useCreatorDetail(id ?? ''); + + if (isLoading) return ; + if (!data) throw new ApiError('Creator not found', 404); + if (error) throw error; // caught by CreatorPageErrorBoundary + return ; +} + +export default function CreatorDetailPage() { + return ( + + + + ); +} +``` + +### Example C — Buy flow (mutation failure → Mode 1 + Mode 5) + +A trade mutation has both the prompt (toast), the persistent retry +banner, and the detail drawer: + +```tsx +// src/components/common/CreatorCard.tsx (excerpt) +const buy = useBuyCreatorKey(); + +const handleBuy = async (amount: number) => { + try { + await buy.mutateAsync({ creatorId, amount }); + } catch (err) { + const apiError = err as ApiError; + + // Mode 1: quick prompt + showToast.error( + apiError.status === 0 + ? 'Network error. Check your connection.' + : apiError.message + ); + + // Mode 5: persistent banner + drawer for support detail + setFailure({ + errorMessage: apiError.message, + errorCode: apiError.response?.code, + timestamp: Date.now(), + }); + } +}; +``` + +--- + +## Quick Reference + +| Question | Answer | +| ----------------------------------------------------- | ----------------------------------------------------- | +| Failure from a button click? | Toast (Mode 1), add drawer for on-chain trades | +| Failure from `useQuery` that **is** the page? | Throw → Page boundary (Mode 4) | +| Failure from `useQuery` that **enriches** the page? | Inline state with `refetch` (Mode 2) | +| Component throws during render (not a query error)? | Wrap in `SectionErrorBoundary` (Mode 3) | +| Wallet is connected but wrong chain? | `NetworkMismatchBanner` (Mode 6) | +| User clicked "Cancel" on the wallet signature prompt? | `getSignatureErrorMessage` (Mode 1, specialised copy) | +| New kind of wallet error? | Extend `WALLET_ERROR_COPY` | +| New pre-action disabled reason? | Extend `ClaimActionDisabledReasonKey` + lookup | + +--- + +## Cross-references + +- [State Management](./state-management.md) — particularly + [§3 Error Architecture Strategy](./state-management.md#3-error-architecture-strategy-boundary-vs-inline-states) + for the boundary-vs-inline executive summary and the loading/error/data + three-state pattern. +- [Error Handling in React Query Hooks](./error-handling-in-hooks.md) — + `ApiError` shape, `useQuery` / `useMutation` patterns, and the full + status-code table. +- [API Layer Conventions](./api-layer.md) — the service layer and + `BaseApiService.handleError` that produces every `ApiError`. +- [React Query Cache Conventions](./react-query-cache-conventions.md) — + invalidation patterns that run alongside error handlers. +- [Shared Components](./shared-components.md) — toast, skeleton, and + empty-state families referenced alongside error states. +- [Adding a Page and Data Fetching](./adding-page-and-data-fetching.md) — + end-to-end guide for wiring routes and their error boundaries. +- [BuyActionHelperText — Disabled Reason](./BuyActionHelperText-DisabledReason.md) and + [Claim Action Disabled Reason Helper Text](./ClaimActionHelperText-DisabledReason.md) — + pre-action copy systems that follow the same `Record` + classification pattern documented above. diff --git a/docs/state-management.md b/docs/state-management.md index 698d63e2..80a33945 100644 --- a/docs/state-management.md +++ b/docs/state-management.md @@ -89,6 +89,8 @@ Match your structural loading fallbacks strictly to your structural data card la - **Error Boundaries (`CreatorPageErrorBoundary`):** Use at the route level to safely isolate catastrophic runtime engine failures, critical layout state breakdowns, or complete backend authorization drops across whole pages. - **Inline Contextual States (`SectionErrorBoundary`):** Use for sub-components, standalone layout modules, tabs, or localized search bars where a remote service query issue shouldn't block a user from browsing the remainder of the active application canvas. Always supply the React Query context `refetch` callback method directly to retry controls. +> The full convention — every error display mode (toast, inline, section boundary, page boundary, transaction surfaces, network banner), the `ApiError` status cheat sheet, and how to add a new error type to the classification system — lives in **[Error Handling Conventions](./error-handling-conventions.md)**. + ### 4. Code Implementation Blueprint ```tsx