You've already forked AstralRinth
forked from xxxOFFxxx/AstralRinth
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:
@@ -18,6 +18,7 @@
|
|||||||
v-else-if="item.link ?? item.to"
|
v-else-if="item.link ?? item.to"
|
||||||
:to="(item.link ?? item.to) as string"
|
:to="(item.link ?? item.to) as string"
|
||||||
class="nav-item inline-flex w-full cursor-pointer items-center gap-2 text-nowrap rounded-xl border-none bg-transparent px-4 py-2.5 text-left text-base font-semibold leading-tight text-button-text transition-all hover:bg-button-bg hover:text-contrast active:scale-[0.97]"
|
class="nav-item inline-flex w-full cursor-pointer items-center gap-2 text-nowrap rounded-xl border-none bg-transparent px-4 py-2.5 text-left text-base font-semibold leading-tight text-button-text transition-all hover:bg-button-bg hover:text-contrast active:scale-[0.97]"
|
||||||
|
:class="{ 'is-active': isActive(item as NavStackLinkItem) }"
|
||||||
>
|
>
|
||||||
<component
|
<component
|
||||||
:is="item.icon"
|
:is="item.icon"
|
||||||
@@ -80,6 +81,7 @@ type NavStackLinkItem = NavStackBaseItem & {
|
|||||||
link?: string | null
|
link?: string | null
|
||||||
to?: string | null
|
to?: string | null
|
||||||
action?: (() => void) | null
|
action?: (() => void) | null
|
||||||
|
matchNested?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type NavStackSeparator = { type: 'separator' }
|
type NavStackSeparator = { type: 'separator' }
|
||||||
@@ -96,6 +98,8 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const ariaLabel = computed(() => props.ariaLabel ?? 'Section navigation')
|
const ariaLabel = computed(() => props.ariaLabel ?? 'Section navigation')
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
const hasSlotContent = computed(() => {
|
const hasSlotContent = computed(() => {
|
||||||
const content = slots.default?.()
|
const content = slots.default?.()
|
||||||
@@ -117,6 +121,19 @@ function getKey(item: NavStackEntry, idx: number) {
|
|||||||
return link ? `link-${link}` : `action-${(item as NavStackLinkItem).label}-${idx}`
|
return link ? `link-${link}` : `action-${(item as NavStackLinkItem).label}-${idx}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isActive(item: NavStackLinkItem): boolean {
|
||||||
|
const link = item.link ?? item.to
|
||||||
|
if (!link) return false
|
||||||
|
|
||||||
|
const currentPath = route.path
|
||||||
|
|
||||||
|
if (item.matchNested) {
|
||||||
|
return currentPath.startsWith(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentPath === link
|
||||||
|
}
|
||||||
|
|
||||||
const filteredItems = computed(() => props.items?.filter((x) => x.shown === undefined || x.shown))
|
const filteredItems = computed(() => props.items?.filter((x) => x.shown === undefined || x.shown))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -124,11 +141,13 @@ const filteredItems = computed(() => props.items?.filter((x) => x.shown === unde
|
|||||||
li {
|
li {
|
||||||
text-align: unset;
|
text-align: unset;
|
||||||
}
|
}
|
||||||
.router-link-exact-active.nav-item {
|
.router-link-exact-active.nav-item,
|
||||||
|
.nav-item.is-active {
|
||||||
background: var(--color-button-bg-selected);
|
background: var(--color-button-bg-selected);
|
||||||
color: var(--color-button-text-selected);
|
color: var(--color-button-text-selected);
|
||||||
}
|
}
|
||||||
.router-link-exact-active.nav-item .text-contrast {
|
.router-link-exact-active.nav-item .text-contrast,
|
||||||
|
.nav-item.is-active .text-contrast {
|
||||||
color: var(--color-button-text-selected);
|
color: var(--color-button-text-selected);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,12 +9,12 @@
|
|||||||
<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">{{
|
||||||
isIncome
|
transaction.type === 'payout_available'
|
||||||
? formatPayoutSource(transaction.payout_source)
|
? formatPayoutSource(transaction.payout_source)
|
||||||
: formatMethodName(transaction.method_type || transaction.method)
|
: formatMethodName(transaction.method_type || transaction.method)
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="text-xs text-secondary md:text-sm">
|
<span class="text-xs text-secondary md:text-sm">
|
||||||
<template v-if="!isIncome">
|
<template v-if="transaction.type === 'withdrawal'">
|
||||||
<span
|
<span
|
||||||
:class="[
|
:class="[
|
||||||
transaction.status === 'cancelling' || transaction.status === 'cancelled'
|
transaction.status === 'cancelling' || transaction.status === 'cancelled'
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
>{{ formatTransactionStatus(transaction.status) }} <BulletDivider
|
>{{ formatTransactionStatus(transaction.status) }} <BulletDivider
|
||||||
/></span>
|
/></span>
|
||||||
</template>
|
</template>
|
||||||
{{ $dayjs(transaction.created).format('MMM DD YYYY') }}
|
{{ dayjs(transaction.created).format('MMM DD YYYY') }}
|
||||||
<template v-if="!isIncome && transaction.fee">
|
<template v-if="transaction.type === 'withdrawal' && transaction.fee">
|
||||||
<BulletDivider /> Fee {{ formatMoney(transaction.fee) }}
|
<BulletDivider /> Fee {{ formatMoney(transaction.fee) }}
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
@@ -33,13 +33,13 @@
|
|||||||
<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="isIncome ? 'text-green' : 'text-contrast'"
|
:class="transaction.type === 'payout_available' ? 'text-green' : 'text-contrast'"
|
||||||
>{{ formatMoney(transaction.amount) }}</span
|
>{{ formatMoney(transaction.amount) }}</span
|
||||||
>
|
>
|
||||||
<template v-if="!isIncome && 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>
|
||||||
<span class="my-auto align-middle"
|
<span class="my-auto align-middle"
|
||||||
><ButtonStyled circular size="small">
|
><ButtonStyled circular type="outlined" size="small">
|
||||||
<button class="align-middle" @click="cancelPayout">
|
<button class="align-middle" @click="cancelPayout">
|
||||||
<XIcon />
|
<XIcon />
|
||||||
</button> </ButtonStyled
|
</button> </ButtonStyled
|
||||||
@@ -54,32 +54,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ArrowDownIcon, ArrowUpIcon, XIcon } from '@modrinth/assets'
|
import { ArrowDownIcon, ArrowUpIcon, XIcon } from '@modrinth/assets'
|
||||||
import { BulletDivider, ButtonStyled, injectNotificationManager } from '@modrinth/ui'
|
import { BulletDivider, ButtonStyled, injectNotificationManager } from '@modrinth/ui'
|
||||||
import { capitalizeString, formatMoney } from '@modrinth/utils'
|
import { capitalizeString, formatMoney } from '@modrinth/utils'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
import { Tooltip } from 'floating-vue'
|
import { Tooltip } from 'floating-vue'
|
||||||
|
|
||||||
const props = defineProps({
|
type PayoutStatus = 'in-transit' | 'cancelling' | 'cancelled' | 'success' | 'failed'
|
||||||
transaction: {
|
type PayoutMethodType = 'paypal' | 'venmo' | 'tremendous' | 'muralpay'
|
||||||
type: Object,
|
type PayoutSource = 'creator_rewards' | 'affilites'
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['cancelled'])
|
type WithdrawalTransaction = {
|
||||||
|
type: 'withdrawal'
|
||||||
|
id: string
|
||||||
|
status: PayoutStatus
|
||||||
|
created: string
|
||||||
|
amount: number
|
||||||
|
fee?: number | null
|
||||||
|
method_type?: PayoutMethodType | null
|
||||||
|
method?: string
|
||||||
|
method_id?: string
|
||||||
|
method_address?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type PayoutAvailableTransaction = {
|
||||||
|
type: 'payout_available'
|
||||||
|
created: string
|
||||||
|
payout_source: PayoutSource
|
||||||
|
amount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Transaction = WithdrawalTransaction | PayoutAvailableTransaction
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
transaction: Transaction
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'cancelled'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
const auth = await useAuth()
|
|
||||||
|
|
||||||
const isIncome = computed(() => props.transaction.type === 'payout_available')
|
const isIncome = computed(() => props.transaction.type === 'payout_available')
|
||||||
|
|
||||||
function formatTransactionStatus(status) {
|
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) {
|
function formatMethodName(method: string | undefined): string {
|
||||||
if (!method) return 'Unknown'
|
if (!method) return 'Unknown'
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case 'paypal':
|
case 'paypal':
|
||||||
@@ -95,24 +120,27 @@ function formatMethodName(method) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPayoutSource(source) {
|
function formatPayoutSource(source: string | undefined): string {
|
||||||
if (!source) return 'Income'
|
if (!source) return 'Income'
|
||||||
return source
|
return source
|
||||||
.split('_')
|
.split('_')
|
||||||
.map((word) => capitalizeString(word))
|
.map((word: string) => capitalizeString(word))
|
||||||
.join(' ')
|
.join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelPayout() {
|
async function cancelPayout(): Promise<void> {
|
||||||
startLoading()
|
startLoading()
|
||||||
try {
|
try {
|
||||||
await useBaseFetch(`payout/${props.transaction.id}`, {
|
const transaction = props.transaction
|
||||||
|
if (transaction.type !== 'withdrawal') return
|
||||||
|
|
||||||
|
await useBaseFetch(`payout/${transaction.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
apiVersion: 3,
|
apiVersion: 3,
|
||||||
})
|
})
|
||||||
await useAuth(auth.value.token)
|
await useAuth()
|
||||||
emit('cancelled')
|
emit('cancelled')
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'Failed to cancel transaction',
|
title: 'Failed to cancel transaction',
|
||||||
text: err.data ? err.data.description : err,
|
text: err.data ? err.data.description : err,
|
||||||
|
|||||||
@@ -854,9 +854,21 @@
|
|||||||
"dashboard.revenue.processing.tooltip": {
|
"dashboard.revenue.processing.tooltip": {
|
||||||
"message": "Revenue stays in processing until the end of the month, then becomes available 60 days later."
|
"message": "Revenue stays in processing until the end of the month, then becomes available 60 days later."
|
||||||
},
|
},
|
||||||
|
"dashboard.revenue.stats.received": {
|
||||||
|
"message": "Received"
|
||||||
|
},
|
||||||
|
"dashboard.revenue.stats.transactions": {
|
||||||
|
"message": "Transactions"
|
||||||
|
},
|
||||||
|
"dashboard.revenue.stats.withdrawn": {
|
||||||
|
"message": "Withdrawn"
|
||||||
|
},
|
||||||
"dashboard.revenue.tos": {
|
"dashboard.revenue.tos": {
|
||||||
"message": "By uploading projects to Modrinth and withdrawing money from your account, you agree to our <terms-link>Rewards Program Terms</terms-link>. Learn more about the <info-link>Reward Program</info-link>."
|
"message": "By uploading projects to Modrinth and withdrawing money from your account, you agree to our <terms-link>Rewards Program Terms</terms-link>. Learn more about the <info-link>Reward Program</info-link>."
|
||||||
},
|
},
|
||||||
|
"dashboard.revenue.transactions.btn.download-csv": {
|
||||||
|
"message": "Download as CSV"
|
||||||
|
},
|
||||||
"dashboard.revenue.transactions.header": {
|
"dashboard.revenue.transactions.header": {
|
||||||
"message": "Transactions"
|
"message": "Transactions"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
icon: AffiliateIcon,
|
icon: AffiliateIcon,
|
||||||
shown: isAffiliate,
|
shown: isAffiliate,
|
||||||
},
|
},
|
||||||
{ link: '/dashboard/revenue', label: 'Revenue', icon: CurrencyIcon },
|
{ link: '/dashboard/revenue', label: 'Revenue', icon: CurrencyIcon, matchNested: true },
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -193,7 +193,7 @@
|
|||||||
@cancelled="refreshPayouts"
|
@cancelled="refreshPayouts"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div data-svg-wrapper>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 154 94"
|
viewBox="0 0 154 94"
|
||||||
|
|||||||
@@ -1,34 +1,67 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mb-6 flex flex-col gap-4 p-4 py-0 !pt-4 md:p-8 lg:p-12">
|
<div class="mb-20 flex flex-col gap-4 lg:pl-8">
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<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">{{
|
<span class="text-xl font-semibold text-contrast md:text-2xl">{{
|
||||||
formatMessage(messages.transactionsHeader)
|
formatMessage(messages.transactionsHeader)
|
||||||
}}</span>
|
}}</span>
|
||||||
<div
|
<div class="flex w-full flex-row gap-2 sm:max-w-[250px] md:items-center">
|
||||||
class="flex w-full flex-col gap-2 min-[480px]:flex-row min-[480px]:items-center sm:max-w-[400px]"
|
|
||||||
>
|
|
||||||
<Combobox
|
<Combobox
|
||||||
v-model="selectedYear"
|
v-model="selectedYear"
|
||||||
:options="yearOptions"
|
:options="yearOptions"
|
||||||
:display-value="selectedYear === 'all' ? 'All years' : String(selectedYear)"
|
:display-value="selectedYear === 'all' ? 'All years' : String(selectedYear)"
|
||||||
listbox
|
listbox
|
||||||
/>
|
/>
|
||||||
<Combobox
|
<ButtonStyled circular>
|
||||||
v-model="selectedMethod"
|
<button
|
||||||
:options="methodOptions"
|
v-tooltip="formatMessage(messages.downloadCsv)"
|
||||||
:display-value="selectedMethod === 'all' ? 'All types' : formatTypeLabel(selectedMethod)"
|
:disabled="buildingCsv"
|
||||||
listbox
|
@click="onDownloadCSV"
|
||||||
/>
|
>
|
||||||
|
<SpinnerIcon v-if="buildingCsv" class="animate-spin" />
|
||||||
|
<DownloadIcon v-else />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<div v-if="Object.keys(groupedTransactions).length > 0" class="flex flex-col gap-5 md:gap-6">
|
<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
|
<div
|
||||||
v-for="(transactions, period) in groupedTransactions"
|
v-for="(transactions, period) in groupedTransactions"
|
||||||
:key="period"
|
:key="period"
|
||||||
class="flex flex-col"
|
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">
|
<div class="flex flex-col gap-3 md:gap-4">
|
||||||
<RevenueTransaction
|
<RevenueTransaction
|
||||||
v-for="transaction in transactions"
|
v-for="transaction in transactions"
|
||||||
@@ -50,8 +83,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Combobox } from '@modrinth/ui'
|
import {
|
||||||
import { capitalizeString } from '@modrinth/utils'
|
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 { defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
@@ -93,68 +133,12 @@ const yearOptions = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const selectedYear = ref('all')
|
const selectedYear = ref('all')
|
||||||
|
const buildingCsv = ref(false)
|
||||||
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 filteredTransactions = computed(() =>
|
const filteredTransactions = computed(() =>
|
||||||
sortedTransactions.value
|
sortedTransactions.value.filter(
|
||||||
.filter((x) => selectedYear.value === 'all' || dayjs(x.created).year() === selectedYear.value)
|
(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
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
function getPeriodLabel(date) {
|
function getPeriodLabel(date) {
|
||||||
@@ -186,11 +170,141 @@ const groupedTransactions = computed(() => {
|
|||||||
return groups
|
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({
|
const messages = defineMessages({
|
||||||
transactionsHeader: {
|
transactionsHeader: {
|
||||||
id: 'dashboard.revenue.transactions.header',
|
id: 'dashboard.revenue.transactions.header',
|
||||||
defaultMessage: 'Transactions',
|
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: {
|
noTransactions: {
|
||||||
id: 'dashboard.revenue.transactions.none',
|
id: 'dashboard.revenue.transactions.none',
|
||||||
defaultMessage: 'No transactions',
|
defaultMessage: 'No transactions',
|
||||||
@@ -199,6 +313,9 @@ const messages = defineMessages({
|
|||||||
id: 'dashboard.revenue.transactions.none.desc',
|
id: 'dashboard.revenue.transactions.none.desc',
|
||||||
defaultMessage: 'Your payouts and withdrawals will appear here.',
|
defaultMessage: 'Your payouts and withdrawals will appear here.',
|
||||||
},
|
},
|
||||||
|
downloadCsv: {
|
||||||
|
id: 'dashboard.revenue.transactions.btn.download-csv',
|
||||||
|
defaultMessage: 'Download as CSV',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<style scoped></style>
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import _ArchiveIcon from './icons/archive.svg?component'
|
|||||||
import _ArrowBigRightDashIcon from './icons/arrow-big-right-dash.svg?component'
|
import _ArrowBigRightDashIcon from './icons/arrow-big-right-dash.svg?component'
|
||||||
import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component'
|
import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component'
|
||||||
import _ArrowDownIcon from './icons/arrow-down.svg?component'
|
import _ArrowDownIcon from './icons/arrow-down.svg?component'
|
||||||
|
import _ArrowDownLeftIcon from './icons/arrow-down-left.svg?component'
|
||||||
import _ArrowLeftRightIcon from './icons/arrow-left-right.svg?component'
|
import _ArrowLeftRightIcon from './icons/arrow-left-right.svg?component'
|
||||||
import _ArrowUpIcon from './icons/arrow-up.svg?component'
|
import _ArrowUpIcon from './icons/arrow-up.svg?component'
|
||||||
import _ArrowUpRightIcon from './icons/arrow-up-right.svg?component'
|
import _ArrowUpRightIcon from './icons/arrow-up-right.svg?component'
|
||||||
@@ -77,6 +78,7 @@ import _FolderSearchIcon from './icons/folder-search.svg?component'
|
|||||||
import _GameIcon from './icons/game.svg?component'
|
import _GameIcon from './icons/game.svg?component'
|
||||||
import _GapIcon from './icons/gap.svg?component'
|
import _GapIcon from './icons/gap.svg?component'
|
||||||
import _GaugeIcon from './icons/gauge.svg?component'
|
import _GaugeIcon from './icons/gauge.svg?component'
|
||||||
|
import _GenericListIcon from './icons/generic-list.svg?component'
|
||||||
import _GiftIcon from './icons/gift.svg?component'
|
import _GiftIcon from './icons/gift.svg?component'
|
||||||
import _GitGraphIcon from './icons/git-graph.svg?component'
|
import _GitGraphIcon from './icons/git-graph.svg?component'
|
||||||
import _GlassesIcon from './icons/glasses.svg?component'
|
import _GlassesIcon from './icons/glasses.svg?component'
|
||||||
@@ -219,6 +221,7 @@ export const AlignLeftIcon = _AlignLeftIcon
|
|||||||
export const ArchiveIcon = _ArchiveIcon
|
export const ArchiveIcon = _ArchiveIcon
|
||||||
export const ArrowBigRightDashIcon = _ArrowBigRightDashIcon
|
export const ArrowBigRightDashIcon = _ArrowBigRightDashIcon
|
||||||
export const ArrowBigUpDashIcon = _ArrowBigUpDashIcon
|
export const ArrowBigUpDashIcon = _ArrowBigUpDashIcon
|
||||||
|
export const ArrowDownLeftIcon = _ArrowDownLeftIcon
|
||||||
export const ArrowDownIcon = _ArrowDownIcon
|
export const ArrowDownIcon = _ArrowDownIcon
|
||||||
export const ArrowLeftRightIcon = _ArrowLeftRightIcon
|
export const ArrowLeftRightIcon = _ArrowLeftRightIcon
|
||||||
export const ArrowUpRightIcon = _ArrowUpRightIcon
|
export const ArrowUpRightIcon = _ArrowUpRightIcon
|
||||||
@@ -290,6 +293,7 @@ export const FolderSearchIcon = _FolderSearchIcon
|
|||||||
export const GameIcon = _GameIcon
|
export const GameIcon = _GameIcon
|
||||||
export const GapIcon = _GapIcon
|
export const GapIcon = _GapIcon
|
||||||
export const GaugeIcon = _GaugeIcon
|
export const GaugeIcon = _GaugeIcon
|
||||||
|
export const GenericListIcon = _GenericListIcon
|
||||||
export const GiftIcon = _GiftIcon
|
export const GiftIcon = _GiftIcon
|
||||||
export const GitGraphIcon = _GitGraphIcon
|
export const GitGraphIcon = _GitGraphIcon
|
||||||
export const GlassesIcon = _GlassesIcon
|
export const GlassesIcon = _GlassesIcon
|
||||||
|
|||||||
5
packages/assets/icons/arrow-down-left.svg
Normal file
5
packages/assets/icons/arrow-down-left.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-left-icon lucide-arrow-down-left">
|
||||||
|
<path d="M17 7 7 17" />
|
||||||
|
<path d="M17 17H7V7" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 283 B |
9
packages/assets/icons/generic-list.svg
Normal file
9
packages/assets/icons/generic-list.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-icon lucide-list">
|
||||||
|
<path d="M3 5h.01" />
|
||||||
|
<path d="M3 12h.01" />
|
||||||
|
<path d="M3 19h.01" />
|
||||||
|
<path d="M8 5h13" />
|
||||||
|
<path d="M8 12h13" />
|
||||||
|
<path d="M8 19h13" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 350 B |
Reference in New Issue
Block a user