Improve support for non-USD Tremendous gift cards (#4887)

* Improve support for non-USD Tremendous gift cards

* add forex info to tremendous payout methods

* fix: partially fix DEV-535

* feat: wip

* eur/usd to usd/eur

* feat: better denom picking

* feat: qa changes

* fix: intl

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
aecsocket
2025-12-18 18:02:29 +00:00
committed by GitHub
parent 514c6f6e34
commit dc16a65b62
5 changed files with 436 additions and 42 deletions

View File

@@ -111,28 +111,163 @@
</Combobox>
</div>
<span v-if="selectedMethodDetails" class="text-secondary">
{{ formatMoney(effectiveMinAmount) }} min,
{{ formatMoney(selectedMethodDetails.interval?.standard?.max ?? effectiveMaxAmount) }}
{{ formatMoney(fixedDenominationMin ?? effectiveMinAmount)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
formatAmountForDisplay(
fixedDenominationMin ?? effectiveMinAmount,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
min,
{{
formatMoney(
fixedDenominationMax ??
selectedMethodDetails.interval?.standard?.max ??
effectiveMaxAmount,
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
formatAmountForDisplay(
fixedDenominationMax ??
selectedMethodDetails.interval?.standard?.max ??
effectiveMaxAmount,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
max withdrawal amount.
</span>
<span
v-if="selectedMethodDetails && effectiveMinAmount > roundedMaxAmount"
class="text-sm text-red"
>
You need at least {{ formatMoney(effectiveMinAmount)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
formatAmountForDisplay(
effectiveMinAmount,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
to use this gift card.
</span>
</div>
<div class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast"
>{{ formatMessage(formFieldLabels.amount) }} <span class="text-red">*</span></span
>{{ formatMessage(formFieldLabels.amount) }}
<template v-if="useDenominationSuggestions"> ({{ selectedMethodCurrencyCode }})</template>
<span class="text-red">*</span></span
>
</label>
<div v-if="showGiftCardSelector && useFixedDenominations" class="flex flex-col gap-2.5">
<Chips
v-model="selectedDenomination"
:items="denominationOptions"
:format-label="(amt: number) => formatMoney(amt)"
:never-empty="false"
:capitalize="false"
/>
<span v-if="denominationOptions.length === 0" class="text-error text-sm">
<template v-if="useDenominationSuggestions">
<input
v-model.number="denominationSearchInput"
type="number"
step="0.01"
:min="0"
:placeholder="formatMessage(messages.enterDenominationPlaceholder)"
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
@input="hasTouchedSuggestions = true"
/>
<Transition
enter-active-class="transition-opacity duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<span v-if="!denominationSearchInput" class="text-sm text-secondary">
{{ formatMessage(messages.enterAmountHint) }}
</span>
</Transition>
</template>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-96"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-96"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="!useDenominationSuggestions || displayedSuggestions.length > 0"
class="overflow-hidden"
:class="[useDenominationSuggestions ? 'p-[2px]' : '']"
>
<Chips
v-model="selectedDenomination"
:items="useDenominationSuggestions ? displayedSuggestions : denominationOptions"
:format-label="
(amt: number) =>
formatAmountForDisplay(
amt,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
"
:never-empty="false"
:capitalize="false"
/>
</div>
</Transition>
<Transition
enter-active-class="transition-opacity duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<span
v-if="
useDenominationSuggestions &&
denominationSearchInput &&
displayedSuggestions.length === 0
"
class="text-sm text-secondary"
>
{{ noSuggestionsMessage }}
</span>
</Transition>
<Transition
enter-active-class="transition-opacity duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<span
v-if="
useDenominationSuggestions &&
hasTouchedSuggestions &&
displayedSuggestions.length > 0 &&
!hasSelectedDenomination
"
class="text-sm text-orange"
>
{{ formatMessage(messages.selectDenominationRequired) }}
</span>
</Transition>
<span
v-if="!useDenominationSuggestions && denominationOptions.length === 0"
class="text-error text-sm"
>
No denominations available for your current balance
</span>
</div>
@@ -153,8 +288,8 @@
:amount="formData.amount || 0"
:fee="calculatedFee"
:fee-loading="feeLoading"
:exchange-rate="exchangeRate"
:local-currency="showPayPalCurrencySelector ? selectedCurrency : undefined"
:exchange-rate="giftCardExchangeRate"
:local-currency="giftCardCurrencyCode"
/>
<Checkbox v-model="agreedTerms">
@@ -285,6 +420,9 @@ const formData = ref<Record<string, any>>({
const selectedGiftCardId = ref<string | null>(withdrawData.value.selection.methodId || null)
const denominationSearchInput = ref<number | undefined>(undefined)
const hasTouchedSuggestions = ref(false)
const currencyOptions = [
{ value: 'USD', label: 'USD' },
{ value: 'AUD', label: 'AUD' },
@@ -373,6 +511,8 @@ const rewardOptions = ref<
fixed?: { values: number[] }
standard?: { min: number; max: number }
}
currencyCode?: string | null
exchangeRate?: number | null
}
}>
>([])
@@ -390,24 +530,187 @@ const selectedMethodDetails = computed(() => {
return option?.methodDetails || null
})
const selectedMethodCurrencyCode = computed(() => selectedMethodDetails.value?.currencyCode || null)
const selectedMethodExchangeRate = computed(() => selectedMethodDetails.value?.exchangeRate || null)
const giftCardCurrencyCode = computed(() => {
if (showPayPalCurrencySelector.value) {
return selectedCurrency.value !== 'USD' ? selectedCurrency.value : undefined
}
if (
showGiftCardSelector.value &&
selectedMethodCurrencyCode.value &&
selectedMethodCurrencyCode.value !== 'USD'
) {
return selectedMethodCurrencyCode.value
}
return undefined
})
const giftCardExchangeRate = computed(() => {
if (showPayPalCurrencySelector.value) {
return exchangeRate.value
}
if (
showGiftCardSelector.value &&
selectedMethodCurrencyCode.value &&
selectedMethodCurrencyCode.value !== 'USD'
) {
return selectedMethodExchangeRate.value
}
return exchangeRate.value
})
function formatAmountForDisplay(
usdAmount: number,
currencyCode: string | null | undefined,
rate: number | null | undefined,
): string {
if (!currencyCode || currencyCode === 'USD' || !rate) {
return formatMoney(usdAmount)
}
const localAmount = usdAmount * rate
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(localAmount)
} catch {
return `${currencyCode} ${localAmount.toFixed(2)}`
}
}
const useFixedDenominations = computed(() => {
const hasFixed = !!selectedMethodDetails.value?.interval?.fixed?.values
debug('Use fixed denominations:', hasFixed, selectedMethodDetails.value?.interval)
return hasFixed
const interval = selectedMethodDetails.value?.interval
if (!interval) return false
if (interval.fixed?.values?.length) {
debug('Use fixed denominations: true (has fixed values)')
return true
}
// treat min=max as single fixed value
if (interval.standard) {
const { min, max } = interval.standard
const isSingleValue = min === max
debug('Use fixed denominations:', isSingleValue, '(min=max:', min, '=', max, ')')
return isSingleValue
}
return false
})
const useDenominationSuggestions = computed(() => {
if (!useFixedDenominations.value) return false
const interval = selectedMethodDetails.value?.interval
if (!interval?.fixed?.values) return false
return interval.fixed.values.length > 35
})
const denominationSuggestions = computed(() => {
const input = denominationSearchInput.value
if (!input || input <= 0) return []
const allDenominations = denominationOptions.value
if (allDenominations.length === 0) return []
// convert local currency input to USD for filtering (denominations are stored in USD)
const exchangeRate = selectedMethodExchangeRate.value
const inputInUsd = exchangeRate ? input / exchangeRate : input
const rangeSize = inputInUsd * 0.2
let lowerBound = inputInUsd - rangeSize / 2
let upperBound = inputInUsd + rangeSize / 2
const minAvailable = allDenominations[0]
const maxAvailable = allDenominations[allDenominations.length - 1]
// shift range when hitting boundaries to maintain ~20% total range
if (upperBound > maxAvailable) {
const overflow = upperBound - maxAvailable
upperBound = maxAvailable
lowerBound = Math.max(minAvailable, lowerBound - overflow)
} else if (lowerBound < minAvailable) {
const underflow = minAvailable - lowerBound
lowerBound = minAvailable
upperBound = Math.min(maxAvailable, upperBound + underflow)
}
return allDenominations
.filter((amt) => amt >= lowerBound && amt <= upperBound)
.sort((a, b) => a - b)
})
const maxDisplayedSuggestions = 10
const displayedSuggestions = computed(() => {
const all = denominationSuggestions.value
if (all.length <= maxDisplayedSuggestions) return all
const input = denominationSearchInput.value
if (!input) return all.slice(0, maxDisplayedSuggestions)
const exchangeRate = selectedMethodExchangeRate.value
const inputInUsd = exchangeRate ? input / exchangeRate : input
// select values closest to input, then sort ascending for display
const closest = [...all]
.sort((a, b) => Math.abs(a - inputInUsd) - Math.abs(b - inputInUsd))
.slice(0, maxDisplayedSuggestions)
return closest.sort((a, b) => a - b)
})
const noSuggestionsMessage = computed(() => {
if (!denominationSearchInput.value || denominationSearchInput.value <= 0) {
return null
}
if (denominationSuggestions.value.length === 0) {
const maxDenom = fixedDenominationMax.value
if (maxDenom) {
const maxInLocal = formatAmountForDisplay(
maxDenom,
selectedMethodCurrencyCode.value,
selectedMethodExchangeRate.value,
)
return `No denominations near this amount. The highest available is ${maxInLocal}.`
}
return 'No denominations near this amount'
}
return null
})
const hasSelectedDenomination = computed(() => {
return (
formData.value.amount !== undefined &&
formData.value.amount > 0 &&
denominationOptions.value.includes(formData.value.amount)
)
})
const denominationOptions = computed(() => {
const fixedValues = selectedMethodDetails.value?.interval?.fixed?.values
if (!fixedValues) return []
const interval = selectedMethodDetails.value?.interval
if (!interval) return []
const filtered = fixedValues
.filter((amount) => amount <= roundedMaxAmount.value)
.sort((a, b) => a - b)
let values: number[] = []
if (interval.fixed?.values) {
values = [...interval.fixed.values]
} else if (interval.standard && interval.standard.min === interval.standard.max) {
// min=max case: treat as single fixed value
values = [interval.standard.min]
}
if (values.length === 0) return []
const filtered = values.filter((amount) => amount <= roundedMaxAmount.value).sort((a, b) => a - b)
debug(
'Denomination options (filtered by max):',
filtered,
'from',
fixedValues,
values,
'max:',
roundedMaxAmount.value,
)
@@ -426,6 +729,20 @@ const effectiveMaxAmount = computed(() => {
return roundedMaxAmount.value
})
const fixedDenominationMin = computed(() => {
if (!useFixedDenominations.value) return null
const options = denominationOptions.value
if (options.length === 0) return null
return options[0]
})
const fixedDenominationMax = computed(() => {
if (!useFixedDenominations.value) return null
const options = denominationOptions.value
if (options.length === 0) return null
return options[options.length - 1]
})
const selectedDenomination = computed({
get: () => formData.value.amount,
set: (value) => {
@@ -542,6 +859,8 @@ onMounted(async () => {
id: m.id,
name: m.name,
interval: m.interval,
currencyCode: m.currency_code,
exchangeRate: m.exchange_rate,
},
}))
@@ -564,6 +883,8 @@ watch(
selectedGiftCardId.value = null
calculatedFee.value = 0
exchangeRate.value = null
denominationSearchInput.value = undefined
hasTouchedSuggestions.value = false
// Clear currency when switching away from PayPal International
if (newMethod !== 'paypal' && withdrawData.value.providerData.type === 'tremendous') {
@@ -573,6 +894,15 @@ watch(
},
)
watch(selectedGiftCardId, (newId, oldId) => {
if (oldId && newId !== oldId) {
// Reset denomination search when gift card changes
denominationSearchInput.value = undefined
hasTouchedSuggestions.value = false
formData.value.amount = undefined
}
})
async function switchToDirectPaypal() {
withdrawData.value.selection.country = {
id: 'US',
@@ -649,5 +979,21 @@ const messages = defineMessages({
defaultMessage:
'You selected USD for PayPal International. <direct-paypal-link>Switch to direct PayPal</direct-paypal-link> for better fees (≈2% instead of ≈6%).',
},
enterDenominationPlaceholder: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.enter-denomination-placeholder',
defaultMessage: 'Enter amount',
},
enterAmountHint: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.enter-amount-hint',
defaultMessage: 'Enter an amount to see available gift card denominations',
},
selectDenominationHint: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.select-denomination-hint',
defaultMessage: 'Select a denomination:',
},
selectDenominationRequired: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.select-denomination-required',
defaultMessage: 'Please select a denomination to continue',
},
})
</script>