feat: 2nd batch of withdraw QA changes (#4724)

* polish: increase gap between svg and text in empty state

* fix: use ts & change cancel btn style

* fix: btn style

* polish: new transaction page design

* fix: navstack match nested + csv download

* fix: lint & i18n

* Add tooltip to CSV download button + standard btn style

Signed-off-by: Calum H. <contact@cal.engineer>

* fix: lint & i18n

---------

Signed-off-by: Calum H. <contact@cal.engineer>
This commit is contained in:
Calum H.
2025-11-06 21:55:07 +00:00
committed by GitHub
parent 7674433f88
commit 60ffa75653
9 changed files with 299 additions and 105 deletions

View File

@@ -22,7 +22,7 @@
icon: AffiliateIcon,
shown: isAffiliate,
},
{ link: '/dashboard/revenue', label: 'Revenue', icon: CurrencyIcon },
{ link: '/dashboard/revenue', label: 'Revenue', icon: CurrencyIcon, matchNested: true },
]"
/>
</div>

View File

@@ -193,7 +193,7 @@
@cancelled="refreshPayouts"
/>
</div>
<div v-else class="mx-auto flex flex-col justify-center gap-4 p-6 text-center">
<div v-else class="mx-auto flex flex-col justify-center gap-8 p-6 text-center">
<div data-svg-wrapper>
<svg
viewBox="0 0 154 94"

View File

@@ -1,34 +1,67 @@
<template>
<div class="mb-6 flex flex-col gap-4 p-4 py-0 !pt-4 md:p-8 lg:p-12">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="mb-20 flex flex-col gap-4 lg:pl-8">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<span class="text-xl font-semibold text-contrast md:text-2xl">{{
formatMessage(messages.transactionsHeader)
}}</span>
<div
class="flex w-full flex-col gap-2 min-[480px]:flex-row min-[480px]:items-center sm:max-w-[400px]"
>
<div class="flex w-full flex-row gap-2 sm:max-w-[250px] md:items-center">
<Combobox
v-model="selectedYear"
:options="yearOptions"
:display-value="selectedYear === 'all' ? 'All years' : String(selectedYear)"
listbox
/>
<Combobox
v-model="selectedMethod"
:options="methodOptions"
:display-value="selectedMethod === 'all' ? 'All types' : formatTypeLabel(selectedMethod)"
listbox
/>
<ButtonStyled circular>
<button
v-tooltip="formatMessage(messages.downloadCsv)"
:disabled="buildingCsv"
@click="onDownloadCSV"
>
<SpinnerIcon v-if="buildingCsv" class="animate-spin" />
<DownloadIcon v-else />
</button>
</ButtonStyled>
</div>
</div>
<div v-if="Object.keys(groupedTransactions).length > 0" class="flex flex-col gap-5 md:gap-6">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="flex flex-col gap-3 rounded-2xl bg-surface-3 p-5">
<div class="flex w-full justify-between">
<span class="my-auto font-medium">{{ formatMessage(messages.received) }}</span>
<ArrowDownLeftIcon class="size-6" />
</div>
<span class="text-2xl font-semibold text-contrast md:text-4xl">{{
formatMoney(totalReceived)
}}</span>
</div>
<div class="flex flex-col gap-3 rounded-2xl bg-surface-3 p-5">
<div class="flex w-full justify-between">
<span class="my-auto font-medium">{{ formatMessage(messages.withdrawn) }}</span>
<ArrowUpRightIcon class="size-6" />
</div>
<span class="text-2xl font-semibold text-contrast md:text-4xl">{{
formatMoney(totalWithdrawn)
}}</span>
</div>
<div class="flex flex-col gap-3 rounded-2xl bg-surface-3 p-5">
<div class="flex w-full justify-between">
<span class="my-auto font-medium">{{ formatMessage(messages.transactions) }}</span>
<GenericListIcon class="size-6" />
</div>
<span class="text-2xl font-semibold text-contrast md:text-4xl">{{
filteredTransactions.length
}}</span>
</div>
</div>
<div
v-if="Object.keys(groupedTransactions).length > 0"
class="-mt-2 flex flex-col gap-5 md:gap-6"
>
<div
v-for="(transactions, period) in groupedTransactions"
:key="period"
class="flex flex-col"
>
<h3 class="text-sm font-medium text-primary md:text-base">{{ period }}</h3>
<h3 class="text-base font-medium text-primary md:text-lg">{{ period }}</h3>
<div class="flex flex-col gap-3 md:gap-4">
<RevenueTransaction
v-for="transaction in transactions"
@@ -50,8 +83,15 @@
</div>
</template>
<script setup>
import { Combobox } from '@modrinth/ui'
import { capitalizeString } from '@modrinth/utils'
import {
ArrowDownLeftIcon,
ArrowUpRightIcon,
DownloadIcon,
GenericListIcon,
SpinnerIcon,
} from '@modrinth/assets'
import { ButtonStyled, Combobox } from '@modrinth/ui'
import { formatMoney } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
@@ -93,68 +133,12 @@ const yearOptions = computed(() => {
})
const selectedYear = ref('all')
const methodOptions = computed(() => {
const types = new Set()
sortedTransactions.value.forEach((x) => {
if (x.type === 'payout_available' && x.payout_source) {
types.add(x.payout_source)
} else if (x.type === 'withdrawal' && (x.method_type || x.method)) {
types.add(x.method_type || x.method)
}
})
const typeValues = ['all', ...Array.from(types)]
return typeValues.map((type) => ({
value: type,
label: type === 'all' ? 'All types' : formatTypeLabel(type),
}))
})
const selectedMethod = ref('all')
function formatMethodLabel(method) {
switch (method) {
case 'paypal':
return 'PayPal'
case 'venmo':
return 'Venmo'
case 'tremendous':
return 'Tremendous'
case 'muralpay':
return 'Muralpay'
default:
return capitalizeString(method)
}
}
function formatTypeLabel(type) {
// Check if it's a payout method (withdrawal)
const payoutMethods = ['paypal', 'venmo', 'tremendous', 'muralpay']
if (payoutMethods.includes(type)) {
return formatMethodLabel(type)
}
// Otherwise it's a payout_source (income), convert snake_case to Title Case
return type
.split('_')
.map((word) => capitalizeString(word))
.join(' ')
}
const buildingCsv = ref(false)
const filteredTransactions = computed(() =>
sortedTransactions.value
.filter((x) => selectedYear.value === 'all' || dayjs(x.created).year() === selectedYear.value)
.filter((x) => {
if (selectedMethod.value === 'all') return true
// Check if it's an income source
if (x.type === 'payout_available') {
return x.payout_source === selectedMethod.value
}
// Check if it's a withdrawal method
return x.type === 'withdrawal' && (x.method_type || x.method) === selectedMethod.value
}),
sortedTransactions.value.filter(
(x) => selectedYear.value === 'all' || dayjs(x.created).year() === selectedYear.value,
),
)
function getPeriodLabel(date) {
@@ -186,11 +170,141 @@ const groupedTransactions = computed(() => {
return groups
})
const totalReceived = computed(() => {
return filteredTransactions.value
.filter((txn) => txn.type === 'payout_available')
.reduce((sum, txn) => sum + Number(txn.amount), 0)
})
const totalWithdrawn = computed(() => {
return filteredTransactions.value
.filter((txn) => txn.type === 'withdrawal')
.reduce((sum, txn) => sum + Number(txn.amount), 0)
})
function escapeCSVField(field) {
if (field === null || field === undefined) return ''
const stringField = String(field)
if (stringField.includes(',') || stringField.includes('"') || stringField.includes('\n')) {
return `"${stringField.replace(/"/g, '""')}"`
}
return stringField
}
function transactionsToCSV() {
if (!filteredTransactions.value || filteredTransactions.value.length === 0) {
return ''
}
const newline = '\n'
const header = ['Date', 'Type', 'Source', 'Status', 'Amount', 'Fee'].join(',')
const rows = filteredTransactions.value.map((txn) => {
const date = dayjs(txn.created).format('YYYY-MM-DD HH:mm:ss')
const type = txn.type === 'withdrawal' ? 'Withdrawal' : 'Payout'
let methodOrSource = ''
let status = ''
let fee = ''
if (txn.type === 'withdrawal') {
const method = txn.method_type || txn.method || 'Unknown'
switch (method) {
case 'paypal':
methodOrSource = 'PayPal'
break
case 'venmo':
methodOrSource = 'Venmo'
break
case 'tremendous':
methodOrSource = 'Tremendous'
break
case 'muralpay':
methodOrSource = 'Muralpay'
break
default:
methodOrSource = method.charAt(0).toUpperCase() + method.slice(1)
}
status = txn.status
? txn.status.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
: 'Unknown'
fee = txn.fee ? Number(txn.fee).toFixed(2) : '0.00'
} else {
methodOrSource = txn.payout_source
? txn.payout_source
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
: 'Unknown'
status = 'N/A'
fee = 'N/A'
}
const amount = Number(txn.amount).toFixed(2)
return [
escapeCSVField(date),
escapeCSVField(type),
escapeCSVField(methodOrSource),
escapeCSVField(status),
escapeCSVField(amount),
escapeCSVField(fee),
].join(',')
})
return [header, ...rows].join(newline)
}
const downloadTransactionsCSV = () => {
buildingCsv.value = true
try {
const csv = transactionsToCSV()
if (!csv) {
return
}
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
const yearSuffix = selectedYear.value === 'all' ? 'all' : selectedYear.value
const filename = `modrinth-transactions-${yearSuffix}.csv`
link.setAttribute('href', url)
link.setAttribute('download', filename)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} finally {
buildingCsv.value = false
}
}
const onDownloadCSV = useClientTry(async () => await downloadTransactionsCSV())
const messages = defineMessages({
transactionsHeader: {
id: 'dashboard.revenue.transactions.header',
defaultMessage: 'Transactions',
},
received: {
id: 'dashboard.revenue.stats.received',
defaultMessage: 'Received',
},
withdrawn: {
id: 'dashboard.revenue.stats.withdrawn',
defaultMessage: 'Withdrawn',
},
transactions: {
id: 'dashboard.revenue.stats.transactions',
defaultMessage: 'Transactions',
},
noTransactions: {
id: 'dashboard.revenue.transactions.none',
defaultMessage: 'No transactions',
@@ -199,6 +313,9 @@ const messages = defineMessages({
id: 'dashboard.revenue.transactions.none.desc',
defaultMessage: 'Your payouts and withdrawals will appear here.',
},
downloadCsv: {
id: 'dashboard.revenue.transactions.btn.download-csv',
defaultMessage: 'Download as CSV',
},
})
</script>
<style scoped></style>