From a6cd4dfc0fb601539df9eed16104e8478a91e0cc Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Tue, 6 Jan 2026 00:46:34 +0000 Subject: [PATCH] feat: improve error handling for withdraw modal (#5054) * feat: improve error handling for withdraw modal * fix: add headers to error info * prepr --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com> --- .../ui/dashboard/CreatorWithdrawModal.vue | 243 ++++++++++++++---- apps/frontend/src/locales/en-US/index.json | 26 +- .../src/components/nav/NotificationPanel.vue | 23 +- .../ui/src/providers/web-notifications.ts | 1 + 4 files changed, 243 insertions(+), 50 deletions(-) diff --git a/apps/frontend/src/components/ui/dashboard/CreatorWithdrawModal.vue b/apps/frontend/src/components/ui/dashboard/CreatorWithdrawModal.vue index 486e78e9..b356c711 100644 --- a/apps/frontend/src/components/ui/dashboard/CreatorWithdrawModal.vue +++ b/apps/frontend/src/components/ui/dashboard/CreatorWithdrawModal.vue @@ -128,6 +128,7 @@ import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue' import { createWithdrawContext, + type PaymentProvider, type PayoutMethod, provideWithdrawContext, TAX_THRESHOLD_ACTUAL, @@ -332,51 +333,76 @@ function continueWithLimit() { setStage(nextStep.value) } -// TODO: God we need better errors from the backend (e.g error ids), this shit is insane -function getWithdrawalError(error: any): { title: string; text: string } { - const description = error?.data?.description?.toLowerCase() || '' +function buildSupportData(error: any): Record { + // Extract response headers, excluding sensitive ones + const responseHeaders: Record = {} + if (error?.response?.headers) { + const headers = error.response.headers + const entries = + typeof headers.entries === 'function' ? [...headers.entries()] : Object.entries(headers) + for (const [key, value] of entries) { + const lowerKey = key.toLowerCase() + // Exclude sensitive headers + if (!['authorization', 'cookie', 'set-cookie'].includes(lowerKey)) { + responseHeaders[key] = value + } + } + } + return { + timestamp: new Date().toISOString(), + provider: withdrawData.value.selection.provider, + method: withdrawData.value.selection.method, + methodId: withdrawData.value.selection.methodId, + country: withdrawData.value.selection.country?.id, + amount: withdrawData.value.calculation?.amount, + fee: withdrawData.value.calculation?.fee, + request: { + url: 'POST /api/v3/payout', + }, + response: { + status: error?.response?.status ?? error?.statusCode, + statusText: error?.response?.statusText, + headers: responseHeaders, + body: error?.data, + }, + } +} + +function formatBilingualText( + messageDescriptor: { id: string; defaultMessage: string }, + values?: Record, +): string { + const localized = formatMessage(messageDescriptor, values) + // Interpolate values into the English default message + let english = messageDescriptor.defaultMessage + if (values) { + for (const [key, value] of Object.entries(values)) { + english = english.replace(`{${key}}`, value) + } + } + + if (localized === english) { + return localized + } + + return `${localized}\n${english}` +} + +function getWithdrawalError( + error: any, + provider: PaymentProvider | null, +): { title: string; text: string; supportData: Record } { + const description = error?.data?.description?.toLowerCase() || '' + const supportData = buildSupportData(error) + + // === Common patterns (all providers) === // Tax form error if (description.includes('tax form')) { return { title: formatMessage(messages.errorTaxFormTitle), - text: formatMessage(messages.errorTaxFormText), - } - } - - // Invalid crypto wallet address - if ( - (description.includes('wallet') && description.includes('invalid')) || - description.includes('wallet_address') || - (description.includes('blockchain') && description.includes('invalid')) - ) { - return { - title: formatMessage(messages.errorInvalidWalletTitle), - text: formatMessage(messages.errorInvalidWalletText), - } - } - - // Invalid bank details - if ( - (description.includes('bank') || description.includes('account')) && - (description.includes('invalid') || description.includes('failed')) - ) { - return { - title: formatMessage(messages.errorInvalidBankTitle), - text: formatMessage(messages.errorInvalidBankText), - } - } - - // Invalid/fraudulent address - if ( - description.includes('address') && - (description.includes('invalid') || - description.includes('verification') || - description.includes('fraudulent')) - ) { - return { - title: formatMessage(messages.errorInvalidAddressTitle), - text: formatMessage(messages.errorInvalidAddressText), + text: formatBilingualText(messages.errorTaxFormText), + supportData, } } @@ -388,14 +414,106 @@ function getWithdrawalError(error: any): { title: string; text: string } { ) { return { title: formatMessage(messages.errorMinimumNotMetTitle), - text: formatMessage(messages.errorMinimumNotMetText), + text: formatBilingualText(messages.errorMinimumNotMetText), + supportData, + } + } + + // Insufficient balance + if (description.includes('enough funds') || description.includes('insufficient')) { + return { + title: formatMessage(messages.errorInsufficientBalanceTitle), + text: formatBilingualText(messages.errorInsufficientBalanceText), + supportData, + } + } + + // Email verification required + if (description.includes('verify your email') || description.includes('email_verified')) { + return { + title: formatMessage(messages.errorEmailVerificationTitle), + text: formatBilingualText(messages.errorEmailVerificationText), + supportData, + } + } + + // === MuralPay-only patterns === + if (provider === 'muralpay') { + // Invalid crypto wallet address + if ( + (description.includes('wallet') && description.includes('invalid')) || + description.includes('wallet_address') || + (description.includes('blockchain') && description.includes('invalid')) + ) { + return { + title: formatMessage(messages.errorInvalidWalletTitle), + text: formatBilingualText(messages.errorInvalidWalletText), + supportData, + } + } + + // Invalid bank details + if ( + (description.includes('bank') || description.includes('account')) && + (description.includes('invalid') || description.includes('failed')) + ) { + return { + title: formatMessage(messages.errorInvalidBankTitle), + text: formatBilingualText(messages.errorInvalidBankText), + supportData, + } + } + + // Invalid/fraudulent address (physical address for KYC) + if ( + description.includes('address') && + (description.includes('invalid') || + description.includes('verification') || + description.includes('fraudulent')) + ) { + return { + title: formatMessage(messages.errorInvalidAddressTitle), + text: formatBilingualText(messages.errorInvalidAddressText), + supportData, + } + } + } + + // === PayPal/Venmo-only patterns === + if (provider === 'paypal' || provider === 'venmo') { + // Account not linked + if ( + description.includes('not linked') || + description.includes('link') || + description.includes('paypal account') || + description.includes('venmo') + ) { + return { + title: formatMessage(messages.errorAccountNotLinkedTitle), + text: formatBilingualText(messages.errorAccountNotLinkedText), + supportData, + } + } + + // Country mismatch for PayPal + if ( + provider === 'paypal' && + (description.includes('us paypal') || description.includes('international paypal')) + ) { + return { + title: formatMessage(messages.errorPaypalCountryMismatchTitle), + text: formatBilingualText(messages.errorPaypalCountryMismatchText), + supportData, + } } } // Generic fallback + const errorDescription = error?.data?.description || '' return { title: formatMessage(messages.errorGenericTitle), - text: formatMessage(messages.errorGenericText), + text: formatBilingualText(messages.errorGenericText, { error: errorDescription }), + supportData, } } @@ -409,11 +527,15 @@ async function handleWithdraw() { } catch (error) { console.error('Withdrawal failed:', error) - const { title, text } = getWithdrawalError(error) + const { title, text, supportData } = getWithdrawalError( + error, + withdrawData.value.selection.provider, + ) addNotification({ title, text, type: 'error', + supportData, }) } finally { isSubmitting.value = false @@ -570,7 +692,40 @@ const messages = defineMessages({ errorGenericText: { id: 'dashboard.withdraw.error.generic.text', defaultMessage: - 'We were unable to submit your withdrawal request, please check your details or contact support.', + 'We were unable to submit your withdrawal request, please check your details or contact support.\n{error}', + }, + errorInsufficientBalanceTitle: { + id: 'dashboard.withdraw.error.insufficient-balance.title', + defaultMessage: 'Insufficient balance', + }, + errorInsufficientBalanceText: { + id: 'dashboard.withdraw.error.insufficient-balance.text', + defaultMessage: 'You do not have enough funds to make this withdrawal.', + }, + errorEmailVerificationTitle: { + id: 'dashboard.withdraw.error.email-verification.title', + defaultMessage: 'Email verification required', + }, + errorEmailVerificationText: { + id: 'dashboard.withdraw.error.email-verification.text', + defaultMessage: 'You must verify your email address before withdrawing funds.', + }, + errorAccountNotLinkedTitle: { + id: 'dashboard.withdraw.error.account-not-linked.title', + defaultMessage: 'Account not linked', + }, + errorAccountNotLinkedText: { + id: 'dashboard.withdraw.error.account-not-linked.text', + defaultMessage: 'Please link your payment account before withdrawing.', + }, + errorPaypalCountryMismatchTitle: { + id: 'dashboard.withdraw.error.paypal-country-mismatch.title', + defaultMessage: 'PayPal region mismatch', + }, + errorPaypalCountryMismatchText: { + id: 'dashboard.withdraw.error.paypal-country-mismatch.text', + defaultMessage: + 'Please use the correct PayPal transfer option for your region (US or International).', }, }) diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index 99814da4..6a5f63fb 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -980,12 +980,30 @@ "dashboard.withdraw.completion.wallet": { "message": "Wallet" }, + "dashboard.withdraw.error.account-not-linked.text": { + "message": "Please link your payment account before withdrawing." + }, + "dashboard.withdraw.error.account-not-linked.title": { + "message": "Account not linked" + }, + "dashboard.withdraw.error.email-verification.text": { + "message": "You must verify your email address before withdrawing funds." + }, + "dashboard.withdraw.error.email-verification.title": { + "message": "Email verification required" + }, "dashboard.withdraw.error.generic.text": { - "message": "We were unable to submit your withdrawal request, please check your details or contact support." + "message": "We were unable to submit your withdrawal request, please check your details or contact support.\n{error}" }, "dashboard.withdraw.error.generic.title": { "message": "Unable to withdraw" }, + "dashboard.withdraw.error.insufficient-balance.text": { + "message": "You do not have enough funds to make this withdrawal." + }, + "dashboard.withdraw.error.insufficient-balance.title": { + "message": "Insufficient balance" + }, "dashboard.withdraw.error.invalid-address.text": { "message": "The address you provided could not be verified. Please check your address details." }, @@ -1010,6 +1028,12 @@ "dashboard.withdraw.error.minimum-not-met.title": { "message": "Amount too low" }, + "dashboard.withdraw.error.paypal-country-mismatch.text": { + "message": "Please use the correct PayPal transfer option for your region (US or International)." + }, + "dashboard.withdraw.error.paypal-country-mismatch.title": { + "message": "PayPal region mismatch" + }, "dashboard.withdraw.error.tax-form.text": { "message": "You must complete a tax form to submit your withdrawal request." }, diff --git a/packages/ui/src/components/nav/NotificationPanel.vue b/packages/ui/src/components/nav/NotificationPanel.vue index 3b7af36d..b201279c 100644 --- a/packages/ui/src/components/nav/NotificationPanel.vue +++ b/packages/ui/src/components/nav/NotificationPanel.vue @@ -49,8 +49,13 @@ x{{ item.count }} - @@ -106,18 +111,26 @@ function createNotifText(notif: WebNotification): string { return [notif.title, notif.text, notif.errorCode].filter(Boolean).join('\n') } +function getCopyKey(notif: WebNotification): string { + return notif.supportData ? `support-${notif.id}` : createNotifText(notif) +} + function checkIntercomPresence(): void { isIntercomPresent.value = !!document.querySelector('.intercom-lightweight-app') } function copyToClipboard(notif: WebNotification): void { - const text = createNotifText(notif) + // If supportData is present, copy the full JSON for support; otherwise copy plain text + const text = notif.supportData + ? JSON.stringify(notif.supportData, null, 2) + : createNotifText(notif) - copied.value[text] = true + const key = getCopyKey(notif) + copied.value[key] = true navigator.clipboard.writeText(text) setTimeout(() => { - const { [text]: _, ...rest } = copied.value + const { [key]: _, ...rest } = copied.value copied.value = rest }, 2000) } diff --git a/packages/ui/src/providers/web-notifications.ts b/packages/ui/src/providers/web-notifications.ts index 4af009d9..42483a65 100644 --- a/packages/ui/src/providers/web-notifications.ts +++ b/packages/ui/src/providers/web-notifications.ts @@ -8,6 +8,7 @@ export interface WebNotification { errorCode?: string count?: number timer?: NodeJS.Timeout + supportData?: Record } export type NotificationPanelLocation = 'left' | 'right'