feat: creator revenue page overhaul (#4204)

* feat: start on tax compliance

* feat: avarala1099 composable

* fix: shouldShow should be managed on the page itself

* refactor: move show logic to revenue page

* feat: security practices rather than info

* feat: withdraw page lock

* fix: empty modal bug & lint issues

* feat: hide behind feature flag

* Use standard admonition components, make casing consistent

* modal title

* lint

* feat: withdrawal check

* feat: tax cap on withdrawals warning

* feat: start on revenue page overhaul

* feat: segment generation for bar

* feat: tooltips and links

* fix: tooltip border

* feat: finish initial layout, start on withdraw modal

* feat: start on withdrawal limit stage

* feat: shade support for primary colors

* feat: start on withdraw details stage

* fix: convert swatches to hex

* feat: payout method/region dropdown temporarily using multiselect

* feat: fix modal open issues and use teleport dropdowns

* feat: hide transactions section if there are no transactions

* refactor: NavStack surfaces

* feat: new dropdown component

* feat: remove teleport dropdown modal in favour of new combobox component

* fix: lint

* refactor: dashboard sidebar layout

* feat: cleanup

* fix: niche bugs

* fix: ComboBox styling

* feat: first part of qa

* feat: animate flash rather than tooltip

* fix: lint

* feat: qa border gradient

* fix: seg hover flashes

* feat: i18n

* feat: i18n and final QA

* fix: lint

* feat: QA

* fix: lint

* fix: merge conflicts

* fix: intl

* fix: blue hover

* fix: transfers page

* feat: surface variables & gradients

* feat: text vars

* fix: lint

* fix: intl

* feat: stages

* fix: lint

* feat: region selection

* feat: method selection btns

* fix: flex col on transactions

* feat: hook up method selection to ctx

* feat: muralpay kyc stage info

* wip: muralpay integration

* Basic Mural Pay API bindings

* Fix clippy

* use dotenvy in muralpay example

* Refactor payout creation code

* wip: muralpay payout requests

* Mural Pay payouts work

* Fix clippy

* feat: progress

* fix: broken tax form stage logic

* polish: tax form stage and method selection stage layout

* add mural pay fees API

* Work on payout fee API

* Fees API for more payment methods

* Fix CI

* polish: muralpay qa

* refactor: clean up combobox component

* polish: change from critical -> warning admonition in MuralpayDetailsStage

* Temporarily disable Venmo and PayPal methods from frontend

* polish: clean up transaction component & page

* polish: navbar qa, text color-contrast in chips type buttonstyled, mb on rev/index.vue page

* fix: incorrectly using available balance as tax form withdraw limit after tax forms submitted

* wip: counterparties

* Start on counterparties and payment methods API

* polish: combobox component

* polish: fix broken scroll logic using a composable & web:fix

* fix: lint

* polish: various QA fixes

* feat: hook up with backend (wip)

* feat: draft muralpay rails dynamic logic

* polish: modify rails to support backend changes

* Mural Pay multiple methods when fetching

* Don't send supported_countries to frontend

* Mural Pay multiple methods when fetching

* Don't send supported_countries to frontend

* feat: fees & methods endpoint hookup

* chore: remove duplicates fix

* polish: qa changes + figma match

* Add countries to muralpay fiat methods

* Compile fix

* Add exchange rate info to fees endpoint

* Add fees to premium Tremendous options

* polish: i18n and better document type dropdown -> id input labels

* feat: tremendous

* fix: lint & i18n

* feat: reintroduce tin mismatch logic to index.vue

* polish: qa

* fix: i18n

* feat: remove teleport dropdown menu - combobox should be used

* fix: lint

* fix: jsdoc

* feat: checkbox for reward program terms

* Add delivery email field to Tremendous payouts

* Add Tremendous product category to payout methods

* Add bank details API to muralpay

* Fix CI

* Fix CI

* polish: qa changes

* feat: i18n pass

* feat: deduplicate methods endpoint & fix i18n issues

* chore: deduplicate i18n strings into common-messages.ts

* fix: lint

* fix: i18n

* feat: estimates

* polish: more QA

* Remove prepaid visa, compute fees properly for Tremendous methods

* Add more details to Tremendous errors

* feat: withdraw endpoint impl & internals refactor

* Add more details to Tremendous errors

* feat: completion stage

* Add fees to Mural

* feat: transactions page match figma

* fix: i18n

* polish: QA changes

* polish: qa

* Payout history route and bank details

* polish: autofill and requirements checks

* fix: i18n + lint

* fix: fiat rail fees

* polish: move scroll fade stuff into NewModal rather than just CreatorWithdrawModal

* feat: simplify action btn logic & tax form error

* fix: tax -> Tax form

* Re-add legacy PayPal/Venmo options for US

* feat: mobile responsiveness fixes for modal

* fix: responsiveness issues

* feat: navstack responsiveness

* fix: responsiveness

* move the mural bank details route

* fix: generated state cleanup & bank details input

* fix: lint & i18n

* Add utoipa support to payout endpoints

* address some PR comments

* polish: qa

* add CORS to new utoipa routes

* feat: legacy paypal/venmo stage

* polish: reset amount on back qa

* revert: navstack mr changes

* polish: loading indicator on method selection stage

* fix: paypal modal doesnt reopen after auth

* fix: lint & i18n

* fix: paypal flow

* polish: qa changes

* fix: gitignore

* polish: qa fixes

* fix: payouts_available in payouts.rs

* fix: bug when limit is zero

* polish: qa changes

* fix: qa stuff & muralpay sub-division fix

* Immediately approve mural payouts

* Add currency support to Tremendous payouts

* Currency forex

* add forex to tremendous fee request

* polish: qa & currency support for paypal tremendous

* polish: fx qa

* feat: demo mode flag

* fix: i18n & padding issues

* polish: qa changes

* fix: ml

* Add Mural balance to bank balance info

* polish: show warning for paypal international USD withdrawals + more currencies

* Add more Tremendous currencies support

* fix: colors on balance bars

* fix: empty states

* fix: pl-8 mobile issue

* fix: hide see all

* Transaction payouts available use the correct date

* Address my own review comment

* Address PR comments

* Change Mural withdrawal limit to 3k

* fix: empty state + paypal warning

* maybe fix tremendous gift cards

* Change how Mural minimum withdrawals are calculated

* Tweak min/max withdrawal values

* fix: segment brightness

* fix: min & max for muralpay & legacy paypal

* Fix some icon issues

* more issues

* fix user menu

* fix: remove + network

---------

Signed-off-by: Calum H. <contact@cal.engineer>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: aecsocket <aecsocket@tutanota.com>
Co-authored-by: Alejandro González <me@alegon.dev>
This commit is contained in:
Calum H.
2025-11-03 23:15:25 +00:00
committed by GitHub
parent 92698e4bb5
commit 3765a6ded8
108 changed files with 9071 additions and 2664 deletions

View File

@@ -53,12 +53,15 @@
</svg>
</template>
<script setup>
<script setup lang="ts">
const loading = useLoading()
const config = useRuntimeConfig()
const flags = useFeatureFlags()
const api = computed(() => {
if (flags.value.demoMode) return 'prod'
const apiUrl = config.public.apiBaseUrl
if (apiUrl.startsWith('https://api.modrinth.com')) {
return 'prod'

View File

@@ -90,7 +90,7 @@ defineProps({
},
})
const tags = useTags()
const tags = useGeneratedState()
</script>
<style lang="scss" scoped>
.environment {

View File

@@ -1,32 +1,134 @@
<template>
<nav>
<ul>
<slot />
<nav :aria-label="ariaLabel" class="w-full">
<ul class="m-0 flex list-none flex-col items-start gap-1.5 rounded-2xl bg-bg-raised p-4">
<slot v-if="hasSlotContent" />
<template v-else>
<li v-for="(item, idx) in filteredItems" :key="getKey(item, idx)" class="contents">
<hr v-if="isSeparator(item)" class="my-1 w-full border-t border-solid" />
<div
v-else-if="isHeading(item)"
class="px-4 pb-1 pt-2 text-xs font-bold uppercase tracking-wide text-secondary"
>
{{ item.label }}
</div>
<NuxtLink
v-else-if="item.link ?? item.to"
: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]"
>
<component
:is="item.icon"
v-if="item.icon"
aria-hidden="true"
class="h-5 w-5 shrink-0"
/>
<span class="text-contrast">{{ item.label }}</span>
<span
v-if="item.badge != null"
class="rounded-full bg-brand-highlight px-2 text-sm font-bold text-brand"
>
{{ String(item.badge) }}
</span>
<span v-if="item.chevron" class="ml-auto"><ChevronRightIcon /></span>
</NuxtLink>
<button
v-else-if="item.action"
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="{ 'danger-button': item.danger }"
@click="item.action"
>
<component
:is="item.icon"
v-if="item.icon"
aria-hidden="true"
class="h-5 w-5 shrink-0"
/>
<span class="text-contrast">{{ item.label }}</span>
<span
v-if="item.badge != null"
class="rounded-full bg-brand-highlight px-2 text-sm font-bold text-brand"
>
{{ String(item.badge) }}
</span>
</button>
<span v-else>You frog. 🐸</span>
</li>
</template>
</ul>
</nav>
</template>
<script>
export default {}
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import { type Component, computed, useSlots } from 'vue'
type NavStackBaseItem = {
label: string
icon?: Component | string
badge?: string | number | null
chevron?: boolean
danger?: boolean
}
type NavStackLinkItem = NavStackBaseItem & {
type?: 'item'
link?: string | null
to?: string | null
action?: (() => void) | null
}
type NavStackSeparator = { type: 'separator' }
type NavStackHeading = { type: 'heading'; label: string }
export type NavStackEntry = (NavStackLinkItem | NavStackSeparator | NavStackHeading) & {
shown?: boolean
}
const props = defineProps<{
items?: NavStackEntry[]
ariaLabel?: string
}>()
const ariaLabel = computed(() => props.ariaLabel ?? 'Section navigation')
const slots = useSlots()
const hasSlotContent = computed(() => {
const content = slots.default?.()
return !!(content && content.length)
})
function isSeparator(item: NavStackEntry): item is NavStackSeparator {
return (item as any).type === 'separator'
}
function isHeading(item: NavStackEntry): item is NavStackHeading {
return (item as any).type === 'heading'
}
function getKey(item: NavStackEntry, idx: number) {
if (isSeparator(item)) return `sep-${idx}`
if (isHeading(item)) return `head-${item.label}-${idx}`
const link = (item as NavStackLinkItem).link ?? (item as NavStackLinkItem).to
return link ? `link-${link}` : `action-${(item as NavStackLinkItem).label}-${idx}`
}
const filteredItems = computed(() => props.items?.filter((x) => x.shown === undefined || x.shown))
</script>
<style lang="scss" scoped>
ul {
display: flex;
flex-direction: column;
grid-gap: var(--spacing-card-xs);
flex-wrap: wrap;
list-style-type: none;
margin: 0;
padding: 0;
> :first-child {
margin-top: 0;
}
}
li {
display: unset;
text-align: unset;
}
.router-link-exact-active.nav-item {
background: var(--color-button-bg-selected);
color: var(--color-button-text-selected);
}
.router-link-exact-active.nav-item .text-contrast {
color: var(--color-button-text-selected);
}
</style>

View File

@@ -1,64 +0,0 @@
<template>
<NuxtLink v-if="link !== null" :to="link" class="nav-item">
<slot />
<span>{{ label }}</span>
<span v-if="badge" class="rounded-full bg-brand-highlight px-2 text-sm font-bold text-brand">{{
badge
}}</span>
<span v-if="chevron" class="ml-auto"><ChevronRightIcon /></span>
</NuxtLink>
<button v-else-if="action" class="nav-item" :class="{ 'danger-button': danger }" @click="action">
<slot />
<span>{{ label }}</span>
<span v-if="badge" class="rounded-full bg-brand-highlight px-2 text-sm font-bold text-brand">{{
badge
}}</span>
</button>
<span v-else>i forgor 💀</span>
</template>
<script>
import { ChevronRightIcon } from '@modrinth/assets'
export default {
components: {
ChevronRightIcon,
},
props: {
link: {
default: null,
type: String,
},
action: {
default: null,
type: Function,
},
label: {
required: true,
type: String,
},
badge: {
default: null,
type: String,
},
chevron: {
default: false,
type: Boolean,
},
danger: {
default: false,
type: Boolean,
},
},
}
</script>
<style lang="scss" scoped>
.nav-item {
@apply flex w-full cursor-pointer items-center gap-2 text-nowrap rounded-xl border-none bg-transparent px-4 py-2 text-left font-semibold text-button-text transition-all hover:bg-button-bg hover:text-contrast active:scale-[0.97];
}
.router-link-exact-active.nav-item {
@apply bg-button-bgSelected text-button-textSelected;
}
</style>

View File

@@ -376,7 +376,7 @@ const props = defineProps({
})
const flags = useFeatureFlags()
const tags = useTags()
const tags = useGeneratedState()
const type = computed(() =>
!props.notification.body || props.notification.body.type === 'legacy_markdown'

View File

@@ -212,7 +212,7 @@ export default {
},
},
setup() {
const tags = useTags()
const tags = useGeneratedState()
const formatRelativeTime = useRelativeTime()
return { tags, formatRelativeTime }

View File

@@ -0,0 +1,457 @@
<template>
<NewModal
ref="withdrawModal"
:closable="currentStage !== 'completion'"
:hide-header="currentStage === 'completion'"
:merge-header="currentStage === 'completion'"
:scrollable="true"
max-content-height="72vh"
:on-hide="onModalHide"
>
<template #title>
<div v-if="shouldShowTitle" class="flex flex-wrap items-center gap-1 text-secondary">
<template v-if="currentStage === 'tax-form'">
<span class="text-lg font-bold text-contrast sm:text-xl">{{
formatMessage(messages.taxFormStage)
}}</span>
</template>
<template v-else-if="currentStage === 'method-selection'">
<span class="text-lg font-bold text-contrast sm:text-xl">{{
formatMessage(messages.methodSelectionStage)
}}</span>
<ChevronRightIcon class="size-5 text-secondary" stroke-width="3" />
<span class="text-lg text-secondary sm:text-xl">{{
formatMessage(messages.detailsLabel)
}}</span>
</template>
<template v-else-if="isDetailsStage">
<button
class="active:scale-9 bg-transparent p-0 text-lg text-secondary transition-colors duration-200 hover:text-primary sm:text-xl"
@click="goToBreadcrumbStage('method-selection')"
>
{{ formatMessage(messages.methodSelectionStage) }}
</button>
<ChevronRightIcon class="size-5 text-secondary" stroke-width="3" />
<span class="text-lg font-bold text-contrast sm:text-xl">{{
formatMessage(messages.detailsLabel)
}}</span>
</template>
</div>
</template>
<div class="w-full max-w-[496px] lg:min-w-[496px]">
<TaxFormStage
v-if="currentStage === 'tax-form'"
:balance="balance"
:on-show-tax-form="showTaxFormModal"
/>
<MethodSelectionStage
v-else-if="currentStage === 'method-selection'"
:on-show-tax-form="showTaxFormModal"
@close-modal="withdrawModal?.hide()"
/>
<TremendousDetailsStage v-else-if="currentStage === 'tremendous-details'" />
<MuralpayKycStage v-else-if="currentStage === 'muralpay-kyc'" />
<MuralpayDetailsStage v-else-if="currentStage === 'muralpay-details'" />
<LegacyPaypalDetailsStage v-else-if="currentStage === 'paypal-details'" />
<CompletionStage v-else-if="currentStage === 'completion'" />
<div v-else>Something went wrong</div>
</div>
<template #actions>
<div v-if="currentStage === 'completion'" class="mt-4 flex w-full gap-3">
<ButtonStyled class="flex-1">
<button class="w-full text-contrast" @click="handleClose">
{{ formatMessage(messages.closeButton) }}
</button>
</ButtonStyled>
<ButtonStyled class="flex-1">
<button class="w-full text-contrast" @click="handleViewTransactions">
{{ formatMessage(messages.transactionsButton) }}
</button>
</ButtonStyled>
</div>
<div v-else class="mt-4 flex flex-col justify-end gap-2 sm:flex-row">
<ButtonStyled type="outlined">
<button
class="!border-surface-5"
:disabled="leftButtonConfig.disabled"
@click="leftButtonConfig.handler"
>
<component :is="leftButtonConfig.icon" />
{{ leftButtonConfig.label }}
</button>
</ButtonStyled>
<ButtonStyled :color="rightButtonConfig.color">
<button :disabled="rightButtonConfig.disabled" @click="rightButtonConfig.handler">
<component
:is="rightButtonConfig.icon"
v-if="rightButtonConfig.iconPosition === 'before'"
:class="rightButtonConfig.iconClass"
/>
{{ rightButtonConfig.label }}
<component
:is="rightButtonConfig.icon"
v-if="rightButtonConfig.iconPosition === 'after'"
:class="rightButtonConfig.iconClass"
/>
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
<CreatorTaxFormModal
ref="taxFormModal"
close-button-text="Continue"
@success="onTaxFormSuccess"
@cancelled="onTaxFormCancelled"
/>
</template>
<script setup lang="ts">
import {
ArrowLeftRightIcon,
ChevronRightIcon,
FileTextIcon,
LeftArrowIcon,
RightArrowIcon,
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonStyled, commonMessages, injectNotificationManager, NewModal } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'
import {
createWithdrawContext,
type PayoutMethod,
provideWithdrawContext,
TAX_THRESHOLD_ACTUAL,
type WithdrawStage,
} from '@/providers/creator-withdraw.ts'
import CreatorTaxFormModal from './CreatorTaxFormModal.vue'
import CompletionStage from './withdraw-stages/CompletionStage.vue'
import LegacyPaypalDetailsStage from './withdraw-stages/LegacyPaypalDetailsStage.vue'
import MethodSelectionStage from './withdraw-stages/MethodSelectionStage.vue'
import MuralpayDetailsStage from './withdraw-stages/MuralpayDetailsStage.vue'
import MuralpayKycStage from './withdraw-stages/MuralpayKycStage.vue'
import TaxFormStage from './withdraw-stages/TaxFormStage.vue'
import TremendousDetailsStage from './withdraw-stages/TremendousDetailsStage.vue'
type FormCompletionStatus = 'unknown' | 'unrequested' | 'unsigned' | 'tin-mismatch' | 'complete'
interface UserBalanceResponse {
available: number
withdrawn_lifetime: number
withdrawn_ytd: number
pending: number
dates: Record<string, number>
requested_form_type: string | null
form_completion_status: FormCompletionStatus | null
}
const props = defineProps<{
balance: UserBalanceResponse | null
preloadedPaymentData?: { country: string; methods: PayoutMethod[] } | null
}>()
const emit = defineEmits<{
(e: 'refresh-data' | 'hide'): void
}>()
const withdrawModal = useTemplateRef<InstanceType<typeof NewModal>>('withdrawModal')
const taxFormModal = ref<InstanceType<typeof CreatorTaxFormModal> | null>(null)
const isSubmitting = ref(false)
function show(preferred?: WithdrawStage) {
if (preferred) {
setStage(preferred, true)
withdrawModal.value?.show()
return
}
const firstStage = stages.value[0]
if (firstStage) {
setStage(firstStage, true)
}
withdrawModal.value?.show()
}
defineExpose({
show,
})
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const withdrawContext = createWithdrawContext(
props.balance,
props.preloadedPaymentData || undefined,
)
provideWithdrawContext(withdrawContext)
const {
currentStage,
previousStep,
nextStep,
canProceed,
setStage,
withdrawData,
resetData,
stages,
submitWithdrawal,
restoreStateFromStorage,
clearSavedState,
} = withdrawContext
watch(
() => props.balance,
(newBalance) => {
if (newBalance) {
withdrawContext.balance.value = newBalance
}
},
{ deep: true },
)
onMounted(() => {
const route = useRoute()
const router = useRouter()
if (route.query.paypal_auth_return === 'true') {
const savedState = restoreStateFromStorage()
if (savedState?.data) {
withdrawData.value = { ...savedState.data }
nextTick(() => {
show(savedState.stage)
})
clearSavedState()
}
const query = { ...route.query }
delete query.paypal_auth_return
router.replace({ query })
}
})
const needsTaxForm = computed(() => {
if (!props.balance || currentStage.value !== 'tax-form') return false
const ytd = props.balance.withdrawn_ytd ?? 0
const available = props.balance.available ?? 0
const status = props.balance.form_completion_status
return status !== 'complete' && ytd + available >= 600
})
const remainingLimit = computed(() => {
if (!props.balance) return 0
const ytd = props.balance.withdrawn_ytd ?? 0
const raw = TAX_THRESHOLD_ACTUAL - ytd
if (raw <= 0) return 0
const cents = Math.floor(raw * 100)
return cents / 100
})
const leftButtonConfig = computed(() => {
if (previousStep.value) {
return {
icon: LeftArrowIcon,
label: formatMessage(commonMessages.backButton),
handler: () => setStage(previousStep.value, true),
disabled: isSubmitting.value,
}
}
return {
icon: XIcon,
label: formatMessage(commonMessages.cancelButton),
handler: () => withdrawModal.value?.hide(),
disabled: isSubmitting.value,
}
})
const rightButtonConfig = computed(() => {
const stage = currentStage.value
const isTaxFormStage = stage === 'tax-form'
const isDetailsStage =
stage === 'muralpay-details' || stage === 'tremendous-details' || stage === 'paypal-details'
if (isTaxFormStage && needsTaxForm.value && remainingLimit.value > 0) {
return {
icon: RightArrowIcon,
label: formatMessage(messages.continueWithLimit),
handler: continueWithLimit,
disabled: false,
color: 'standard' as const,
iconPosition: 'after' as const,
}
}
if (isTaxFormStage && needsTaxForm.value) {
return {
icon: FileTextIcon,
label: formatMessage(messages.completeTaxForm),
handler: showTaxFormModal,
disabled: false,
color: 'orange' as const,
iconPosition: 'before' as const,
}
}
if (isDetailsStage) {
return {
icon: isSubmitting.value ? SpinnerIcon : ArrowLeftRightIcon,
iconClass: isSubmitting.value ? 'animate-spin' : undefined,
label: formatMessage(messages.withdrawButton),
handler: handleWithdraw,
disabled: !canProceed.value || isSubmitting.value,
color: 'brand' as const,
iconPosition: 'before' as const,
}
}
return {
icon: RightArrowIcon,
label: formatMessage(commonMessages.nextButton),
handler: () => setStage(nextStep.value),
disabled: !canProceed.value,
color: 'standard' as const,
iconPosition: 'after' as const,
}
})
function continueWithLimit() {
withdrawData.value.tax.skipped = true
setStage(nextStep.value)
}
async function handleWithdraw() {
if (isSubmitting.value) return
try {
isSubmitting.value = true
await submitWithdrawal()
setStage('completion')
} catch (error) {
console.error('Withdrawal failed:', error)
if ((error as any)?.data?.description?.toLower?.()?.includes('Tax form')) {
addNotification({
title: 'Please complete tax form',
text: 'You must complete a tax form to submit your withdrawal request.',
type: 'error',
})
} else {
addNotification({
title: 'Unable to withdraw',
text: 'We were unable to submit your withdrawal request, please check your details or contact support.',
type: 'error',
})
}
} finally {
isSubmitting.value = false
}
}
const shouldShowTitle = computed(() => {
return currentStage.value !== 'completion'
})
const isDetailsStage = computed(() => {
const detailsStages: WithdrawStage[] = [
'tremendous-details',
'muralpay-kyc',
'muralpay-details',
'paypal-details',
]
const current = currentStage.value
return current ? detailsStages.includes(current) : false
})
function showTaxFormModal(e?: MouseEvent) {
withdrawModal.value?.hide()
taxFormModal.value?.startTaxForm(e ?? new MouseEvent('click'))
}
function onTaxFormSuccess() {
emit('refresh-data')
nextTick(() => {
show('method-selection')
})
}
function onTaxFormCancelled() {
show('tax-form')
}
function onModalHide() {
resetData()
emit('hide')
}
function goToBreadcrumbStage(stage: WithdrawStage) {
setStage(stage, true)
}
function handleClose() {
withdrawModal.value?.hide()
emit('refresh-data')
}
function handleViewTransactions() {
withdrawModal.value?.hide()
navigateTo('/dashboard/revenue/transfers')
}
const messages = defineMessages({
taxFormStage: {
id: 'dashboard.creator-withdraw-modal.stage.tax-form',
defaultMessage: 'Tax form',
},
methodSelectionStage: {
id: 'dashboard.creator-withdraw-modal.stage.method-selection',
defaultMessage: 'Method',
},
tremendousDetailsStage: {
id: 'dashboard.creator-withdraw-modal.stage.tremendous-details',
defaultMessage: 'Details',
},
muralpayKycStage: {
id: 'dashboard.creator-withdraw-modal.stage.muralpay-kyc',
defaultMessage: 'Verification',
},
muralpayDetailsStage: {
id: 'dashboard.creator-withdraw-modal.stage.muralpay-details',
defaultMessage: 'Account Details',
},
completionStage: {
id: 'dashboard.creator-withdraw-modal.stage.completion',
defaultMessage: 'Complete',
},
detailsLabel: {
id: 'dashboard.creator-withdraw-modal.details-label',
defaultMessage: 'Details',
},
completeTaxForm: {
id: 'dashboard.creator-withdraw-modal.complete-tax-form',
defaultMessage: 'Complete tax form',
},
continueWithLimit: {
id: 'dashboard.creator-withdraw-modal.continue-with-limit',
defaultMessage: 'Continue with limit',
},
withdrawButton: {
id: 'dashboard.creator-withdraw-modal.withdraw-button',
defaultMessage: 'Withdraw',
},
closeButton: {
id: 'dashboard.withdraw.completion.close-button',
defaultMessage: 'Close',
},
transactionsButton: {
id: 'dashboard.withdraw.completion.transactions-button',
defaultMessage: 'Transactions',
},
})
</script>

View File

@@ -0,0 +1,149 @@
<template>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div class="relative flex-1">
<input
ref="amountInput"
:value="modelValue"
type="number"
step="0.01"
:min="minAmount"
:max="maxAmount"
:placeholder="formatMessage(formFieldPlaceholders.amountPlaceholder)"
class="w-full rounded-[14px] bg-surface-4 py-2.5 pl-4 pr-4 text-contrast placeholder:text-secondary"
@input="handleInput"
/>
</div>
<Combobox
v-if="showCurrencySelector"
:model-value="selectedCurrency"
:options="currencyOptions"
class="w-min"
@update:model-value="$emit('update:selectedCurrency', $event)"
>
<template v-for="option in currencyOptions" :key="option.value" #[`option-${option.value}`]>
<span class="font-semibold leading-tight">{{ option.label }}</span>
</template>
</Combobox>
<ButtonStyled>
<button class="px-4 py-2" @click="setMaxAmount">
{{ formatMessage(commonMessages.maxButton) }}
</button>
</ButtonStyled>
</div>
<div>
<span class="my-1 mt-0 text-secondary">{{ formatMoney(maxAmount) }} available.</span>
<Transition name="fade">
<span v-if="isBelowMinimum" class="text-red">
Amount must be at least {{ formatMoney(minAmount) }}.
</span>
</Transition>
<Transition name="fade">
<span v-if="isAboveMaximum" class="text-red">
Amount cannot exceed {{ formatMoney(maxAmount) }}.
</span>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled, Combobox, commonMessages, formFieldPlaceholders } from '@modrinth/ui'
import { formatMoney } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import { computed, nextTick, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
modelValue: number | undefined
maxAmount: number
minAmount?: number
showCurrencySelector?: boolean
selectedCurrency?: string
currencyOptions?: Array<{ value: string; label: string }>
}>(),
{
minAmount: 0.01,
showCurrencySelector: false,
currencyOptions: () => [],
},
)
const emit = defineEmits<{
'update:modelValue': [value: number | undefined]
'update:selectedCurrency': [value: string]
}>()
const { formatMessage } = useVIntl()
const amountInput = ref<HTMLInputElement | null>(null)
const isBelowMinimum = computed(() => {
return (
props.modelValue !== undefined && props.modelValue > 0 && props.modelValue < props.minAmount
)
})
const isAboveMaximum = computed(() => {
return props.modelValue !== undefined && props.modelValue > props.maxAmount
})
async function setMaxAmount() {
const maxValue = props.maxAmount
emit('update:modelValue', maxValue)
await nextTick()
if (amountInput.value) {
amountInput.value.value = maxValue.toFixed(2)
}
}
function handleInput(event: Event) {
const input = event.target as HTMLInputElement
const value = input.value
if (value && value.includes('.')) {
const parts = value.split('.')
if (parts[1] && parts[1].length > 2) {
const rounded = Math.floor(parseFloat(value) * 100) / 100
emit('update:modelValue', rounded)
input.value = rounded.toString()
return
}
}
const numValue = value === '' ? undefined : parseFloat(value)
emit('update:modelValue', numValue)
}
watch(
() => props.modelValue,
async (newAmount) => {
if (newAmount !== undefined && newAmount !== null) {
if (newAmount > props.maxAmount) {
emit('update:modelValue', props.maxAmount)
await nextTick()
if (amountInput.value) {
amountInput.value.value = props.maxAmount.toFixed(2)
}
} else if (newAmount < 0) {
emit('update:modelValue', 0)
}
}
},
)
</script>
<style scoped>
.fade-enter-active {
transition: opacity 200ms ease-out;
}
.fade-leave-active {
transition: opacity 150ms ease-in;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<div class="flex flex-row gap-2 md:gap-3">
<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"
>
<ArrowDownIcon v-if="isIncome" class="my-auto size-6 text-secondary md:size-8" />
<ArrowUpIcon v-else class="my-auto size-6 text-secondary md:size-8" />
</div>
<div class="flex w-full flex-row justify-between">
<div class="flex flex-col">
<span class="text-base font-semibold text-contrast md:text-lg">{{
isIncome
? formatPayoutSource(transaction.payout_source)
: formatMethodName(transaction.method_type || transaction.method)
}}</span>
<span class="text-xs text-secondary md:text-sm">
<template v-if="!isIncome">
<span
:class="[
transaction.status === 'cancelling' || transaction.status === 'cancelled'
? 'text-red'
: '',
]"
>{{ formatTransactionStatus(transaction.status) }} <BulletDivider
/></span>
</template>
{{ $dayjs(transaction.created).format('MMM DD YYYY') }}
<template v-if="!isIncome && transaction.fee">
<BulletDivider /> Fee {{ formatMoney(transaction.fee) }}
</template>
</span>
</div>
<div class="my-auto flex flex-row items-center gap-2">
<span
class="text-base font-semibold md:text-lg"
:class="isIncome ? 'text-green' : 'text-contrast'"
>{{ formatMoney(transaction.amount) }}</span
>
<template v-if="!isIncome && transaction.status === 'in-transit'">
<Tooltip theme="dismissable-prompt" :triggers="['hover', 'focus']" no-auto-focus>
<span class="my-auto align-middle"
><ButtonStyled circular size="small">
<button class="align-middle" @click="cancelPayout">
<XIcon />
</button> </ButtonStyled
></span>
<template #popper>
<div class="font-semibold text-contrast">Cancel transaction</div>
</template>
</Tooltip>
</template>
</div>
</div>
</div>
</template>
<script setup>
import { ArrowDownIcon, ArrowUpIcon, XIcon } from '@modrinth/assets'
import { BulletDivider, ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import { capitalizeString, formatMoney } from '@modrinth/utils'
import { Tooltip } from 'floating-vue'
const props = defineProps({
transaction: {
type: Object,
required: true,
},
})
const emit = defineEmits(['cancelled'])
const { addNotification } = injectNotificationManager()
const auth = await useAuth()
const isIncome = computed(() => props.transaction.type === 'payout_available')
function formatTransactionStatus(status) {
if (status === 'in-transit') return 'In Transit'
return capitalizeString(status)
}
function formatMethodName(method) {
if (!method) return 'Unknown'
switch (method) {
case 'paypal':
return 'PayPal'
case 'venmo':
return 'Venmo'
case 'tremendous':
return 'Tremendous'
case 'muralpay':
return 'Muralpay'
default:
return capitalizeString(method)
}
}
function formatPayoutSource(source) {
if (!source) return 'Income'
return source
.split('_')
.map((word) => capitalizeString(word))
.join(' ')
}
async function cancelPayout() {
startLoading()
try {
await useBaseFetch(`payout/${props.transaction.id}`, {
method: 'DELETE',
apiVersion: 3,
})
await useAuth(auth.value.token)
emit('cancelled')
} catch (err) {
addNotification({
title: 'Failed to cancel transaction',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
</script>

View File

@@ -0,0 +1,119 @@
<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-40"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-40"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="amount > 0" class="flex flex-col gap-2.5 rounded-[20px] bg-surface-2 p-4">
<div class="flex items-center justify-between">
<span class="text-primary">{{ formatMessage(messages.feeBreakdownAmount) }}</span>
<span class="font-semibold text-contrast">{{ formatMoney(amount || 0) }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-primary">{{ formatMessage(messages.feeBreakdownFee) }}</span>
<span class="h-4 font-semibold text-contrast">
<template v-if="feeLoading">
<LoaderCircleIcon class="size-5 animate-spin !text-secondary" />
</template>
<template v-else>-{{ formatMoney(fee || 0) }}</template>
</span>
</div>
<div class="h-px bg-surface-5" />
<div class="flex items-center justify-between">
<span class="text-primary">{{ formatMessage(messages.feeBreakdownNetAmount) }}</span>
<span class="font-semibold text-contrast">
{{ formatMoney(netAmount) }}
<template v-if="shouldShowExchangeRate">
<span> ({{ formattedLocalCurrency }})</span>
</template>
</span>
</div>
<template v-if="shouldShowExchangeRate">
<div class="flex items-center justify-between text-sm">
<span class="text-primary">{{ formatMessage(messages.feeBreakdownExchangeRate) }}</span>
<span class="text-secondary"
>1 USD = {{ exchangeRate?.toFixed(4) }} {{ localCurrency }}</span
>
</div>
</template>
</div>
</Transition>
</template>
<script setup lang="ts">
import { LoaderCircleIcon } from '@modrinth/assets'
import { formatMoney } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
amount: number
fee: number | null
feeLoading: boolean
exchangeRate?: number | null
localCurrency?: string
}>(),
{
exchangeRate: null,
localCurrency: undefined,
},
)
const { formatMessage } = useVIntl()
const netAmount = computed(() => {
const amount = props.amount || 0
const fee = props.fee || 0
return Math.max(0, amount - fee)
})
const shouldShowExchangeRate = computed(() => {
if (!props.localCurrency) return false
if (props.localCurrency === 'USD') return false
return props.exchangeRate !== null && props.exchangeRate !== undefined && props.exchangeRate > 0
})
const netAmountInLocalCurrency = computed(() => {
if (!shouldShowExchangeRate.value) return null
return netAmount.value * (props.exchangeRate || 0)
})
const formattedLocalCurrency = computed(() => {
if (!shouldShowExchangeRate.value || !netAmountInLocalCurrency.value || !props.localCurrency)
return ''
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: props.localCurrency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(netAmountInLocalCurrency.value)
} catch {
return `${props.localCurrency} ${netAmountInLocalCurrency.value.toFixed(2)}`
}
})
const messages = defineMessages({
feeBreakdownAmount: {
id: 'dashboard.creator-withdraw-modal.fee-breakdown-amount',
defaultMessage: 'Amount',
},
feeBreakdownFee: {
id: 'dashboard.creator-withdraw-modal.fee-breakdown-fee',
defaultMessage: 'Fee',
},
feeBreakdownNetAmount: {
id: 'dashboard.creator-withdraw-modal.fee-breakdown-net-amount',
defaultMessage: 'Net amount',
},
feeBreakdownExchangeRate: {
id: 'dashboard.creator-withdraw-modal.fee-breakdown-exchange-rate',
defaultMessage: 'FX rate',
},
})
</script>

View File

@@ -0,0 +1,301 @@
<template>
<div class="flex flex-col items-center gap-4">
<div class="flex w-full items-center justify-center gap-2.5">
<span class="text-xl font-semibold text-contrast sm:text-2xl">
{{ formatMessage(messages.title) }}
</span>
</div>
<div class="flex w-full flex-col gap-3">
<div class="span-4 flex w-full flex-col gap-2.5 rounded-2xl bg-surface-2 p-4">
<div
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
>
<span class="text-sm font-normal text-primary sm:text-[1rem]">
{{ formatMessage(messages.method) }}
</span>
<span class="break-words text-sm font-semibold text-contrast sm:text-[1rem]">
{{ result?.methodType || 'N/A' }}
</span>
</div>
<div
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
>
<span class="text-sm font-normal text-primary sm:text-[1rem]">
{{ formatMessage(messages.recipient) }}
</span>
<span class="break-words text-sm font-semibold text-contrast sm:text-[1rem]">
{{ result?.recipientDisplay || 'N/A' }}
</span>
</div>
<div
v-if="destinationLabel && destinationValue"
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
>
<span class="text-sm font-normal text-primary sm:text-[1rem]">
{{ destinationLabel }}
</span>
<span class="break-words font-mono text-sm font-semibold text-contrast sm:text-[1rem]">
{{ destinationValue }}
</span>
</div>
<div
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
>
<span class="text-sm font-normal text-primary sm:text-[1rem]">
{{ formatMessage(messages.date) }}
</span>
<span class="break-words text-sm font-semibold text-contrast sm:text-[1rem]">
{{ formattedDate }}
</span>
</div>
<div
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
>
<span class="text-sm font-normal text-primary sm:text-[1rem]">
{{ formatMessage(messages.amount) }}
</span>
<span class="break-words text-sm font-semibold text-contrast sm:text-[1rem]">
{{ formatMoney(result?.amount || 0) }}
</span>
</div>
<div
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
>
<span class="text-sm font-normal text-primary sm:text-[1rem]">
{{ formatMessage(messages.fee) }}
</span>
<span class="break-words text-sm font-semibold text-contrast sm:text-[1rem]">
{{ formatMoney(result?.fee || 0) }}
</span>
</div>
<div class="border-b-1 h-0 w-full rounded-full border-b border-solid border-divider" />
<div
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
>
<span class="text-sm font-normal text-primary sm:text-[1rem]">
{{ formatMessage(messages.netAmount) }}
</span>
<span class="break-words text-sm font-semibold text-contrast sm:text-[1rem]">
{{ formatMoney(result?.netAmount || 0) }}
<template v-if="shouldShowExchangeRate">
<span> ({{ formattedLocalCurrency }})</span>
</template>
</span>
</div>
<template v-if="shouldShowExchangeRate">
<div
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
>
<span class="text-sm font-normal text-primary sm:text-[1rem]">
{{ formatMessage(messages.exchangeRate) }}
</span>
<span class="break-words text-sm font-normal text-secondary sm:text-[1rem]">
1 USD = {{ withdrawData.calculation.exchangeRate?.toFixed(4) }}
{{ localCurrency }}
</span>
</div>
</template>
</div>
<span
v-if="withdrawData.providerData.type === 'tremendous'"
class="w-full break-words text-center text-sm font-normal text-primary sm:text-[1rem]"
>
<IntlFormatted
:message-id="messages.emailConfirmation"
:values="{ email: withdrawData.result?.recipientDisplay }"
>
<template #b="{ children }">
<strong>
<component :is="() => normalizeChildren(children)" />
</strong>
</template>
</IntlFormatted>
</span>
</div>
<Teleport to="body">
<div
v-if="showConfetti"
class="pointer-events-none fixed inset-0 z-[9999] flex items-center justify-center"
>
<ConfettiExplosion />
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { formatMoney } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import dayjs from 'dayjs'
import { computed, onMounted, ref } from 'vue'
import ConfettiExplosion from 'vue-confetti-explosion'
import { type TremendousProviderData, useWithdrawContext } from '@/providers/creator-withdraw.ts'
import { getRailConfig } from '@/utils/muralpay-rails'
import { normalizeChildren } from '@/utils/vue-children.ts'
const { withdrawData } = useWithdrawContext()
const { formatMessage } = useVIntl()
const result = computed(() => withdrawData.value.result)
const showConfetti = ref(false)
onMounted(() => {
showConfetti.value = true
setTimeout(() => {
showConfetti.value = false
}, 5000)
})
const formattedDate = computed(() => {
if (!result.value?.created) return 'N/A'
return dayjs(result.value.created).format('MMMM D, YYYY')
})
const selectedRail = computed(() => {
const railId = withdrawData.value.selection.method
return railId ? getRailConfig(railId) : null
})
const localCurrency = computed(() => {
// Check if it's Tremendous withdrawal with currency
if (withdrawData.value.providerData.type === 'tremendous') {
return (withdrawData.value.providerData as TremendousProviderData).currency
}
// Fall back to MuralPay rail currency
return selectedRail.value?.currency
})
const shouldShowExchangeRate = computed(() => {
if (!localCurrency.value) return false
if (localCurrency.value === 'USD') return false
const exchangeRate = withdrawData.value.calculation.exchangeRate
return exchangeRate !== null && exchangeRate !== undefined && exchangeRate > 0
})
const netAmountInLocalCurrency = computed(() => {
if (!shouldShowExchangeRate.value) return null
const netAmount = result.value?.netAmount || 0
const exchangeRate = withdrawData.value.calculation.exchangeRate || 0
return netAmount * exchangeRate
})
const formattedLocalCurrency = computed(() => {
if (!shouldShowExchangeRate.value || !netAmountInLocalCurrency.value || !localCurrency.value)
return ''
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: localCurrency.value,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(netAmountInLocalCurrency.value)
} catch {
return `${localCurrency.value} ${netAmountInLocalCurrency.value.toFixed(2)}`
}
})
const isMuralPayWithdrawal = computed(() => {
return withdrawData.value.providerData.type === 'muralpay'
})
const destinationLabel = computed(() => {
if (!isMuralPayWithdrawal.value) return null
const rail = selectedRail.value
if (!rail) return null
if (rail.type === 'crypto') {
return formatMessage(messages.wallet)
} else if (rail.type === 'fiat') {
return formatMessage(messages.account)
}
return null
})
const destinationValue = computed(() => {
if (!isMuralPayWithdrawal.value || withdrawData.value.providerData.type !== 'muralpay') {
return null
}
const accountDetails = withdrawData.value.providerData.accountDetails
const rail = selectedRail.value
if (rail?.type === 'crypto' && accountDetails.walletAddress) {
const addr = accountDetails.walletAddress
if (addr.length > 10) {
return `${addr.slice(0, 6)}...${addr.slice(-4)}`
}
return addr
} else if (rail?.type === 'fiat' && accountDetails.bankAccountNumber) {
const accountType = accountDetails.accountType || 'Account'
const last4 = accountDetails.bankAccountNumber.slice(-4)
const formattedType = accountType.charAt(0) + accountType.slice(1).toLowerCase()
return `${formattedType} (${last4})`
}
return null
})
const messages = defineMessages({
title: {
id: 'dashboard.withdraw.completion.title',
defaultMessage: 'Withdraw complete',
},
method: {
id: 'dashboard.withdraw.completion.method',
defaultMessage: 'Method',
},
recipient: {
id: 'dashboard.withdraw.completion.recipient',
defaultMessage: 'Recipient',
},
wallet: {
id: 'dashboard.withdraw.completion.wallet',
defaultMessage: 'Wallet',
},
account: {
id: 'dashboard.withdraw.completion.account',
defaultMessage: 'Account',
},
date: {
id: 'dashboard.withdraw.completion.date',
defaultMessage: 'Date',
},
amount: {
id: 'dashboard.withdraw.completion.amount',
defaultMessage: 'Amount',
},
fee: {
id: 'dashboard.withdraw.completion.fee',
defaultMessage: 'Fee',
},
netAmount: {
id: 'dashboard.withdraw.completion.net-amount',
defaultMessage: 'Net amount',
},
exchangeRate: {
id: 'dashboard.withdraw.completion.exchange-rate',
defaultMessage: 'Exchange rate',
},
emailConfirmation: {
id: 'dashboard.withdraw.completion.email-confirmation',
defaultMessage:
"You'll receive an email at <b>{email}</b> with instructions to redeem your withdrawal.",
},
closeButton: {
id: 'dashboard.withdraw.completion.close-button',
defaultMessage: 'Close',
},
transactionsButton: {
id: 'dashboard.withdraw.completion.transactions-button',
defaultMessage: 'Transactions',
},
})
</script>

View File

@@ -0,0 +1,351 @@
<template>
<div class="flex flex-col gap-4 sm:gap-5">
<div v-if="isPayPal" class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast"
>{{ formatMessage(messages.paypalAccount) }} <span class="text-red">*</span></span
>
</label>
<div class="flex flex-col gap-2">
<ButtonStyled v-if="!isPayPalAuthenticated" color="standard">
<a :href="paypalAuthUrl" class="w-min" @click="handlePayPalAuth">
<PayPalColorIcon class="size-5" />
{{ formatMessage(messages.signInWithPaypal) }}
</a>
</ButtonStyled>
<ButtonStyled v-else>
<button class="w-min" @click="handleDisconnectPaypal">
<XIcon /> {{ formatMessage(messages.disconnectButton) }}
</button>
</ButtonStyled>
</div>
</div>
<div v-if="isPayPal && isPayPalAuthenticated" class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">{{
formatMessage(messages.account)
}}</span>
</label>
<div class="flex flex-col gap-2 rounded-2xl bg-surface-2 px-4 py-2.5">
<span>{{ paypalEmail }}</span>
</div>
</div>
<div v-if="isVenmo" class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast"
>{{ formatMessage(messages.venmoHandle) }} <span class="text-red">*</span></span
>
</label>
<div class="flex flex-row gap-2">
<input
v-model="venmoHandle"
type="text"
:placeholder="formatMessage(messages.venmoHandlePlaceholder)"
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
/>
<ButtonStyled color="brand">
<button
v-tooltip="!hasVenmoChanged ? 'Change the venmo username to save.' : undefined"
:disabled="venmoSaving || !hasVenmoChanged"
@click="saveVenmoHandle"
>
<CheckIcon v-if="venmoSaveSuccess" />
<SaveIcon v-else />
{{
venmoSaveSuccess
? formatMessage(messages.savedButton)
: formatMessage(messages.saveButton)
}}
</button>
</ButtonStyled>
</div>
<span v-if="venmoSaveError" class="text-sm font-bold text-red">
{{ venmoSaveError }}
</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
>
</label>
<RevenueInputField
v-model="formData.amount"
:max-amount="effectiveMaxAmount"
:min-amount="selectedMethodDetails?.interval?.standard?.min || 0.01"
/>
<WithdrawFeeBreakdown
:amount="formData.amount || 0"
:fee="calculatedFee"
:fee-loading="feeLoading"
/>
<Checkbox v-model="agreedTerms">
<span>
<IntlFormatted :message-id="financialMessages.rewardsProgramTermsAgreement">
<template #terms-link="{ children }">
<nuxt-link to="/legal/cmp" class="text-link">
<component :is="() => normalizeChildren(children)" />
</nuxt-link>
</template>
</IntlFormatted>
</span>
</Checkbox>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckIcon, PayPalColorIcon, SaveIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, Checkbox, financialMessages, formFieldLabels } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { useDebounceFn } from '@vueuse/core'
import { computed, onMounted, ref, watch } from 'vue'
import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue'
import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
import { getAuthUrl, removeAuthProvider, useAuth } from '@/composables/auth.js'
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
import { normalizeChildren } from '@/utils/vue-children.ts'
const { withdrawData, maxWithdrawAmount, availableMethods, calculateFees, saveStateToStorage } =
useWithdrawContext()
const { formatMessage } = useVIntl()
const auth = await useAuth()
const isPayPal = computed(() => withdrawData.value.selection.provider === 'paypal')
const isVenmo = computed(() => withdrawData.value.selection.provider === 'venmo')
const selectedMethodDetails = computed(() => {
const methodId = withdrawData.value.selection.methodId
if (!methodId) return null
return availableMethods.value.find((m) => m.id === methodId) || null
})
const isPayPalAuthenticated = computed(() => {
return (auth.value.user as any)?.auth_providers?.includes('paypal') || false
})
const paypalEmail = computed(() => {
return (auth.value.user as any)?.payout_data?.paypal_address || ''
})
const paypalAuthUrl = computed(() => {
const route = useRoute()
const separator = route.fullPath.includes('?') ? '&' : '?'
const returnUrl = `${route.fullPath}${separator}paypal_auth_return=true`
return getAuthUrl('paypal', returnUrl)
})
function handlePayPalAuth() {
saveStateToStorage()
}
async function handleDisconnectPaypal() {
try {
await removeAuthProvider('paypal')
} catch (error) {
console.error('Failed to disconnect PayPal:', error)
}
}
const venmoHandle = ref<string>((auth.value.user as any)?.venmo_handle || '')
const initialVenmoHandle = ref<string>((auth.value.user as any)?.venmo_handle || '')
const venmoSaving = ref(false)
const venmoSaveSuccess = ref(false)
const venmoSaveError = ref<string | null>(null)
const hasVenmoChanged = computed(() => {
return venmoHandle.value.trim() !== initialVenmoHandle.value.trim()
})
async function saveVenmoHandle() {
if (!venmoHandle.value.trim()) {
venmoSaveError.value = 'Please enter a Venmo handle'
return
}
venmoSaving.value = true
venmoSaveError.value = null
venmoSaveSuccess.value = false
try {
await useBaseFetch(`user/${(auth.value.user as any)?.id}`, {
method: 'PATCH',
body: {
venmo_handle: venmoHandle.value.trim(),
},
})
// @ts-expect-error auth.js is not typed
await useAuth(auth.value.token)
initialVenmoHandle.value = venmoHandle.value.trim()
venmoSaveSuccess.value = true
setTimeout(() => {
venmoSaveSuccess.value = false
}, 3000)
} catch (error) {
console.error('Failed to update Venmo handle:', error)
venmoSaveError.value = 'Failed to save Venmo handle. Please try again.'
} finally {
venmoSaving.value = false
}
}
const maxAmount = computed(() => maxWithdrawAmount.value)
const roundedMaxAmount = computed(() => Math.floor(maxAmount.value * 100) / 100)
const effectiveMaxAmount = computed(() => {
const apiMax = selectedMethodDetails.value?.interval?.standard?.max
if (apiMax) {
return Math.min(roundedMaxAmount.value, apiMax)
}
return roundedMaxAmount.value
})
const formData = ref<Record<string, any>>({
amount: withdrawData.value.calculation.amount || undefined,
})
const agreedTerms = computed({
get: () => withdrawData.value.agreedTerms,
set: (value) => {
withdrawData.value.agreedTerms = value
},
})
const isComponentValid = computed(() => {
const hasAmount = (formData.value.amount || 0) > 0
const hasAgreed = agreedTerms.value
if (!hasAmount || !hasAgreed) return false
if (isPayPal.value) {
return isPayPalAuthenticated.value
} else if (isVenmo.value) {
return venmoHandle.value.trim().length > 0
}
return false
})
const calculatedFee = ref<number>(0)
const feeLoading = ref(false)
const calculateFeesDebounced = useDebounceFn(async () => {
const amount = formData.value.amount
if (!amount || amount <= 0) {
calculatedFee.value = 0
return
}
const methodId = withdrawData.value.selection.methodId
if (!methodId) {
calculatedFee.value = 0
return
}
feeLoading.value = true
try {
await calculateFees()
calculatedFee.value = withdrawData.value.calculation.fee ?? 0
} catch (error) {
console.error('Failed to calculate fees:', error)
calculatedFee.value = 0
} finally {
feeLoading.value = false
}
}, 500)
watch(
() => formData.value.amount,
() => {
withdrawData.value.calculation.amount = formData.value.amount ?? 0
if (formData.value.amount) {
feeLoading.value = true
calculateFeesDebounced()
} else {
calculatedFee.value = 0
feeLoading.value = false
}
},
{ deep: true },
)
watch(
[isComponentValid, venmoHandle, () => formData.value.amount, agreedTerms, isPayPalAuthenticated],
() => {
withdrawData.value.stageValidation.paypalDetails = isComponentValid.value
},
{ immediate: true },
)
onMounted(async () => {
if (formData.value.amount) {
feeLoading.value = true
calculateFeesDebounced()
}
})
const messages = defineMessages({
paymentMethod: {
id: 'dashboard.creator-withdraw-modal.paypal-details.payment-method',
defaultMessage: 'Payment method',
},
paypalAccount: {
id: 'dashboard.creator-withdraw-modal.paypal-details.paypal-account',
defaultMessage: 'PayPal account',
},
account: {
id: 'dashboard.creator-withdraw-modal.paypal-details.account',
defaultMessage: 'Account',
},
signInWithPaypal: {
id: 'dashboard.creator-withdraw-modal.paypal-details.sign-in-with-paypal',
defaultMessage: 'Sign in with PayPal',
},
paypalAuthDescription: {
id: 'dashboard.creator-withdraw-modal.paypal-details.paypal-auth-description',
defaultMessage: 'Connect your PayPal account to receive payments directly.',
},
venmoHandle: {
id: 'dashboard.creator-withdraw-modal.paypal-details.venmo-handle',
defaultMessage: 'Venmo handle',
},
venmoHandlePlaceholder: {
id: 'dashboard.creator-withdraw-modal.paypal-details.venmo-handle-placeholder',
defaultMessage: '@username',
},
venmoDescription: {
id: 'dashboard.creator-withdraw-modal.paypal-details.venmo-description',
defaultMessage: 'Enter your Venmo handle to receive payments.',
},
disconnectButton: {
id: 'dashboard.creator-withdraw-modal.paypal-details.disconnect-account',
defaultMessage: 'Disconnect account',
},
saveButton: {
id: 'dashboard.creator-withdraw-modal.paypal-details.save-button',
defaultMessage: 'Save',
},
savedButton: {
id: 'dashboard.creator-withdraw-modal.paypal-details.saved-button',
defaultMessage: 'Saved',
},
saveSuccess: {
id: 'dashboard.creator-withdraw-modal.paypal-details.save-success',
defaultMessage: 'Venmo handle saved successfully!',
},
})
</script>

View File

@@ -0,0 +1,323 @@
<template>
<div class="flex flex-col gap-4">
<Admonition v-if="shouldShowTaxLimitWarning" type="warning">
<IntlFormatted
:message-id="messages.taxLimitWarning"
:values="{
amount: formatMoney(maxWithdrawAmount),
}"
>
<template #b="{ children }">
<span class="font-semibold">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
<template #tax-link="{ children }">
<span class="cursor-pointer text-link" @click="onShowTaxForm">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</Admonition>
<div class="flex flex-col">
<div class="flex flex-col gap-2.5">
<div class="flex flex-row gap-1 align-middle">
<span class="align-middle font-semibold text-contrast">{{
formatMessage(messages.region)
}}</span>
<UnknownIcon
v-tooltip="formatMessage(messages.regionTooltip)"
class="mt-auto size-5 text-secondary"
/>
</div>
<Combobox
:model-value="selectedCountryCode"
:options="countries"
:placeholder="formatMessage(messages.countryPlaceholder)"
searchable
:search-placeholder="formatMessage(messages.countrySearchPlaceholder)"
:max-height="240"
class="h-12"
@update:model-value="handleCountryChange"
/>
</div>
<div class="flex flex-col gap-2.5">
<div class="flex flex-row gap-1 align-middle">
<span class="align-middle font-semibold text-contrast">{{
formatMessage(messages.selectMethod)
}}</span>
</div>
<div v-if="loading" class="flex min-h-[120px] items-center justify-center">
<SpinnerIcon class="size-8 animate-spin text-contrast" />
</div>
<template v-else>
<ButtonStyled
v-for="method in paymentOptions"
:key="method.value"
:color="withdrawData.selection.method === method.value ? 'green' : 'standard'"
:highlighted="withdrawData.selection.method === method.value"
type="chip"
>
<button
class="!justify-start !gap-2 !text-left sm:!h-10"
@click="handleMethodSelection(method)"
>
<component :is="method.icon" class="shrink-0" />
<span class="flex-1 truncate text-sm sm:text-[1rem]">
{{ typeof method.label === 'string' ? method.label : formatMessage(method.label) }}
</span>
<span class="ml-auto shrink-0 text-xs font-normal text-secondary sm:text-sm">{{
method.fee
}}</span>
</button>
</ButtonStyled>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { SpinnerIcon, UnknownIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
Combobox,
injectNotificationManager,
useDebugLogger,
} from '@modrinth/ui'
import { formatMoney } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { useGeolocation } from '@vueuse/core'
import { useCountries, useFormattedCountries, useUserCountry } from '@/composables/country.ts'
import { type PayoutMethod, useWithdrawContext } from '@/providers/creator-withdraw.ts'
import { normalizeChildren } from '@/utils/vue-children.ts'
const debug = useDebugLogger('MethodSelectionStage')
const {
withdrawData,
availableMethods,
paymentOptions,
balance,
maxWithdrawAmount,
paymentMethodsCache,
} = useWithdrawContext()
const userCountry = useUserCountry()
const allCountries = useCountries()
const { coords } = useGeolocation()
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const auth = await useAuth()
const messages = defineMessages({
taxLimitWarning: {
id: 'dashboard.creator-withdraw-modal.method-selection.tax-limit-warning',
defaultMessage:
'Your withdraw limit is <b>{amount}</b>, <tax-link>complete a tax form</tax-link> to withdraw more.',
},
region: {
id: 'dashboard.creator-withdraw-modal.method-selection.region',
defaultMessage: 'Region',
},
regionTooltip: {
id: 'dashboard.creator-withdraw-modal.method-selection.region-tooltip',
defaultMessage: 'Some payout methods are not available in certain regions.',
},
countryPlaceholder: {
id: 'dashboard.creator-withdraw-modal.method-selection.country-placeholder',
defaultMessage: 'Select your country',
},
countrySearchPlaceholder: {
id: 'dashboard.creator-withdraw-modal.method-selection.country-search-placeholder',
defaultMessage: 'Search countries...',
},
selectMethod: {
id: 'dashboard.creator-withdraw-modal.method-selection.select-method',
defaultMessage: 'Select withdraw method',
},
errorTitle: {
id: 'dashboard.creator-withdraw-modal.method-selection.error-title',
defaultMessage: 'Failed to load payment methods',
},
errorText: {
id: 'dashboard.creator-withdraw-modal.method-selection.error-text',
defaultMessage: 'Unable to fetch available payment methods. Please try again later.',
},
})
defineProps<{
onShowTaxForm: () => void
}>()
const emit = defineEmits<{
(e: 'close-modal'): void
}>()
const countries = useFormattedCountries()
const selectedCountryCode = computed(() => withdrawData.value.selection.country?.id)
const shouldShowTaxLimitWarning = computed(() => {
const balanceValue = balance.value
if (!balanceValue) return false
const formIncomplete = balanceValue.form_completion_status !== 'complete'
const wouldHitLimit = (balanceValue.withdrawn_ytd ?? 0) + (balanceValue.available ?? 0) >= 600
return formIncomplete && wouldHitLimit
})
const loading = ref(false)
watch(
() => withdrawData.value.selection.country,
async (country) => {
debug('Watch triggered, country:', country)
if (!country) {
availableMethods.value = []
return
}
if (paymentMethodsCache.value[country.id]) {
debug('Using cached methods for', country.id)
availableMethods.value = paymentMethodsCache.value[country.id]
return
}
loading.value = true
debug('Fetching payout methods for country:', country.id)
try {
const methods = (await useBaseFetch('payout/methods', {
apiVersion: 3,
query: { country: country.id },
})) as PayoutMethod[]
debug('Received payout methods:', methods)
paymentMethodsCache.value[country.id] = methods
availableMethods.value = methods
} catch (e) {
console.error('[MethodSelectionStage] Failed to fetch payout methods:', e)
addNotification({
title: formatMessage(messages.errorTitle),
text: formatMessage(messages.errorText),
type: 'error',
})
emit('close-modal')
} finally {
loading.value = false
}
},
{ immediate: true },
)
function handleMethodSelection(option: {
value: string
methodId: string | undefined
type: string
}) {
withdrawData.value.selection.method = option.value
withdrawData.value.selection.methodId = option.methodId ?? null
if (option.type === 'tremendous') {
withdrawData.value.selection.provider = 'tremendous'
} else if (option.type === 'fiat' || option.type === 'crypto') {
withdrawData.value.selection.provider = 'muralpay'
} else if (option.type === 'paypal') {
withdrawData.value.selection.provider = 'paypal'
} else if (option.type === 'venmo') {
withdrawData.value.selection.provider = 'venmo'
} else {
withdrawData.value.selection.provider = 'muralpay'
}
}
watch(paymentOptions, (newOptions) => {
withdrawData.value.selection.method = null
withdrawData.value.selection.methodId = null
withdrawData.value.selection.provider = null
if (newOptions.length === 1) {
const option = newOptions[0]
handleMethodSelection(option)
}
})
watch(
() => withdrawData.value.selection.provider,
(newProvider) => {
if (newProvider === 'tremendous') {
const userEmail = (auth.value.user as any)?.email || ''
withdrawData.value.providerData = {
type: 'tremendous',
deliveryEmail: userEmail,
giftCardDetails: null,
currency: undefined,
}
} else if (newProvider === 'muralpay') {
withdrawData.value.providerData = {
type: 'muralpay',
kycData: {} as any,
accountDetails: {},
}
} else if (newProvider === 'paypal' || newProvider === 'venmo') {
withdrawData.value.providerData = {
type: newProvider,
}
}
},
)
function handleCountryChange(countryCode: string | null) {
debug('handleCountryChange called with:', countryCode)
if (countryCode) {
const normalizedCode = countryCode.toUpperCase()
const country = allCountries.value.find((c) => c.alpha2 === normalizedCode)
debug('Found country:', country)
if (country) {
withdrawData.value.selection.country = {
id: country.alpha2,
name: country.alpha2 === 'TW' ? 'Taiwan' : country.nameShort,
}
debug('Set selectedCountry to:', withdrawData.value.selection.country)
}
} else {
withdrawData.value.selection.country = null
}
}
debug('Setup: userCountry.value =', userCountry.value)
debug('Setup: current selectedCountry =', withdrawData.value.selection.country)
if (!withdrawData.value.selection.country) {
const defaultCountryCode = userCountry.value || 'US'
debug('Setup: calling handleCountryChange with', defaultCountryCode)
handleCountryChange(defaultCountryCode)
debug('Setup: selectedCountryCode computed =', selectedCountryCode.value)
}
async function getCountryFromGeoIP(lat: number, lon: number): Promise<string | null> {
try {
const response = await fetch(
`https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lon}&localityLanguage=en`,
)
const data = await response.json()
return data.countryCode || null
} catch {
return null
}
}
onMounted(async () => {
if (withdrawData.value.selection.country?.id === 'US' && !userCountry.value) {
if (coords.value.latitude && coords.value.longitude) {
const geoCountry = await getCountryFromGeoIP(coords.value.latitude, coords.value.longitude)
if (geoCountry) {
handleCountryChange(geoCountry)
}
}
}
})
</script>

View File

@@ -0,0 +1,516 @@
<template>
<div class="flex flex-col gap-3 sm:gap-4">
<Admonition
v-if="selectedRail?.warningMessage"
type="warning"
:header="formatMessage(messages.cryptoWarningHeader)"
>
{{ formatMessage(selectedRail.warningMessage) }}
</Admonition>
<div v-if="selectedRail?.type === 'crypto'" class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.coin) }}
</span>
</label>
<div
class="flex min-h-[44px] items-center gap-2 rounded-[14px] bg-surface-2 px-4 py-2.5 sm:min-h-0"
>
<component
:is="getCurrencyIcon(selectedRail.currency)"
class="size-5 shrink-0"
:class="getCurrencyColor(selectedRail.currency)"
/>
<span class="text-sm font-semibold text-contrast sm:text-[1rem]">{{
selectedRail.currency
}}</span>
</div>
</div>
<div v-if="selectedRail?.type === 'fiat'" class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.accountOwner) }}
</span>
</label>
<div class="w-full rounded-[14px] bg-surface-2 p-3 sm:p-4">
<div class="flex flex-col gap-1">
<span class="break-words text-sm font-semibold text-contrast sm:text-[1rem]">
{{ accountOwnerName }}
</span>
<span class="break-words text-xs text-primary sm:text-sm">
{{ accountOwnerAddress }}
</span>
</div>
</div>
</div>
<div v-if="selectedRail?.requiresBankName" class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(formFieldLabels.bankName) }}
<span class="text-red">*</span>
</span>
</label>
<Combobox
v-if="shouldShowBankNameDropdown"
v-model="formData.bankName"
:options="bankNameOptions"
:searchable="true"
:placeholder="formatMessage(formFieldPlaceholders.bankNamePlaceholderDropdown)"
class="h-10"
/>
<input
v-else
v-model="formData.bankName"
type="text"
:placeholder="formatMessage(formFieldPlaceholders.bankNamePlaceholder)"
autocomplete="off"
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
/>
</div>
<div v-for="field in selectedRail?.fields" :key="field.name" class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(field.label) }}
<span v-if="field.required" class="text-red">*</span>
</span>
</label>
<input
v-if="['text', 'email', 'tel'].includes(field.type)"
v-model="formData[field.name]"
:type="field.type"
:placeholder="field.placeholder ? formatMessage(field.placeholder) : undefined"
:pattern="field.pattern"
:autocomplete="field.autocomplete || 'off'"
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
/>
<Combobox
v-else-if="field.type === 'select'"
v-model="formData[field.name]"
:options="
(field.options || []).map((opt) => ({
value: opt.value,
label: formatMessage(opt.label),
}))
"
:placeholder="field.placeholder ? formatMessage(field.placeholder) : undefined"
class="h-10"
/>
<input
v-else-if="field.type === 'date'"
v-model="formData[field.name]"
type="date"
class="w-full rounded-[14px] bg-surface-4 px-4 py-2.5 text-contrast placeholder:text-secondary"
/>
<span v-if="field.helpText" class="text-sm text-secondary">
{{ formatMessage(field.helpText) }}
</span>
</div>
<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-40"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-40"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="dynamicDocumentNumberField" class="overflow-hidden">
<div class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ dynamicDocumentNumberField.label }}
<span v-if="dynamicDocumentNumberField.required" class="text-red">*</span>
</span>
</label>
<input
v-model="formData.documentNumber"
:type="dynamicDocumentNumberField.type"
:placeholder="dynamicDocumentNumberField.placeholder"
autocomplete="off"
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
/>
</div>
</div>
</Transition>
<div v-if="selectedRail?.blockchain" class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.network) }}
</span>
</label>
<div
class="flex min-h-[44px] items-center gap-2 rounded-[14px] bg-surface-2 px-4 py-2.5 sm:min-h-0"
>
<component
:is="getBlockchainIcon(selectedRail.blockchain)"
class="size-5 shrink-0"
:class="getBlockchainColor(selectedRail.blockchain)"
/>
<span class="text-sm font-semibold text-contrast sm:text-[1rem]">{{
selectedRail.blockchain
}}</span>
</div>
</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>
</label>
<RevenueInputField
v-model="formData.amount"
:min-amount="effectiveMinAmount"
:max-amount="effectiveMaxAmount"
/>
<WithdrawFeeBreakdown
v-if="allRequiredFieldsFilled"
:amount="formData.amount || 0"
:fee="calculatedFee"
:fee-loading="feeLoading"
:exchange-rate="exchangeRate"
:local-currency="selectedRail?.currency"
/>
<Checkbox v-model="agreedTerms">
<span
><IntlFormatted :message-id="financialMessages.rewardsProgramTermsAgreement">
<template #terms-link="{ children }">
<nuxt-link to="/legal/cmp" class="text-link">
<component :is="() => normalizeChildren(children)" />
</nuxt-link>
</template> </IntlFormatted
></span>
</Checkbox>
</div>
</div>
</template>
<script setup lang="ts">
import {
Admonition,
Checkbox,
Combobox,
financialMessages,
formFieldLabels,
formFieldPlaceholders,
} from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { useDebounceFn } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue'
import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
import { useGeneratedState } from '@/composables/generated'
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
import {
getBlockchainColor,
getBlockchainIcon,
getCurrencyColor,
getCurrencyIcon,
} from '@/utils/finance-icons.ts'
import { getRailConfig } from '@/utils/muralpay-rails'
import { normalizeChildren } from '@/utils/vue-children.ts'
const { withdrawData, maxWithdrawAmount, availableMethods, calculateFees } = useWithdrawContext()
const { formatMessage } = useVIntl()
const generatedState = useGeneratedState()
const selectedRail = computed(() => {
const railId = withdrawData.value.selection.method
return railId ? getRailConfig(railId) : null
})
const selectedMethodDetails = computed(() => {
const methodId = withdrawData.value.selection.methodId
if (!methodId) return null
return availableMethods.value.find((m) => m.id === methodId) || null
})
const maxAmount = computed(() => maxWithdrawAmount.value)
const roundedMaxAmount = computed(() => Math.floor(maxAmount.value * 100) / 100)
const effectiveMinAmount = computed(
() => selectedMethodDetails.value?.interval?.standard?.min || 0.01,
)
const effectiveMaxAmount = computed(() => {
const apiMax = selectedMethodDetails.value?.interval?.standard?.max
if (apiMax) {
return Math.min(roundedMaxAmount.value, apiMax)
}
return roundedMaxAmount.value
})
const availableBankNames = computed(() => {
const rail = selectedRail.value
if (!rail || !rail.railCode) return []
const bankDetails = generatedState.value.muralBankDetails?.[rail.railCode]
return bankDetails?.bankNames || []
})
const shouldShowBankNameDropdown = computed(() => {
return availableBankNames.value.length > 0
})
const bankNameOptions = computed(() => {
return availableBankNames.value.map((name) => ({
value: name,
label: name,
}))
})
const providerData = withdrawData.value.providerData
const existingAccountDetails = providerData.type === 'muralpay' ? providerData.accountDetails : {}
const existingAmount = withdrawData.value.calculation.amount
const formData = ref<Record<string, any>>({
amount: existingAmount || undefined,
bankName: existingAccountDetails?.bankName ?? '',
...existingAccountDetails,
})
const agreedTerms = computed({
get: () => withdrawData.value.agreedTerms,
set: (value) => {
withdrawData.value.agreedTerms = value
},
})
const calculatedFee = ref<number | null>(null)
const exchangeRate = ref<number | null>(null)
const feeLoading = ref(false)
const hasDocumentTypeField = computed(() => {
const rail = selectedRail.value
if (!rail) return false
return rail.fields.some((field) => field.name === 'documentType')
})
const dynamicDocumentNumberField = computed(() => {
if (!hasDocumentTypeField.value) return null
const documentType = formData.value.documentType
if (!documentType) return null
const labelMap: Record<string, string> = {
NATIONAL_ID: formatMessage(messages.documentNumberNationalId),
PASSPORT: formatMessage(messages.documentNumberPassport),
RESIDENT_ID: formatMessage(messages.documentNumberResidentId),
RUC: formatMessage(messages.documentNumberRuc),
TAX_ID: formatMessage(messages.documentNumberTaxId),
}
const placeholderMap: Record<string, string> = {
NATIONAL_ID: formatMessage(messages.documentNumberNationalIdPlaceholder),
PASSPORT: formatMessage(messages.documentNumberPassportPlaceholder),
RESIDENT_ID: formatMessage(messages.documentNumberResidentIdPlaceholder),
RUC: formatMessage(messages.documentNumberRucPlaceholder),
TAX_ID: formatMessage(messages.documentNumberTaxIdPlaceholder),
}
return {
name: 'documentNumber',
type: 'text' as const,
label: labelMap[documentType] || 'Document Number',
placeholder: placeholderMap[documentType] || 'Enter document number',
required: true,
}
})
const accountOwnerName = computed(() => {
const providerDataValue = withdrawData.value.providerData
if (providerDataValue.type !== 'muralpay') return ''
const kycData = providerDataValue.kycData
if (!kycData) return ''
if (kycData.type === 'individual') {
return `${kycData.firstName} ${kycData.lastName}`
} else if (kycData.type === 'business') {
return kycData.name
}
return ''
})
const accountOwnerAddress = computed(() => {
const providerDataValue = withdrawData.value.providerData
if (providerDataValue.type !== 'muralpay') return ''
const kycData = providerDataValue.kycData
if (!kycData || !kycData.physicalAddress) return ''
const addr = kycData.physicalAddress
const parts = [
addr.address1,
addr.address2,
addr.city,
addr.state,
addr.zip,
addr.country,
].filter(Boolean)
return parts.join(', ')
})
const allRequiredFieldsFilled = computed(() => {
const rail = selectedRail.value
if (!rail) return false
const amount = formData.value.amount
if (!amount || amount <= 0) return false
if (rail.requiresBankName && !formData.value.bankName) return false
const requiredFields = rail.fields.filter((f) => f.required)
const allRequiredPresent = requiredFields.every((f) => {
const value = formData.value[f.name]
return value !== undefined && value !== null && value !== ''
})
if (!allRequiredPresent) return false
if (dynamicDocumentNumberField.value?.required && !formData.value.documentNumber) return false
return true
})
const calculateFeesDebounced = useDebounceFn(async () => {
const amount = formData.value.amount
const rail = selectedRail.value
const providerDataValue = withdrawData.value.providerData
const kycData = providerDataValue.type === 'muralpay' ? providerDataValue.kycData : null
if (!amount || amount <= 0 || !rail || !kycData) {
calculatedFee.value = null
exchangeRate.value = null
return
}
feeLoading.value = true
try {
await calculateFees()
calculatedFee.value = withdrawData.value.calculation.fee
exchangeRate.value = withdrawData.value.calculation.exchangeRate
} catch (error) {
console.error('Failed to calculate fees:', error)
calculatedFee.value = 0
exchangeRate.value = null
} finally {
feeLoading.value = false
}
}, 500)
watch(
formData,
() => {
withdrawData.value.calculation.amount = formData.value.amount ?? 0
if (withdrawData.value.providerData.type === 'muralpay') {
withdrawData.value.providerData.accountDetails = { ...formData.value }
}
if (allRequiredFieldsFilled.value) {
feeLoading.value = true
calculateFeesDebounced()
} else {
calculatedFee.value = null
exchangeRate.value = null
feeLoading.value = false
}
},
{ deep: true },
)
if (allRequiredFieldsFilled.value) {
feeLoading.value = true
calculateFeesDebounced()
}
watch(
() => withdrawData.value.selection.method,
(newMethod, oldMethod) => {
if (oldMethod && newMethod !== oldMethod) {
formData.value = {
amount: undefined,
bankName: '',
}
if (withdrawData.value.providerData.type === 'muralpay') {
withdrawData.value.providerData.accountDetails = {}
}
withdrawData.value.calculation.amount = 0
calculatedFee.value = null
exchangeRate.value = null
}
},
)
const messages = defineMessages({
accountOwner: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.account-owner',
defaultMessage: 'Account owner',
},
cryptoWarningHeader: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.crypto-warning-header',
defaultMessage: 'Confirm your wallet address',
},
coin: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.coin',
defaultMessage: 'Coin',
},
network: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.network',
defaultMessage: 'Network',
},
documentNumberNationalId: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-national-id',
defaultMessage: 'National ID Number',
},
documentNumberPassport: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-passport',
defaultMessage: 'Passport Number',
},
documentNumberResidentId: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-resident-id',
defaultMessage: 'Resident ID Number',
},
documentNumberRuc: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-ruc',
defaultMessage: 'RUC Number',
},
documentNumberTaxId: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-tax-id',
defaultMessage: 'Tax ID Number',
},
documentNumberNationalIdPlaceholder: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-national-id-placeholder',
defaultMessage: 'Enter national ID number',
},
documentNumberPassportPlaceholder: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-passport-placeholder',
defaultMessage: 'Enter passport number',
},
documentNumberResidentIdPlaceholder: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-resident-id-placeholder',
defaultMessage: 'Enter resident ID number',
},
documentNumberRucPlaceholder: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-ruc-placeholder',
defaultMessage: 'Enter RUC number',
},
documentNumberTaxIdPlaceholder: {
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-tax-id-placeholder',
defaultMessage: 'Enter tax ID number',
},
})
</script>

View File

@@ -0,0 +1,362 @@
<template>
<div class="flex flex-col gap-3 sm:gap-4">
<div class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.entityQuestion) }}
<span class="text-red">*</span>
</span>
</label>
<Chips
v-model="entityType"
:items="['individual', 'business']"
:format-label="
(item: string) =>
item === 'individual'
? formatMessage(messages.privateIndividual)
: formatMessage(messages.businessEntity)
"
:never-empty="false"
:capitalize="false"
/>
<span class="leading-tight text-primary">
{{ formatMessage(messages.entityDescription) }}
</span>
</div>
<div v-if="entityType" class="flex flex-col gap-4">
<div v-if="entityType === 'business'" class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(formFieldLabels.businessName) }}
<span class="text-red">*</span>
</span>
</label>
<input
v-model="formData.businessName"
type="text"
:placeholder="formatMessage(formFieldPlaceholders.businessNamePlaceholder)"
autocomplete="organization"
class="w-full rounded-[14px] bg-surface-4 px-4 py-2.5 text-contrast placeholder:text-secondary"
/>
</div>
<div class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(formFieldLabels.email) }}
<span class="text-red">*</span>
</span>
</label>
<input
v-model="formData.email"
type="email"
:placeholder="formatMessage(formFieldPlaceholders.emailPlaceholder)"
autocomplete="email"
class="w-full rounded-[14px] bg-surface-4 px-4 py-2.5 text-contrast placeholder:text-secondary"
/>
</div>
<div v-if="entityType === 'individual'" class="flex flex-col gap-6">
<div class="flex flex-col gap-3 sm:flex-row sm:gap-4">
<div class="flex flex-1 flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(formFieldLabels.firstName) }}
<span class="text-red">*</span>
</span>
</label>
<input
v-model="formData.firstName"
type="text"
:placeholder="formatMessage(formFieldPlaceholders.firstNamePlaceholder)"
autocomplete="given-name"
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
/>
</div>
<div class="flex flex-1 flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(formFieldLabels.lastName) }}
<span class="text-red">*</span>
</span>
</label>
<input
v-model="formData.lastName"
type="text"
:placeholder="formatMessage(formFieldPlaceholders.lastNamePlaceholder)"
autocomplete="family-name"
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
/>
</div>
</div>
<div class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(formFieldLabels.dateOfBirth) }}
<span class="text-red">*</span>
</span>
</label>
<input
v-model="formData.dateOfBirth"
type="date"
:max="maxDate"
autocomplete="bday"
class="w-full rounded-[14px] bg-surface-4 px-4 py-2.5 text-contrast placeholder:text-secondary"
/>
</div>
</div>
<div class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(formFieldLabels.addressLine) }}
<span class="text-red">*</span>
</span>
</label>
<input
v-model="formData.physicalAddress.address1"
type="text"
:placeholder="formatMessage(formFieldPlaceholders.addressPlaceholder)"
autocomplete="address-line1"
class="w-full rounded-[14px] bg-surface-4 px-4 py-2.5 text-contrast placeholder:text-secondary"
/>
</div>
<div class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(formFieldLabels.addressLine2) }}
</span>
</label>
<input
v-model="formData.physicalAddress.address2"
type="text"
:placeholder="formatMessage(formFieldPlaceholders.address2Placeholder)"
autocomplete="address-line2"
class="w-full rounded-[14px] bg-surface-4 px-4 py-2.5 text-contrast placeholder:text-secondary"
/>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:gap-4">
<div class="flex flex-1 flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(formFieldLabels.city) }}
<span class="text-red">*</span>
</span>
</label>
<input
v-model="formData.physicalAddress.city"
type="text"
:placeholder="formatMessage(formFieldPlaceholders.cityPlaceholder)"
autocomplete="address-level2"
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
/>
</div>
<div class="flex flex-1 flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(formFieldLabels.stateProvince) }}
<span class="text-red">*</span>
</span>
</label>
<Combobox
v-if="subdivisionOptions.length > 0"
v-model="formData.physicalAddress.state"
:options="subdivisionOptions"
:placeholder="formatMessage(formFieldPlaceholders.statePlaceholder)"
searchable
search-placeholder="Search subdivisions..."
/>
<input
v-else
v-model="formData.physicalAddress.state"
type="text"
:placeholder="formatMessage(formFieldPlaceholders.statePlaceholder)"
autocomplete="address-level1"
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
/>
</div>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:gap-4">
<div class="flex flex-1 flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(formFieldLabels.postalCode) }}
<span class="text-red">*</span>
</span>
</label>
<input
v-model="formData.physicalAddress.zip"
type="text"
:placeholder="formatMessage(formFieldPlaceholders.postalCodePlaceholder)"
autocomplete="postal-code"
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
/>
</div>
<div class="flex flex-1 flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(formFieldLabels.country) }}
<span class="text-red">*</span>
</span>
</label>
<Combobox
v-model="formData.physicalAddress.country"
:options="countryOptions"
:placeholder="formatMessage(formFieldPlaceholders.countryPlaceholder)"
searchable
search-placeholder="Search countries..."
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Chips, Combobox, formFieldLabels, formFieldPlaceholders } from '@modrinth/ui'
import { useVIntl } from '@vintl/vintl'
import { useFormattedCountries } from '@/composables/country.ts'
import { useGeneratedState } from '@/composables/generated.ts'
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
const { withdrawData } = useWithdrawContext()
const { formatMessage } = useVIntl()
const generatedState = useGeneratedState()
const providerData = withdrawData.value.providerData
const existingKycData = providerData.type === 'muralpay' ? providerData.kycData : null
const entityType = ref<'individual' | 'business' | null>(existingKycData?.type ?? null)
interface PayoutRecipientInfoMerged {
email: string
firstName?: string
lastName?: string
dateOfBirth?: string
businessName?: string
physicalAddress: {
address1: string
address2: string | null
country: string
state: string
city: string
zip: string
}
}
const auth = await useAuth()
const formData = ref<PayoutRecipientInfoMerged>({
email: existingKycData?.email ?? `${(auth.value.user as any)?.email}`,
firstName: existingKycData?.type === 'individual' ? existingKycData.firstName : '',
lastName: existingKycData?.type === 'individual' ? existingKycData.lastName : '',
dateOfBirth: existingKycData?.type === 'individual' ? existingKycData.dateOfBirth : '',
businessName: existingKycData?.type === 'business' ? existingKycData.name : '',
physicalAddress: {
address1: existingKycData?.physicalAddress?.address1 ?? '',
address2: existingKycData?.physicalAddress?.address2 ?? null,
country:
existingKycData?.physicalAddress?.country ?? withdrawData.value.selection.country?.id ?? '',
state: existingKycData?.physicalAddress?.state ?? '',
city: existingKycData?.physicalAddress?.city ?? '',
zip: existingKycData?.physicalAddress?.zip ?? '',
},
})
const maxDate = computed(() => {
const today = new Date()
const year = today.getFullYear() - 18
const month = String(today.getMonth() + 1).padStart(2, '0')
const day = String(today.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
})
const countryOptions = useFormattedCountries()
const subdivisionOptions = computed(() => {
const selectedCountry = formData.value.physicalAddress.country
if (!selectedCountry) return []
const subdivisions = generatedState.value.subdivisions?.[selectedCountry] ?? []
return subdivisions.map((sub) => ({
value: sub.code.includes('-') ? sub.code.split('-')[1] : sub.code,
label: sub.localVariant || sub.name,
}))
})
watch(
[entityType, formData],
() => {
if (!entityType.value) {
if (withdrawData.value.providerData.type === 'muralpay') {
withdrawData.value.providerData.kycData = null as any
}
return
}
if (withdrawData.value.providerData.type !== 'muralpay') return
if (entityType.value === 'individual') {
if (formData.value.dateOfBirth) {
withdrawData.value.providerData.kycData = {
type: 'individual',
firstName: formData.value.firstName || '',
lastName: formData.value.lastName || '',
email: formData.value.email,
dateOfBirth: formData.value.dateOfBirth,
physicalAddress: {
address1: formData.value.physicalAddress.address1,
address2: formData.value.physicalAddress.address2 || undefined,
country: formData.value.physicalAddress.country,
state: formData.value.physicalAddress.state,
city: formData.value.physicalAddress.city,
zip: formData.value.physicalAddress.zip,
},
}
}
} else {
withdrawData.value.providerData.kycData = {
type: 'business',
name: formData.value.businessName || '',
email: formData.value.email,
physicalAddress: {
address1: formData.value.physicalAddress.address1,
address2: formData.value.physicalAddress.address2 || undefined,
country: formData.value.physicalAddress.country,
state: formData.value.physicalAddress.state,
city: formData.value.physicalAddress.city,
zip: formData.value.physicalAddress.zip,
},
}
}
},
{ deep: true },
)
const messages = defineMessages({
entityQuestion: {
id: 'dashboard.creator-withdraw-modal.kyc.entity-question',
defaultMessage: 'Are you a withdrawing as an individual or business?',
},
entityDescription: {
id: 'dashboard.creator-withdraw-modal.kyc.entity-description',
defaultMessage:
'A business entity refers to a registered organization such as a corporation, partnership, or LLC.',
},
privateIndividual: {
id: 'dashboard.creator-withdraw-modal.kyc.private-individual',
defaultMessage: 'Private individual',
},
businessEntity: {
id: 'dashboard.creator-withdraw-modal.kyc.business-entity',
defaultMessage: 'Business entity',
},
})
</script>

View File

@@ -0,0 +1,159 @@
<template>
<div class="flex flex-col gap-2.5 sm:gap-3">
<div class="flex flex-col gap-3">
<div class="flex w-full flex-col gap-1 sm:flex-row sm:justify-between sm:gap-0">
<span class="font-semibold text-contrast">{{ formatMessage(messages.withdrawLimit) }}</span>
<div>
<span class="text-orange">{{ formatMoney(usedLimit) }}</span> /
<span class="text-contrast">{{ formatMoney(600) }}</span>
</div>
</div>
<div class="flex h-2.5 w-full overflow-hidden rounded-full bg-surface-2">
<div
v-if="usedLimit > 0"
class="gradient-border bg-orange"
:style="{ width: `${(usedLimit / 600) * 100}%` }"
></div>
</div>
</div>
<template v-if="remainingLimit > 0">
<span>
<IntlFormatted
:message-id="messages.nearingThreshold"
:values="{
amountRemaining: formatMoney(remainingLimit),
}"
>
<template #b="{ children }">
<span class="font-medium">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</span>
<Admonition
type="warning"
show-actions-underneath
:header="formatMessage(messages.taxFormRequiredHeader)"
>
<span class="text-sm font-normal md:text-base">
{{
formatMessage(messages.taxFormRequiredBodyWithLimit, {
limit: formatMoney(remainingLimit),
})
}}
</span>
<template #icon="{ iconClass }">
<FileTextIcon :class="iconClass" />
</template>
<template #actions>
<ButtonStyled color="orange">
<button @click="showTaxFormModal">
{{ formatMessage(messages.completeTaxForm) }}
</button>
</ButtonStyled>
</template>
</Admonition>
</template>
<template v-else>
<span>
<IntlFormatted
:message-id="messages.withdrawLimitUsed"
:values="{ withdrawLimit: formatMoney(600) }"
>
<template #b="{ children }">
<b>
<component :is="() => normalizeChildren(children)" />
</b>
</template>
</IntlFormatted>
</span>
</template>
</div>
</template>
<script setup lang="ts">
import { FileTextIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled } from '@modrinth/ui'
import { formatMoney } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { computed } from 'vue'
import { TAX_THRESHOLD_ACTUAL } from '@/providers/creator-withdraw.ts'
import { normalizeChildren } from '@/utils/vue-children.ts'
const props = defineProps<{
balance: any
onShowTaxForm: () => void
}>()
const { formatMessage } = useVIntl()
const usedLimit = computed(() => props.balance?.withdrawn_ytd ?? 0)
const remainingLimit = computed(() => {
const raw = TAX_THRESHOLD_ACTUAL - usedLimit.value
if (raw <= 0) return 0
const cents = Math.floor(raw * 100)
return cents / 100
})
function showTaxFormModal() {
props.onShowTaxForm()
}
const messages = defineMessages({
withdrawLimit: {
id: 'dashboard.creator-withdraw-modal.withdraw-limit',
defaultMessage: 'Withdraw limit',
},
nearingThreshold: {
id: 'dashboard.creator-withdraw-modal.nearing-threshold',
defaultMessage:
"You're nearing the withdraw threshold. You can withdraw <b>{amountRemaining}</b> now, but a tax form is required for more.",
},
taxFormRequiredHeader: {
id: 'dashboard.creator-withdraw-modal.tax-form-required.header',
defaultMessage: 'Tax form required',
},
taxFormRequiredBody: {
id: 'dashboard.creator-withdraw-modal.tax-form-required.body',
defaultMessage:
'To withdraw your full <b>{available}</b> available balance please complete the form below. It is required for tax reporting and only needs to be done once.',
},
taxFormRequiredBodyWithLimit: {
id: 'dashboard.creator-withdraw-modal.tax-form-required.body-with-limit',
defaultMessage:
"You must complete a W-9 or W-8 form for Modrinth's tax records so we remain compliant with tax regulations.",
},
completeTaxForm: {
id: 'dashboard.creator-withdraw-modal.complete-tax-form',
defaultMessage: 'Complete tax form',
},
withdrawLimitUsed: {
id: 'dashboard.creator-withdraw-modal.withdraw-limit-used',
defaultMessage:
"You've used up your <b>{withdrawLimit}</b> withdrawal limit. You must complete a tax form to withdraw more.",
},
})
</script>
<style lang="css" scoped>
.gradient-border {
position: relative;
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.3), transparent);
border-radius: inherit;
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: xor;
padding: 2px;
pointer-events: none;
}
}
</style>

View File

@@ -0,0 +1,649 @@
<template>
<div class="flex flex-col gap-4 sm:gap-5">
<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-40"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-40"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="isUnverifiedEmail" class="overflow-hidden">
<Admonition type="warning" :header="formatMessage(messages.unverifiedEmailHeader)">
{{ formatMessage(messages.unverifiedEmailMessage) }}
</Admonition>
</div>
</Transition>
<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-40"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-40"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="shouldShowUsdWarning" class="overflow-hidden">
<Admonition type="warning" :header="formatMessage(messages.usdPaypalWarningHeader)">
<IntlFormatted :message-id="messages.usdPaypalWarningMessage">
<template #direct-paypal-link="{ children }">
<span class="cursor-pointer text-link" @click="switchToDirectPaypal">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</Admonition>
</div>
</Transition>
<div v-if="!showGiftCardSelector && selectedMethodDisplay" class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.paymentMethod) }}
</span>
</label>
<div
class="flex min-h-[44px] items-center gap-2 rounded-[14px] bg-surface-2 px-4 py-2.5 sm:min-h-0"
>
<component :is="selectedMethodDisplay.icon" class="size-5 shrink-0" />
<span class="break-words text-sm font-semibold text-contrast sm:text-[1rem]">{{
typeof selectedMethodDisplay.label === 'string'
? selectedMethodDisplay.label
: formatMessage(selectedMethodDisplay.label)
}}</span>
</div>
</div>
<div class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast"
>{{ formatMessage(formFieldLabels.email) }} <span class="text-red">*</span></span
>
</label>
<input
v-model="deliveryEmail"
type="email"
:placeholder="formatMessage(formFieldPlaceholders.emailPlaceholder)"
autocomplete="email"
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
/>
</div>
<div v-if="showGiftCardSelector" class="flex flex-col gap-1">
<div class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast"
>{{ categoryLabel }} <span class="text-red">*</span></span
>
</label>
<Combobox
v-model="selectedGiftCardId"
:options="rewardOptions"
:placeholder="`Select ${categoryLabel.toLowerCase()}`"
searchable
:search-placeholder="`Search ${categoryLabelPlural.toLowerCase()}...`"
class="h-10"
>
<template #selected>
<div v-if="selectedRewardOption" class="flex items-center gap-2">
<img
v-if="selectedRewardOption.imageUrl"
:src="selectedRewardOption.imageUrl"
:alt="selectedRewardOption.label"
class="size-5 rounded-full object-cover"
loading="lazy"
/>
<span class="font-semibold leading-tight">{{ selectedRewardOption.label }}</span>
</div>
</template>
<template v-for="option in rewardOptions" :key="option.value" #[`option-${option.value}`]>
<div class="flex items-center gap-2">
<img
v-if="option.imageUrl"
:src="option.imageUrl"
:alt="option.label"
class="size-5 rounded-full object-cover"
loading="lazy"
/>
<span class="font-semibold leading-tight">{{ option.label }}</span>
</div>
</template>
</Combobox>
</div>
<span v-if="selectedMethodDetails" class="text-secondary">
{{ formatMoney(effectiveMinAmount) }} min,
{{ formatMoney(selectedMethodDetails.interval?.standard?.max ?? effectiveMaxAmount) }}
max withdrawal amount.
</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
>
</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">
No denominations available for your current balance
</span>
</div>
<div v-else class="flex flex-col gap-2">
<RevenueInputField
v-model="formData.amount"
v-model:selected-currency="selectedCurrency"
:max-amount="effectiveMaxAmount"
:min-amount="effectiveMinAmount"
:show-currency-selector="showPayPalCurrencySelector"
:currency-options="currencyOptions"
/>
</div>
<WithdrawFeeBreakdown
v-if="allRequiredFieldsFilled"
:amount="formData.amount || 0"
:fee="calculatedFee"
:fee-loading="feeLoading"
:exchange-rate="exchangeRate"
:local-currency="showPayPalCurrencySelector ? selectedCurrency : undefined"
/>
<Checkbox v-model="agreedTerms">
<span>
<IntlFormatted :message-id="financialMessages.rewardsProgramTermsAgreement">
<template #terms-link="{ children }">
<nuxt-link to="/legal/cmp" class="text-link">
<component :is="() => normalizeChildren(children)" />
</nuxt-link>
</template>
</IntlFormatted>
</span>
</Checkbox>
</div>
</div>
</template>
<script setup lang="ts">
import {
Admonition,
Checkbox,
Chips,
Combobox,
financialMessages,
formFieldLabels,
formFieldPlaceholders,
paymentMethodMessages,
useDebugLogger,
} from '@modrinth/ui'
import { formatMoney } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { useDebounceFn } from '@vueuse/core'
import { computed, onMounted, ref, watch } from 'vue'
import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue'
import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
import { useAuth } from '@/composables/auth.js'
import { useBaseFetch } from '@/composables/fetch.js'
import { type PayoutMethod, useWithdrawContext } from '@/providers/creator-withdraw.ts'
import { normalizeChildren } from '@/utils/vue-children.ts'
const debug = useDebugLogger('TremendousDetailsStage')
const {
withdrawData,
maxWithdrawAmount,
availableMethods,
paymentOptions,
calculateFees,
setStage,
paymentMethodsCache,
} = useWithdrawContext()
const { formatMessage } = useVIntl()
const auth = await useAuth()
const userEmail = computed(() => {
return (auth.value.user as any)?.email || ''
})
const providerData = withdrawData.value.providerData
const initialDeliveryEmail =
providerData.type === 'tremendous'
? providerData.deliveryEmail || userEmail.value || ''
: userEmail.value || ''
const deliveryEmail = ref<string>(initialDeliveryEmail)
const showGiftCardSelector = computed(() => {
const method = withdrawData.value.selection.method
return method === 'merchant_card' || method === 'charity'
})
const showPayPalCurrencySelector = computed(() => {
const method = withdrawData.value.selection.method
return method === 'paypal'
})
const shouldShowUsdWarning = computed(() => {
const method = withdrawData.value.selection.method
const currency = selectedCurrency.value
return method === 'paypal' && currency === 'USD'
})
const selectedMethodDisplay = computed(() => {
const method = withdrawData.value.selection.method
if (!method) return null
return paymentOptions.value.find((m) => m.value === method) || null
})
const categoryLabel = computed(() => {
const method = withdrawData.value.selection.method
switch (method) {
case 'visa_card':
return formatMessage(paymentMethodMessages.virtualVisa)
case 'merchant_card':
return formatMessage(paymentMethodMessages.giftCard)
case 'charity':
return formatMessage(paymentMethodMessages.charity)
default:
return formatMessage(messages.reward)
}
})
const categoryLabelPlural = computed(() => {
const method = withdrawData.value.selection.method
switch (method) {
case 'visa_card':
return formatMessage(paymentMethodMessages.virtualVisaPlural)
case 'merchant_card':
return formatMessage(paymentMethodMessages.giftCardPlural)
case 'charity':
return formatMessage(paymentMethodMessages.charityPlural)
default:
return formatMessage(messages.rewardPlural)
}
})
const isUnverifiedEmail = computed(() => {
if (!deliveryEmail.value || !userEmail.value) return false
return deliveryEmail.value.toLowerCase() !== userEmail.value.toLowerCase()
})
const maxAmount = computed(() => maxWithdrawAmount.value)
const roundedMaxAmount = computed(() => Math.floor(maxAmount.value * 100) / 100)
const formData = ref<Record<string, any>>({
amount: withdrawData.value.calculation.amount || undefined,
})
const selectedGiftCardId = ref<string | null>(withdrawData.value.selection.methodId || null)
const currencyOptions = [
{ value: 'USD', label: 'USD' },
{ value: 'AUD', label: 'AUD' },
{ value: 'CAD', label: 'CAD' },
{ value: 'CHF', label: 'CHF' },
{ value: 'CZK', label: 'CZK' },
{ value: 'DKK', label: 'DKK' },
{ value: 'EUR', label: 'EUR' },
{ value: 'GBP', label: 'GBP' },
{ value: 'MXN', label: 'MXN' },
{ value: 'NOK', label: 'NOK' },
{ value: 'NZD', label: 'NZD' },
{ value: 'PLN', label: 'PLN' },
{ value: 'SEK', label: 'SEK' },
{ value: 'SGD', label: 'SGD' },
]
function getCurrencyFromCountryCode(countryCode: string | undefined): string {
if (!countryCode) return 'USD'
const code = countryCode.toUpperCase()
const countryToCurrency: Record<string, string> = {
US: 'USD', // United States
GB: 'GBP', // UK
CA: 'CAD', // Canada
AU: 'AUD', // Australia
CH: 'CHF', // Switzerland
CZ: 'CZK', // Czech Republic
DK: 'DKK', // Denmark
MX: 'MXN', // Mexico
NO: 'NOK', // Norway
NZ: 'NZD', // New Zealand
PL: 'PLN', // Poland
SE: 'SEK', // Sweden
SG: 'SGD', // Singapore
// Eurozone countries
AT: 'EUR', // Austria
BE: 'EUR', // Belgium
CY: 'EUR', // Cyprus
EE: 'EUR', // Estonia
FI: 'EUR', // Finland
FR: 'EUR', // France
DE: 'EUR', // Germany
GR: 'EUR', // Greece
IE: 'EUR', // Ireland
IT: 'EUR', // Italy
LV: 'EUR', // Latvia
LT: 'EUR', // Lithuania
LU: 'EUR', // Luxembourg
MT: 'EUR', // Malta
NL: 'EUR', // Netherlands
PT: 'EUR', // Portugal
SK: 'EUR', // Slovakia
SI: 'EUR', // Slovenia
ES: 'EUR', // Spain
}
return countryToCurrency[code] || 'USD'
}
const initialCurrency = getCurrencyFromCountryCode(withdrawData.value.selection.country?.id)
const selectedCurrency = ref<string>(initialCurrency)
const agreedTerms = computed({
get: () => withdrawData.value.agreedTerms,
set: (value) => {
withdrawData.value.agreedTerms = value
},
})
const calculatedFee = ref<number>(0)
const exchangeRate = ref<number | null>(null)
const feeLoading = ref(false)
const rewardOptions = ref<
Array<{
value: string
label: string
imageUrl?: string
methodDetails?: {
id: string
name: string
interval?: {
fixed?: { values: number[] }
standard?: { min: number; max: number }
}
}
}>
>([])
const selectedRewardOption = computed(() => {
if (!selectedGiftCardId.value) return null
return rewardOptions.value.find((opt) => opt.value === selectedGiftCardId.value) || null
})
const selectedMethodDetails = computed(() => {
console.log(rewardOptions.value, selectedGiftCardId.value)
if (!selectedGiftCardId.value) return null
const option = rewardOptions.value.find((opt) => opt.value === selectedGiftCardId.value)
debug('Selected method details:', option?.methodDetails)
return option?.methodDetails || null
})
const useFixedDenominations = computed(() => {
const hasFixed = !!selectedMethodDetails.value?.interval?.fixed?.values
debug('Use fixed denominations:', hasFixed, selectedMethodDetails.value?.interval)
return hasFixed
})
const denominationOptions = computed(() => {
const fixedValues = selectedMethodDetails.value?.interval?.fixed?.values
if (!fixedValues) return []
const filtered = fixedValues
.filter((amount) => amount <= roundedMaxAmount.value)
.sort((a, b) => a - b)
debug(
'Denomination options (filtered by max):',
filtered,
'from',
fixedValues,
'max:',
roundedMaxAmount.value,
)
return filtered
})
const effectiveMinAmount = computed(() => {
return selectedMethodDetails.value?.interval?.standard?.min || 0.01
})
const effectiveMaxAmount = computed(() => {
const methodMax = selectedMethodDetails.value?.interval?.standard?.max
if (methodMax !== undefined && methodMax !== null) {
return Math.min(roundedMaxAmount.value, methodMax)
}
return roundedMaxAmount.value
})
const selectedDenomination = computed({
get: () => formData.value.amount,
set: (value) => {
formData.value.amount = value
},
})
const allRequiredFieldsFilled = computed(() => {
const amount = formData.value.amount
if (!amount || amount <= 0) return false
if (!deliveryEmail.value) return false
if (showGiftCardSelector.value && !selectedGiftCardId.value) return false
return true
})
const calculateFeesDebounced = useDebounceFn(async () => {
const amount = formData.value.amount
if (!amount || amount <= 0) {
calculatedFee.value = 0
exchangeRate.value = null
return
}
const methodId = showGiftCardSelector.value
? selectedGiftCardId.value
: withdrawData.value.selection.methodId
if (!methodId) {
calculatedFee.value = 0
exchangeRate.value = null
return
}
feeLoading.value = true
try {
await calculateFees()
calculatedFee.value = withdrawData.value.calculation.fee ?? 0
exchangeRate.value = withdrawData.value.calculation.exchangeRate
} catch (error) {
console.error('Failed to calculate fees:', error)
calculatedFee.value = 0
exchangeRate.value = null
} finally {
feeLoading.value = false
}
}, 500)
watch(deliveryEmail, (newEmail) => {
if (withdrawData.value.providerData.type === 'tremendous') {
withdrawData.value.providerData.deliveryEmail = newEmail
}
})
watch(
selectedCurrency,
(newCurrency) => {
if (withdrawData.value.providerData.type === 'tremendous') {
;(withdrawData.value.providerData as any).currency = newCurrency
}
},
{ immediate: true },
)
watch(
() => withdrawData.value.selection.country?.id,
(newCountryId) => {
if (showPayPalCurrencySelector.value && newCountryId) {
const detectedCurrency = getCurrencyFromCountryCode(newCountryId)
selectedCurrency.value = detectedCurrency
}
},
)
watch(
[() => formData.value.amount, selectedGiftCardId, deliveryEmail, selectedCurrency],
() => {
withdrawData.value.calculation.amount = formData.value.amount ?? 0
if (showGiftCardSelector.value && selectedGiftCardId.value) {
withdrawData.value.selection.methodId = selectedGiftCardId.value
}
if (allRequiredFieldsFilled.value) {
feeLoading.value = true
calculateFeesDebounced()
} else {
calculatedFee.value = 0
exchangeRate.value = null
feeLoading.value = false
}
},
{ deep: true },
)
onMounted(async () => {
const methods = availableMethods.value
const selectedMethod = withdrawData.value.selection.method
rewardOptions.value = methods
.filter((m) => m.type === 'tremendous')
.filter((m) => m.category === selectedMethod)
.map((m) => ({
value: m.id,
label: m.name,
imageUrl: m.image_url || m.image_logo_url || undefined,
methodDetails: {
id: m.id,
name: m.name,
interval: m.interval,
},
}))
debug('Loaded reward options:', rewardOptions.value.length, 'methods')
debug('Sample method with interval:', rewardOptions.value[0]?.methodDetails)
if (allRequiredFieldsFilled.value) {
feeLoading.value = true
calculateFeesDebounced()
}
})
watch(
() => withdrawData.value.selection.method,
(newMethod, oldMethod) => {
if (oldMethod && newMethod !== oldMethod) {
formData.value = {
amount: undefined,
}
selectedGiftCardId.value = null
calculatedFee.value = 0
exchangeRate.value = null
// Clear currency when switching away from PayPal International
if (newMethod !== 'paypal' && withdrawData.value.providerData.type === 'tremendous') {
;(withdrawData.value.providerData as any).currency = undefined
}
}
},
)
async function switchToDirectPaypal() {
withdrawData.value.selection.country = {
id: 'US',
name: 'United States',
}
let usMethods = paymentMethodsCache.value['US']
if (!usMethods) {
try {
usMethods = (await useBaseFetch('payout/methods', {
apiVersion: 3,
query: { country: 'US' },
})) as PayoutMethod[]
paymentMethodsCache.value['US'] = usMethods
} catch (error) {
console.error('Failed to fetch US payment methods:', error)
return
}
}
availableMethods.value = usMethods
const directPaypal = usMethods.find((m) => m.type === 'paypal')
if (directPaypal) {
withdrawData.value.selection.provider = 'paypal'
withdrawData.value.selection.method = directPaypal.id
withdrawData.value.selection.methodId = directPaypal.id
withdrawData.value.providerData = {
type: 'paypal',
}
await setStage('paypal-details', true)
} else {
console.error('An error occured - no paypal in US region??')
}
}
const messages = defineMessages({
unverifiedEmailHeader: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.unverified-email-header',
defaultMessage: 'Unverified email',
},
unverifiedEmailMessage: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.unverified-email-message',
defaultMessage:
'The delivery email you have entered is not associated with your Modrinth account. Modrinth cannot recover rewards sent to an incorrect email address.',
},
paymentMethod: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.payment-method',
defaultMessage: 'Payment method',
},
reward: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.reward',
defaultMessage: 'Reward',
},
rewardPlaceholder: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.reward-placeholder',
defaultMessage: 'Select reward',
},
rewardPlural: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.reward-plural',
defaultMessage: 'Rewards',
},
usdPaypalWarningHeader: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.usd-paypal-warning-header',
defaultMessage: 'Lower fees available',
},
usdPaypalWarningMessage: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.usd-paypal-warning-message',
defaultMessage:
'You selected USD for PayPal International. <direct-paypal-link>Switch to direct PayPal</direct-paypal-link> for better fees (≈2% instead of ≈6%).',
},
})
</script>

View File

@@ -26,7 +26,7 @@ export default {
},
},
setup() {
const tags = useTags()
const tags = useGeneratedState()
return { tags }
},

View File

@@ -27,13 +27,13 @@
</p>
</div>
<TeleportDropdownMenu
<Combobox
:id="'interval-field'"
v-model="backupIntervalsLabel"
:disabled="!autoBackupEnabled || isSaving"
name="interval"
:options="Object.keys(backupIntervals)"
placeholder="Backup interval"
:options="Object.keys(backupIntervals).map((k) => ({ value: k, label: k }))"
:display-value="backupIntervalsLabel"
/>
<div class="mt-4 flex justify-start gap-4">
@@ -57,12 +57,7 @@
<script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
injectNotificationManager,
NewModal,
TeleportDropdownMenu,
} from '@modrinth/ui'
import { ButtonStyled, Combobox, injectNotificationManager, NewModal } from '@modrinth/ui'
import { computed, ref } from 'vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'

View File

@@ -60,16 +60,24 @@
</NuxtLink>
</div>
</div>
<TeleportDropdownMenu
<Combobox
v-model="selectedVersion"
name="Project"
:options="filteredVersions"
placeholder="No valid versions found"
:options="
filteredVersions.map((v) => ({
value: v,
label: typeof v === 'object' ? v.version_number : String(v),
}))
"
:display-value="
selectedVersion
? typeof selectedVersion === 'object'
? selectedVersion.version_number
: String(selectedVersion)
: 'No valid versions found'
"
class="!min-w-full"
:disabled="filteredVersions.length === 0"
:display-name="
(version) => (typeof version === 'object' ? version?.version_number : version)
"
/>
<Checkbox v-model="showBetaAlphaReleases"> Show any beta and alpha releases </Checkbox>
</template>
@@ -237,14 +245,7 @@ import {
LockOpenIcon,
XIcon,
} from '@modrinth/assets'
import {
Admonition,
Avatar,
ButtonStyled,
CopyCode,
NewModal,
TeleportDropdownMenu,
} from '@modrinth/ui'
import { Admonition, Avatar, ButtonStyled, Combobox, CopyCode, NewModal } from '@modrinth/ui'
import TagItem from '@modrinth/ui/src/components/base/TagItem.vue'
import { formatCategory, formatVersionsForDisplay, type Mod, type Version } from '@modrinth/utils'
import { computed, ref } from 'vue'
@@ -282,7 +283,7 @@ const versionsError = ref('')
const showBetaAlphaReleases = ref(false)
const unlockFilterAccordion = ref()
const versionFilter = ref(true)
const tags = useTags()
const tags = useGeneratedState()
const noCompatibleVersions = ref(false)
const { pluginLoaders, modLoaders } = tags.value.loaders.reduce(

View File

@@ -17,10 +17,11 @@
</div>
<div class="flex w-full flex-col gap-4">
<TeleportDropdownMenu
<Combobox
v-if="props.versions?.length"
v-model="selectedVersion"
:options="versionOptions"
:options="versionOptions.map((v) => ({ value: v, label: v }))"
:display-value="selectedVersion || 'Select version...'"
placeholder="Select version..."
name="version"
class="w-full max-w-full"
@@ -68,12 +69,7 @@
<script setup lang="ts">
import { DownloadIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
injectNotificationManager,
NewModal,
TeleportDropdownMenu,
} from '@modrinth/ui'
import { ButtonStyled, Combobox, injectNotificationManager, NewModal } from '@modrinth/ui'
import { ModrinthServersFetchError } from '@modrinth/utils'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
@@ -96,7 +92,7 @@ const emit = defineEmits<{
const modal = ref()
const hardReset = ref(false)
const isLoading = ref(false)
const selectedVersion = ref('')
const selectedVersion = ref(props.currentVersion?.version_number || '')
const versionOptions = computed(() => props.versions?.map((v) => v.version_number) || [])

View File

@@ -54,11 +54,12 @@
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="text-lg font-bold text-contrast">Minecraft version</div>
<TeleportDropdownMenu
<Combobox
v-model="selectedMCVersion"
name="mcVersion"
:options="mcVersions"
class="w-full max-w-[100%]"
:options="mcVersions.map((v) => ({ value: v, label: v }))"
:display-value="selectedMCVersion || 'Select Minecraft version...'"
class="!w-full"
placeholder="Select Minecraft version..."
/>
<div class="mt-2 flex items-center justify-between gap-2">
@@ -108,10 +109,17 @@
</div>
</template>
<template v-else-if="selectedLoaderVersions.length > 0">
<TeleportDropdownMenu
<Combobox
v-model="selectedLoaderVersion"
name="loaderVersion"
:options="selectedLoaderVersions"
:options="selectedLoaderVersions.map((v) => ({ value: v, label: v }))"
:display-value="
selectedLoaderVersion ||
(selectedLoader.toLowerCase() === 'paper' ||
selectedLoader.toLowerCase() === 'purpur'
? 'Select build number...'
: 'Select loader version...')
"
class="w-full max-w-[100%]"
:placeholder="
selectedLoader.toLowerCase() === 'paper' ||
@@ -201,9 +209,9 @@ import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from '@modrinth/asset
import {
BackupWarning,
ButtonStyled,
Combobox,
injectNotificationManager,
NewModal,
TeleportDropdownMenu,
Toggle,
} from '@modrinth/ui'
import { type Loaders, ModrinthServersFetchError } from '@modrinth/utils'
@@ -433,7 +441,7 @@ onMounted(() => {
fetchLoaderVersions()
})
const tags = useTags()
const tags = useGeneratedState()
const mcVersions = computed(() =>
tags.value.gameVersions
.filter((x) =>

View File

@@ -1,458 +0,0 @@
<template>
<div class="relative inline-block h-9 w-full max-w-80">
<button
ref="triggerRef"
type="button"
aria-haspopup="listbox"
:aria-expanded="dropdownVisible"
:aria-controls="listboxId"
:aria-labelledby="listboxId"
class="duration-50 flex h-full w-full cursor-pointer select-none appearance-none items-center justify-between gap-4 rounded-xl border-none bg-button-bg px-4 py-2 shadow-sm !outline-none transition-all ease-in-out"
:class="triggerClasses"
@click="toggleDropdown"
@keydown="handleTriggerKeyDown"
>
<span>{{ selectedOption }}</span>
<DropdownIcon
class="transition-transform duration-200 ease-in-out"
:class="{ 'rotate-180': dropdownVisible }"
/>
</button>
<Teleport to="#teleports">
<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-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="dropdownVisible"
:id="listboxId"
ref="optionsContainer"
role="listbox"
tabindex="-1"
:aria-activedescendant="activeDescendant"
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg outline-none"
:class="{
'rounded-b-xl': !isRenderingUp,
'rounded-t-xl': isRenderingUp,
}"
:style="positionStyle"
@keydown="handleListboxKeyDown"
>
<div
class="overflow-y-auto"
:style="{ height: `${virtualListHeight}px` }"
@scroll="handleScroll"
>
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
<div
v-for="item in visibleOptions"
:key="item.index"
:style="{
position: 'absolute',
top: 0,
transform: `translateY(${item.index * ITEM_HEIGHT}px)`,
width: '100%',
height: `${ITEM_HEIGHT}px`,
}"
>
<div
:id="`${listboxId}-option-${item.index}`"
role="option"
:aria-selected="selectedValue === item.option"
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out"
:class="{
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
'bg-bg-raised': focusedOptionIndex === item.index,
'rounded-b-xl': item.index === props.options.length - 1 && !isRenderingUp,
'rounded-t-xl': item.index === 0 && isRenderingUp,
}"
@click="selectOption(item.option, item.index)"
@mousemove="focusedOptionIndex = item.index"
>
{{ displayName(item.option) }}
</div>
</div>
</div>
</div>
</div>
</transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets'
import type { CSSProperties } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
const ITEM_HEIGHT = 44
const BUFFER_ITEMS = 5
type OptionValue = string | number | Record<string, any>
interface Props {
options: OptionValue[]
name: string
defaultValue?: OptionValue | null
placeholder?: string | number | null
modelValue?: OptionValue | null
renderUp?: boolean
disabled?: boolean
displayName?: (option: OptionValue) => string
}
const props = withDefaults(defineProps<Props>(), {
defaultValue: null,
placeholder: null,
modelValue: null,
renderUp: false,
disabled: false,
displayName: (option: OptionValue) => String(option),
})
const emit = defineEmits<{
(e: 'input' | 'update:modelValue', value: OptionValue): void
(e: 'change', value: { option: OptionValue; index: number }): void
}>()
const dropdownVisible = ref(false)
const selectedValue = ref<OptionValue | null>(props.modelValue || props.defaultValue)
const focusedOptionIndex = ref<number | null>(null)
const optionsContainer = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const isRenderingUp = ref(false)
const virtualListHeight = ref(300)
const isOpen = ref(false)
const openDropdownCount = ref(0)
const listboxId = `pyro-listbox-${Math.random().toString(36).substring(2, 11)}`
const triggerRef = ref<HTMLButtonElement | null>(null)
const positionStyle = ref<CSSProperties>({
position: 'fixed',
top: '0px',
left: '0px',
width: '0px',
zIndex: 999,
})
const totalHeight = computed(() => props.options.length * ITEM_HEIGHT)
const visibleOptions = computed(() => {
const startIndex = Math.floor(scrollTop.value / ITEM_HEIGHT) - BUFFER_ITEMS
const visibleCount = Math.ceil(virtualListHeight.value / ITEM_HEIGHT) + 2 * BUFFER_ITEMS
return Array.from({ length: visibleCount }, (_, i) => {
const index = startIndex + i
if (index >= 0 && index < props.options.length) {
return {
index,
option: props.options[index],
}
}
return null
}).filter((item): item is { index: number; option: OptionValue } => item !== null)
})
const selectedOption = computed(() => {
if (selectedValue.value !== null && selectedValue.value !== undefined) {
return props.displayName(selectedValue.value as OptionValue)
}
return props.placeholder || 'Select an option'
})
const radioValue = computed<OptionValue>({
get() {
return props.modelValue ?? selectedValue.value ?? ''
},
set(newValue: OptionValue) {
emit('update:modelValue', newValue)
selectedValue.value = newValue
},
})
const triggerClasses = computed(() => ({
'!cursor-not-allowed opacity-50 grayscale': props.disabled,
'rounded-b-none': dropdownVisible.value && !isRenderingUp.value && !props.disabled,
'rounded-t-none': dropdownVisible.value && isRenderingUp.value && !props.disabled,
}))
const updatePosition = async () => {
if (!triggerRef.value) return
await nextTick()
const triggerRect = triggerRef.value.getBoundingClientRect()
const viewportHeight = window.innerHeight
const margin = 8
const contentHeight = props.options.length * ITEM_HEIGHT
const preferredHeight = Math.min(contentHeight, 300)
const spaceBelow = viewportHeight - triggerRect.bottom
const spaceAbove = triggerRect.top
isRenderingUp.value = spaceBelow < preferredHeight && spaceAbove > spaceBelow
virtualListHeight.value = isRenderingUp.value
? Math.min(spaceAbove - margin, preferredHeight)
: Math.min(spaceBelow - margin, preferredHeight)
positionStyle.value = {
position: 'fixed',
left: `${triggerRect.left}px`,
width: `${triggerRect.width}px`,
zIndex: 999,
...(isRenderingUp.value
? { bottom: `${viewportHeight - triggerRect.top}px`, top: 'auto' }
: { top: `${triggerRect.bottom}px`, bottom: 'auto' }),
}
}
const toggleDropdown = () => {
if (!props.disabled) {
if (dropdownVisible.value) {
closeDropdown()
} else {
openDropdown()
}
}
}
const handleResize = () => {
if (dropdownVisible.value) {
requestAnimationFrame(() => {
updatePosition()
})
}
}
const handleScroll = (event: Event) => {
const target = event.target as HTMLElement
scrollTop.value = target.scrollTop
}
const closeAllDropdowns = () => {
const event = new CustomEvent('close-all-dropdowns')
window.dispatchEvent(event)
}
const selectOption = (option: OptionValue, index: number) => {
radioValue.value = option
emit('change', { option, index })
closeDropdown()
}
const focusNextOption = () => {
if (focusedOptionIndex.value === null) {
focusedOptionIndex.value = 0
} else {
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length
}
scrollToFocused()
}
const focusPreviousOption = () => {
if (focusedOptionIndex.value === null) {
focusedOptionIndex.value = props.options.length - 1
} else {
focusedOptionIndex.value =
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length
}
scrollToFocused()
}
const scrollToFocused = () => {
if (focusedOptionIndex.value === null) return
const optionsElement = optionsContainer.value?.querySelector('.overflow-y-auto')
if (!optionsElement) return
const targetScrollTop = focusedOptionIndex.value * ITEM_HEIGHT
const scrollBottom = optionsElement.clientHeight
if (targetScrollTop < optionsElement.scrollTop) {
optionsElement.scrollTop = targetScrollTop
} else if (targetScrollTop + ITEM_HEIGHT > optionsElement.scrollTop + scrollBottom) {
optionsElement.scrollTop = targetScrollTop - scrollBottom + ITEM_HEIGHT
}
}
const openDropdown = async () => {
if (!props.disabled) {
closeAllDropdowns()
dropdownVisible.value = true
isOpen.value = true
openDropdownCount.value++
document.body.style.overflow = 'hidden'
await updatePosition()
nextTick(() => {
optionsContainer.value?.focus()
})
}
}
const closeDropdown = () => {
if (isOpen.value) {
dropdownVisible.value = false
isOpen.value = false
openDropdownCount.value--
if (openDropdownCount.value === 0) {
document.body.style.overflow = ''
}
focusedOptionIndex.value = null
triggerRef.value?.focus()
}
}
const handleTriggerKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowDown':
case 'ArrowUp':
event.preventDefault()
if (!dropdownVisible.value) {
openDropdown()
focusedOptionIndex.value = event.key === 'ArrowUp' ? props.options.length - 1 : 0
} else if (event.key === 'ArrowDown') {
focusNextOption()
} else {
focusPreviousOption()
}
break
case 'Enter':
case ' ':
event.preventDefault()
if (!dropdownVisible.value) {
openDropdown()
focusedOptionIndex.value = 0
} else if (focusedOptionIndex.value !== null) {
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value)
}
break
case 'Escape':
event.preventDefault()
closeDropdown()
break
case 'Tab':
if (dropdownVisible.value) {
event.preventDefault()
}
break
}
}
const handleListboxKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault()
if (focusedOptionIndex.value !== null) {
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value)
}
break
case 'ArrowDown':
event.preventDefault()
focusNextOption()
break
case 'ArrowUp':
event.preventDefault()
focusPreviousOption()
break
case 'Escape':
event.preventDefault()
closeDropdown()
break
case 'Tab':
event.preventDefault()
break
case 'Home':
event.preventDefault()
focusedOptionIndex.value = 0
scrollToFocused()
break
case 'End':
event.preventDefault()
focusedOptionIndex.value = props.options.length - 1
scrollToFocused()
break
default:
if (event.key.length === 1) {
const char = event.key.toLowerCase()
const index = props.options.findIndex((option) =>
props.displayName(option).toLowerCase().startsWith(char),
)
if (index !== -1) {
focusedOptionIndex.value = index
scrollToFocused()
}
}
break
}
}
onMounted(() => {
window.addEventListener('resize', handleResize)
window.addEventListener('scroll', handleResize, true)
window.addEventListener('click', (event) => {
if (!isChildOfDropdown(event.target as HTMLElement)) {
closeDropdown()
}
})
window.addEventListener('close-all-dropdowns', closeDropdown)
if (selectedValue.value) {
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
}
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
window.removeEventListener('scroll', handleResize, true)
window.removeEventListener('click', (event) => {
if (!isChildOfDropdown(event.target as HTMLElement)) {
closeDropdown()
}
})
window.removeEventListener('close-all-dropdowns', closeDropdown)
if (isOpen.value) {
openDropdownCount.value--
if (openDropdownCount.value === 0) {
document.body.style.overflow = ''
}
}
})
watch(
() => props.modelValue,
(newValue) => {
selectedValue.value = newValue
},
)
watch(dropdownVisible, async (newValue) => {
if (newValue) {
await updatePosition()
scrollTop.value = 0
}
})
const activeDescendant = computed(() =>
focusedOptionIndex.value !== null ? `${listboxId}-option-${focusedOptionIndex.value}` : undefined,
)
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
let currentNode: HTMLElement | null = element
while (currentNode) {
if (currentNode === triggerRef.value || currentNode === optionsContainer.value) {
return true
}
currentNode = currentNode.parentElement
}
return false
}
</script>