Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/e2e/browse-error.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ afterEach(async () => {

describe('browse when the catalog fetch fails', () => {
it('surfaces a friendly error (not the raw fetch message) instead of the grid', async () => {
// Force /v3/catalog/unified → 500. fetchUnified throws → Assets renders <p class="error"> with a
// Force /v3/catalog/unified → 500. fetchUnified throws → Assets renders an <ErrorNotice> with a
// generic web2 message — never the raw "fetchUnified 500". See src/pages/Assets.tsx.
app = await launchApp({ path: '/assets', errors: { '/v3/catalog/unified': { status: 500 } } })
const { page } = app

await page.waitForSelector('.error', { timeout: 20000 })
await page.waitForSelector('.error-notice', { timeout: 20000 })
await waitForText(page, 'load items')

// The raw fetch error must NOT leak to the user (web2 convention).
Expand Down
28 changes: 5 additions & 23 deletions app/src/components/BuyModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,8 @@ import { isOwnTrade } from '~/lib/ownership'
import { CREDIT_PACKS, createPackCheckout } from '~/lib/payments'
import { RESUME_BUY_KEY } from '~/lib/resume-buy'
import { t } from '~/intl/i18n'

function friendlyError(e: unknown): string {
const err = e as { code?: number; message?: string }
const msg = (err.message ?? '').toLowerCase()
if (err.code === 4001 || msg.includes('reject') || msg.includes('denied') || msg.includes('cancel')) {
return t('getCredits.errorCanceled')
}
if (msg.includes('not for sale') || msg.includes('not found') || msg.includes('404')) {
return t('buyModal.error.soldOrRemoved')
}
if (msg.includes('your own listing')) return t('buyModal.error.cantBuyOwn')
return t('buyModal.error.generic')
}

// True when an authorize failure means "not enough credits" (server 402 / insufficient) rather than
// a genuine error — those route to the pack picker instead of the error state.
function isInsufficient(e: unknown): boolean {
const err = e as { code?: number; status?: number; message?: string }
return err.code === 402 || err.status === 402 || (err.message ?? '').toLowerCase().includes('insufficient')
}
import { friendlyError, isInsufficient } from '~/lib/errors'
import { ErrorNotice } from '~/components/ErrorNotice'

// The three top-up packs offered when the buyer is short on credits. The cheapest one that still
// clears the shortfall is pre-selected. Packs come from the canonical shop catalogue.
Expand Down Expand Up @@ -158,7 +140,7 @@ export function BuyModal({
error_code: errorCode(e),
})
setPhase('error')
setError(friendlyError(e))
setError(friendlyError(e, t('buyModal.error.generic'), { sale: true }))
}
})()
return () => {
Expand Down Expand Up @@ -220,7 +202,7 @@ export function BuyModal({
error_code: errorCode(e),
})
void qc.invalidateQueries({ queryKey: ['usd-balance'] })
setError(friendlyError(e))
setError(friendlyError(e, t('buyModal.error.generic'), { sale: true }))
setPhase('error')
}
}
Expand Down Expand Up @@ -300,7 +282,7 @@ export function BuyModal({
{/* Error */}
{phase === 'error' && (
<div className="buy-modal__body">
<p className="buy-modal__error">{error}</p>
<ErrorNotice message={error} />
<div className="buy-modal__ctas">
<button className="buy-modal__btn buy-modal__btn--gradient" onClick={onClose}>
{t('buyModal.close')}
Expand Down
3 changes: 2 additions & 1 deletion app/src/components/CartCheckoutModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { CreditPack } from '~/lib/payments'
import { CurrencyIcon } from '~/components/CurrencyIcon'
import { formatCredits } from '~/lib/currency'
import { t } from '~/intl/i18n'
import { ErrorNotice } from '~/components/ErrorNotice'

// A cart line as the modal displays it: the item + the LIVE credit price it will be charged.
export type CheckoutLine = { item: CatalogItem; priceCredits: number }
Expand Down Expand Up @@ -91,7 +92,7 @@ export function CartCheckoutModal(props: Props) {
)}
{phase === 'error' && (
<div className="buy-modal__body">
<p className="buy-modal__error">{props.message}</p>
<ErrorNotice message={props.message} />
<div className="buy-modal__ctas">
<button className="buy-modal__btn buy-modal__btn--gradient" onClick={onClose}>
{t('buyModal.close')}
Expand Down
24 changes: 24 additions & 0 deletions app/src/components/ErrorNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Shared inline error banner: a consistent, styled notice (alert icon + tinted surface) for the
// ad-hoc red `<p className="error">` text that used to sit bare across pages and modals. Renders
// nothing when there's no message, so callers can pass a nullable value directly. `role="alert"`
// so screen readers announce it when it appears.

export function ErrorNotice({
message,
className
}: {
message?: string | null
className?: string
}) {
if (!message) return null
return (
<p className={className ? `error-notice ${className}` : 'error-notice'} role="alert">
<svg className="error-notice__ico" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M12 2 1 21h22L12 2zm0 6a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V9a1 1 0 0 1 1-1zm0 8.25a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5z" />
</svg>
<span className="error-notice__msg">{message}</span>
</p>
)
}

export default ErrorNotice

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] All 11 consumers use the named import { ErrorNotice } — this default export is unused. Consider removing it to keep a single canonical import path.

15 changes: 8 additions & 7 deletions app/src/components/MarketCheckout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,19 @@ import { buyGasless, waitForSettlement, GaslessUnavailableError, SettlementPendi
import { gaslessEnabled } from '~/lib/gasless-config'
import { isOwnTrade } from '~/lib/ownership'
import { t } from '~/intl/i18n'
import { isRejection } from '~/lib/errors'
import { ErrorNotice } from '~/components/ErrorNotice'

// Market-specific mapping: keeps the "…Refreshing the market…" sold-out copy (the market view
// refetches live prices on this failure), so it maps locally rather than via the shared soldOrRemoved.
function friendlyError(e: unknown): string {
const err = e as { code?: number; message?: string }
const msg = (err.message ?? '').toLowerCase()
if (err.code === 4001 || msg.includes('reject') || msg.includes('denied') || msg.includes('cancel')) {
return t('getCredits.errorCanceled')
}
if (isRejection(e)) return t('errors.rejected')
const msg = ((e as { message?: string }).message ?? '').toLowerCase()
if (msg.includes('insufficient')) return t('marketCheckout.error.insufficient', { currency: CURRENCY.name })
if (msg.includes('not found') || msg.includes('no active listing') || msg.includes('404')) {
return t('marketCheckout.error.soldOrRemoved')
}
if (msg.includes('your own listing')) return t('buyModal.error.cantBuyOwn')
if (msg.includes('your own listing')) return t('errors.cantBuyOwn')
return t('marketCheckout.error.generic')
}

Expand Down Expand Up @@ -307,7 +308,7 @@ export function MarketCheckout({
<p className="muted mkt-modal__note">{t('marketCheckout.needMore', { currency: CURRENCY.name })}</p>
) : null}
{status && phase === 'working' ? <p className="muted mkt-modal__note">{status}</p> : null}
{error ? <p className="error mkt-modal__note">{error}</p> : null}
<ErrorNotice message={error} className="mkt-modal__note" />

<div className="mkt-modal__actions">
<button className="btn btn--ghost" onClick={cancel} disabled={busy}>
Expand Down
15 changes: 4 additions & 11 deletions app/src/components/PrimaryListModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,11 @@ import { showsWalletConfirmations } from '~/lib/wallet-kind'
import { track, errorCode } from '~/lib/analytics'
import { captureError } from '~/lib/monitoring'
import { t } from '~/intl/i18n'
import { friendlyError } from '~/lib/errors'
import { ErrorNotice } from '~/components/ErrorNotice'

const SIX_MONTHS_MS = 1000 * 60 * 60 * 24 * 182

function friendlyError(e: unknown): string {
const err = e as { code?: number; message?: string }
const msg = (err.message ?? '').toLowerCase()
if (err.code === 4001 || msg.includes('reject') || msg.includes('denied') || msg.includes('cancel')) {
return t('getCredits.errorCanceled')
}
return t('primaryList.errorGeneric')
}

export function PrimaryListModal({
item,
session,
Expand Down Expand Up @@ -116,7 +109,7 @@ export function PrimaryListModal({
} catch (e) {
captureError(e, { flow: 'list_primary' })
track('Shop Listing Failed', { listing_type: 'primary', error_code: errorCode(e) })
setError(friendlyError(e))
setError(friendlyError(e, t('primaryList.errorGeneric')))
setStatus(null)
} finally {
setBusy(false)
Expand Down Expand Up @@ -212,7 +205,7 @@ export function PrimaryListModal({
) : null}

{status ? <p className="muted">{status}</p> : null}
{error ? <p className="error">{error}</p> : null}
<ErrorNotice message={error} />

<div className="modal__actions">
<button className="btn btn--ghost" onClick={onClose} disabled={busy}>
Expand Down
15 changes: 4 additions & 11 deletions app/src/components/SellModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,11 @@ import { CurrencyIcon } from '~/components/CurrencyIcon'
import { track, errorCode } from '~/lib/analytics'
import { captureError } from '~/lib/monitoring'
import { t } from '~/intl/i18n'
import { friendlyError } from '~/lib/errors'
import { ErrorNotice } from '~/components/ErrorNotice'

const SIX_MONTHS_MS = 1000 * 60 * 60 * 24 * 182

function friendlyError(e: unknown): string {
const err = e as { code?: number; message?: string }
const msg = (err.message ?? '').toLowerCase()
if (err.code === 4001 || msg.includes('reject') || msg.includes('denied') || msg.includes('cancel')) {
return t('getCredits.errorCanceled')
}
return t('sellModal.errorGeneric')
}

export function SellModal({ asset, session, onClose }: { asset: MyAsset; session: Session; onClose: () => void }) {
const queryClient = useQueryClient()
const navigate = useNavigate()
Expand Down Expand Up @@ -80,7 +73,7 @@ export function SellModal({ asset, session, onClose }: { asset: MyAsset; session
} catch (e) {
captureError(e, { flow: 'list_secondary' })
track('Shop Listing Failed', { listing_type: 'secondary', error_code: errorCode(e) })
setError(friendlyError(e))
setError(friendlyError(e, t('sellModal.errorGeneric')))
setStatus(null)
} finally {
setBusy(false)
Expand Down Expand Up @@ -143,7 +136,7 @@ export function SellModal({ asset, session, onClose }: { asset: MyAsset; session
</p>

{status ? <p className="muted">{status}</p> : null}
{error ? <p className="error">{error}</p> : null}
<ErrorNotice message={error} />

<div className="modal__actions">
<button className="btn btn--ghost" onClick={onClose} disabled={busy}>
Expand Down
22 changes: 22 additions & 0 deletions app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,28 @@ h2 {
.error {
color: var(--err);
}
.error-notice {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 10px 12px;
border: 1px solid rgba(221, 51, 51, 0.25);
border-radius: var(--radius-btn);
background: rgba(221, 51, 51, 0.08);
color: var(--err);
font-size: 14px;
line-height: 1.4;
text-align: left;
}
.error-notice__ico {
flex: none;
width: 18px;
height: 18px;
}
.error-notice__msg {
color: var(--text-2);
}
.link {
background: none;
border: 0;
Expand Down
8 changes: 6 additions & 2 deletions app/src/intl/en.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
{
"errors": {
"rejected": "You cancelled the request.",
"insufficient": "You don't have enough {currency} — get more first.",
"soldOrRemoved": "This item was just sold or removed.",
"cantBuyOwn": "You can't buy your own listing."
},
"nav": {
"overview": "Overview",
"collectibles": "Collectibles",
Expand Down Expand Up @@ -219,8 +225,6 @@
"tryInWorld": "Try in world",
"itemFallback": "Item",
"error": {
"soldOrRemoved": "This item was just sold or removed.",
"cantBuyOwn": "You can't buy your own listing.",
"generic": "Couldn't complete the purchase — please try again.",
"creditsCheckout": "Couldn't start the credits checkout — please try again."
}
Expand Down
8 changes: 6 additions & 2 deletions app/src/intl/es.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
{
"errors": {
"rejected": "Cancelaste la solicitud.",
"insufficient": "No tienes suficientes {currency}. Consigue más primero.",
"soldOrRemoved": "Este artículo acaba de venderse o eliminarse.",
"cantBuyOwn": "No puedes comprar tu propia publicación."
},
"nav": {
"overview": "Inicio",
"collectibles": "Coleccionables",
Expand Down Expand Up @@ -219,8 +225,6 @@
"tryInWorld": "Probar en el mundo",
"itemFallback": "Artículo",
"error": {
"soldOrRemoved": "Este artículo acaba de venderse o eliminarse.",
"cantBuyOwn": "No puedes comprar tu propia publicación.",
"generic": "No pudimos completar la compra. Inténtalo de nuevo.",
"creditsCheckout": "No pudimos iniciar el pago de créditos. Inténtalo de nuevo."
}
Expand Down
42 changes: 42 additions & 0 deletions app/src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { t } from '~/intl/i18n'
import { CURRENCY } from '~/lib/currency'

// Central, safe mapping from a thrown error to a localized, user-facing string. The golden rule:
// NEVER surface raw backend/exception text to the buyer (it's unpredictable, untranslated, and can
// leak internals) — every path returns a curated `t()` message or the caller's own `fallback`.

type ErrLike = { code?: number; status?: number; message?: string; name?: string }

/** User bailed out: wallet rejection (EIP-1193 4001), an aborted fetch, or a reject/deny/cancel message. */
export function isRejection(e: unknown): boolean {
const err = e as ErrLike
return err.code === 4001 || err.name === 'AbortError' || /reject|denied|cancel/i.test(err.message ?? '')
}

/**
* A "not enough credits" failure (server 402 / "insufficient"). Purchase flows treat this as a normal
* top-up prompt (route to the pack picker) rather than an error state, so it's exposed separately.
*/
export function isInsufficient(e: unknown): boolean {
const err = e as ErrLike
return err.code === 402 || err.status === 402 || (err.message ?? '').toLowerCase().includes('insufficient')
}

/**
* Map a thrown error to a safe, localized string for display.
* - Wallet/abort rejection is handled universally.
* - Purchase flows pass `sale: true` to also map funds/availability failures (insufficient credits,
* sold/removed item, own listing) to their curated messages.
* - Anything unrecognized returns `fallback` — a context-specific generic the caller supplies
* (e.g. "Couldn't list your item…" vs "Couldn't complete checkout…"), never the raw error.
*/
export function friendlyError(e: unknown, fallback: string, opts: { sale?: boolean } = {}): string {
if (isRejection(e)) return t('errors.rejected')
if (opts.sale) {
const msg = ((e as ErrLike).message ?? '').toLowerCase()
if (msg.includes('insufficient')) return t('errors.insufficient', { currency: CURRENCY.name })
if (/not for sale|not found|no active listing|404/.test(msg)) return t('errors.soldOrRemoved')
if (msg.includes('your own listing')) return t('errors.cantBuyOwn')
}
return fallback
}
3 changes: 2 additions & 1 deletion app/src/pages/Assets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useSeo } from '~/hooks/useSeo'
import { SUBCAT_MAP } from '~/lib/categories'
import { track } from '~/lib/analytics'
import { t } from '~/intl/i18n'
import { ErrorNotice } from '~/components/ErrorNotice'

// Items fetched per page (infinite scroll pages by cumulative offset — see useInfiniteGrid).
const PAGE_SIZE = 48
Expand Down Expand Up @@ -329,7 +330,7 @@ export function Assets() {
<p className="market-banner market-banner--warn">{t('assets.marketUnavailable')}</p>
) : null}

{error ? <p className="error">{t('assets.loadError')}</p> : null}
{error ? <ErrorNotice message={t('assets.loadError')} /> : null}

<div className="grid">
{isLoading ? (
Expand Down
Loading