Provide more specific payout method names on frontend (#4977)

* Provide more specific payout method names on frontend

Been getting a lot of confused tickets recently of people withdrawing to PayPal but then not recognizing what "Tremendous" means. This should clarify things.

* feat: improve icons + names for withdrawals

* Update apps/frontend/src/components/ui/dashboard/RevenueTransaction.vue

Co-authored-by: Emma Alexia <emma@modrinth.com>
Signed-off-by: Calum H. <hendersoncal117@gmail.com>

* fix: icons

* fix: object cover

* feat: icons for crypto + bank

* fix: remove empty null

* fix: qa

---------

Signed-off-by: Calum H. <hendersoncal117@gmail.com>
Co-authored-by: Calum H. <contact@cal.engineer>
This commit is contained in:
Emma Alexia
2025-12-29 08:08:33 -05:00
committed by GitHub
parent e0d159c010
commit 30106d5f82
6 changed files with 164 additions and 11 deletions

View File

@@ -1,17 +1,28 @@
<template> <template>
<div class="flex flex-row gap-2 md:gap-3"> <div class="flex flex-row gap-2 md:gap-3">
<div <div
class="flex h-10 min-h-10 w-10 min-w-10 justify-center rounded-full border-[1px] border-solid border-button-bg bg-bg-raised !p-0 shadow-md md:h-12 md:min-h-12 md:w-12 md:min-w-12" class="flex h-10 min-h-10 w-10 min-w-10 items-center justify-center rounded-full border-[1px] border-solid border-button-bg bg-bg-raised !p-0 shadow-md md:h-12 md:min-h-12 md:w-12 md:min-w-12"
> >
<ArrowDownIcon v-if="isIncome" class="my-auto size-6 text-secondary md:size-8" /> <img
<ArrowUpIcon v-else class="my-auto size-6 text-secondary md:size-8" /> v-if="methodIconUrl"
:src="methodIconUrl"
alt=""
class="size-6 rounded-full object-cover md:size-8"
/>
<component
:is="methodIconComponent"
v-else-if="methodIconComponent"
class="size-6 md:size-8"
/>
<ArrowDownIcon v-else-if="isIncome" class="size-6 text-secondary md:size-8" />
<ArrowUpIcon v-else class="size-6 text-secondary md:size-8" />
</div> </div>
<div class="flex w-full flex-row justify-between"> <div class="flex w-full flex-row justify-between">
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-base font-semibold text-contrast md:text-lg">{{ <span class="text-base font-semibold text-contrast md:text-lg">{{
transaction.type === 'payout_available' transaction.type === 'payout_available'
? formatPayoutSource(transaction.payout_source) ? formatPayoutSource(transaction.payout_source)
: formatMethodName(transaction.method_type || transaction.method) : formatMethodName(transaction.method_type || transaction.method, transaction.method_id)
}}</span> }}</span>
<span class="text-xs text-secondary md:text-sm"> <span class="text-xs text-secondary md:text-sm">
<template v-if="transaction.type === 'withdrawal'"> <template v-if="transaction.type === 'withdrawal'">
@@ -33,8 +44,8 @@
<div class="my-auto flex flex-row items-center gap-2"> <div class="my-auto flex flex-row items-center gap-2">
<span <span
class="text-base font-semibold md:text-lg" class="text-base font-semibold md:text-lg"
:class="transaction.type === 'payout_available' ? 'text-green' : 'text-contrast'" :class="isIncome ? 'text-green' : 'text-contrast'"
>{{ formatMoney(transaction.amount) }}</span >{{ isIncome ? '' : '-' }}{{ formatMoney(transaction.amount) }}</span
> >
<template v-if="transaction.type === 'withdrawal' && transaction.status === 'in-transit'"> <template v-if="transaction.type === 'withdrawal' && transaction.status === 'in-transit'">
<Tooltip theme="dismissable-prompt" :triggers="['hover', 'focus']" no-auto-focus> <Tooltip theme="dismissable-prompt" :triggers="['hover', 'focus']" no-auto-focus>
@@ -55,12 +66,27 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ArrowDownIcon, ArrowUpIcon, XIcon } from '@modrinth/assets' import {
import { BulletDivider, ButtonStyled, injectNotificationManager } from '@modrinth/ui' ArrowDownIcon,
ArrowUpIcon,
LandmarkIcon,
PayPalColorIcon,
VenmoColorIcon,
XIcon,
} from '@modrinth/assets'
import {
BulletDivider,
ButtonStyled,
getCurrencyIcon,
injectNotificationManager,
} from '@modrinth/ui'
import { capitalizeString, formatMoney } from '@modrinth/utils' import { capitalizeString, formatMoney } from '@modrinth/utils'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue' import { Tooltip } from 'floating-vue'
import { useGeneratedState } from '~/composables/generated'
import { findRail } from '~/utils/muralpay-rails'
type PayoutStatus = 'in-transit' | 'cancelling' | 'cancelled' | 'success' | 'failed' type PayoutStatus = 'in-transit' | 'cancelling' | 'cancelled' | 'success' | 'failed'
type PayoutMethodType = 'paypal' | 'venmo' | 'tremendous' | 'muralpay' type PayoutMethodType = 'paypal' | 'venmo' | 'tremendous' | 'muralpay'
type PayoutSource = 'creator_rewards' | 'affilites' type PayoutSource = 'creator_rewards' | 'affilites'
@@ -96,15 +122,73 @@ const emit = defineEmits<{
}>() }>()
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const generatedState = useGeneratedState()
const isIncome = computed(() => props.transaction.type === 'payout_available') const isIncome = computed(() => props.transaction.type === 'payout_available')
const methodIconUrl = computed(() => {
if (props.transaction.type !== 'withdrawal') return null
const method = props.transaction.method_type || props.transaction.method
const methodId = props.transaction.method_id
if (method === 'tremendous' && methodId) {
const methodInfo = generatedState.value.tremendousIdMap?.[methodId]
if (methodInfo?.name?.toLowerCase()?.includes('paypal')) return null
return methodInfo?.image_url ?? null
}
return null
})
const methodIconComponent = computed(() => {
if (props.transaction.type !== 'withdrawal') return null
const method = props.transaction.method_type || props.transaction.method
switch (method) {
case 'paypal':
return PayPalColorIcon
case 'tremendous': {
const methodId = props.transaction.method_id
if (methodId) {
const info = generatedState.value.tremendousIdMap?.[methodId]
if (info?.name?.toLowerCase()?.includes('paypal')) {
return PayPalColorIcon
}
}
return null
}
case 'venmo':
return VenmoColorIcon
case 'muralpay': {
const methodId = props.transaction.method_id
if (methodId) {
const rail = findRail(methodId)
if (rail) {
if (rail.type === 'crypto') {
const currencyIcon = getCurrencyIcon(rail.currency)
if (currencyIcon) return currencyIcon
}
if (rail.type === 'fiat') {
const currencyIcon = getCurrencyIcon(rail.currency)
if (currencyIcon) return currencyIcon
return LandmarkIcon
}
}
}
return null
}
default:
return null
}
})
function formatTransactionStatus(status: string): string { function formatTransactionStatus(status: string): string {
if (status === 'in-transit') return 'In Transit' if (status === 'in-transit') return 'In Transit'
return capitalizeString(status) return capitalizeString(status)
} }
function formatMethodName(method: string | undefined): string { const { formatMessage } = useVIntl()
function formatMethodName(method: string | undefined, method_id: string | undefined): string {
if (!method) return 'Unknown' if (!method) return 'Unknown'
switch (method) { switch (method) {
case 'paypal': case 'paypal':
@@ -112,9 +196,19 @@ function formatMethodName(method: string | undefined): string {
case 'venmo': case 'venmo':
return 'Venmo' return 'Venmo'
case 'tremendous': case 'tremendous':
if (method_id) {
const info = generatedState.value.tremendousIdMap?.[method_id]
if (info) return `${info.name}`
}
return 'Tremendous' return 'Tremendous'
case 'muralpay': case 'muralpay':
return 'Muralpay' if (method_id) {
const rail = findRail(method_id)
if (rail) {
return formatMessage(rail.name)
}
}
return 'Mural Pay (Unknown)'
default: default:
return capitalizeString(method) return capitalizeString(method)
} }

View File

@@ -54,6 +54,9 @@ export const useGeneratedState = () =>
muralBankDetails: generatedState.muralBankDetails as muralBankDetails: generatedState.muralBankDetails as
| Record<string, { bankNames: string[] }> | Record<string, { bankNames: string[] }>
| undefined, | undefined,
tremendousIdMap: generatedState.tremendousIdMap as
| Record<string, { name: string; image_url: string | null }>
| undefined,
countries: (generatedState.countries ?? []) as ISO3166.Country[], countries: (generatedState.countries ?? []) as ISO3166.Country[],
subdivisions: (generatedState.subdivisions ?? {}) as Record<string, ISO3166.Subdivision[]>, subdivisions: (generatedState.subdivisions ?? {}) as Record<string, ISO3166.Subdivision[]>,

View File

@@ -95,8 +95,11 @@ import { formatMoney } from '@modrinth/utils'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import RevenueTransaction from '~/components/ui/dashboard/RevenueTransaction.vue' import RevenueTransaction from '~/components/ui/dashboard/RevenueTransaction.vue'
import { useGeneratedState } from '~/composables/generated'
import { findRail } from '~/utils/muralpay-rails'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const generatedState = useGeneratedState()
useHead({ useHead({
title: 'Transaction history - Modrinth', title: 'Transaction history - Modrinth',
@@ -216,10 +219,24 @@ function transactionsToCSV() {
methodOrSource = 'Venmo' methodOrSource = 'Venmo'
break break
case 'tremendous': case 'tremendous':
if (txn.method_id) {
const info = generatedState.value.tremendousIdMap?.[txn.method_id]
if (info) {
methodOrSource = `Tremendous (${info.name})`
break
}
}
methodOrSource = 'Tremendous' methodOrSource = 'Tremendous'
break break
case 'muralpay': case 'muralpay':
methodOrSource = 'Muralpay' if (txn.method_id) {
const rail = findRail(txn.method_id)
if (rail) {
methodOrSource = `${rail.name.defaultMessage}`
break
}
}
methodOrSource = 'Mural Pay (Unknown)'
break break
default: default:
methodOrSource = method.charAt(0).toUpperCase() + method.slice(1) methodOrSource = method.charAt(0).toUpperCase() + method.slice(1)

View File

@@ -855,6 +855,7 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
currency: 'USDC', currency: 'USDC',
type: 'crypto', type: 'crypto',
fee: '≈ 1%', fee: '≈ 1%',
railCode: 'blockchain-usdc-polygon',
blockchain: 'POLYGON', blockchain: 'POLYGON',
warningMessage: defineMessage({ warningMessage: defineMessage({
id: 'muralpay.warning.wallet-address', id: 'muralpay.warning.wallet-address',
@@ -888,6 +889,7 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
currency: 'USDC', currency: 'USDC',
type: 'crypto', type: 'crypto',
fee: '≈ 1%', fee: '≈ 1%',
railCode: 'blockchain-usdc-base',
blockchain: 'BASE', blockchain: 'BASE',
warningMessage: defineMessage({ warningMessage: defineMessage({
id: 'muralpay.warning.wallet-address', id: 'muralpay.warning.wallet-address',
@@ -924,6 +926,7 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
currency: 'USDC', currency: 'USDC',
type: 'crypto', type: 'crypto',
fee: '≈ 1%', fee: '≈ 1%',
railCode: 'blockchain-usdc-ethereum',
blockchain: 'ETHEREUM', blockchain: 'ETHEREUM',
warningMessage: defineMessage({ warningMessage: defineMessage({
id: 'muralpay.warning.wallet-address', id: 'muralpay.warning.wallet-address',
@@ -957,6 +960,7 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
currency: 'USDC', currency: 'USDC',
type: 'crypto', type: 'crypto',
fee: '≈ 1%', fee: '≈ 1%',
railCode: 'blockchain-usdc-celo',
blockchain: 'CELO', blockchain: 'CELO',
warningMessage: defineMessage({ warningMessage: defineMessage({
id: 'muralpay.warning.wallet-address', id: 'muralpay.warning.wallet-address',
@@ -996,3 +1000,7 @@ export function getRailsByType(type: 'fiat' | 'crypto'): RailConfig[] {
export function getRailConfig(railId: string): RailConfig | undefined { export function getRailConfig(railId: string): RailConfig | undefined {
return MURALPAY_RAILS[railId] return MURALPAY_RAILS[railId]
} }
export function findRail(railCode: string): RailConfig | undefined {
return Object.values(MURALPAY_RAILS).find((rail) => rail.railCode === railCode)
}

View File

@@ -40,6 +40,7 @@ export class LabrinthStateModule extends AbstractModule {
products, products,
muralBankDetails, muralBankDetails,
iso3166Data, iso3166Data,
payoutMethods,
] = await Promise.all([ ] = await Promise.all([
// Tag endpoints // Tag endpoints
this.client this.client
@@ -114,8 +115,23 @@ export class LabrinthStateModule extends AbstractModule {
this.client.iso3166.data this.client.iso3166.data
.build() .build()
.catch((err) => handleError(err, { countries: [], subdivisions: {} })), .catch((err) => handleError(err, { countries: [], subdivisions: {} })),
// Payout methods for tremendous ID mapping
this.client
.request<Labrinth.State.PayoutMethodInfo[]>('/payout/methods', {
api: 'labrinth',
version: 3,
method: 'GET',
})
.catch((err) => handleError(err, [])),
]) ])
const tremendousIdMap = Object.fromEntries(
(payoutMethods as Labrinth.State.PayoutMethodInfo[])
.filter((m) => m.type === 'tremendous')
.map((m) => [m.id, { name: m.name, image_url: m.image_logo_url }]),
)
return { return {
categories, categories,
loaders, loaders,
@@ -127,6 +143,7 @@ export class LabrinthStateModule extends AbstractModule {
homePageNotifs, homePageNotifs,
products, products,
muralBankDetails: muralBankDetails?.bankDetails, muralBankDetails: muralBankDetails?.bankDetails,
tremendousIdMap,
countries: iso3166Data.countries, countries: iso3166Data.countries,
subdivisions: iso3166Data.subdivisions, subdivisions: iso3166Data.subdivisions,
errors, errors,

View File

@@ -699,6 +699,13 @@ export namespace Labrinth {
} }
export namespace State { export namespace State {
export interface PayoutMethodInfo {
id: string
type: string
name: string
image_logo_url: string | null
}
export interface GeneratedState { export interface GeneratedState {
categories: Tags.v2.Category[] categories: Tags.v2.Category[]
loaders: Tags.v2.Loader[] loaders: Tags.v2.Loader[]
@@ -711,6 +718,13 @@ export namespace Labrinth {
bankNames: string[] bankNames: string[]
} }
> >
tremendousIdMap?: Record<
string,
{
name: string
image_url: string | null
}
>
homePageProjects?: Projects.v2.Project[] homePageProjects?: Projects.v2.Project[]
homePageSearch?: Search.v2.SearchResults homePageSearch?: Search.v2.SearchResults