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
229 changes: 192 additions & 37 deletions apps/frontend/src/components/ui/dashboard/CreatorWithdrawModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'

import {
createWithdrawContext,
type PaymentProvider,
type PayoutMethod,
provideWithdrawContext,
TAX_THRESHOLD_ACTUAL,
Expand Down Expand Up @@ -332,70 +333,187 @@ 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 } {
function buildSupportData(error: any): Record<string, unknown> {
// Extract response headers, excluding sensitive ones
const responseHeaders: Record<string, string> = {}
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, string>,
): 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<string, unknown> } {
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),
text: formatBilingualText(messages.errorTaxFormText),
supportData,
}
}

// Invalid crypto wallet address
// Minimum amount not met
if (
(description.includes('wallet') && description.includes('invalid')) ||
description.includes('wallet_address') ||
(description.includes('blockchain') && description.includes('invalid'))
description.includes('payoutminimumnotmeterror') ||
description.includes('minimum') ||
(description.includes('amount') && description.includes('less'))
) {
return {
title: formatMessage(messages.errorInvalidWalletTitle),
text: formatMessage(messages.errorInvalidWalletText),
title: formatMessage(messages.errorMinimumNotMetTitle),
text: formatBilingualText(messages.errorMinimumNotMetText),
supportData,
}
}

// Invalid bank details
if (
(description.includes('bank') || description.includes('account')) &&
(description.includes('invalid') || description.includes('failed'))
) {
// Insufficient balance
if (description.includes('enough funds') || description.includes('insufficient')) {
return {
title: formatMessage(messages.errorInvalidBankTitle),
text: formatMessage(messages.errorInvalidBankText),
title: formatMessage(messages.errorInsufficientBalanceTitle),
text: formatBilingualText(messages.errorInsufficientBalanceText),
supportData,
}
}

// Invalid/fraudulent address
if (
description.includes('address') &&
(description.includes('invalid') ||
description.includes('verification') ||
description.includes('fraudulent'))
) {
// Email verification required
if (description.includes('verify your email') || description.includes('email_verified')) {
return {
title: formatMessage(messages.errorInvalidAddressTitle),
text: formatMessage(messages.errorInvalidAddressText),
title: formatMessage(messages.errorEmailVerificationTitle),
text: formatBilingualText(messages.errorEmailVerificationText),
supportData,
}
}

// Minimum amount not met
if (
description.includes('payoutminimumnotmeterror') ||
description.includes('minimum') ||
(description.includes('amount') && description.includes('less'))
) {
return {
title: formatMessage(messages.errorMinimumNotMetTitle),
text: formatMessage(messages.errorMinimumNotMetText),
// === 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,
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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).',
},
})
</script>
26 changes: 25 additions & 1 deletion apps/frontend/src/locales/en-US/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand All @@ -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."
},
Expand Down
23 changes: 18 additions & 5 deletions packages/ui/src/components/nav/NotificationPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,13 @@
x{{ item.count }}
</div>
<ButtonStyled circular size="small">
<button v-tooltip="'Copy to clipboard'" @click="copyToClipboard(item)">
<CheckIcon v-if="copied[createNotifText(item)]" />
<button
v-tooltip="
item.supportData ? 'Copy error details for support' : 'Copy to clipboard'
"
@click="copyToClipboard(item)"
>
<CheckIcon v-if="copied[getCopyKey(item)]" />
<CopyIcon v-else />
</button>
</ButtonStyled>
Expand Down Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/providers/web-notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface WebNotification {
errorCode?: string
count?: number
timer?: NodeJS.Timeout
supportData?: Record<string, unknown>
}

export type NotificationPanelLocation = 'left' | 'right'
Expand Down