You've already forked AstralRinth
forked from didirus/AstralRinth
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:
@@ -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'
|
||||
|
||||
@@ -90,7 +90,7 @@ defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const tags = useTags()
|
||||
const tags = useGeneratedState()
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.environment {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
@@ -212,7 +212,7 @@ export default {
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const tags = useTags()
|
||||
const tags = useGeneratedState()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
return { tags, formatRelativeTime }
|
||||
|
||||
@@ -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>
|
||||
149
apps/frontend/src/components/ui/dashboard/RevenueInputField.vue
Normal file
149
apps/frontend/src/components/ui/dashboard/RevenueInputField.vue
Normal 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>
|
||||
124
apps/frontend/src/components/ui/dashboard/RevenueTransaction.vue
Normal file
124
apps/frontend/src/components/ui/dashboard/RevenueTransaction.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -26,7 +26,7 @@ export default {
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const tags = useTags()
|
||||
const tags = useGeneratedState()
|
||||
|
||||
return { tags }
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) || [])
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user