feat: creator revenue page overhaul (#4204)

* feat: start on tax compliance

* feat: avarala1099 composable

* fix: shouldShow should be managed on the page itself

* refactor: move show logic to revenue page

* feat: security practices rather than info

* feat: withdraw page lock

* fix: empty modal bug & lint issues

* feat: hide behind feature flag

* Use standard admonition components, make casing consistent

* modal title

* lint

* feat: withdrawal check

* feat: tax cap on withdrawals warning

* feat: start on revenue page overhaul

* feat: segment generation for bar

* feat: tooltips and links

* fix: tooltip border

* feat: finish initial layout, start on withdraw modal

* feat: start on withdrawal limit stage

* feat: shade support for primary colors

* feat: start on withdraw details stage

* fix: convert swatches to hex

* feat: payout method/region dropdown temporarily using multiselect

* feat: fix modal open issues and use teleport dropdowns

* feat: hide transactions section if there are no transactions

* refactor: NavStack surfaces

* feat: new dropdown component

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

* fix: lint

* refactor: dashboard sidebar layout

* feat: cleanup

* fix: niche bugs

* fix: ComboBox styling

* feat: first part of qa

* feat: animate flash rather than tooltip

* fix: lint

* feat: qa border gradient

* fix: seg hover flashes

* feat: i18n

* feat: i18n and final QA

* fix: lint

* feat: QA

* fix: lint

* fix: merge conflicts

* fix: intl

* fix: blue hover

* fix: transfers page

* feat: surface variables & gradients

* feat: text vars

* fix: lint

* fix: intl

* feat: stages

* fix: lint

* feat: region selection

* feat: method selection btns

* fix: flex col on transactions

* feat: hook up method selection to ctx

* feat: muralpay kyc stage info

* wip: muralpay integration

* Basic Mural Pay API bindings

* Fix clippy

* use dotenvy in muralpay example

* Refactor payout creation code

* wip: muralpay payout requests

* Mural Pay payouts work

* Fix clippy

* feat: progress

* fix: broken tax form stage logic

* polish: tax form stage and method selection stage layout

* add mural pay fees API

* Work on payout fee API

* Fees API for more payment methods

* Fix CI

* polish: muralpay qa

* refactor: clean up combobox component

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

* Temporarily disable Venmo and PayPal methods from frontend

* polish: clean up transaction component & page

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

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

* wip: counterparties

* Start on counterparties and payment methods API

* polish: combobox component

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

* fix: lint

* polish: various QA fixes

* feat: hook up with backend (wip)

* feat: draft muralpay rails dynamic logic

* polish: modify rails to support backend changes

* Mural Pay multiple methods when fetching

* Don't send supported_countries to frontend

* Mural Pay multiple methods when fetching

* Don't send supported_countries to frontend

* feat: fees & methods endpoint hookup

* chore: remove duplicates fix

* polish: qa changes + figma match

* Add countries to muralpay fiat methods

* Compile fix

* Add exchange rate info to fees endpoint

* Add fees to premium Tremendous options

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

* feat: tremendous

* fix: lint & i18n

* feat: reintroduce tin mismatch logic to index.vue

* polish: qa

* fix: i18n

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

* fix: lint

* fix: jsdoc

* feat: checkbox for reward program terms

* Add delivery email field to Tremendous payouts

* Add Tremendous product category to payout methods

* Add bank details API to muralpay

* Fix CI

* Fix CI

* polish: qa changes

* feat: i18n pass

* feat: deduplicate methods endpoint & fix i18n issues

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

* fix: lint

* fix: i18n

* feat: estimates

* polish: more QA

* Remove prepaid visa, compute fees properly for Tremendous methods

* Add more details to Tremendous errors

* feat: withdraw endpoint impl & internals refactor

* Add more details to Tremendous errors

* feat: completion stage

* Add fees to Mural

* feat: transactions page match figma

* fix: i18n

* polish: QA changes

* polish: qa

* Payout history route and bank details

* polish: autofill and requirements checks

* fix: i18n + lint

* fix: fiat rail fees

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

* feat: simplify action btn logic & tax form error

* fix: tax -> Tax form

* Re-add legacy PayPal/Venmo options for US

* feat: mobile responsiveness fixes for modal

* fix: responsiveness issues

* feat: navstack responsiveness

* fix: responsiveness

* move the mural bank details route

* fix: generated state cleanup & bank details input

* fix: lint & i18n

* Add utoipa support to payout endpoints

* address some PR comments

* polish: qa

* add CORS to new utoipa routes

* feat: legacy paypal/venmo stage

* polish: reset amount on back qa

* revert: navstack mr changes

* polish: loading indicator on method selection stage

* fix: paypal modal doesnt reopen after auth

* fix: lint & i18n

* fix: paypal flow

* polish: qa changes

* fix: gitignore

* polish: qa fixes

* fix: payouts_available in payouts.rs

* fix: bug when limit is zero

* polish: qa changes

* fix: qa stuff & muralpay sub-division fix

* Immediately approve mural payouts

* Add currency support to Tremendous payouts

* Currency forex

* add forex to tremendous fee request

* polish: qa & currency support for paypal tremendous

* polish: fx qa

* feat: demo mode flag

* fix: i18n & padding issues

* polish: qa changes

* fix: ml

* Add Mural balance to bank balance info

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

* Add more Tremendous currencies support

* fix: colors on balance bars

* fix: empty states

* fix: pl-8 mobile issue

* fix: hide see all

* Transaction payouts available use the correct date

* Address my own review comment

* Address PR comments

* Change Mural withdrawal limit to 3k

* fix: empty state + paypal warning

* maybe fix tremendous gift cards

* Change how Mural minimum withdrawals are calculated

* Tweak min/max withdrawal values

* fix: segment brightness

* fix: min & max for muralpay & legacy paypal

* Fix some icon issues

* more issues

* fix user menu

* fix: remove + network

---------

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

View File

@@ -15,8 +15,8 @@ import {
ButtonStyled,
Checkbox,
Chips,
Combobox,
injectNotificationManager,
TeleportDropdownMenu,
} from '@modrinth/ui'
import {
formatCategory,
@@ -158,6 +158,21 @@ const selectableGameVersionNumbers = computed(() => {
.map((x) => x.version)
})
const gameVersionOptions = computed(() =>
(selectableGameVersionNumbers.value ?? []).map((v) => ({ value: v, label: v })),
)
const loaderVersionOptions = computed(() =>
(selectableLoaderVersions.value ?? []).map((opt, index) => ({ value: index, label: opt.id })),
)
const loaderVersionLabel = computed(() => {
const idx = loaderVersionIndex.value
return idx >= 0 && selectableLoaderVersions.value
? selectableLoaderVersions.value[idx]?.id
: 'Select version'
})
const selectableLoaderVersions: ComputedRef<ManifestLoaderVersion[] | undefined> = computed(() => {
if (gameVersion.value) {
if (loader.value === 'fabric') {
@@ -647,11 +662,11 @@ const messages = defineMessages({
{{ formatMessage(messages.gameVersion) }}
</h2>
<div class="flex flex-wrap mt-2 gap-2">
<TeleportDropdownMenu
<Combobox
v-if="selectableGameVersionNumbers !== undefined"
v-model="gameVersion"
:options="selectableGameVersionNumbers"
name="Game Version Dropdown"
:options="gameVersionOptions"
:display-value="gameVersion || formatMessage(messages.unknownVersion)"
/>
<Checkbox
v-if="hasSnapshots"
@@ -663,14 +678,13 @@ const messages = defineMessages({
<h2 class="m-0 mt-4 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.loaderVersion, { loader: formatCategory(loader) }) }}
</h2>
<TeleportDropdownMenu
<Combobox
v-if="selectableLoaderVersions"
:model-value="selectableLoaderVersions[loaderVersionIndex]"
:options="selectableLoaderVersions"
:display-name="(option: ManifestLoaderVersion) => option?.id"
v-model="loaderVersionIndex"
:options="loaderVersionOptions"
:display-value="loaderVersionLabel"
name="Version selector"
class="mt-2"
@change="(value) => (loaderVersionIndex = value.index)"
/>
<div v-else class="mt-2 text-brand-red flex gap-2 items-center">
<IssuesIcon />

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
import { Combobox, ThemeSelector, Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
@@ -50,7 +50,7 @@ watch(
:model-value="themeStore.advancedRendering"
@update:model-value="
(e) => {
themeStore.advancedRendering = e
themeStore.advancedRendering = !!e
settings.advanced_rendering = themeStore.advancedRendering
}
"
@@ -86,12 +86,13 @@ watch(
<h2 class="m-0 text-lg font-extrabold text-contrast">Default landing page</h2>
<p class="m-0 mt-1">Change the page to which the launcher opens on.</p>
</div>
<TeleportDropdownMenu
<Combobox
id="opening-page"
v-model="settings.default_page"
name="Opening page dropdown"
class="w-40"
:options="['Home', 'Library']"
:options="['Home', 'Library'].map((v) => ({ value: v, label: v }))"
:display-value="settings.default_page ?? 'Select an option'"
/>
</div>
@@ -122,7 +123,7 @@ watch(
:model-value="settings.toggle_sidebar"
@update:model-value="
(e) => {
settings.toggle_sidebar = e
settings.toggle_sidebar = !!e
themeStore.toggleSidebar = settings.toggle_sidebar
}
"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { TeleportDropdownMenu } from '@modrinth/ui'
import { Combobox } from '@modrinth/ui'
import { defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import type { ServerPackStatus } from '@/helpers/worlds.ts'
@@ -74,12 +74,19 @@ defineExpose({ resourcePackOptions })
{{ formatMessage(messages.resourcePack) }}
</h2>
<div>
<TeleportDropdownMenu
<Combobox
v-model="resourcePack"
:options="resourcePackOptions"
:options="
resourcePackOptions.map((o) => ({
value: o,
label: formatMessage(resourcePackOptionMessages[o]),
}))
"
name="Server resource pack"
:display-name="
(option: ServerPackStatus) => formatMessage(resourcePackOptionMessages[option])
:display-value="
resourcePack
? formatMessage(resourcePackOptionMessages[resourcePack])
: 'Select an option'
"
/>
</div>

View File

@@ -7,10 +7,16 @@ import { promises as fs } from 'fs'
import { globIterate } from 'glob'
import { defineNuxtConfig } from 'nuxt/config'
import { $fetch } from 'ofetch'
import Papa from 'papaparse'
import { basename, relative, resolve } from 'pathe'
import svgLoader from 'vite-svg-loader'
import type { GeneratedState } from './src/composables/generated'
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
// ISO 3166 data from https://github.com/ipregistry/iso3166
// Licensed under CC BY-SA 4.0
const ISO3166_REPO = 'https://raw.githubusercontent.com/ipregistry/iso3166/master'
const preloadedFonts = [
'inter/Inter-Regular.woff2',
@@ -133,20 +139,7 @@ export default defineNuxtConfig({
// 30 minutes
const TTL = 30 * 60 * 1000
let state: {
lastGenerated?: string
apiUrl?: string
categories?: any[]
loaders?: any[]
gameVersions?: any[]
donationPlatforms?: any[]
reportTypes?: any[]
homePageProjects?: any[]
homePageSearch?: any[]
homePageNotifs?: any[]
products?: any[]
errors?: number[]
} = {}
let state: Partial<GeneratedState> = {}
try {
state = JSON.parse(await fs.readFile('./src/generated/state.json', 'utf8'))
@@ -200,6 +193,9 @@ export default defineNuxtConfig({
homePageSearch,
homePageNotifs,
products,
muralBankDetails,
countriesCSV,
subdivisionsCSV,
] = await Promise.all([
$fetch(`${API_URL}tag/category`, headers).catch((err) => handleFetchError(err, [])),
$fetch(`${API_URL}tag/loader`, headers).catch((err) => handleFetchError(err, [])),
@@ -220,8 +216,53 @@ export default defineNuxtConfig({
$fetch(`${API_URL.replace('/v2/', '/_internal/')}billing/products`, headers).catch((err) =>
handleFetchError(err, []),
),
$fetch(`${API_URL.replace('/v2/', '/_internal/')}mural/bank-details`, headers).catch(
(err) => handleFetchError(err, null),
),
$fetch<string>(`${ISO3166_REPO}/countries.csv`, {
...headers,
responseType: 'text',
}).catch((err) => handleFetchError(err, '')),
$fetch<string>(`${ISO3166_REPO}/subdivisions.csv`, {
...headers,
responseType: 'text',
}).catch((err) => handleFetchError(err, '')),
])
const countriesData = Papa.parse(countriesCSV, {
header: true,
skipEmptyLines: true,
transformHeader: (header) => (header.startsWith('#') ? header.slice(1) : header),
}).data
const subdivisionsData = Papa.parse(subdivisionsCSV, {
header: true,
skipEmptyLines: true,
transformHeader: (header) => (header.startsWith('#') ? header.slice(1) : header),
}).data
const subdivisionsByCountry = (subdivisionsData as any[]).reduce(
(acc, sub) => {
const countryCode = sub.country_code_alpha2
if (!countryCode || typeof countryCode !== 'string' || countryCode.trim() === '') {
return acc
}
if (!acc[countryCode]) acc[countryCode] = []
acc[countryCode].push({
code: sub['subdivision_code_iso3166-2'],
name: sub.subdivision_name,
localVariant: sub.localVariant || null,
category: sub.category,
parent: sub.parent_subdivision || null,
language: sub.language_code,
})
return acc
},
{} as Record<string, any[]>,
)
state.categories = categories
state.loaders = loaders
state.gameVersions = gameVersions
@@ -231,6 +272,15 @@ export default defineNuxtConfig({
state.homePageSearch = homePageSearch
state.homePageNotifs = homePageNotifs
state.products = products
state.muralBankDetails = muralBankDetails.bankDetails
state.countries = (countriesData as any[]).map((c) => ({
alpha2: c.country_code_alpha2,
alpha3: c.country_code_alpha3,
numeric: c.numeric_code,
nameShort: c.name_short,
nameLong: c.name_long,
}))
state.subdivisions = subdivisionsByCountry
state.errors = [...caughtErrorCodes]
await fs.writeFile('./src/generated/state.json', JSON.stringify(state))
@@ -475,6 +525,12 @@ export default defineNuxtConfig({
'Critical-CH': 'Sec-CH-Prefers-Color-Scheme',
},
},
'/dashboard/revenue/withdraw': {
redirect: {
to: '/dashboard/revenue',
statusCode: 410,
},
},
'/email/**': {
redirect: '/_internal/templates/email/**',
},

View File

@@ -18,6 +18,7 @@
"@nuxt/devtools": "^1.3.3",
"@types/dompurify": "^3.0.5",
"@types/node": "^20.1.0",
"@types/papaparse": "^5.3.15",
"@vintl/compact-number": "^2.0.5",
"@vintl/how-ago": "^3.0.1",
"@vintl/nuxt": "^1.9.2",
@@ -56,10 +57,10 @@
"floating-vue": "^5.2.2",
"fuse.js": "^6.6.2",
"highlight.js": "^11.7.0",
"iso-3166-1": "^2.1.1",
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"markdown-it": "14.1.0",
"papaparse": "^5.4.1",
"pathe": "^1.1.2",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^4.4.1",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,39 @@
import { useGeneratedState } from '@/composables/generated.ts'
import { useRequestHeaders, useState } from '#imports'
export const useCountries = () => {
const generated = useGeneratedState()
return computed(() => generated.value.countries ?? [])
}
export const useFormattedCountries = () => {
const countries = useCountries()
return computed(() =>
countries.value.map((country) => {
let label = country.nameShort
if (country.alpha2 === 'TW') {
label = 'Taiwan'
} else if (country.nameShort.length > 30) {
label = `${country.nameShort} (${country.alpha2})`
}
return {
value: country.alpha2,
label,
}
}),
)
}
export const useSubdivisions = (countryCode: ComputedRef<string> | Ref<string> | string) => {
const generated = useGeneratedState()
const code = isRef(countryCode) ? countryCode : ref(countryCode)
return computed(() => generated.value.subdivisions?.[unref(code)] ?? [])
}
export const useUserCountry = () => {
const country = useState<string>('userCountry', () => 'US')
const fromServer = useState<boolean>('userCountryFromServer', () => false)

View File

@@ -19,6 +19,7 @@ const validateValues = <K extends PropertyKey>(flags: Record<K, FlagValue>) => f
export const DEFAULT_FEATURE_FLAGS = validateValues({
// Developer flags
developerMode: false,
demoMode: false,
showVersionFilesInTable: false,
showAdsWithPlus: false,
alwaysShowChecklistAsPopup: true,

View File

@@ -0,0 +1,146 @@
import generatedState from '~/generated/state.json'
export interface ProjectType {
actual: string
id: string
display: string
}
export interface LoaderData {
pluginLoaders: string[]
pluginPlatformLoaders: string[]
allPluginLoaders: string[]
dataPackLoaders: string[]
modLoaders: string[]
hiddenModLoaders: string[]
}
export interface Country {
alpha2: string
alpha3: string
numeric: string
nameShort: string
nameLong: string
}
export interface Subdivision {
code: string // Full ISO 3166-2 code (e.g., "US-NY")
name: string // Official name in local language
localVariant: string | null // English variant if different
category: string // STATE, PROVINCE, REGION, etc.
parent: string | null // Parent subdivision code
language: string // Language code
}
export interface GeneratedState {
categories: any[]
loaders: any[]
gameVersions: any[]
donationPlatforms: any[]
reportTypes: any[]
muralBankDetails: Record<
string,
{
bankNames: string[]
}
>
countries: Country[]
subdivisions: Record<string, Subdivision[]>
projectTypes: ProjectType[]
loaderData: LoaderData
projectViewModes: string[]
approvedStatuses: string[]
rejectedStatuses: string[]
staffRoles: string[]
homePageProjects?: any[]
homePageSearch?: any
homePageNotifs?: any
products?: any[]
// Metadata
lastGenerated?: string
apiUrl?: string
errors?: number[]
}
/**
* Composable for accessing the complete generated state.
* This includes both fetched data and runtime-defined constants.
*/
export const useGeneratedState = () =>
useState<GeneratedState>('generatedState', () => ({
categories: generatedState.categories ?? [],
loaders: generatedState.loaders ?? [],
gameVersions: generatedState.gameVersions ?? [],
donationPlatforms: generatedState.donationPlatforms ?? [],
reportTypes: generatedState.reportTypes ?? [],
muralBankDetails: generatedState.muralBankDetails ?? null,
countries: generatedState.countries ?? [],
subdivisions: generatedState.subdivisions ?? {},
projectTypes: [
{
actual: 'mod',
id: 'mod',
display: 'mod',
},
{
actual: 'mod',
id: 'plugin',
display: 'plugin',
},
{
actual: 'mod',
id: 'datapack',
display: 'data pack',
},
{
actual: 'shader',
id: 'shader',
display: 'shader',
},
{
actual: 'resourcepack',
id: 'resourcepack',
display: 'resource pack',
},
{
actual: 'modpack',
id: 'modpack',
display: 'modpack',
},
],
loaderData: {
pluginLoaders: ['bukkit', 'spigot', 'paper', 'purpur', 'sponge', 'folia'],
pluginPlatformLoaders: ['bungeecord', 'waterfall', 'velocity'],
allPluginLoaders: [
'bukkit',
'spigot',
'paper',
'purpur',
'sponge',
'bungeecord',
'waterfall',
'velocity',
'folia',
],
dataPackLoaders: ['datapack'],
modLoaders: ['forge', 'fabric', 'quilt', 'liteloader', 'modloader', 'rift', 'neoforge'],
hiddenModLoaders: ['liteloader', 'modloader', 'rift'],
},
projectViewModes: ['list', 'grid', 'gallery'],
approvedStatuses: ['approved', 'archived', 'unlisted', 'private'],
rejectedStatuses: ['rejected', 'withheld'],
staffRoles: ['moderator', 'admin'],
homePageProjects: generatedState.homePageProjects,
homePageSearch: generatedState.homePageSearch,
homePageNotifs: generatedState.homePageNotifs,
products: generatedState.products,
lastGenerated: generatedState.lastGenerated,
apiUrl: generatedState.apiUrl,
errors: generatedState.errors,
}))

View File

@@ -1,64 +0,0 @@
import tags from '~/generated/state.json'
export const useTags = () =>
useState('tags', () => ({
categories: tags.categories,
loaders: tags.loaders,
gameVersions: tags.gameVersions,
donationPlatforms: tags.donationPlatforms,
reportTypes: tags.reportTypes,
projectTypes: [
{
actual: 'mod',
id: 'mod',
display: 'mod',
},
{
actual: 'mod',
id: 'plugin',
display: 'plugin',
},
{
actual: 'mod',
id: 'datapack',
display: 'data pack',
},
{
actual: 'shader',
id: 'shader',
display: 'shader',
},
{
actual: 'resourcepack',
id: 'resourcepack',
display: 'resource pack',
},
{
actual: 'modpack',
id: 'modpack',
display: 'modpack',
},
],
loaderData: {
pluginLoaders: ['bukkit', 'spigot', 'paper', 'purpur', 'sponge', 'folia'],
pluginPlatformLoaders: ['bungeecord', 'waterfall', 'velocity'],
allPluginLoaders: [
'bukkit',
'spigot',
'paper',
'purpur',
'sponge',
'bungeecord',
'waterfall',
'velocity',
'folia',
],
dataPackLoaders: ['datapack'],
modLoaders: ['forge', 'fabric', 'quilt', 'liteloader', 'modloader', 'rift', 'neoforge'],
hiddenModLoaders: ['liteloader', 'modloader', 'rift'],
},
projectViewModes: ['list', 'grid', 'gallery'],
approvedStatuses: ['approved', 'archived', 'unlisted', 'private'],
rejectedStatuses: ['rejected', 'withheld'],
staffRoles: ['moderator', 'admin'],
}))

View File

@@ -3,7 +3,7 @@ export const getProjectTypeForUrl = (type, categories) => {
}
export const getProjectTypeForUrlShorthand = (type, categories, overrideTags) => {
const tags = overrideTags ?? useTags().value
const tags = overrideTags ?? useGeneratedState().value
if (type === 'mod') {
const isMod = categories.some((category) => {
@@ -87,7 +87,7 @@ export function getVersionsToDisplay(project) {
}
export function formatVersionsForDisplay(gameVersions, overrideTags) {
const tags = overrideTags ?? useTags().value
const tags = overrideTags ?? useGeneratedState().value
const inputVersions = gameVersions.slice()
const allVersions = tags.gameVersions.slice()

View File

@@ -552,11 +552,14 @@
<template #notifications>
<BellIcon aria-hidden="true" /> {{ formatMessage(commonMessages.notificationsLabel) }}
</template>
<template #reports>
<ReportIcon aria-hidden="true" /> {{ formatMessage(messages.activeReports) }}
</template>
<template #saved>
<BookmarkIcon aria-hidden="true" /> {{ formatMessage(messages.savedProjects) }}
<LibraryIcon aria-hidden="true" /> {{ formatMessage(commonMessages.collectionsLabel) }}
</template>
<template #servers>
<ServerIcon aria-hidden="true" /> {{ formatMessage(commonMessages.serversLabel) }}
<ServerIcon aria-hidden="true" /> {{ formatMessage(messages.myServers) }}
</template>
<template #plus>
<ArrowBigUpDashIcon aria-hidden="true" />
@@ -750,7 +753,9 @@
<button
class="tab button-animation"
:title="formatMessage(messages.toggleMenu)"
:aria-label="isMobileMenuOpen ? 'Close menu' : 'Open menu'"
:aria-label="
isMobileMenuOpen ? formatMessage(messages.closeMenu) : formatMessage(messages.openMenu)
"
@click="toggleMobileMenu()"
>
<template v-if="!auth.user">
@@ -825,7 +830,9 @@
</template>
</IntlFormatted>
</p>
<p class="m-0">© 2025 Rinth, Inc.</p>
<p class="m-0">
{{ formatMessage(footerMessages.copyright, { year: currentYear }) }}
</p>
</div>
</div>
<div class="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:contents">
@@ -869,7 +876,6 @@ import {
ArrowBigUpDashIcon,
BellIcon,
BlueskyIcon,
BookmarkIcon,
BookTextIcon,
BoxIcon,
BracesIcon,
@@ -1224,6 +1230,22 @@ const messages = defineMessages({
id: 'layout.nav.analytics',
defaultMessage: 'Analytics',
},
activeReports: {
id: 'layout.nav.active-reports',
defaultMessage: 'Active reports',
},
myServers: {
id: 'layout.nav.my-servers',
defaultMessage: 'My servers',
},
openMenu: {
id: 'layout.mobile.open-menu',
defaultMessage: 'Open menu',
},
closeMenu: {
id: 'layout.mobile.close-menu',
defaultMessage: 'Close menu',
},
})
const footerMessages = defineMessages({
@@ -1236,6 +1258,10 @@ const footerMessages = defineMessages({
defaultMessage:
'NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT.',
},
copyright: {
id: 'layout.footer.copyright',
defaultMessage: '© {year} Rinth, Inc.',
},
})
useHead({
@@ -1278,6 +1304,8 @@ useSeoMeta({
const developerModeCounter = ref(0)
const currentYear = new Date().getFullYear()
const isMobileMenuOpen = ref(false)
const isBrowseMenuOpen = ref(false)
const navRoutes = computed(() => [
@@ -1320,14 +1348,6 @@ const userMenuOptions = computed(() => {
color: 'purple',
shown: !flags.value.hidePlusPromoInUserMenu && !isPermission(auth.value.user.badges, 1 << 0),
},
{
id: 'notifications',
link: '/dashboard/notifications',
},
{
id: 'saved',
link: '/dashboard/collections',
},
{
id: 'servers',
link: '/servers/manage',
@@ -1349,6 +1369,21 @@ const userMenuOptions = computed(() => {
{
divider: true,
},
{
id: 'notifications',
link: '/dashboard/notifications',
},
{
id: 'reports',
link: '/dashboard/reports',
},
{
id: 'saved',
link: '/dashboard/collections',
},
{
divider: true,
},
{
id: 'projects',
link: '/dashboard/projects',
@@ -1357,6 +1392,10 @@ const userMenuOptions = computed(() => {
id: 'organizations',
link: '/dashboard/organizations',
},
{
id: 'analytics',
link: '/dashboard/analytics',
},
{
id: 'affiliate-links',
link: '/dashboard/affiliate-links',
@@ -1366,10 +1405,6 @@ const userMenuOptions = computed(() => {
id: 'revenue',
link: '/dashboard/revenue',
},
{
id: 'analytics',
link: '/dashboard/analytics',
},
]
options = [

View File

@@ -635,6 +635,291 @@
"dashboard.creator-tax-form-modal.us-citizen.question": {
"message": "Are you a US citizen?"
},
"dashboard.creator-withdraw-modal.complete-tax-form": {
"message": "Complete tax form"
},
"dashboard.creator-withdraw-modal.continue-with-limit": {
"message": "Continue with limit"
},
"dashboard.creator-withdraw-modal.details-label": {
"message": "Details"
},
"dashboard.creator-withdraw-modal.fee-breakdown-amount": {
"message": "Amount"
},
"dashboard.creator-withdraw-modal.fee-breakdown-exchange-rate": {
"message": "FX rate"
},
"dashboard.creator-withdraw-modal.fee-breakdown-fee": {
"message": "Fee"
},
"dashboard.creator-withdraw-modal.fee-breakdown-net-amount": {
"message": "Net amount"
},
"dashboard.creator-withdraw-modal.kyc.business-entity": {
"message": "Business entity"
},
"dashboard.creator-withdraw-modal.kyc.entity-description": {
"message": "A business entity refers to a registered organization such as a corporation, partnership, or LLC."
},
"dashboard.creator-withdraw-modal.kyc.entity-question": {
"message": "Are you a withdrawing as an individual or business?"
},
"dashboard.creator-withdraw-modal.kyc.private-individual": {
"message": "Private individual"
},
"dashboard.creator-withdraw-modal.method-selection.country-placeholder": {
"message": "Select your country"
},
"dashboard.creator-withdraw-modal.method-selection.country-search-placeholder": {
"message": "Search countries..."
},
"dashboard.creator-withdraw-modal.method-selection.error-text": {
"message": "Unable to fetch available payment methods. Please try again later."
},
"dashboard.creator-withdraw-modal.method-selection.error-title": {
"message": "Failed to load payment methods"
},
"dashboard.creator-withdraw-modal.method-selection.region": {
"message": "Region"
},
"dashboard.creator-withdraw-modal.method-selection.region-tooltip": {
"message": "Some payout methods are not available in certain regions."
},
"dashboard.creator-withdraw-modal.method-selection.select-method": {
"message": "Select withdraw method"
},
"dashboard.creator-withdraw-modal.method-selection.tax-limit-warning": {
"message": "Your withdraw limit is <b>{amount}</b>, <tax-link>complete a tax form</tax-link> to withdraw more."
},
"dashboard.creator-withdraw-modal.muralpay-details.account-owner": {
"message": "Account owner"
},
"dashboard.creator-withdraw-modal.muralpay-details.coin": {
"message": "Coin"
},
"dashboard.creator-withdraw-modal.muralpay-details.crypto-warning-header": {
"message": "Confirm your wallet address"
},
"dashboard.creator-withdraw-modal.muralpay-details.document-number-national-id": {
"message": "National ID Number"
},
"dashboard.creator-withdraw-modal.muralpay-details.document-number-national-id-placeholder": {
"message": "Enter national ID number"
},
"dashboard.creator-withdraw-modal.muralpay-details.document-number-passport": {
"message": "Passport Number"
},
"dashboard.creator-withdraw-modal.muralpay-details.document-number-passport-placeholder": {
"message": "Enter passport number"
},
"dashboard.creator-withdraw-modal.muralpay-details.document-number-resident-id": {
"message": "Resident ID Number"
},
"dashboard.creator-withdraw-modal.muralpay-details.document-number-resident-id-placeholder": {
"message": "Enter resident ID number"
},
"dashboard.creator-withdraw-modal.muralpay-details.document-number-ruc": {
"message": "RUC Number"
},
"dashboard.creator-withdraw-modal.muralpay-details.document-number-ruc-placeholder": {
"message": "Enter RUC number"
},
"dashboard.creator-withdraw-modal.muralpay-details.document-number-tax-id": {
"message": "Tax ID Number"
},
"dashboard.creator-withdraw-modal.muralpay-details.document-number-tax-id-placeholder": {
"message": "Enter tax ID number"
},
"dashboard.creator-withdraw-modal.muralpay-details.network": {
"message": "Network"
},
"dashboard.creator-withdraw-modal.nearing-threshold": {
"message": "You're nearing the withdraw threshold. You can withdraw <b>{amountRemaining}</b> now, but a tax form is required for more."
},
"dashboard.creator-withdraw-modal.paypal-details.account": {
"message": "Account"
},
"dashboard.creator-withdraw-modal.paypal-details.disconnect-account": {
"message": "Disconnect account"
},
"dashboard.creator-withdraw-modal.paypal-details.payment-method": {
"message": "Payment method"
},
"dashboard.creator-withdraw-modal.paypal-details.paypal-account": {
"message": "PayPal account"
},
"dashboard.creator-withdraw-modal.paypal-details.paypal-auth-description": {
"message": "Connect your PayPal account to receive payments directly."
},
"dashboard.creator-withdraw-modal.paypal-details.save-button": {
"message": "Save"
},
"dashboard.creator-withdraw-modal.paypal-details.save-success": {
"message": "Venmo handle saved successfully!"
},
"dashboard.creator-withdraw-modal.paypal-details.saved-button": {
"message": "Saved"
},
"dashboard.creator-withdraw-modal.paypal-details.sign-in-with-paypal": {
"message": "Sign in with PayPal"
},
"dashboard.creator-withdraw-modal.paypal-details.venmo-description": {
"message": "Enter your Venmo handle to receive payments."
},
"dashboard.creator-withdraw-modal.paypal-details.venmo-handle": {
"message": "Venmo handle"
},
"dashboard.creator-withdraw-modal.paypal-details.venmo-handle-placeholder": {
"message": "@username"
},
"dashboard.creator-withdraw-modal.stage.completion": {
"message": "Complete"
},
"dashboard.creator-withdraw-modal.stage.method-selection": {
"message": "Method"
},
"dashboard.creator-withdraw-modal.stage.muralpay-details": {
"message": "Account Details"
},
"dashboard.creator-withdraw-modal.stage.muralpay-kyc": {
"message": "Verification"
},
"dashboard.creator-withdraw-modal.stage.tax-form": {
"message": "Tax form"
},
"dashboard.creator-withdraw-modal.stage.tremendous-details": {
"message": "Details"
},
"dashboard.creator-withdraw-modal.tax-form-required.body": {
"message": "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."
},
"dashboard.creator-withdraw-modal.tax-form-required.body-with-limit": {
"message": "You must complete a W-9 or W-8 form for Modrinth's tax records so we remain compliant with tax regulations."
},
"dashboard.creator-withdraw-modal.tax-form-required.header": {
"message": "Tax form required"
},
"dashboard.creator-withdraw-modal.tremendous-details.payment-method": {
"message": "Payment method"
},
"dashboard.creator-withdraw-modal.tremendous-details.reward": {
"message": "Reward"
},
"dashboard.creator-withdraw-modal.tremendous-details.reward-placeholder": {
"message": "Select reward"
},
"dashboard.creator-withdraw-modal.tremendous-details.reward-plural": {
"message": "Rewards"
},
"dashboard.creator-withdraw-modal.tremendous-details.unverified-email-header": {
"message": "Unverified email"
},
"dashboard.creator-withdraw-modal.tremendous-details.unverified-email-message": {
"message": "The delivery email you have entered is not associated with your Modrinth account. Modrinth cannot recover rewards sent to an incorrect email address."
},
"dashboard.creator-withdraw-modal.tremendous-details.usd-paypal-warning-header": {
"message": "Lower fees available"
},
"dashboard.creator-withdraw-modal.tremendous-details.usd-paypal-warning-message": {
"message": "You selected USD for PayPal International. <direct-paypal-link>Switch to direct PayPal</direct-paypal-link> for better fees (≈2% instead of ≈6%)."
},
"dashboard.creator-withdraw-modal.withdraw-button": {
"message": "Withdraw"
},
"dashboard.creator-withdraw-modal.withdraw-limit": {
"message": "Withdraw limit"
},
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
"message": "You've used up your <b>{withdrawLimit}</b> withdrawal limit. You must complete a tax form to withdraw more."
},
"dashboard.revenue.available-now": {
"message": "Available now"
},
"dashboard.revenue.balance": {
"message": "Balance"
},
"dashboard.revenue.estimated-tooltip.msg1": {
"message": "Estimated revenue may be subject to change until it is made available."
},
"dashboard.revenue.estimated-tooltip.msg2": {
"message": "Click to read about how Modrinth handles your revenue."
},
"dashboard.revenue.estimated-with-date": {
"message": "Estimated {date}"
},
"dashboard.revenue.processing": {
"message": "Processing"
},
"dashboard.revenue.processing.tooltip": {
"message": "Revenue stays in processing until the end of the month, then becomes available 60 days later."
},
"dashboard.revenue.tos": {
"message": "By uploading projects to Modrinth and withdrawing money from your account, you agree to our <terms-link>Rewards Program Terms</terms-link>. Learn more about the <info-link>Reward Program</info-link>."
},
"dashboard.revenue.transactions.header": {
"message": "Transactions"
},
"dashboard.revenue.transactions.none": {
"message": "No transactions"
},
"dashboard.revenue.transactions.none.desc": {
"message": "Your payouts and withdrawals will appear here."
},
"dashboard.revenue.transactions.see-all": {
"message": "See all"
},
"dashboard.revenue.withdraw.blocked-tin-mismatch": {
"message": "Your withdrawals are temporarily locked because your TIN or SSN didn't match IRS records. Please contact support to reset and resubmit your tax form."
},
"dashboard.revenue.withdraw.card.description": {
"message": "Withdraw from your available balance to any payout method."
},
"dashboard.revenue.withdraw.card.title": {
"message": "Withdraw"
},
"dashboard.revenue.withdraw.header": {
"message": "Withdraw"
},
"dashboard.withdraw.completion.account": {
"message": "Account"
},
"dashboard.withdraw.completion.amount": {
"message": "Amount"
},
"dashboard.withdraw.completion.close-button": {
"message": "Close"
},
"dashboard.withdraw.completion.date": {
"message": "Date"
},
"dashboard.withdraw.completion.email-confirmation": {
"message": "You'll receive an email at <b>{email}</b> with instructions to redeem your withdrawal."
},
"dashboard.withdraw.completion.exchange-rate": {
"message": "Exchange rate"
},
"dashboard.withdraw.completion.fee": {
"message": "Fee"
},
"dashboard.withdraw.completion.method": {
"message": "Method"
},
"dashboard.withdraw.completion.net-amount": {
"message": "Net amount"
},
"dashboard.withdraw.completion.recipient": {
"message": "Recipient"
},
"dashboard.withdraw.completion.title": {
"message": "Withdraw complete"
},
"dashboard.withdraw.completion.transactions-button": {
"message": "Transactions"
},
"dashboard.withdraw.completion.wallet": {
"message": "Wallet"
},
"error.collection.404.list_item.1": {
"message": "You may have mistyped the collection's URL."
},
@@ -986,6 +1271,9 @@
"layout.footer.about.status": {
"message": "Status"
},
"layout.footer.copyright": {
"message": "© {year} Rinth, Inc."
},
"layout.footer.legal": {
"message": "Legal"
},
@@ -1064,6 +1352,15 @@
"layout.meta.og-description": {
"message": "Discover and publish Minecraft content!"
},
"layout.mobile.close-menu": {
"message": "Close menu"
},
"layout.mobile.open-menu": {
"message": "Open menu"
},
"layout.nav.active-reports": {
"message": "Active reports"
},
"layout.nav.analytics": {
"message": "Analytics"
},
@@ -1091,6 +1388,9 @@
"layout.nav.modrinth-home-page": {
"message": "Modrinth home page"
},
"layout.nav.my-servers": {
"message": "My servers"
},
"layout.nav.organizations": {
"message": "Organizations"
},
@@ -1136,6 +1436,273 @@
"moderation.technical.search.placeholder": {
"message": "Search tech reviews..."
},
"muralpay.account-type.checking": {
"message": "Checking"
},
"muralpay.account-type.savings": {
"message": "Savings"
},
"muralpay.country.at": {
"message": "Austria"
},
"muralpay.country.be": {
"message": "Belgium"
},
"muralpay.country.cy": {
"message": "Cyprus"
},
"muralpay.country.de": {
"message": "Germany"
},
"muralpay.country.ee": {
"message": "Estonia"
},
"muralpay.country.es": {
"message": "Spain"
},
"muralpay.country.fi": {
"message": "Finland"
},
"muralpay.country.fr": {
"message": "France"
},
"muralpay.country.gr": {
"message": "Greece"
},
"muralpay.country.ie": {
"message": "Ireland"
},
"muralpay.country.it": {
"message": "Italy"
},
"muralpay.country.lt": {
"message": "Lithuania"
},
"muralpay.country.lu": {
"message": "Luxembourg"
},
"muralpay.country.lv": {
"message": "Latvia"
},
"muralpay.country.mt": {
"message": "Malta"
},
"muralpay.country.nl": {
"message": "Netherlands"
},
"muralpay.country.pt": {
"message": "Portugal"
},
"muralpay.country.sk": {
"message": "Slovakia"
},
"muralpay.document-type.national-id": {
"message": "National ID"
},
"muralpay.document-type.passport": {
"message": "Passport"
},
"muralpay.document-type.resident-id": {
"message": "Resident ID"
},
"muralpay.document-type.ruc": {
"message": "RUC"
},
"muralpay.document-type.tax-id": {
"message": "Tax ID"
},
"muralpay.field.account-number": {
"message": "Account Number"
},
"muralpay.field.account-number-cbu-cvu": {
"message": "Account Number (CBU/CVU)"
},
"muralpay.field.account-number-cci": {
"message": "Account Number (CCI)"
},
"muralpay.field.account-number-type": {
"message": "Account Number Type"
},
"muralpay.field.account-type": {
"message": "Account Type"
},
"muralpay.field.bank-account-number": {
"message": "Account Number"
},
"muralpay.field.branch-code": {
"message": "Branch Code"
},
"muralpay.field.clabe": {
"message": "CLABE"
},
"muralpay.field.country": {
"message": "Country"
},
"muralpay.field.cpf-cnpj": {
"message": "CPF/CNPJ"
},
"muralpay.field.cuit-cuil": {
"message": "CUIT/CUIL"
},
"muralpay.field.document-type": {
"message": "Document Type"
},
"muralpay.field.iban": {
"message": "IBAN"
},
"muralpay.field.phone-number": {
"message": "Phone Number"
},
"muralpay.field.pix-email": {
"message": "PIX Email"
},
"muralpay.field.pix-key-type": {
"message": "PIX Key Type"
},
"muralpay.field.pix-phone": {
"message": "PIX Phone"
},
"muralpay.field.routing-number": {
"message": "Routing Number"
},
"muralpay.field.swift-bic": {
"message": "SWIFT/BIC"
},
"muralpay.field.wallet-address": {
"message": "Wallet Address"
},
"muralpay.help.cbu-cvu": {
"message": "Clave Bancaria Uniforme or Clave Virtual Uniforme"
},
"muralpay.help.cci": {
"message": "Código de Cuenta Interbancaria"
},
"muralpay.help.clabe": {
"message": "Clave Bancaria Estandarizada (Mexican bank account number)"
},
"muralpay.help.cpf-cnpj": {
"message": "Brazilian tax identification number"
},
"muralpay.help.cuit-cuil": {
"message": "Argentine tax ID"
},
"muralpay.help.iban": {
"message": "International Bank Account Number"
},
"muralpay.help.swift-bic": {
"message": "Bank Identifier Code"
},
"muralpay.pix-type.bank-account": {
"message": "Bank Account"
},
"muralpay.pix-type.document": {
"message": "CPF/CNPJ"
},
"muralpay.pix-type.email": {
"message": "Email"
},
"muralpay.pix-type.phone": {
"message": "Phone Number"
},
"muralpay.placeholder.account-number": {
"message": "Enter account number"
},
"muralpay.placeholder.cbu-cvu": {
"message": "Enter CBU or CVU"
},
"muralpay.placeholder.cbu-cvu-type": {
"message": "CBU or CVU"
},
"muralpay.placeholder.cci": {
"message": "Enter 20-digit CCI"
},
"muralpay.placeholder.cuit-cuil": {
"message": "Enter CUIT or CUIL"
},
"muralpay.placeholder.enter-account-number": {
"message": "Enter account number"
},
"muralpay.placeholder.enter-branch-code": {
"message": "Enter branch code"
},
"muralpay.placeholder.enter-clabe": {
"message": "Enter 18-digit CLABE"
},
"muralpay.placeholder.enter-cpf-cnpj": {
"message": "Enter CPF or CNPJ"
},
"muralpay.placeholder.enter-iban": {
"message": "Enter IBAN"
},
"muralpay.placeholder.enter-pix-email": {
"message": "Enter PIX email"
},
"muralpay.placeholder.enter-routing-number": {
"message": "Enter 9-digit routing number"
},
"muralpay.placeholder.enter-swift-bic": {
"message": "Enter SWIFT/BIC code"
},
"muralpay.placeholder.iban-crc": {
"message": "Enter Costa Rican IBAN"
},
"muralpay.placeholder.phone-cop": {
"message": "+57..."
},
"muralpay.placeholder.pix-phone": {
"message": "+55..."
},
"muralpay.placeholder.wallet-address-eth": {
"message": "0x..."
},
"muralpay.rail.fiat-ars.name": {
"message": "Bank Transfer (ARS)"
},
"muralpay.rail.fiat-brl.name": {
"message": "PIX Transfer (BRL)"
},
"muralpay.rail.fiat-clp.name": {
"message": "Bank Transfer (CLP)"
},
"muralpay.rail.fiat-cop.name": {
"message": "Bank Transfer (COP)"
},
"muralpay.rail.fiat-crc.name": {
"message": "Bank Transfer (CRC)"
},
"muralpay.rail.fiat-eur.name": {
"message": "Bank Transfer (EUR)"
},
"muralpay.rail.fiat-mxn.name": {
"message": "Bank Transfer (MXN)"
},
"muralpay.rail.fiat-pen.name": {
"message": "Bank Transfer (PEN)"
},
"muralpay.rail.fiat-usd-peru.name": {
"message": "Bank Transfer (USD - Peru)"
},
"muralpay.rail.fiat-usd.name": {
"message": "Bank Transfer (USD)"
},
"muralpay.rail.fiat-zar.name": {
"message": "Bank Transfer (ZAR)"
},
"muralpay.rail.usdc-base.name": {
"message": "USDC (Base)"
},
"muralpay.rail.usdc-celo.name": {
"message": "USDC (Celo)"
},
"muralpay.rail.usdc-ethereum.name": {
"message": "USDC (Ethereum)"
},
"muralpay.rail.usdc-polygon.name": {
"message": "Crypto (USDC)"
},
"muralpay.warning.wallet-address": {
"message": "Double-check your wallet address. Funds sent to an incorrect address cannot be recovered."
},
"profile.bio.fallback.creator": {
"message": "A Modrinth creator."
},
@@ -1691,18 +2258,6 @@
"report.submit": {
"message": "Submit report"
},
"revenue.transfers.total": {
"message": "You have withdrawn {amount} in total."
},
"revenue.transfers.total.method": {
"message": "You have withdrawn {amount} through {method}."
},
"revenue.transfers.total.year": {
"message": "You have withdrawn {amount} in {year}."
},
"revenue.transfers.total.year_method": {
"message": "You have withdrawn {amount} in {year} through {method}."
},
"scopes.analytics.description": {
"message": "Access your analytics data"
},

View File

@@ -1032,7 +1032,7 @@ const { addNotification } = notifications
const auth = await useAuth()
const user = await useUser()
const tags = useTags()
const tags = useGeneratedState()
const flags = useFeatureFlags()
const cosmetics = useCosmetics()

View File

@@ -112,7 +112,7 @@ useSeoMeta({
const router = useNativeRouter()
const route = useNativeRoute()
const tags = useTags()
const tags = useGeneratedState()
const currentPage = ref(Number(route.query.page ?? 1))
const filteredVersions = computed(() => {

View File

@@ -14,9 +14,9 @@ import {
import { commonMessages, commonProjectSettingsMessages } from '@modrinth/ui'
import type { Project, ProjectV3Partial } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import { computed } from 'vue'
import NavStack from '~/components/ui/NavStack.vue'
import NavStackItem from '~/components/ui/NavStackItem.vue'
const { formatMessage } = useVIntl()
@@ -38,106 +38,85 @@ const members = defineModel<any>('members')
const allMembers = defineModel<any>('allMembers')
const dependencies = defineModel<any>('dependencies')
const organization = defineModel<any>('organization')
const navItems = computed(() => {
const base = `${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
const items = [
{
link: `/${base}/settings`,
label: formatMessage(commonProjectSettingsMessages.general),
icon: InfoIcon,
},
flags.value.newProjectGeneralSettings
? {
link: `/${base}/settings/general`,
label: formatMessage(commonProjectSettingsMessages.general),
badge: formatMessage(commonMessages.newBadge),
icon: InfoIcon,
}
: null,
flags.value.newProjectEnvironmentSettings &&
projectV3.value.project_types.some((type: string) => ['mod', 'modpack'].includes(type))
? {
link: `/${base}/settings/environment`,
label: formatMessage(commonProjectSettingsMessages.environment),
badge: formatMessage(commonMessages.newBadge),
icon: GlobeIcon,
}
: null,
{
link: `/${base}/settings/tags`,
label: formatMessage(commonProjectSettingsMessages.tags),
icon: TagsIcon,
},
{
link: `/${base}/settings/description`,
label: formatMessage(commonProjectSettingsMessages.description),
icon: AlignLeftIcon,
},
{
link: `/${base}/settings/license`,
label: formatMessage(commonProjectSettingsMessages.license),
icon: BookTextIcon,
},
{
link: `/${base}/settings/links`,
label: formatMessage(commonProjectSettingsMessages.links),
icon: LinkIcon,
},
{
link: `/${base}/settings/members`,
label: formatMessage(commonProjectSettingsMessages.members),
icon: UsersIcon,
},
{ type: 'heading', label: formatMessage(commonProjectSettingsMessages.view) },
{
link: `/${base}/settings/analytics`,
label: formatMessage(commonProjectSettingsMessages.analytics),
icon: ChartIcon,
chevron: true,
},
{ type: 'heading', label: formatMessage(commonProjectSettingsMessages.upload) },
{
link: `/${base}/gallery`,
label: formatMessage(commonProjectSettingsMessages.gallery),
icon: ImageIcon,
chevron: true,
},
{
link: `/${base}/versions`,
label: formatMessage(commonProjectSettingsMessages.versions),
icon: VersionIcon,
chevron: true,
},
]
return items.filter(Boolean) as any[]
})
</script>
<template>
<div class="experimental-styles-within grid gap-4 lg:grid-cols-[1fr_3fr]">
<div>
<aside class="universal-card">
<NavStack>
<NavStackItem
:link="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
:label="formatMessage(commonProjectSettingsMessages.general)"
>
<InfoIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
v-if="flags.newProjectGeneralSettings"
:link="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings/general`"
:label="formatMessage(commonProjectSettingsMessages.general)"
:badge="formatMessage(commonMessages.newBadge)"
>
<InfoIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
v-if="
flags.newProjectEnvironmentSettings &&
projectV3.project_types.some((type) => ['mod', 'modpack'].includes(type))
"
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/environment`"
:label="formatMessage(commonProjectSettingsMessages.environment)"
:badge="formatMessage(commonMessages.newBadge)"
>
<GlobeIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/tags`"
:label="formatMessage(commonProjectSettingsMessages.tags)"
>
<TagsIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/description`"
:label="formatMessage(commonProjectSettingsMessages.description)"
>
<AlignLeftIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/license`"
:label="formatMessage(commonProjectSettingsMessages.license)"
>
<BookTextIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/links`"
:label="formatMessage(commonProjectSettingsMessages.links)"
>
<LinkIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/members`"
:label="formatMessage(commonProjectSettingsMessages.members)"
>
<UsersIcon aria-hidden="true" />
</NavStackItem>
<h3>{{ formatMessage(commonProjectSettingsMessages.view) }}</h3>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/analytics`"
:label="formatMessage(commonProjectSettingsMessages.analytics)"
chevron
>
<ChartIcon aria-hidden="true" />
</NavStackItem>
<h3>{{ formatMessage(commonProjectSettingsMessages.upload) }}</h3>
<NavStackItem
:link="`/${project.project_type}/${project.slug ? project.slug : project.id}/gallery`"
:label="formatMessage(commonProjectSettingsMessages.gallery)"
chevron
>
<ImageIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${project.slug ? project.slug : project.id}/versions`"
:label="formatMessage(commonProjectSettingsMessages.versions)"
chevron
>
<VersionIcon aria-hidden="true" />
</NavStackItem>
</NavStack>
</aside>
<NavStack :items="navItems" />
</div>
<div class="min-w-0">
<NuxtPage

View File

@@ -300,7 +300,7 @@ const props = defineProps({
},
})
const tags = useTags()
const tags = useGeneratedState()
const router = useNativeRouter()
const name = ref(props.project.title)

View File

@@ -176,7 +176,7 @@ import { SaveIcon, TriangleAlertIcon } from '@modrinth/assets'
import { commonLinkDomains, isCommonUrl, isDiscordUrl, isLinkShortener } from '@modrinth/moderation'
import { DropdownSelect } from '@modrinth/ui'
const tags = useTags()
const tags = useGeneratedState()
const props = defineProps({
project: {

View File

@@ -161,7 +161,7 @@ interface Props {
patchProject?: (data: any) => void
}
const tags = useTags()
const tags = useGeneratedState()
const props = withDefaults(defineProps<Props>(), {
allMembers: () => [],

View File

@@ -130,7 +130,7 @@ const version = computed(() => {
const route = useNativeRoute()
// const auth = await useAuth();
// const tags = useTags();
// const tags = useGeneratedState();
const versionsListLink = computed(() => {
if (router.options.history.state.back) {

View File

@@ -744,7 +744,7 @@ export default defineNuxtComponent({
const route = useNativeRoute()
const auth = await useAuth()
const tags = useTags()
const tags = useGeneratedState()
const flags = useFeatureFlags()
const path = route.name.split('-')

View File

@@ -226,7 +226,7 @@ const props = defineProps({
},
})
const tags = useTags()
const tags = useGeneratedState()
const flags = useFeatureFlags()
const auth = await useAuth()

View File

@@ -20,11 +20,10 @@
<span class="text-lg font-semibold text-contrast"> Type </span>
<span>Select target to credit.</span>
</label>
<TeleportDropdownMenu
<Combobox
v-model="mode"
:options="modeOptions"
:display-name="(x) => x.name"
name="Type"
placeholder="Select type"
class="max-w-[8rem]"
/>
</div>
@@ -42,7 +41,7 @@
/>
</div>
<div v-if="mode.id === 'nodes'" class="flex flex-col gap-3">
<div v-if="mode === 'nodes'" class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="node-input" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Node hostnames </span>
@@ -77,12 +76,10 @@
<span class="text-lg font-semibold text-contrast"> Region </span>
<span>This will credit all active servers in the region.</span>
</label>
<TeleportDropdownMenu
id="region-select"
<Combobox
v-model="selectedRegion"
:options="regions"
:display-name="(x) => x.display"
name="Region"
placeholder="Select region"
class="max-w-[24rem]"
/>
</div>
@@ -147,10 +144,10 @@
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Combobox,
injectNotificationManager,
NewModal,
TagItem,
TeleportDropdownMenu,
Toggle,
} from '@modrinth/ui'
import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
@@ -168,17 +165,17 @@ const sendEmail = ref(true)
const message = ref('')
const modeOptions = [
{ id: 'nodes', name: 'Nodes' },
{ id: 'region', name: 'Region' },
{ value: 'nodes', label: 'Nodes' },
{ value: 'region', label: 'Region' },
]
const mode = ref(modeOptions[0])
const mode = ref<string>('nodes')
const nodeInput = ref('')
const selectedNodes = ref<string[]>([])
type RegionOpt = { key: string; display: string }
type RegionOpt = { value: string; label: string }
const regions = ref<RegionOpt[]>([])
const selectedRegion = ref<RegionOpt | null>(null)
const selectedRegion = ref<string | null>(null)
const nodeHostnames = ref<string[]>([])
function openBatchModal() {
@@ -209,7 +206,7 @@ function removeNode(v: string) {
const applyDisabled = computed(() => {
if (days.value < 1) return true
if (mode.value.id === 'nodes') return selectedNodes.value.length === 0
if (mode.value === 'nodes') return selectedNodes.value.length === 0
return !selectedRegion.value
})
@@ -218,11 +215,11 @@ async function ensureOverview() {
try {
const data = await useServersFetch<any>('/nodes/overview', { version: 'internal' })
regions.value = (data.regions || []).map((r: any) => ({
key: r.key,
display: `${r.display_name} (${r.key})`,
value: r.key,
label: `${r.display_name} (${r.key})`,
}))
nodeHostnames.value = data.node_hostnames || []
if (!selectedRegion.value && regions.value.length) selectedRegion.value = regions.value[0]
if (!selectedRegion.value && regions.value.length) selectedRegion.value = regions.value[0].value
} catch (err) {
addNotification({ title: 'Failed to load nodes overview', text: String(err), type: 'error' })
}
@@ -231,7 +228,7 @@ async function ensureOverview() {
async function apply() {
try {
const body =
mode.value.id === 'nodes'
mode.value === 'nodes'
? {
nodes: selectedNodes.value.slice(),
days: Math.max(1, Math.floor(days.value)),
@@ -239,7 +236,7 @@ async function apply() {
message: message.value?.trim() || DEFAULT_CREDIT_EMAIL_MESSAGE,
}
: {
region: selectedRegion.value!.key,
region: selectedRegion.value!,
days: Math.max(1, Math.floor(days.value)),
send_email: sendEmail.value,
message: message.value?.trim() || DEFAULT_CREDIT_EMAIL_MESSAGE,

View File

@@ -11,12 +11,12 @@
<span class="text-lg font-semibold text-contrast"> Level </span>
<span>Determines how the notice should be styled.</span>
</label>
<TeleportDropdownMenu
<Combobox
id="level-selector"
v-model="newNoticeLevel"
class="max-w-[10rem]"
:options="levelOptions"
:display-name="(x) => formatMessage(x.name)"
:options="levelOptions.map((x) => ({ value: x, label: formatMessage(x.name) }))"
:display-value="newNoticeLevel ? formatMessage(newNoticeLevel.name) : 'Select level'"
name="Level"
/>
</div>
@@ -264,13 +264,13 @@
import { EditIcon, PlusIcon, SaveIcon, SettingsIcon, TrashIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Combobox,
commonMessages,
CopyCode,
injectNotificationManager,
NewModal,
ServerNotice,
TagItem,
TeleportDropdownMenu,
Toggle,
useRelativeTime,
} from '@modrinth/ui'

View File

@@ -33,27 +33,27 @@
<section class="third-party">
<a class="btn" :href="getAuthUrl('discord', redirectTarget)">
<SSODiscordIcon />
<DiscordColorIcon />
<span>Discord</span>
</a>
<a class="btn" :href="getAuthUrl('github', redirectTarget)">
<SSOGitHubIcon />
<GitHubColorIcon />
<span>GitHub</span>
</a>
<a class="btn" :href="getAuthUrl('microsoft', redirectTarget)">
<SSOMicrosoftIcon />
<MicrosoftColorIcon />
<span>Microsoft</span>
</a>
<a class="btn" :href="getAuthUrl('google', redirectTarget)">
<SSOGoogleIcon />
<GoogleColorIcon />
<span>Google</span>
</a>
<a class="btn" :href="getAuthUrl('steam', redirectTarget)">
<SSOSteamIcon />
<SteamColorIcon />
<span>Steam</span>
</a>
<a class="btn" :href="getAuthUrl('gitlab', redirectTarget)">
<SSOGitLabIcon />
<GitLabColorIcon />
<span>GitLab</span>
</a>
</section>
@@ -130,15 +130,15 @@
<script setup>
import {
DiscordColorIcon,
GitHubColorIcon,
GitLabColorIcon,
GoogleColorIcon,
KeyIcon,
MailIcon,
MicrosoftColorIcon,
RightArrowIcon,
SSODiscordIcon,
SSOGitHubIcon,
SSOGitLabIcon,
SSOGoogleIcon,
SSOMicrosoftIcon,
SSOSteamIcon,
SteamColorIcon,
} from '@modrinth/assets'
import { commonMessages, injectNotificationManager } from '@modrinth/ui'
import { IntlFormatted } from '@vintl/vintl/components'

View File

@@ -4,27 +4,27 @@
<section class="third-party">
<a class="btn discord-btn" :href="getAuthUrl('discord', redirectTarget)">
<SSODiscordIcon />
<DiscordColorIcon />
<span>Discord</span>
</a>
<a class="btn" :href="getAuthUrl('github', redirectTarget)">
<SSOGitHubIcon />
<GitHubColorIcon />
<span>GitHub</span>
</a>
<a class="btn" :href="getAuthUrl('microsoft', redirectTarget)">
<SSOMicrosoftIcon />
<MicrosoftColorIcon />
<span>Microsoft</span>
</a>
<a class="btn" :href="getAuthUrl('google', redirectTarget)">
<SSOGoogleIcon />
<GoogleColorIcon />
<span>Google</span>
</a>
<a class="btn" :href="getAuthUrl('steam', redirectTarget)">
<SSOSteamIcon />
<SteamColorIcon />
<span>Steam</span>
</a>
<a class="btn" :href="getAuthUrl('gitlab', redirectTarget)">
<SSOGitLabIcon />
<GitLabColorIcon />
<span>GitLab</span>
</a>
</section>
@@ -134,15 +134,15 @@
<script setup>
import {
DiscordColorIcon,
GitHubColorIcon,
GitLabColorIcon,
GoogleColorIcon,
KeyIcon,
MailIcon,
MicrosoftColorIcon,
RightArrowIcon,
SSODiscordIcon,
SSOGitHubIcon,
SSOGitLabIcon,
SSOGoogleIcon,
SSOMicrosoftIcon,
SSOSteamIcon,
SteamColorIcon,
UserIcon,
} from '@modrinth/assets'
import { Checkbox, commonMessages, injectNotificationManager } from '@modrinth/ui'

View File

@@ -503,7 +503,7 @@ const data = useNuxtApp()
const route = useNativeRoute()
const auth = await useAuth()
const cosmetics = useCosmetics()
const tags = useTags()
const tags = useGeneratedState()
const isEditing = ref(false)

View File

@@ -1,49 +1,32 @@
<template>
<div class="normal-page">
<div class="normal-page !mt-8">
<div class="normal-page__sidebar">
<aside class="universal-card">
<h1>Dashboard</h1>
<NavStack>
<NavStackItem link="/dashboard" label="Overview">
<DashboardIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/dashboard/notifications" label="Notifications">
<NotificationsIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/dashboard/reports" label="Active reports">
<ReportIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/dashboard/analytics" label="Analytics">
<ChartIcon aria-hidden="true" />
</NavStackItem>
<h3>Manage</h3>
<NavStackItem v-if="true" link="/dashboard/projects" label="Projects">
<ListIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem v-if="true" link="/dashboard/organizations" label="Organizations">
<OrganizationIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
link="/dashboard/collections"
:label="formatMessage(commonMessages.collectionsLabel)"
>
<LibraryIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
v-if="isAffiliate"
link="/dashboard/affiliate-links"
:label="formatMessage(commonMessages.affiliateLinksButton)"
>
<AffiliateIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/dashboard/revenue" label="Revenue">
<CurrencyIcon aria-hidden="true" />
</NavStackItem>
</NavStack>
</aside>
<NavStack
:items="[
{ type: 'heading', label: 'Dashboard' },
{ link: '/dashboard', label: 'Overview', icon: DashboardIcon },
{ link: '/dashboard/notifications', label: 'Notifications', icon: NotificationsIcon },
{ link: '/dashboard/reports', label: 'Active reports', icon: ReportIcon },
{
link: '/dashboard/collections',
label: formatMessage(commonMessages.collectionsLabel),
icon: LibraryIcon,
},
{ type: 'heading', label: 'Creators' },
{ link: '/dashboard/projects', label: 'Projects', icon: ListIcon },
{ link: '/dashboard/organizations', label: 'Organizations', icon: OrganizationIcon },
{ link: '/dashboard/analytics', label: 'Analytics', icon: ChartIcon },
{
link: '/dashboard/affiliates',
label: formatMessage(commonMessages.affiliateLinksButton),
icon: AffiliateIcon,
shown: isAffiliate,
},
{ link: '/dashboard/revenue', label: 'Revenue', icon: CurrencyIcon },
]"
/>
</div>
<div class="normal-page__content">
<div class="normal-page__content mt-4 lg:!mt-0">
<NuxtPage :route="route" />
</div>
</div>
@@ -64,7 +47,6 @@ import { commonMessages } from '@modrinth/ui'
import { type User, UserBadge } from '@modrinth/utils'
import NavStack from '~/components/ui/NavStack.vue'
import NavStackItem from '~/components/ui/NavStackItem.vue'
const auth = (await useAuth()) as Ref<{ user: User | null }>

View File

@@ -1,256 +1,675 @@
<template>
<div class="experimental-styles-within">
<section class="universal-card">
<h2 class="text-2xl">Revenue</h2>
<div class="grid-display">
<div class="grid-display__item">
<div class="label">Available now</div>
<div class="value">
{{ $formatMoney(userBalance.available) }}
</div>
</div>
<div class="grid-display__item">
<div class="label">
Total pending
<nuxt-link
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
class="align-middle text-link"
to="/legal/cmp-info#pending"
>
<UnknownIcon />
</nuxt-link>
</div>
<div class="value">
{{ $formatMoney(userBalance.pending) }}
</div>
</div>
<div class="grid-display__item">
<h3 class="label m-0">
Available soon
<nuxt-link
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
class="align-middle text-link"
to="/legal/cmp-info#pending"
>
<UnknownIcon />
</nuxt-link>
</h3>
<ul class="m-0 list-none p-0">
<li
v-for="date in availableSoonDateKeys"
:key="date"
class="flex items-center justify-between border-0 border-solid border-b-divider p-0 [&:not(:last-child)]:mb-1 [&:not(:last-child)]:border-b-[1px] [&:not(:last-child)]:pb-1"
>
<span
v-tooltip="
availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1
? `Revenue period is ongoing. \nThis amount is not yet finalized.`
: null
"
:class="{
'cursor-help':
availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1,
}"
class="inline-flex items-center gap-1 font-bold"
>
{{ $formatMoney(availableSoonDates[date]) }}
<template
v-if="availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1"
>
<InProgressIcon />
</template>
</span>
<span class="text-sm text-secondary">
{{ formatDate(dayjs(date)) }}
</span>
</li>
</ul>
</div>
<CreatorWithdrawModal
ref="withdrawModal"
:balance="userBalance"
:preloaded-payment-data="preloadedPaymentMethods"
@refresh-data="refreshData"
/>
<div class="mb-20 flex flex-col gap-6 lg:pl-8">
<div class="flex flex-col gap-4 md:gap-5">
<div class="flex flex-col gap-1">
<span class="text-xl font-semibold text-contrast md:text-2xl">{{
formatMessage(messages.balanceLabel)
}}</span>
<span
class="bg-gradient-to-r from-brand-purple via-brand-orange via-20% to-brand-orange bg-clip-text text-3xl font-extrabold text-transparent md:text-4xl"
>{{ formatMoney(grandTotal) }}</span
>
</div>
<div class="input-group mt-4">
<ButtonStyled color="brand">
<nuxt-link
v-if="!(userBalance.available < minWithdraw || blockedByTax)"
to="/dashboard/revenue/withdraw"
>
<TransferIcon /> Withdraw
</nuxt-link>
<button v-else class="disabled"><TransferIcon /> Withdraw</button>
</ButtonStyled>
<ButtonStyled>
<NuxtLink to="/dashboard/revenue/transfers">
<HistoryIcon />
View transfer history
</NuxtLink>
</ButtonStyled>
</div>
<p v-if="blockedByTax" class="text-sm font-bold text-orange">
You have withdrawn over $600 this year. To continue withdrawing, you must complete a tax
form.
</p>
<p class="text-sm text-secondary">
By uploading projects to Modrinth and withdrawing money from your account, you agree to the
<nuxt-link class="text-link" to="/legal/cmp">Rewards Program Terms</nuxt-link>. For more
information on how the rewards system works, see our information page
<nuxt-link class="text-link" to="/legal/cmp-info">here</nuxt-link>.
</p>
</section>
<section class="universal-card">
<h2 class="text-2xl">Payout methods</h2>
<h3>PayPal</h3>
<template v-if="auth.user.auth_providers.includes('paypal')">
<p>
Your PayPal {{ auth.user.payout_data.paypal_country }} account is currently connected with
email
{{ auth.user.payout_data.paypal_address }}
</p>
<ButtonStyled>
<button class="mt-4" @click="handleRemoveAuthProvider('paypal')">
<XIcon />
Disconnect account
</button>
</ButtonStyled>
</template>
<template v-else>
<p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p>
<a :href="`${getAuthUrl('paypal')}&token=${auth.token}`" class="btn mt-4">
<PayPalIcon />
Sign in with PayPal
</a>
</template>
<h3>Tremendous</h3>
<p>
Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email,
visit
<nuxt-link class="text-link" to="/settings/account">here</nuxt-link>.
</p>
<h3>Venmo</h3>
<p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p>
<label class="hidden" for="venmo">Venmo address</label>
<input
id="venmo"
v-model="auth.user.payout_data.venmo_handle"
autocomplete="off"
name="search"
placeholder="@example"
type="search"
/>
<ButtonStyled color="brand">
<button class="mt-4" @click="updateVenmo">
<SaveIcon />
Save information
<div class="flex h-3 w-full gap-2 overflow-hidden rounded-full bg-bg-raised md:h-4">
<div
v-for="(seg, index) in segments"
:key="seg.key"
class="h-full hover:brightness-105"
:style="{ width: seg.widthPct }"
@mouseenter="hoveredSeg = seg.key"
@mouseleave="hoveredSeg = null"
>
<span
class="block h-full w-full transition duration-150"
:class="[
seg.class,
seg.key === 'available' ? 'gradient-border' : '',
index === 0 ? 'rounded-l-full' : '',
index === segments.length - 1 ? 'rounded-r-full' : '',
]"
></span>
</div>
</div>
<div class="flex flex-col">
<div
class="flex flex-row justify-between border-0 !border-b-[2px] border-solid border-button-bg p-1.5 md:p-2"
>
<span class="my-auto flex flex-row items-center gap-2 text-sm leading-none md:text-base"
><span
class="gradient-border my-auto block size-4 rounded-full bg-brand-green md:size-5"
></span>
{{ formatMessage(messages.availableNow) }}</span
>
<span
class="text-base font-semibold text-contrast md:text-lg"
:class="{ '!text-green': hoveredSeg === 'available' }"
>{{ formatMoney(totalAvailable) }}</span
>
</div>
<div
v-for="(date, i) in dateSegments"
:key="date.date"
class="flex flex-row justify-between border-0 !border-b-[2px] border-solid border-button-bg p-1.5 md:p-2"
>
<span class="my-auto flex flex-row items-center gap-2 text-sm leading-none md:text-base">
<span
class="zone--striped-small my-auto block size-4 rounded-full md:size-5"
:class="[date.stripeClass, date.highlightClass]"
></span>
{{
formatMessage(messages.estimatedWithDate, {
date: date.date ? dayjs(date.date).format('MMM D, YYYY') : '',
})
}}
<Tooltip theme="dismissable-prompt" :triggers="['hover', 'focus']" no-auto-focus>
<nuxt-link
class="inline-flex items-center justify-center text-link"
to="/legal/cmp-info#pending"
>
<UnknownIcon class="inline-block size-4 align-middle md:size-5" />
</nuxt-link>
<template #popper>
<div class="w-[250px] font-semibold text-contrast">
{{ formatMessage(messages.estimatedTooltip1) }}
<br /><br />
{{ formatMessage(messages.estimatedTooltip2) }}
</div>
</template>
</Tooltip>
</span>
<span
class="text-base font-semibold text-contrast md:text-lg"
:class="{ [date.textClass]: hoveredSeg === `upcoming-${date.date}-${i}` }"
>{{ formatMoney(date?.amount ?? 0) }}</span
>
</div>
<div class="flex flex-row justify-between p-1.5 md:p-2">
<span class="my-auto flex flex-row items-center gap-2 text-sm leading-none md:text-base">
<span
class="zone--striped-small zone--striped--gray my-auto block size-4 rounded-full bg-button-bg opacity-90 md:size-5"
></span>
{{ formatMessage(messages.processing) }}
<Tooltip theme="dismissable-prompt" :triggers="['hover', 'focus']" no-auto-focus>
<InProgressIcon class="inline-block size-4 align-middle md:size-5" />
<template #popper>
<div class="w-[250px] font-semibold text-contrast">
{{ formatMessage(messages.processingTooltip) }}
</div>
</template>
</Tooltip>
</span>
<span
class="text-base font-semibold text-contrast md:text-lg"
:class="{ '!text-gray': hoveredSeg === 'processing' }"
>{{ formatMoney(processingDate?.amount ?? 0) }}</span
>
</div>
</div>
</div>
<div class="flex flex-col gap-3 md:gap-4">
<span class="text-xl font-semibold text-contrast md:text-2xl">{{
formatMessage(messages.withdrawHeader)
}}</span>
<div class="grid grid-cols-1 gap-x-4 gap-y-2 md:grid-cols-2 lg:grid-cols-3">
<button
class="relative flex flex-col overflow-hidden rounded-2xl bg-gradient-to-r from-green to-green-700 p-4 text-inverted shadow-md transition-all duration-200 hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:brightness-100 md:p-5"
:disabled="hasTinMismatch"
@click="openWithdrawModal"
>
<div class="relative z-10 flex flex-row justify-between">
<span class="text-base font-semibold md:text-lg">{{
formatMessage(messages.withdrawCardTitle)
}}</span>
<ArrowUpRightIcon class="my-auto size-5 md:size-6" />
</div>
<div class="relative z-10 text-left text-sm font-medium">
{{ formatMessage(messages.withdrawCardDescription) }}
</div>
<svg
aria-hidden="true"
class="pointer-events-none absolute bottom-0 right-0 z-0 h-full w-auto"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 266 100"
fill="none"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M319.052 54.2233C319.058 37.6952 315.689 21.3441 309.156 6.19864C302.624 -8.94682 293.07 -22.559 281.094 -33.7816C269.119 -45.0042 254.982 -53.5944 239.573 -59.012C224.164 -64.4295 207.815 -66.5571 191.556 -65.2609C175.297 -63.9648 159.479 -59.2729 145.097 -51.4805C130.715 -43.688 118.08 -32.9636 107.987 -19.9818C97.8942 -6.99995 90.5617 7.95837 86.4509 23.9523C82.3401 39.9462 81.5398 56.6297 84.1004 72.9533L103.415 67.7101C100.452 45.7823 104.805 23.4811 115.783 4.35031C126.761 -14.7805 143.734 -29.6435 164.005 -37.8768C184.275 -46.1102 206.681 -47.2415 227.661 -41.0911C248.641 -34.9407 266.991 -21.8613 279.797 -3.93146L262.376 6.25239C255.476 -2.83248 246.698 -10.2779 236.659 -15.5617C226.619 -20.8455 215.561 -23.8398 204.26 -24.3345L206.032 -3.60929C217.266 -2.58081 227.949 1.79213 236.737 8.95915C245.524 16.1262 252.024 25.767 255.418 36.6684C258.812 47.5697 258.949 59.2444 255.81 70.223C252.672 81.2017 246.398 90.9937 237.78 98.3668L248.048 116.384C261.575 105.867 271.303 91.124 275.725 74.437C280.146 57.7501 279.016 40.0505 272.507 24.079L289.873 13.9453C295.192 26.0533 298.028 39.1299 298.209 52.3816L319.052 54.2233Z"
fill="#1BD96A"
fill-opacity="0.16"
/>
<path
d="M145.331 -51.0364C159.653 -58.796 175.404 -63.4691 191.595 -64.7598C207.786 -66.0504 224.065 -63.9308 239.41 -58.5361C254.754 -53.1414 268.832 -44.5878 280.757 -33.4124C292.682 -22.2369 302.196 -8.68194 308.702 6.39988C315.134 21.3138 318.483 37.4019 318.551 53.6733L298.696 51.919C298.455 38.7552 295.611 25.7724 290.326 13.7409L290.103 13.2307L289.625 13.5089L272.26 23.6428L271.882 23.8634L272.048 24.2711C278.514 40.1402 279.638 57.7262 275.245 74.3061C270.901 90.7012 261.401 105.207 248.194 115.632L238.415 98.4753C246.945 91.0702 253.16 81.3016 256.287 70.3626C259.453 59.2887 259.315 47.5128 255.892 36.5169C252.469 25.5209 245.912 15.7962 237.048 8.56694C228.292 1.42595 217.67 -2.9636 206.491 -4.06957L204.803 -23.8035C215.835 -23.2382 226.622 -20.2771 236.429 -15.1154L236.43 -15.1156C246.405 -9.8657 255.126 -2.46736 261.982 6.5593L262.247 6.9086L262.624 6.68831L280.046 -3.49542L280.522 -3.7744L280.2 -4.2262C267.329 -22.247 248.885 -35.3926 227.798 -41.5743C206.712 -47.7558 184.193 -46.6194 163.82 -38.3444C143.447 -30.0694 126.388 -15.1307 115.354 4.09694C104.394 23.1968 100.004 45.441 102.865 67.338L84.5078 72.3214C82.0618 56.2426 82.8841 39.8252 86.9313 24.0789C91.0248 8.15219 98.3266 -6.74338 108.377 -19.6706C118.427 -32.5979 131.01 -43.2767 145.331 -51.0364Z"
stroke="#1BD96A"
stroke-opacity="0.12"
/>
<path
d="M260.003 157.491C244.923 166.318 228.106 171.665 210.752 173.15C193.397 174.636 175.935 172.223 159.61 166.084C143.286 159.945 128.503 150.231 116.318 137.637C104.132 125.042 94.8439 109.878 89.1171 93.226L108.448 87.9782C110.395 93.3018 112.784 98.4486 115.59 103.363C118.525 108.52 121.913 113.398 125.713 117.939L140.152 102.814C131.996 92.3742 126.591 80.0086 124.444 66.8751C122.296 53.7416 123.476 40.2699 127.873 27.7219C132.27 15.1739 139.74 3.96017 149.584 -4.86882C159.427 -13.6978 171.322 -19.8532 184.154 -22.7584L185.891 -2.02536C177.437 0.311457 169.624 4.57902 163.052 10.4495C156.48 16.3201 151.323 23.6373 147.979 31.8393C144.634 40.0412 143.191 48.9096 143.759 57.7633C144.327 66.6169 146.892 75.2202 151.257 82.9123C152.243 84.6198 153.258 86.3022 154.382 87.7452L172.854 68.4135L161.573 52.4568L176.638 25.2047L201.398 12.3472L211.468 19.805L202.636 35.5055L192.974 41.7298L187.498 51.6422L193.955 61.0422C193.955 61.0422 203.56 67.0702 203.576 67.0659L213.41 61.3547L218.72 50.9454L233.753 41.2004L241.537 51.0445L230.214 76.6512L204.201 93.4501L187.642 82.5445L169.003 102.096C176.464 107.133 184.988 110.331 193.89 111.432C202.792 112.534 211.826 111.509 220.268 108.44L230.553 126.503C218.179 131.679 204.694 133.531 191.407 131.879C178.121 130.227 165.481 125.128 154.715 117.075L140.327 132.134C153.557 142.488 169.184 149.244 185.722 151.759C202.26 154.274 219.16 152.465 234.815 146.503C250.471 140.542 264.362 130.626 275.169 117.699C285.976 104.771 293.339 89.2611 296.56 72.6427L317.419 74.4794C314.438 91.7283 307.75 108.104 297.828 122.449C287.906 136.794 274.993 148.756 260.003 157.491Z"
fill="#1BD96A"
fill-opacity="0.16"
/>
<path
d="M149.913 -4.49238C159.551 -13.1371 171.169 -19.2006 183.706 -22.1377L185.36 -2.39778C176.987 -0.0177107 169.248 4.24324 162.723 10.0719C156.094 15.9933 150.893 23.3739 147.519 31.6468C144.146 39.9199 142.69 48.8658 143.263 57.7963C143.837 66.7266 146.424 75.4045 150.826 83.1632L150.828 83.1668C151.816 84.8756 152.845 86.5845 153.993 88.0571L154.344 88.5081L154.739 88.0956L173.211 68.7634L173.5 68.4621L173.258 68.1208L162.16 52.4243L176.998 25.5829L201.351 12.9363L210.816 19.9464L202.265 35.1485L192.707 41.3047L192.602 41.373L192.541 41.4842L187.064 51.3967L186.912 51.6708L187.09 51.9298L193.547 61.3299L193.606 61.4157L193.693 61.4702L193.7 61.4744C193.705 61.4773 193.712 61.4815 193.721 61.4871C193.739 61.4985 193.766 61.5158 193.801 61.5377C193.871 61.5819 193.975 61.6461 194.106 61.7285C194.369 61.8933 194.744 62.1297 195.194 62.4122C196.095 62.9772 197.296 63.7303 198.498 64.4836C199.7 65.2368 200.903 65.9902 201.806 66.5549C202.257 66.8371 202.634 67.0726 202.898 67.2372C203.03 67.3195 203.136 67.3844 203.208 67.4289C203.244 67.4509 203.273 67.4687 203.293 67.4811C203.303 67.487 203.312 67.4932 203.321 67.498C203.324 67.5001 203.332 67.5044 203.341 67.5089C203.344 67.5108 203.354 67.516 203.367 67.522C203.375 67.5257 203.397 67.5353 203.411 67.5406C203.444 67.5507 203.59 67.5693 203.705 67.5525L203.767 67.5355L203.823 67.5021L213.656 61.7911L213.783 61.7179L213.851 61.5856L219.099 51.2968L233.644 41.8683L240.959 51.1188L229.821 76.3076L204.202 92.8511L187.912 82.1225L187.569 81.8961L187.285 82.1952L168.646 101.746L168.233 102.18L168.728 102.515C176.254 107.596 184.85 110.821 193.829 111.932C202.671 113.026 211.64 112.038 220.043 109.052L229.836 126.252C217.685 131.229 204.482 132.997 191.468 131.379C178.266 129.738 165.708 124.671 155.01 116.67L154.66 116.409L154.358 116.725L139.97 131.784L139.584 132.189L140.024 132.532C153.321 142.939 169.026 149.729 185.648 152.257C202.269 154.785 219.255 152.966 234.99 146.974C250.724 140.983 264.686 131.017 275.548 118.024C286.313 105.146 293.676 89.7174 296.957 73.1823L316.833 74.9331C313.819 91.9106 307.198 108.026 297.421 122.16C287.541 136.444 274.683 148.357 259.755 157.055L259.754 157.055C244.738 165.845 227.991 171.169 210.71 172.648C193.429 174.128 176.039 171.725 159.783 165.612C143.528 159.499 128.806 149.826 116.672 137.284C104.662 124.872 95.4819 109.951 89.7657 93.5707L108.141 88.5822C109.945 93.4461 112.116 98.1617 114.637 102.686L115.16 103.615C118.11 108.797 121.515 113.7 125.333 118.264L125.689 118.688L126.07 118.289L140.509 103.163L140.811 102.847L140.541 102.501C132.438 92.1288 127.067 79.8422 124.933 66.7929C122.799 53.7434 123.973 40.3579 128.341 27.8902C132.71 15.4225 140.132 4.28013 149.913 -4.49238Z"
stroke="#1BD96A"
stroke-opacity="0.12"
/>
</svg>
</button>
</ButtonStyled>
</section>
</div>
<span v-if="hasTinMismatch" class="text-sm font-semibold text-red">
{{ formatMessage(messages.withdrawBlockedTinMismatch) }}
</span>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2 sm:flex-row sm:justify-between">
<span class="text-xl font-semibold text-contrast md:text-2xl">{{
formatMessage(messages.transactionsHeader)
}}</span>
<nuxt-link
v-if="sortedPayouts.length > 0"
class="mt-0 font-semibold text-contrast underline underline-offset-2 sm:my-auto"
to="/dashboard/revenue/transfers"
>{{ formatMessage(messages.seeAll) }}</nuxt-link
>
</div>
<div v-if="sortedPayouts.length > 0" class="flex flex-col gap-3 md:gap-4">
<RevenueTransaction
v-for="transaction in sortedPayouts.slice(0, 3)"
:key="transaction.id || transaction.created"
:transaction="transaction"
@cancelled="refreshPayouts"
/>
</div>
<div v-else class="mx-auto flex flex-col justify-center p-6 text-center">
<div data-svg-wrapper className="relative w-full">
<svg
width="250"
height="200"
viewBox="0 0 250 200"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="h-auto w-full"
>
<rect width="250" height="200" fill="var(--surface-1)" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M207 65C210.866 65 214 68.134 214 72C214 75.866 210.866 79 207 79H167C170.866 79 174 82.134 174 86C174 89.866 170.866 93 167 93H189C192.866 93 196 96.134 196 100C196 103.866 192.866 107 189 107H178.826C173.952 107 170 110.134 170 114C170 116.577 172 118.911 176 121C179.866 121 183 124.134 183 128C183 131.866 179.866 135 176 135H93C89.134 135 86 131.866 86 128C86 124.134 89.134 121 93 121H54C50.134 121 47 117.866 47 114C47 110.134 50.134 107 54 107H94C97.866 107 101 103.866 101 100C101 96.134 97.866 93 94 93H69C65.134 93 62 89.866 62 86C62 82.134 65.134 79 69 79H109C105.134 79 102 75.866 102 72C102 68.134 105.134 65 109 65H207ZM207 93C210.866 93 214 96.134 214 100C214 103.866 210.866 107 207 107C203.134 107 200 103.866 200 100C200 96.134 203.134 93 207 93Z"
fill="var(--surface-2)"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M153.672 63.9999L162.974 131.843L163.809 138.649C164.079 140.842 162.519 142.837 160.327 143.107L101.767 150.297C99.5739 150.566 97.5781 149.007 97.3089 146.814L88.2931 73.3867C88.1585 72.2904 88.9381 71.2925 90.0345 71.1579C90.0414 71.157 90.0483 71.1562 90.0553 71.1554L94.9136 70.6104M98.8422 70.1698L103.429 69.6552L98.8422 70.1698Z"
fill="var(--surface-1)"
/>
<path
d="M154.91 63.8302C154.817 63.1462 154.186 62.6678 153.502 62.7615C152.818 62.8553 152.34 63.4858 152.433 64.1697L153.672 63.9999L154.91 63.8302ZM162.974 131.843L164.214 131.69C164.214 131.685 164.213 131.679 164.212 131.673L162.974 131.843ZM163.809 138.649L165.05 138.497L163.809 138.649ZM160.327 143.107L160.479 144.347L160.327 143.107ZM101.767 150.297L101.919 151.538L101.767 150.297ZM97.3089 146.814L98.5496 146.662L97.3089 146.814ZM90.0553 71.1554L90.1946 72.3976L90.0553 71.1554ZM95.053 71.8527C95.739 71.7757 96.2328 71.1572 96.1558 70.4711C96.0789 69.7851 95.4603 69.2913 94.7743 69.3682L94.9136 70.6104L95.053 71.8527ZM98.7028 68.9276C98.0168 69.0045 97.523 69.6231 97.5999 70.3091C97.6769 70.9952 98.2954 71.4889 98.9815 71.412L98.8422 70.1698L98.7028 68.9276ZM103.569 70.8974C104.255 70.8205 104.748 70.2019 104.671 69.5159C104.594 68.8298 103.976 68.3361 103.29 68.413L103.429 69.6552L103.569 70.8974ZM153.672 63.9999L152.433 64.1697L161.735 132.012L162.974 131.843L164.212 131.673L154.91 63.8302L153.672 63.9999ZM162.974 131.843L161.733 131.995L162.569 138.801L163.809 138.649L165.05 138.497L164.214 131.69L162.974 131.843ZM163.809 138.649L162.569 138.801C162.754 140.309 161.682 141.681 160.174 141.866L160.327 143.107L160.479 144.347C163.357 143.994 165.404 141.375 165.05 138.497L163.809 138.649ZM160.327 143.107L160.174 141.866L101.614 149.056L101.767 150.297L101.919 151.538L160.479 144.347L160.327 143.107ZM101.767 150.297L101.614 149.056C100.107 149.241 98.7347 148.169 98.5496 146.662L97.3089 146.814L96.0682 146.967C96.4216 149.844 99.041 151.891 101.919 151.538L101.767 150.297ZM97.3089 146.814L98.5496 146.662L89.5338 73.2344L88.2931 73.3867L87.0524 73.539L96.0682 146.967L97.3089 146.814ZM88.2931 73.3867L89.5338 73.2344C89.4833 72.8232 89.7757 72.449 90.1868 72.3986L90.0345 71.1579L89.8821 69.9172C88.1006 70.1359 86.8337 71.7575 87.0524 73.539L88.2931 73.3867ZM90.0345 71.1579L90.1868 72.3986C90.1894 72.3982 90.192 72.3979 90.1946 72.3976L90.0553 71.1554L89.9159 69.9132C89.9046 69.9145 89.8934 69.9158 89.8821 69.9172L90.0345 71.1579ZM90.0553 71.1554L90.1946 72.3976L95.053 71.8527L94.9136 70.6104L94.7743 69.3682L89.9159 69.9132L90.0553 71.1554ZM98.8422 70.1698L98.9815 71.412L103.569 70.8974L103.429 69.6552L103.29 68.413L98.7028 68.9276L98.8422 70.1698Z"
fill="var(--surface-4)"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M151.14 68.2691L159.56 129.753L160.317 135.921C160.561 137.908 159.167 139.714 157.203 139.956L104.761 146.395C102.798 146.636 101.008 145.22 100.764 143.233L92.6142 76.8567C92.4795 75.7603 93.2592 74.7625 94.3555 74.6278L100.843 73.8313"
fill="var(--surface-2)"
/>
<path
d="M110.672 51.25H156.229C156.958 51.25 157.657 51.5393 158.173 52.0547L171.616 65.4902C172.132 66.0059 172.422 66.7053 172.422 67.4346V130C172.422 131.519 171.191 132.75 169.672 132.75H110.672C109.153 132.75 107.922 131.519 107.922 130V54C107.922 52.4812 109.153 51.25 110.672 51.25Z"
fill="var(--surface-1)"
stroke="var(--surface-4)"
stroke-width="2.5"
/>
<path
d="M156.672 52.4028V64C156.672 65.6569 158.015 67 159.672 67H167.605"
stroke="var(--surface-4)"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M118 118H144M118 67H144H118ZM118 79H161H118ZM118 92H161H118ZM118 105H161H118Z"
stroke="var(--surface-3)"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<span class="text-lg text-contrast md:text-xl">{{
formatMessage(messages.noTransactions)
}}</span>
<span class="max-w-[256px] text-lg leading-none text-secondary">{{
formatMessage(messages.noTransactionsDesc)
}}</span>
</div>
</div>
</div>
</template>
<script setup>
import {
HistoryIcon,
InProgressIcon,
PayPalIcon,
SaveIcon,
TransferIcon,
UnknownIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import { formatDate } from '@modrinth/utils'
<script setup lang="ts">
import { ArrowUpRightIcon, InProgressIcon, UnknownIcon } from '@modrinth/assets'
import { formatMoney } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { Tooltip } from 'floating-vue'
import { getAuthUrl, removeAuthProvider } from '~/composables/auth.js'
import { useUserCountry } from '@/composables/country.ts'
import type { PayoutMethod } from '@/providers/creator-withdraw.ts'
import CreatorWithdrawModal from '~/components/ui/dashboard/CreatorWithdrawModal.vue'
import RevenueTransaction from '~/components/ui/dashboard/RevenueTransaction.vue'
const { addNotification, handleError } = injectNotificationManager()
const auth = await useAuth()
const minWithdraw = ref(0.01)
const { formatMessage } = useVIntl()
const { data: userBalance } = await useAsyncData(`payout/balance`, async () => {
const response = await useBaseFetch(`payout/balance`, { apiVersion: 3 })
return {
...response,
available: parseFloat(response.available),
withdrawn_lifetime: parseFloat(response.withdrawn_lifetime),
withdrawn_ytd: parseFloat(response.withdrawn_ytd),
pending: parseFloat(response.pending),
dates: Object.fromEntries(
Object.entries(response.dates).map(([date, value]) => [date, parseFloat(value)]),
),
}
await useAuth()
// TODO: Deduplicate these types & interfaces in @modrinth/api-client PR.
type FormCompletionStatus = 'unknown' | 'unrequested' | 'unsigned' | 'tin-mismatch' | 'complete'
type UserBalanceResponse = {
available: number
withdrawn_lifetime: number
withdrawn_ytd: number
pending: number
// ISO 8601 date string -> amount
dates: Record<string, number>
// backend returns null when not applicable
requested_form_type: string | null
form_completion_status: FormCompletionStatus | null
}
type RevenueBarSegment = {
key: string
class: string
widthPct: string
amount: number
}
const hoveredSeg = ref<string | null>(null)
const withdrawModal = ref<InstanceType<typeof CreatorWithdrawModal>>()
async function openWithdrawModal() {
withdrawModal.value?.show?.()
}
const messages = defineMessages({
balanceLabel: { id: 'dashboard.revenue.balance', defaultMessage: 'Balance' },
availableNow: { id: 'dashboard.revenue.available-now', defaultMessage: 'Available now' },
estimatedWithDate: {
id: 'dashboard.revenue.estimated-with-date',
defaultMessage: 'Estimated {date}',
},
estimatedTooltip1: {
id: 'dashboard.revenue.estimated-tooltip.msg1',
defaultMessage: 'Estimated revenue may be subject to change until it is made available.',
},
estimatedTooltip2: {
id: 'dashboard.revenue.estimated-tooltip.msg2',
defaultMessage: 'Click to read about how Modrinth handles your revenue.',
},
processing: { id: 'dashboard.revenue.processing', defaultMessage: 'Processing' },
processingTooltip: {
id: 'dashboard.revenue.processing.tooltip',
defaultMessage:
'Revenue stays in processing until the end of the month, then becomes available 60 days later.',
},
withdrawHeader: { id: 'dashboard.revenue.withdraw.header', defaultMessage: 'Withdraw' },
withdrawCardTitle: { id: 'dashboard.revenue.withdraw.card.title', defaultMessage: 'Withdraw' },
withdrawCardDescription: {
id: 'dashboard.revenue.withdraw.card.description',
defaultMessage: 'Withdraw from your available balance to any payout method.',
},
withdrawBlockedTinMismatch: {
id: 'dashboard.revenue.withdraw.blocked-tin-mismatch',
defaultMessage:
"Your withdrawals are temporarily locked because your TIN or SSN didn't match IRS records. Please contact support to reset and resubmit your tax form.",
},
tosLabel: {
id: 'dashboard.revenue.tos',
defaultMessage:
'By uploading projects to Modrinth and withdrawing money from your account, you agree to our <terms-link>Rewards Program Terms</terms-link>. Learn more about the <info-link>Reward Program</info-link>.',
},
transactionsHeader: {
id: 'dashboard.revenue.transactions.header',
defaultMessage: 'Transactions',
},
seeAll: { id: 'dashboard.revenue.transactions.see-all', defaultMessage: 'See all' },
noTransactions: {
id: 'dashboard.revenue.transactions.none',
defaultMessage: 'No transactions',
},
noTransactionsDesc: {
id: 'dashboard.revenue.transactions.none.desc',
defaultMessage: 'Your payouts and withdrawals will appear here.',
},
})
const blockedByTax = computed(() => {
const status = userBalance.value?.form_completion_status ?? 'unknown'
const thresholdMet = (userBalance.value?.withdrawn_ytd ?? 0) >= 600
return thresholdMet && status !== 'complete'
})
const { data: userBalance, refresh: refreshUserBalance } = await useAsyncData(
`payout/balance`,
async () => {
const response = (await useBaseFetch(`payout/balance`, {
apiVersion: 3,
})) as UserBalanceResponse
return {
...response,
available: Number(response.available),
withdrawn_lifetime: Number(response.withdrawn_lifetime),
withdrawn_ytd: Number(response.withdrawn_ytd),
pending: Number(response.pending),
}
},
)
const deadlineEnding = computed(() => {
let deadline = dayjs().subtract(2, 'month').endOf('month').add(60, 'days')
if (deadline.isBefore(dayjs().startOf('day'))) {
deadline = dayjs().subtract(1, 'month').endOf('month').add(60, 'days')
}
return deadline
})
const { data: payouts, refresh: refreshPayouts } = await useAsyncData(`payout/history`, () =>
useBaseFetch(`payout/history`, {
apiVersion: 3,
}),
)
async function handleRemoveAuthProvider(provider) {
const userCountry = useUserCountry()
const { data: preloadedPaymentMethods } = await useAsyncData(`payout/methods-preload`, async () => {
const defaultCountry = userCountry.value || 'US'
try {
await removeAuthProvider(provider)
return {
country: defaultCountry,
methods: (await useBaseFetch('payout/methods', {
apiVersion: 3,
query: { country: defaultCountry },
})) as PayoutMethod[],
}
} catch {
return null
}
})
const sortedPayouts = computed(() => {
if (!payouts.value) return []
return [...payouts.value].sort((a, b) => {
return new Date(b.created).getTime() - new Date(a.created).getTime()
})
})
const totalAvailable = computed(() => (userBalance.value ? userBalance.value.available : 0))
const nextDate = computed<{ date: string; amount: number }[]>(() => {
const dates = userBalance.value?.dates
if (!dates) return []
const now = Date.now()
return Object.entries(dates)
.map(([date, amount]) => ({ date, amount: Number(amount) }))
.filter(({ date }) => new Date(date).getTime() > now)
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
})
const processingDate = computed<{ date: string; amount: number }>(() => {
const nextDates = nextDate.value
if (!nextDates.length) return { date: '', amount: 0 }
const now = dayjs()
const currentMonth = now.format('YYYY-MM')
// Find revenue from the current month (still "processing")
// Revenue earned in month X becomes available at end_of_month(X) + 60 days
// So we calculate: source_month = (date_available - 60 days).startOf('month')
for (const { date, amount } of nextDates) {
const availableDate = dayjs(date)
const sourceMonthEnd = availableDate.subtract(60, 'days')
const sourceMonth = sourceMonthEnd.startOf('month').format('YYYY-MM')
// If this revenue is from the current month, it's still "processing"
if (sourceMonth === currentMonth) {
return { date, amount: Number(amount) }
}
}
// No revenue from current month found
return { date: '', amount: 0 }
})
const grandTotal = computed(() =>
userBalance.value ? userBalance.value.available + userBalance.value.pending : 0,
)
const hasTinMismatch = computed(() => {
const bal = userBalance.value
if (!bal) return false
const status = bal.form_completion_status ?? 'unknown'
return status === 'tin-mismatch'
})
async function refreshData() {
try {
await Promise.all([refreshUserBalance(), refreshPayouts()])
} catch (error) {
handleError(error)
console.error('Failed to refresh data:', error)
}
}
const availableSoonDates = computed(() => {
// Get the next 3 dates from userBalance.dates that are from now to the deadline + 4 months to make sure we get all the pending ones.
const dates = Object.keys(userBalance.value.dates)
.filter((date) => {
const dateObj = dayjs(date)
return (
dateObj.isAfter(dayjs()) && dateObj.isBefore(dayjs(deadlineEnding.value).add(4, 'month'))
)
})
.sort((a, b) => dayjs(a).diff(dayjs(b)))
const dateStripeClasses = [
'zone--striped--blue bg-gradient-to-b from-[#4E9CFF] to-[#4181D3]',
'zone--striped--purple bg-gradient-to-b from-[#c084fc] to-[#a855f7]',
'zone--striped--orange bg-gradient-to-b from-[#fb923c] to-[#f97316]',
'zone--striped--red bg-gradient-to-b from-[#f87171] to-[#ef4444]',
] as const
return dates.reduce((acc, date) => {
acc[date] = userBalance.value.dates[date]
return acc
}, {})
const dateHighlightClasses = [
'bg-highlight-blue',
'bg-highlight-purple',
'bg-highlight-orange',
'bg-highlight-red',
] as const
const dateTextClasses = ['!text-brand-blue', '!text-purple', '!text-orange', '!text-red'] as const
const dateSegments = computed(() => {
const dates = nextDate.value
if (!dates?.length)
return [] as Array<{
date: string
amount: number
stripeClass: string
highlightClass: string
textClass: string
}>
const processing = processingDate.value
// Filter out the processing date (current month's revenue)
// Show only finalized pending dates as "Estimated"
const estimatedDates = processing.date ? dates.filter((d) => d.date !== processing.date) : dates
return estimatedDates.map((d, i) => ({
...d,
stripeClass: dateStripeClasses[i % dateStripeClasses.length],
highlightClass: dateHighlightClasses[i % dateHighlightClasses.length],
textClass: dateTextClasses[i % dateTextClasses.length],
}))
})
const availableSoonDateKeys = computed(() => Object.keys(availableSoonDates.value))
const segments = computed<RevenueBarSegment[]>(() => {
const available = totalAvailable.value || 0
const dates = nextDate.value || []
const processing = processingDate.value
async function updateVenmo() {
startLoading()
try {
const data = {
venmo_handle: auth.value.user.payout_data.venmo_handle ?? null,
// Filter out processing date from upcoming dates (same logic as dateSegments)
const upcoming = processing.date ? dates.filter((d) => d.date !== processing.date) : dates
const totalPending = userBalance.value?.pending ?? 0
const total = available + totalPending
if (total <= 0) return [] as RevenueBarSegment[]
const segs: Array<{ key: string; class: string; width: number; amount: number }> = []
if (available > 0) {
segs.push({
key: 'available',
class: 'bg-gradient-to-b from-[#1CD96A] to-[#17B257]',
width: available / total,
amount: available,
})
}
upcoming.forEach((d, i) => {
const amt = Number(d.amount) || 0
if (amt <= 0) return
const stripe = dateStripeClasses[i % dateStripeClasses.length]
const hi = dateHighlightClasses[i % dateHighlightClasses.length]
segs.push({
key: `upcoming-${d.date}-${i}`,
class: `${stripe} ${hi}`,
width: amt / total,
amount: amt,
})
})
// Always show processing section (even if $0)
const processingAmt = Number(processing.amount) || 0
segs.push({
key: 'processing',
class: 'zone--striped--gray bg-button-bg',
width: processingAmt / total,
amount: processingAmt,
})
let acc = 0
const normalized = segs.map((s, idx) => {
let pct = Math.round(s.width * 10000) / 100
if (idx === segs.length - 1) {
pct = Math.max(0, 100 - acc)
}
acc += pct
return { key: s.key, class: s.class, pct, amount: s.amount }
})
const filtered = normalized.filter((s) => s.pct > 0)
if (!filtered.length) return [] as RevenueBarSegment[]
const sumExceptLast = filtered.slice(0, -1).reduce((sum, s) => sum + s.pct, 0)
filtered[filtered.length - 1].pct = Math.max(0, 100 - sumExceptLast)
return filtered.map((s) => ({
key: s.key,
class: s.class,
widthPct: `${s.pct}%`,
amount: s.amount,
})) as RevenueBarSegment[]
})
</script>
<style scoped lang="scss">
%zone--striped-common {
/* Use scroll so stripes remain static relative to element when page scrolls */
background-attachment: scroll;
background-position: 0 0;
background-size: 9.38px 9.38px;
}
@mixin striped-background($color-variable) {
background-image: linear-gradient(
135deg,
$color-variable 11.54%,
transparent 11.54%,
transparent 50%,
$color-variable 50%,
$color-variable 61.54%,
transparent 61.54%,
transparent 100%
);
}
$striped-colors: 'green', 'blue', 'purple', 'orange', 'red';
@each $color in $striped-colors {
.zone--striped--#{$color} {
@include striped-background(var(--color-#{$color}));
@extend %zone--striped-common;
}
}
.zone--striped--gray {
@extend %zone--striped-common;
background-image: linear-gradient(
135deg,
var(--color-divider-dark) 11.54%,
transparent 11.54%,
transparent 50%,
var(--color-divider-dark) 50%,
var(--color-divider-dark) 61.54%,
transparent 61.54%,
transparent 100%
);
}
.zone--striped-small {
background-size: 6.19px 6.19px !important;
background-position: unset !important;
background-attachment: unset !important;
}
$flash-colors: 'green', 'blue', 'purple', 'orange', 'red', 'gray';
@each $color in $flash-colors {
@keyframes flash-#{$color} {
0%,
100% {
color: var(--color-contrast);
}
await useBaseFetch(`user/${auth.value.user.id}`, {
method: 'PATCH',
body: data,
apiVersion: 3,
})
await useAuth(auth.value.token)
} catch (err) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
50% {
color: var(--color-#{$color});
}
}
.animate-flash-#{$color} {
animation: flash-#{$color} 1.5s ease-in-out infinite;
}
.text-#{$color}.animate-flash-color {
animation: flash-#{$color} 1.5s ease-in-out infinite;
}
stopLoading()
}
</script>
<style lang="scss" scoped>
strong {
color: var(--color-text-dark);
font-weight: 500;
}
.grid-display {
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
.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>
<style>
.v-popper--theme-dismissable-prompt .v-popper__inner {
border-color: transparent !important;
}
.v-popper--theme-dismissable-prompt .v-popper__arrow-outer,
.v-popper--theme-dismissable-prompt .v-popper__arrow-inner {
border-color: transparent !important;
}
</style>

View File

@@ -1,228 +1,204 @@
<template>
<div>
<section class="universal-card payout-history">
<Breadcrumbs
current-title="Transfer history"
:link-stack="[{ href: '/dashboard/revenue', label: 'Revenue' }]"
/>
<h2>Transfer history</h2>
<p>All of your withdrawals from your Modrinth balance will be listed here:</p>
<div class="input-group">
<DropdownSelect
v-model="selectedYear"
:options="years"
:display-name="(x) => (x === 'all' ? 'All years' : x)"
name="Year filter"
/>
<DropdownSelect
v-model="selectedMethod"
:options="methods"
:display-name="
(x) => (x === 'all' ? 'Any method' : x === 'paypal' ? 'PayPal' : capitalizeString(x))
"
name="Method filter"
/>
</div>
<p>
{{
selectedYear !== 'all'
? selectedMethod !== 'all'
? formatMessage(messages.transfersTotalYearMethod, {
amount: $formatMoney(totalAmount),
year: selectedYear,
method: selectedMethod,
})
: formatMessage(messages.transfersTotalYear, {
amount: $formatMoney(totalAmount),
year: selectedYear,
})
: selectedMethod !== 'all'
? formatMessage(messages.transfersTotalMethod, {
amount: $formatMoney(totalAmount),
method: selectedMethod,
})
: formatMessage(messages.transfersTotal, {
amount: $formatMoney(totalAmount),
})
}}
</p>
<div class="mb-6 flex flex-col gap-4 p-4 py-0 !pt-4 md:p-8 lg:p-12">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span class="text-xl font-semibold text-contrast md:text-2xl">{{
formatMessage(messages.transactionsHeader)
}}</span>
<div
v-for="payout in filteredPayouts"
:key="payout.id"
class="universal-card recessed payout"
class="flex w-full flex-col gap-2 min-[480px]:flex-row min-[480px]:items-center sm:max-w-[400px]"
>
<div class="platform">
<PayPalIcon v-if="payout.method === 'paypal'" />
<TremendousIcon v-else-if="payout.method === 'tremendous'" />
<VenmoIcon v-else-if="payout.method === 'venmo'" />
<UnknownIcon v-else />
</div>
<div class="payout-info">
<div>
<strong>
{{ $dayjs(payout.created).format('MMMM D, YYYY [at] h:mm A') }}
</strong>
</div>
<div>
<span class="amount">{{ $formatMoney(payout.amount) }}</span>
<template v-if="payout.fee">⋅ Fee {{ $formatMoney(payout.fee) }}</template>
</div>
<div class="payout-status">
<span>
<Badge v-if="payout.status === 'success'" color="green" type="Success" />
<Badge v-else-if="payout.status === 'cancelling'" color="yellow" type="Cancelling" />
<Badge v-else-if="payout.status === 'cancelled'" color="red" type="Cancelled" />
<Badge v-else-if="payout.status === 'failed'" color="red" type="Failed" />
<Badge v-else-if="payout.status === 'in-transit'" color="yellow" type="In transit" />
<Badge v-else :type="payout.status" />
</span>
<template v-if="payout.method">
<span>⋅</span>
<span>{{ formatWallet(payout.method) }} ({{ payout.method_address }})</span>
</template>
</div>
</div>
<div class="input-group">
<button
v-if="payout.status === 'in-transit'"
class="iconified-button raised-button"
@click="cancelPayout(payout.id)"
>
<XIcon /> Cancel payment
</button>
<Combobox
v-model="selectedYear"
:options="yearOptions"
:display-value="selectedYear === 'all' ? 'All years' : String(selectedYear)"
listbox
/>
<Combobox
v-model="selectedMethod"
:options="methodOptions"
:display-value="selectedMethod === 'all' ? 'All types' : formatTypeLabel(selectedMethod)"
listbox
/>
</div>
</div>
<div v-if="Object.keys(groupedTransactions).length > 0" class="flex flex-col gap-5 md:gap-6">
<div
v-for="(transactions, period) in groupedTransactions"
:key="period"
class="flex flex-col"
>
<h3 class="text-sm font-medium text-primary md:text-base">{{ period }}</h3>
<div class="flex flex-col gap-3 md:gap-4">
<RevenueTransaction
v-for="transaction in transactions"
:key="transaction.id || transaction.created"
:transaction="transaction"
@cancelled="refresh"
/>
</div>
</div>
</section>
</div>
<div v-else class="mx-auto flex flex-col justify-center p-6 text-center">
<span class="text-lg text-contrast md:text-xl">{{
formatMessage(messages.noTransactions)
}}</span>
<span class="max-w-[256px] text-base text-secondary md:text-lg">{{
formatMessage(messages.noTransactionsDesc)
}}</span>
</div>
</div>
</template>
<script setup>
import { PayPalIcon, UnknownIcon, XIcon } from '@modrinth/assets'
import { Badge, Breadcrumbs, DropdownSelect, injectNotificationManager } from '@modrinth/ui'
import { capitalizeString, formatWallet } from '@modrinth/utils'
import { Combobox } from '@modrinth/ui'
import { capitalizeString } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import TremendousIcon from '~/assets/images/external/tremendous.svg?component'
import VenmoIcon from '~/assets/images/external/venmo-small.svg?component'
import RevenueTransaction from '~/components/ui/dashboard/RevenueTransaction.vue'
const { addNotification } = injectNotificationManager()
const vintl = useVIntl()
const { formatMessage } = vintl
const { formatMessage } = useVIntl()
useHead({
title: 'Transfer history - Modrinth',
title: 'Transaction history - Modrinth',
})
const auth = await useAuth()
const { data: payouts, refresh } = await useAsyncData(`payout`, () =>
useBaseFetch(`payout`, {
const { data: transactions, refresh } = await useAsyncData(`payout-history`, () =>
useBaseFetch(`payout/history`, {
apiVersion: 3,
}),
)
const sortedPayouts = computed(() =>
(payouts.value ? [...payouts.value] : []).sort((a, b) => dayjs(b.created) - dayjs(a.created)),
const allTransactions = computed(() => {
if (!transactions.value) return []
return transactions.value.map((txn) => ({
...txn,
type: txn.type || (txn.method_type || txn.method ? 'withdrawal' : 'payout_available'),
}))
})
const sortedTransactions = computed(() =>
[...allTransactions.value].sort((a, b) => dayjs(b.created).diff(dayjs(a.created))),
)
const years = computed(() => {
const values = sortedPayouts.value.map((x) => dayjs(x.created).year())
return ['all', ...new Set(values)]
const yearOptions = computed(() => {
const yearSet = new Set(sortedTransactions.value.map((x) => dayjs(x.created).year()))
const yearValues = ['all', ...Array.from(yearSet).sort((a, b) => b - a)]
return yearValues.map((year) => ({
value: year,
label: year === 'all' ? 'All years' : String(year),
}))
})
const selectedYear = ref('all')
const methods = computed(() => {
const values = sortedPayouts.value.filter((x) => x.method).map((x) => x.method)
return ['all', ...new Set(values)]
const methodOptions = computed(() => {
const types = new Set()
sortedTransactions.value.forEach((x) => {
if (x.type === 'payout_available' && x.payout_source) {
types.add(x.payout_source)
} else if (x.type === 'withdrawal' && (x.method_type || x.method)) {
types.add(x.method_type || x.method)
}
})
const typeValues = ['all', ...Array.from(types)]
return typeValues.map((type) => ({
value: type,
label: type === 'all' ? 'All types' : formatTypeLabel(type),
}))
})
const selectedMethod = ref('all')
const filteredPayouts = computed(() =>
sortedPayouts.value
.filter((x) => selectedYear.value === 'all' || dayjs(x.created).year() === selectedYear.value)
.filter((x) => selectedMethod.value === 'all' || x.method === selectedMethod.value),
)
const totalAmount = computed(() =>
filteredPayouts.value.reduce((sum, payout) => sum + payout.amount, 0),
)
async function cancelPayout(id) {
startLoading()
try {
await useBaseFetch(`payout/${id}`, {
method: 'DELETE',
apiVersion: 3,
})
await refresh()
await useAuth(auth.value.token)
} catch (err) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
function formatMethodLabel(method) {
switch (method) {
case 'paypal':
return 'PayPal'
case 'venmo':
return 'Venmo'
case 'tremendous':
return 'Tremendous'
case 'muralpay':
return 'Muralpay'
default:
return capitalizeString(method)
}
stopLoading()
}
function formatTypeLabel(type) {
// Check if it's a payout method (withdrawal)
const payoutMethods = ['paypal', 'venmo', 'tremendous', 'muralpay']
if (payoutMethods.includes(type)) {
return formatMethodLabel(type)
}
// Otherwise it's a payout_source (income), convert snake_case to Title Case
return type
.split('_')
.map((word) => capitalizeString(word))
.join(' ')
}
const filteredTransactions = computed(() =>
sortedTransactions.value
.filter((x) => selectedYear.value === 'all' || dayjs(x.created).year() === selectedYear.value)
.filter((x) => {
if (selectedMethod.value === 'all') return true
// Check if it's an income source
if (x.type === 'payout_available') {
return x.payout_source === selectedMethod.value
}
// Check if it's a withdrawal method
return x.type === 'withdrawal' && (x.method_type || x.method) === selectedMethod.value
}),
)
function getPeriodLabel(date) {
const txnDate = dayjs(date)
const now = dayjs()
if (txnDate.isSame(now, 'month')) {
return 'This month'
} else if (txnDate.isSame(now.subtract(1, 'month'), 'month')) {
return 'Last month'
} else {
return txnDate.format('MMMM YYYY')
}
}
const groupedTransactions = computed(() => {
const groups = {}
filteredTransactions.value.forEach((transaction) => {
const period = getPeriodLabel(transaction.created)
if (!groups[period]) {
groups[period] = []
}
groups[period].push(transaction)
})
return groups
})
const messages = defineMessages({
transfersTotal: {
id: 'revenue.transfers.total',
defaultMessage: 'You have withdrawn {amount} in total.',
transactionsHeader: {
id: 'dashboard.revenue.transactions.header',
defaultMessage: 'Transactions',
},
transfersTotalYear: {
id: 'revenue.transfers.total.year',
defaultMessage: 'You have withdrawn {amount} in {year}.',
noTransactions: {
id: 'dashboard.revenue.transactions.none',
defaultMessage: 'No transactions',
},
transfersTotalMethod: {
id: 'revenue.transfers.total.method',
defaultMessage: 'You have withdrawn {amount} through {method}.',
},
transfersTotalYearMethod: {
id: 'revenue.transfers.total.year_method',
defaultMessage: 'You have withdrawn {amount} in {year} through {method}.',
noTransactionsDesc: {
id: 'dashboard.revenue.transactions.none.desc',
defaultMessage: 'Your payouts and withdrawals will appear here.',
},
})
</script>
<style lang="scss" scoped>
.payout {
display: flex;
flex-direction: column;
gap: 0.5rem;
.platform {
display: flex;
padding: 0.75rem;
background-color: var(--color-raised-bg);
width: fit-content;
height: fit-content;
border-radius: 20rem;
svg {
width: 2rem;
height: 2rem;
}
}
.payout-status {
display: flex;
gap: 0.5ch;
}
.amount {
color: var(--color-heading);
font-weight: 500;
}
@media screen and (min-width: 800px) {
flex-direction: row;
align-items: center;
.input-group {
margin-left: auto;
}
}
}
</style>
<style scoped></style>

View File

@@ -1,630 +0,0 @@
<template>
<CreatorTaxFormModal
ref="taxFormModalRef"
close-button-text="Continue"
@success="onTaxFormSuccess"
@cancelled="onTaxFormCancelled"
/>
<section class="universal-card">
<Breadcrumbs
current-title="Withdraw"
:link-stack="[{ href: '/dashboard/revenue', label: 'Revenue' }]"
/>
<h2>Withdraw</h2>
<h3>Region</h3>
<Multiselect
id="country-multiselect"
v-model="country"
class="country-multiselect"
placeholder="Select country..."
track-by="id"
label="name"
:options="countries"
:searchable="true"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
/>
<h3>Withdraw method</h3>
<div class="iconified-input">
<label class="hidden" for="search">Search</label>
<SearchIcon aria-hidden="true" />
<input
id="search"
v-model="search"
name="search"
placeholder="Search options..."
autocomplete="off"
/>
</div>
<div class="withdraw-options-scroll">
<div class="withdraw-options">
<button
v-for="method in payoutMethods
.filter((x) => x.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) =>
a.type !== 'tremendous'
? -1
: a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
)"
:key="method.id"
class="withdraw-option button-base"
:class="{ selected: selectedMethodId === method.id }"
@click="() => (selectedMethodId = method.id)"
>
<div class="preview" :class="{ 'show-bg': !method.image_url || method.name === 'ACH' }">
<template v-if="method.image_url && method.name !== 'ACH'">
<div class="preview-badges">
<span class="badge">
{{
getRangeOfMethod(method)
.map($formatMoney)
.map((i) => i.replace('.00', ''))
.join('')
}}
</span>
</div>
<img
v-if="method.image_url && method.name !== 'ACH'"
class="preview-img"
:src="method.image_url"
:alt="method.name"
/>
</template>
<div v-else class="placeholder">
<template v-if="method.type === 'venmo'">
<VenmoIcon class="enlarge" />
</template>
<template v-else>
<PayPalIcon v-if="method.type === 'paypal'" />
<span>{{ method.name }}</span>
</template>
</div>
</div>
<div class="label">
<RadioButtonCheckedIcon v-if="selectedMethodId === method.id" class="radio" />
<RadioButtonIcon v-else class="radio" />
<span>{{ method.name }}</span>
</div>
</button>
</div>
</div>
<h3>Amount</h3>
<p>
You are initiating a transfer of your revenue from Modrinth's Creator Monetization Program.
How much of your
<strong>{{ $formatMoney(userBalance.available) }}</strong> balance would you like to transfer
transfer to {{ selectedMethod.name }}?
</p>
<div class="confirmation-input">
<template v-if="selectedMethod.interval.fixed">
<Chips
v-model="amount"
:items="selectedMethod.interval.fixed.values"
:format-label="(val) => '$' + val"
/>
</template>
<template v-else-if="minWithdrawAmount == maxWithdrawAmount">
<div>
<p>
This method has a fixed transfer amount of
<strong>{{ $formatMoney(minWithdrawAmount) }}</strong
>.
</p>
</div>
</template>
<template v-else>
<div>
<p>
This method has a minimum transfer amount of
<strong>{{ $formatMoney(minWithdrawAmount) }}</strong> and a maximum transfer amount of
<strong>{{ $formatMoney(maxWithdrawAmount) }}</strong
>.
</p>
<input
id="confirmation"
v-model="amount"
type="text"
pattern="^\d*(\.\d{0,2})?$"
autocomplete="off"
placeholder="Amount to transfer..."
/>
<p>
You have entered <strong>{{ $formatMoney(parsedAmount) }}</strong> to transfer.
</p>
</div>
</template>
</div>
<p v-if="willTriggerTaxForm" class="font-bold text-orange">
This withdrawal will exceed $600 for the year. You will be prompted to complete a tax form
before proceeding.
</p>
<p v-else-if="blockedByTax" class="font-bold text-orange">
You have withdrawn over $600 this year. To continue withdrawing, you must complete a tax form.
</p>
<div class="confirm-text">
<template v-if="knownErrors.length === 0 && amount">
<Checkbox v-if="fees > 0" v-model="agreedFees" description="Consent to fee">
I acknowledge that an estimated
{{ formatMoney(fees) }} will be deducted from the amount I receive to cover
{{ formatWallet(selectedMethod.type) }} processing fees.
</Checkbox>
<Checkbox v-model="agreedTransfer" description="Confirm transfer">
<template v-if="selectedMethod.type === 'tremendous'">
I confirm that I am initiating a transfer and I will receive further instructions on how
to redeem this payment via email to:
{{ withdrawAccount }}
</template>
<template v-else>
I confirm that I am initiating a transfer to the following
{{ formatWallet(selectedMethod.type) }} account: {{ withdrawAccount }}
</template>
</Checkbox>
<Checkbox v-model="agreedTerms" class="rewards-checkbox">
I agree to the
<nuxt-link to="/legal/cmp" class="text-link">Rewards Program Terms</nuxt-link>
</Checkbox>
</template>
<template v-else>
<span v-for="(error, index) in knownErrors" :key="index" class="invalid">
{{ error }}
</span>
</template>
</div>
<div class="button-group">
<nuxt-link to="/dashboard/revenue" class="iconified-button">
<XIcon />
Cancel
</nuxt-link>
<button
:disabled="
knownErrors.length > 0 ||
!amount ||
!agreedTransfer ||
!agreedTerms ||
(fees > 0 && !agreedFees) ||
blockedByTax
"
class="iconified-button brand-button"
@click="withdraw"
>
<TransferIcon />
Withdraw
</button>
</div>
</section>
</template>
<script setup>
import {
PayPalIcon,
RadioButtonCheckedIcon,
RadioButtonIcon,
SearchIcon,
TransferIcon,
XIcon,
} from '@modrinth/assets'
import { Breadcrumbs, Checkbox, Chips, injectNotificationManager } from '@modrinth/ui'
import { formatMoney, formatWallet } from '@modrinth/utils'
import { all } from 'iso-3166-1'
import { Multiselect } from 'vue-multiselect'
import VenmoIcon from '~/assets/images/external/venmo.svg?component'
import CreatorTaxFormModal from '~/components/ui/dashboard/CreatorTaxFormModal.vue'
const { addNotification } = injectNotificationManager()
const auth = await useAuth()
const data = useNuxtApp()
const countries = computed(() =>
all().map((x) => ({
id: x.alpha2,
name: x.alpha2 === 'TW' ? 'Taiwan' : x.country,
})),
)
const search = ref('')
const amount = ref('')
const country = ref(
countries.value.find((x) => x.id === (auth.value.user.payout_data.paypal_region ?? 'US')),
)
const [{ data: userBalance }, { data: payoutMethods, refresh: refreshPayoutMethods }] =
await Promise.all([
useAsyncData(`payout/balance`, async () => {
const response = await useBaseFetch(`payout/balance`, { apiVersion: 3 })
return {
...response,
available: parseFloat(response.available),
withdrawn_lifetime: parseFloat(response.withdrawn_lifetime),
withdrawn_ytd: parseFloat(response.withdrawn_ytd),
pending: parseFloat(response.pending),
dates: Object.fromEntries(
Object.entries(response.dates).map(([date, value]) => [date, parseFloat(value)]),
),
}
}),
useAsyncData(`payout/methods?country=${country.value.id}`, () =>
useBaseFetch(`payout/methods?country=${country.value.id}`, { apiVersion: 3 }),
),
])
const selectedMethodId = ref(payoutMethods.value[0].id)
const selectedMethod = computed(() =>
payoutMethods.value.find((x) => x.id === selectedMethodId.value),
)
const parsedAmount = computed(() => {
const regex = /^\$?(\d*(\.\d{2})?)$/gm
const matches = regex.exec(amount.value)
return matches && matches[1] ? parseFloat(matches[1]) : 0.0
})
const fees = computed(() => {
return Math.min(
Math.max(
selectedMethod.value.fee.min,
selectedMethod.value.fee.percentage * parsedAmount.value,
),
selectedMethod.value.fee.max ?? Number.MAX_VALUE,
)
})
const getIntervalRange = (intervalType) => {
if (!intervalType) {
return []
}
const { min, max, values } = intervalType
if (values) {
const first = values[0]
const last = values.slice(-1)[0]
return first === last ? [first] : [first, last]
}
return min === max ? [min] : [min, max]
}
const getRangeOfMethod = (method) => {
return getIntervalRange(method.interval?.fixed || method.interval?.standard)
}
const maxWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval
return interval?.standard ? interval.standard.max : (interval?.fixed?.values.slice(-1)[0] ?? 0)
})
const minWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval
return interval?.standard ? interval.standard.min : (interval?.fixed?.values?.[0] ?? fees.value)
})
const withdrawAccount = computed(() => {
if (selectedMethod.value.type === 'paypal') {
return auth.value.user.payout_data.paypal_address
} else if (selectedMethod.value.type === 'venmo') {
return auth.value.user.payout_data.venmo_handle
} else {
return auth.value.user.email
}
})
const knownErrors = computed(() => {
const errors = []
if (selectedMethod.value.type === 'paypal' && !auth.value.user.payout_data.paypal_address) {
errors.push('Please link your PayPal account in the dashboard to proceed.')
}
if (selectedMethod.value.type === 'venmo' && !auth.value.user.payout_data.venmo_handle) {
errors.push('Please set your Venmo handle in the dashboard to proceed.')
}
if (selectedMethod.value.type === 'tremendous') {
if (!auth.value.user.email) {
errors.push('Please set your email address in your account settings to proceed.')
}
if (!auth.value.user.email_verified) {
errors.push('Please verify your email address to proceed.')
}
}
if (!parsedAmount.value && amount.value.length > 0) {
errors.push(`${amount.value} is not a valid amount`)
} else if (
parsedAmount.value > userBalance.value.available ||
parsedAmount.value > maxWithdrawAmount.value
) {
const maxAmount = Math.min(userBalance.value.available, maxWithdrawAmount.value)
errors.push(`The amount must be no more than ${data.$formatMoney(maxAmount)}`)
} else if (parsedAmount.value <= fees.value || parsedAmount.value < minWithdrawAmount.value) {
const minAmount = Math.max(fees.value + 0.01, minWithdrawAmount.value)
errors.push(`The amount must be at least ${data.$formatMoney(minAmount)}`)
}
return errors
})
const agreedTransfer = ref(false)
const agreedFees = ref(false)
const agreedTerms = ref(false)
const flags = useFeatureFlags()
const blockedByTax = computed(() => {
if (flags.value.testTaxForm) return true
const status = userBalance.value?.form_completion_status ?? 'unknown'
const thresholdMet = (userBalance.value?.withdrawn_ytd ?? 0) >= 600
return thresholdMet && status !== 'complete'
})
const willTriggerTaxForm = computed(() => {
if (flags.value.testTaxForm) return true
const status = userBalance.value?.form_completion_status ?? 'unknown'
const currentWithdrawn = userBalance.value?.withdrawn_ytd ?? 0
const wouldExceedThreshold = currentWithdrawn + parsedAmount.value >= 600
return wouldExceedThreshold && status !== 'complete' && !blockedByTax.value
})
watch(country, async () => {
await refreshPayoutMethods()
if (payoutMethods.value && payoutMethods.value[0]) {
selectedMethodId.value = payoutMethods.value[0].id
}
})
watch(selectedMethod, () => {
if (selectedMethod.value.interval?.fixed) {
amount.value = selectedMethod.value.interval.fixed.values[0]
}
if (maxWithdrawAmount.value === minWithdrawAmount.value) {
amount.value = maxWithdrawAmount.value
}
agreedTransfer.value = false
agreedFees.value = false
agreedTerms.value = false
})
const taxFormModalRef = ref(null)
const taxFormCancelled = ref(false)
async function performWithdrawal() {
startLoading()
try {
const auth = await useAuth()
await useBaseFetch(`payout`, {
method: 'POST',
body: {
amount: parsedAmount.value,
method: selectedMethod.value.type,
method_id: selectedMethod.value.id,
},
apiVersion: 3,
})
await useAuth(auth.value.token)
await navigateTo('/dashboard/revenue')
addNotification({
title: 'Withdrawal complete',
text:
selectedMethod.value.type === 'tremendous'
? 'An email has been sent to your account with further instructions on how to redeem your payout!'
: `Payment has been sent to your ${formatWallet(selectedMethod.value.type)} account!`,
type: 'success',
})
} catch (err) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
async function withdraw() {
if (willTriggerTaxForm.value) {
taxFormCancelled.value = false
if (taxFormModalRef.value && taxFormModalRef.value.startTaxForm) {
await taxFormModalRef.value.startTaxForm(new MouseEvent('click'))
}
return
}
await performWithdrawal()
}
async function onTaxFormSuccess() {
// Skip balance check if testTaxForm flag is enabled
if (flags.value.testTaxForm) {
await performWithdrawal()
return
}
// Refresh user balance to get updated form completion status
const updatedBalance = await useBaseFetch(`payout/balance`, { apiVersion: 3 })
userBalance.value = updatedBalance
if (updatedBalance?.form_completion_status === 'complete') {
await performWithdrawal()
} else {
addNotification({
title: 'Tax form incomplete',
text: 'You must complete a tax form for this withdrawal.',
type: 'error',
})
}
}
function onTaxFormCancelled() {
taxFormCancelled.value = true
addNotification({
title: 'Withdrawal canceled',
text: 'You must complete a tax form for this withdrawal.',
type: 'error',
})
}
</script>
<style lang="scss" scoped>
.withdraw-options-scroll {
max-height: 460px;
overflow-y: auto;
&::-webkit-scrollbar {
width: var(--gap-md);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-track {
background: var(--color-bg);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-thumb {
background-color: var(--color-raised-bg);
border-radius: var(--radius-lg);
border: 3px solid var(--color-bg);
}
}
.withdraw-options {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: var(--gap-lg);
padding-right: 0.5rem;
@media screen and (min-width: 300px) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (min-width: 600px) {
grid-template-columns: repeat(3, 1fr);
}
}
.withdraw-option {
width: 100%;
border-radius: var(--radius-md);
padding: 0;
overflow: hidden;
border: 1px solid var(--color-divider);
background-color: var(--color-button-bg);
color: var(--color-text);
&.selected {
color: var(--color-contrast);
.label svg {
color: var(--color-brand);
}
}
.preview {
display: flex;
justify-content: center;
aspect-ratio: 30 / 19;
position: relative;
.preview-badges {
// These will float over the image in the bottom right corner
position: absolute;
bottom: 0;
right: 0;
padding: var(--gap-sm) var(--gap-xs);
.badge {
background-color: var(--color-button-bg);
border-radius: var(--radius-xs);
padding: var(--gap-xs) var(--gap-sm);
font-size: var(--font-size-xs);
}
}
&.show-bg {
background-color: var(--color-bg);
}
img {
-webkit-user-drag: none;
-khtml-user-drag: none;
-moz-user-drag: none;
-o-user-drag: none;
user-drag: none;
user-select: none;
width: 100%;
height: auto;
object-fit: cover;
}
.placeholder {
display: flex;
align-items: center;
gap: var(--gap-xs);
svg {
width: 2rem;
height: auto;
}
span {
font-weight: var(--font-weight-bold);
font-size: 2rem;
font-style: italic;
}
.enlarge {
width: auto;
height: 1.5rem;
}
}
}
.label {
display: flex;
align-items: center;
padding: var(--gap-md) var(--gap-lg);
svg {
min-height: 1rem;
min-width: 1rem;
margin-right: 0.5rem;
}
span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
.invalid {
color: var(--color-red);
}
.confirm-text {
margin: var(--spacing-card-md) 0;
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
}
.iconified-input {
margin-bottom: var(--spacing-card-md);
}
.country-multiselect,
.iconified-input {
max-width: 16rem;
}
.rewards-checkbox {
a {
margin-left: 0.5ch;
}
}
</style>

View File

@@ -1,39 +1,21 @@
<template>
<div class="normal-page">
<div class="normal-page__sidebar">
<aside class="universal-card">
<h1>Legal</h1>
<NavStack>
<NavStackItem link="/legal/terms" label="Terms of Use">
<HeartHandshakeIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/legal/rules" label="Content Rules">
<ScaleIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/legal/copyright" label="Copyright Policy">
<CopyrightIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/legal/security" label="Security Notice">
<ShieldIcon aria-hidden="true" />
</NavStackItem>
<h3>Privacy</h3>
<NavStackItem link="/legal/privacy" label="Privacy Policy">
<LockIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/legal/ccpa" label="California Privacy Notice">
<InfoIcon aria-hidden="true" />
</NavStackItem>
<h3>Rewards Program</h3>
<NavStackItem link="/legal/cmp" label="Rewards Program Terms">
<CurrencyIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/legal/cmp-info" label="Rewards Program Info">
<InfoIcon aria-hidden="true" />
</NavStackItem>
</NavStack>
</aside>
<NavStack
:items="[
{ type: 'heading', label: 'Platform' },
{ link: '/legal/terms', label: 'Terms of Use', icon: HeartHandshakeIcon },
{ link: '/legal/rules', label: 'Content Rules', icon: ScaleIcon },
{ link: '/legal/copyright', label: 'Copyright Policy', icon: CopyrightIcon },
{ link: '/legal/security', label: 'Security Notice', icon: ShieldIcon },
{ type: 'heading', label: 'Privacy' },
{ link: '/legal/privacy', label: 'Privacy Policy', icon: LockIcon },
{ link: '/legal/ccpa', label: 'California Privacy Notice', icon: InfoIcon },
{ type: 'heading', label: 'Rewards Program' },
{ link: '/legal/cmp', label: 'Rewards Program Terms', icon: CurrencyIcon },
{ link: '/legal/cmp-info', label: 'Rewards Program Info', icon: InfoIcon },
]"
/>
</div>
<div class="normal-page__content">
<NuxtPage class="universal-card" :route="route" />
@@ -53,7 +35,6 @@ import {
} from '@modrinth/assets'
import NavStack from '~/components/ui/NavStack.vue'
import NavStackItem from '~/components/ui/NavStackItem.vue'
const route = useNativeRoute()
</script>

View File

@@ -7,22 +7,13 @@
<ModalCreation ref="modal_creation" :organization-id="organization.id" />
<template v-if="routeHasSettings">
<div class="normal-page__sidebar">
<div class="universal-card">
<Breadcrumbs
current-title="Settings"
:link-stack="[
{ href: `/dashboard/organizations`, label: 'Organizations' },
{
href: `/organization/${organization.slug}`,
label: organization.name,
allowTrimming: true,
},
]"
/>
<div class="page-header__settings">
<div
class="bg-surface mb-4 flex flex-col rounded-xl border border-solid border-surface-4 p-4"
>
<div class="flex items-center gap-4">
<Avatar size="sm" :src="organization.icon_url" />
<div class="title-section">
<h2 class="settings-title">
<div class="flex flex-col justify-center gap-1">
<h2 class="m-0 text-base">
<nuxt-link :to="`/organization/${organization.slug}/settings`">
{{ organization.name }}
</nuxt-link>
@@ -33,33 +24,32 @@
</span>
</div>
</div>
<h2>Organization settings</h2>
<NavStack>
<NavStackItem :link="`/organization/${organization.slug}/settings`" label="Overview">
<SettingsIcon />
</NavStackItem>
<NavStackItem
:link="`/organization/${organization.slug}/settings/members`"
label="Members"
>
<UsersIcon />
</NavStackItem>
<NavStackItem
:link="`/organization/${organization.slug}/settings/projects`"
label="Projects"
>
<BoxIcon />
</NavStackItem>
<NavStackItem
:link="`/organization/${organization.slug}/settings/analytics`"
label="Analytics"
>
<ChartIcon />
</NavStackItem>
</NavStack>
</div>
<NavStack
:items="[
{
link: `/organization/${organization.slug}/settings`,
label: 'Overview',
icon: SettingsIcon,
},
{
link: `/organization/${organization.slug}/settings/members`,
label: 'Members',
icon: UsersIcon,
},
{
link: `/organization/${organization.slug}/settings/projects`,
label: 'Projects',
icon: BoxIcon,
},
{
link: `/organization/${organization.slug}/settings/analytics`,
label: 'Analytics',
icon: ChartIcon,
},
]"
/>
</div>
<div class="normal-page__content">
<NuxtPage />
@@ -271,14 +261,7 @@ import {
UsersIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
Breadcrumbs,
ButtonStyled,
commonMessages,
ContentPageHeader,
OverflowMenu,
} from '@modrinth/ui'
import { Avatar, ButtonStyled, commonMessages, ContentPageHeader, OverflowMenu } from '@modrinth/ui'
import type { Organization, ProjectStatus, ProjectType, ProjectV3 } from '@modrinth/utils'
import { formatNumber } from '@modrinth/utils'
@@ -286,7 +269,6 @@ import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
import NavStack from '~/components/ui/NavStack.vue'
import NavStackItem from '~/components/ui/NavStackItem.vue'
import NavTabs from '~/components/ui/NavTabs.vue'
import ProjectCard from '~/components/ui/ProjectCard.vue'
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
@@ -306,7 +288,7 @@ const user = await useUser()
const cosmetics = useCosmetics()
const route = useNativeRoute()
const router = useRouter()
const tags = useTags()
const tags = useGeneratedState()
const config = useRuntimeConfig()
const orgId = useRouteId()

View File

@@ -298,7 +298,7 @@ import { useImageUpload } from '~/composables/image-upload.ts'
const { addNotification } = injectNotificationManager()
const tags = useTags()
const tags = useGeneratedState()
const route = useNativeRoute()
const router = useRouter()

View File

@@ -353,7 +353,7 @@ const route = useNativeRoute()
const router = useNativeRouter()
const cosmetics = useCosmetics()
const tags = useTags()
const tags = useGeneratedState()
const flags = useFeatureFlags()
const auth = await useAuth()

View File

@@ -77,13 +77,13 @@
v-if="overrides[index] && overrides[index].type === 'dropdown'"
class="mt-2 flex w-full sm:w-[320px] sm:justify-end"
>
<TeleportDropdownMenu
<Combobox
:id="`server-property-${index}`"
v-model="liveProperties[index]"
:name="formatPropertyName(index)"
:options="overrides[index].options || []"
:options="(overrides[index].options || []).map((v) => ({ value: v, label: v }))"
:aria-labelledby="`property-label-${index}`"
placeholder="Select..."
:display-value="String(liveProperties[index] ?? 'Select...')"
/>
</div>
<div v-else-if="typeof property === 'boolean'" class="flex justify-end">
@@ -159,7 +159,7 @@
<script setup lang="ts">
import { EyeIcon, IssuesIcon, SearchIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, TeleportDropdownMenu } from '@modrinth/ui'
import { ButtonStyled, Combobox, injectNotificationManager } from '@modrinth/ui'
import Fuse from 'fuse.js'
import { computed, inject, ref, watch } from 'vue'
@@ -171,7 +171,7 @@ const props = defineProps<{
server: ModrinthServer
}>()
const tags = useTags()
const tags = useGeneratedState()
const isUpdating = ref(false)

View File

@@ -77,12 +77,12 @@
/>
<label for="show-all-versions" class="text-sm">Show all Java versions</label>
</div>
<TeleportDropdownMenu
<Combobox
:id="'java-version-field'"
v-model="jdkVersion"
name="java-version"
:options="displayedJavaVersions"
placeholder="Java Version"
:options="displayedJavaVersions.map((v) => ({ value: v, label: v }))"
:display-value="jdkVersion ?? 'Java Version'"
/>
</div>
<div class="flex flex-col gap-4">
@@ -90,12 +90,12 @@
<span class="text-lg font-bold text-contrast">Runtime</span>
<span> The Java runtime your server will use. </span>
</div>
<TeleportDropdownMenu
<Combobox
:id="'runtime-field'"
v-model="jdkBuild"
name="runtime"
:options="['Corretto', 'Temurin', 'GraalVM']"
placeholder="Runtime"
:options="['Corretto', 'Temurin', 'GraalVM'].map((v) => ({ value: v, label: v }))"
:display-value="jdkBuild ?? 'Runtime'"
/>
</div>
</div>
@@ -113,7 +113,7 @@
<script setup lang="ts">
import { IssuesIcon, UpdatedIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, TeleportDropdownMenu } from '@modrinth/ui'
import { ButtonStyled, Combobox, injectNotificationManager } from '@modrinth/ui'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'

View File

@@ -5,75 +5,79 @@
</div>
<div class="normal-page">
<div class="normal-page__sidebar">
<aside class="universal-card">
<NavStack>
<h3>Display</h3>
<NavStackItem
link="/settings"
:label="formatMessage(commonSettingsMessages.appearance)"
>
<PaintbrushIcon />
</NavStackItem>
<NavStackItem
v-if="isStaging"
:badge="`${formatMessage(commonMessages.beta)}`"
link="/settings/language"
:label="formatMessage(commonSettingsMessages.language)"
>
<LanguagesIcon />
</NavStackItem>
<template v-if="auth.user">
<h3>Account</h3>
<NavStackItem
link="/settings/profile"
:label="formatMessage(commonSettingsMessages.profile)"
>
<UserIcon />
</NavStackItem>
<NavStackItem
link="/settings/account"
:label="formatMessage(commonSettingsMessages.account)"
>
<ShieldIcon />
</NavStackItem>
<NavStackItem
link="/settings/authorizations"
:label="formatMessage(commonSettingsMessages.authorizedApps)"
>
<GridIcon />
</NavStackItem>
<NavStackItem
link="/settings/sessions"
:label="formatMessage(commonSettingsMessages.sessions)"
>
<MonitorSmartphoneIcon />
</NavStackItem>
<NavStackItem
link="/settings/billing"
:label="formatMessage(commonSettingsMessages.billing)"
>
<CardIcon />
</NavStackItem>
</template>
<template v-if="auth.user">
<h3>Developer</h3>
<NavStackItem
link="/settings/pats"
:label="formatMessage(commonSettingsMessages.pats)"
>
<KeyIcon />
</NavStackItem>
<NavStackItem
link="/settings/applications"
:label="formatMessage(commonSettingsMessages.applications)"
>
<ServerIcon />
</NavStackItem>
</template>
</NavStack>
</aside>
<NavStack
:items="
[
{ type: 'heading', label: 'Display' },
{
link: '/settings',
label: formatMessage(commonSettingsMessages.appearance),
icon: PaintbrushIcon,
},
isStaging
? {
link: '/settings/language',
label: formatMessage(commonSettingsMessages.language),
icon: LanguagesIcon,
badge: `${formatMessage(commonMessages.beta)}`,
}
: null,
auth.user ? { type: 'heading', label: 'Account' } : null,
auth.user
? {
link: '/settings/profile',
label: formatMessage(commonSettingsMessages.profile),
icon: UserIcon,
}
: null,
auth.user
? {
link: '/settings/account',
label: formatMessage(commonSettingsMessages.account),
icon: ShieldIcon,
}
: null,
auth.user
? {
link: '/settings/authorizations',
label: formatMessage(commonSettingsMessages.authorizedApps),
icon: GridIcon,
}
: null,
auth.user
? {
link: '/settings/sessions',
label: formatMessage(commonSettingsMessages.sessions),
icon: MonitorSmartphoneIcon,
}
: null,
auth.user
? {
link: '/settings/billing',
label: formatMessage(commonSettingsMessages.billing),
icon: CardIcon,
}
: null,
auth.user ? { type: 'heading', label: 'Developer' } : null,
auth.user
? {
link: '/settings/pats',
label: formatMessage(commonSettingsMessages.pats),
icon: KeyIcon,
}
: null,
auth.user
? {
link: '/settings/applications',
label: formatMessage(commonSettingsMessages.applications),
icon: ServerIcon,
}
: null,
].filter(Boolean)
"
/>
</div>
<div class="normal-page__content">
<div class="normal-page__content mt-3 lg:mt-0">
<NuxtPage :route="route" />
</div>
</div>
@@ -94,7 +98,6 @@ import {
import { commonMessages, commonSettingsMessages } from '@modrinth/ui'
import NavStack from '~/components/ui/NavStack.vue'
import NavStackItem from '~/components/ui/NavStackItem.vue'
const { formatMessage } = useVIntl()

View File

@@ -345,7 +345,7 @@ const toggleFeatures = defineMessages({
const cosmetics = useCosmetics()
const flags = useFeatureFlags()
const tags = useTags()
const tags = useGeneratedState()
const theme = useTheme()

View File

@@ -5,12 +5,7 @@
<NewModal ref="editRoleModal" header="Edit role">
<div class="flex w-80 flex-col gap-4">
<div class="flex flex-col gap-2">
<TeleportDropdownMenu
v-model="selectedRole"
:options="roleOptions"
name="edit-role"
placeholder="Select a role"
/>
<Combobox v-model="selectedRole" :options="roleOptions" placeholder="Select a role" />
</div>
<div class="flex justify-end gap-2">
<ButtonStyled>
@@ -460,13 +455,13 @@ import {
import {
Avatar,
ButtonStyled,
Combobox,
commonMessages,
ContentPageHeader,
injectNotificationManager,
NewModal,
OverflowMenu,
TagItem,
TeleportDropdownMenu,
useRelativeTime,
} from '@modrinth/ui'
import { isAdmin, UserBadge } from '@modrinth/utils'
@@ -492,7 +487,7 @@ const data = useNuxtApp()
const route = useNativeRoute()
const auth = await useAuth()
const cosmetics = useCosmetics()
const tags = useTags()
const tags = useGeneratedState()
const config = useRuntimeConfig()
const vintl = useVIntl()
@@ -841,7 +836,11 @@ const navLinks = computed(() => [
const selectedRole = ref(user.value.role)
const isSavingRole = ref(false)
const roleOptions = ['developer', 'moderator', 'admin']
const roleOptions = [
{ value: 'developer', label: 'Developer' },
{ value: 'moderator', label: 'Moderator' },
{ value: 'admin', label: 'Admin' },
]
const editRoleModal = useTemplateRef('editRoleModal')

View File

@@ -1,7 +1,7 @@
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
export default defineNuxtPlugin((nuxtApp) => {
const tagStore = useTags()
const tagStore = useGeneratedState()
nuxtApp.provide('formatNumber', formatNumber)
nuxtApp.provide('capitalizeString', capitalizeString)

View File

@@ -0,0 +1,917 @@
import {
BadgeDollarSignIcon,
GiftIcon,
HandHelpingIcon,
LandmarkIcon,
PayPalColorIcon,
VenmoColorIcon,
} from '@modrinth/assets'
import { createContext, paymentMethodMessages, useDebugLogger } from '@modrinth/ui'
import type { MessageDescriptor } from '@vintl/vintl'
import { type Component, computed, type ComputedRef, type Ref, ref } from 'vue'
import { getRailConfig } from '@/utils/muralpay-rails'
// Tax form is required when withdrawn_ytd >= $600
// Therefore, the maximum withdrawal without a tax form is $599.99
export const TAX_THRESHOLD_REQUIREMENT = 600
export const TAX_THRESHOLD_ACTUAL = 599.99
export type WithdrawStage =
| 'tax-form'
| 'method-selection'
| 'tremendous-details'
| 'muralpay-kyc'
| 'muralpay-details'
| 'paypal-details'
| 'completion'
export type PaymentProvider = 'tremendous' | 'muralpay' | 'paypal' | 'venmo'
/**
* only used for the method selection stage logic - not actually for API requests
**/
export type PaymentMethod = 'gift_card' | 'paypal' | 'venmo' | 'bank' | 'crypto'
export interface PayoutMethod {
id: string
type: string
name: string
category?: string
image_url: string | null
image_logo_url: string | null
fee: {
percentage: number
min: number
max: number | null
}
interval: {
standard: {
min: number
max: number
}
fixed?: {
values: number[]
}
}
config?: {
fiat?: string | null
blockchain?: string[]
}
}
export interface PaymentOption {
value: string
label: string | MessageDescriptor
icon: Component
methodId: string | undefined
fee: string
type: string
}
export interface Country {
id: string
name: string
}
export interface WithdrawalResult {
created: Date
amount: number
fee: number
netAmount: number
methodType: string
recipientDisplay: string
}
export interface KycData {
type: 'individual' | 'business'
email: string
firstName?: string
lastName?: string
dateOfBirth?: string
name?: string
physicalAddress: {
address1: string
address2?: string
city: string
state: string
country: string
zip: string
}
}
export interface AccountDetails {
bankName?: string
walletAddress?: string
documentNumber?: string
[key: string]: any // for dynamic rail fields
}
export interface GiftCardDetails {
[key: string]: any
}
export interface SelectionData {
country: Country | null
provider: PaymentProvider | null
method: string | null
methodId: string | null
}
export interface TaxData {
skipped: boolean
}
export interface CalculationData {
amount: number
fee: number | null
exchangeRate: number | null
}
export interface TremendousProviderData {
type: 'tremendous'
deliveryEmail: string
giftCardDetails: GiftCardDetails | null
currency?: string
}
export interface MuralPayProviderData {
type: 'muralpay'
kycData: KycData
accountDetails: AccountDetails
}
export interface PayPalVenmoProviderData {
type: 'paypal' | 'venmo'
}
export interface NoProviderData {
type: null
}
export type ProviderData =
| TremendousProviderData
| MuralPayProviderData
| PayPalVenmoProviderData
| NoProviderData
export interface WithdrawData {
selection: SelectionData
tax: TaxData
calculation: CalculationData
providerData: ProviderData
result: WithdrawalResult | null
agreedTerms: boolean
stageValidation: {
paypalDetails?: boolean
}
}
export interface SavedWithdrawState {
timestamp: number
stage: WithdrawStage
data: WithdrawData
}
export interface WithdrawContextValue {
currentStage: Ref<WithdrawStage | undefined>
stages: ComputedRef<WithdrawStage[]>
canProceed: ComputedRef<boolean>
nextStep: ComputedRef<WithdrawStage | undefined>
previousStep: ComputedRef<WithdrawStage | undefined>
currentStepIndex: ComputedRef<number>
withdrawData: Ref<WithdrawData>
balance: Ref<any>
maxWithdrawAmount: ComputedRef<number>
availableMethods: Ref<PayoutMethod[]>
paymentOptions: ComputedRef<PaymentOption[]>
preloadedCountry: Ref<string | undefined>
paymentMethodsCache: Ref<Record<string, PayoutMethod[]>>
setStage: (stage: WithdrawStage | undefined, skipValidation?: boolean) => Promise<void>
validateCurrentStage: () => boolean
resetData: () => void
calculateFees: () => Promise<{ fee: number | null; exchange_rate: number | null }>
submitWithdrawal: () => Promise<void>
saveStateToStorage: () => void
restoreStateFromStorage: () => SavedWithdrawState | null
clearSavedState: () => void
}
export const [injectWithdrawContext, provideWithdrawContext] =
createContext<WithdrawContextValue>('CreatorWithdrawModal')
export function useWithdrawContext() {
return injectWithdrawContext()
}
function isTremendousProvider(data: ProviderData): data is TremendousProviderData {
return data.type === 'tremendous'
}
function isMuralPayProvider(data: ProviderData): data is MuralPayProviderData {
return data.type === 'muralpay'
}
function buildRecipientInfo(kycData: KycData) {
return {
type: kycData.type,
...(kycData.type === 'individual'
? {
firstName: kycData.firstName,
lastName: kycData.lastName,
dateOfBirth: kycData.dateOfBirth,
}
: {
name: kycData.name,
}),
email: kycData.email,
physicalAddress: kycData.physicalAddress,
}
}
function getAccountOwnerName(kycData: KycData): string {
if (kycData.type === 'individual') {
return `${kycData.firstName} ${kycData.lastName}`
}
return kycData.name || ''
}
function getMethodDisplayName(method: string | null): string {
if (!method) return ''
const methodMap: Record<string, string> = {
paypal: 'PayPal',
venmo: 'Venmo',
merchant_card: 'Gift Card',
charity: 'Charity',
visa_card: 'Virtual Visa',
}
if (methodMap[method]) return methodMap[method]
if (method.startsWith('fiat_')) {
return 'Bank Transfer'
}
if (method.startsWith('blockchain_')) {
return 'Crypto'
}
return method
}
function getRecipientDisplay(data: WithdrawData): string {
if (isTremendousProvider(data.providerData)) {
return data.providerData.deliveryEmail
}
if (isMuralPayProvider(data.providerData)) {
const kycData = data.providerData.kycData
if (kycData.type === 'individual') {
return `${kycData.firstName} ${kycData.lastName}`
}
return kycData.name || ''
}
return ''
}
interface PayoutPayload {
amount: number
method: 'tremendous' | 'muralpay' | 'paypal' | 'venmo'
method_id: string
method_details?: {
delivery_email?: string
payout_details?: any
recipient_info?: any
}
}
function buildPayoutPayload(data: WithdrawData): PayoutPayload {
if (data.selection.provider === 'paypal' || data.selection.provider === 'venmo') {
return {
amount: data.calculation.amount,
method: data.selection.provider,
method_id: data.selection.methodId!,
}
} else if (data.selection.provider === 'tremendous') {
if (!isTremendousProvider(data.providerData)) {
throw new Error('Invalid provider data for Tremendous')
}
const methodDetails: any = {
delivery_email: data.providerData.deliveryEmail,
}
if (data.providerData.currency && data.selection.method === 'paypal') {
methodDetails.currency = data.providerData.currency
}
return {
amount: data.calculation.amount,
method: 'tremendous',
method_id: data.selection.methodId!,
method_details: methodDetails,
}
} else if (data.selection.provider === 'muralpay') {
if (!isMuralPayProvider(data.providerData)) {
throw new Error('Invalid provider data for MuralPay')
}
const railId = data.selection.method!
const rail = getRailConfig(railId)
if (!rail) throw new Error('Invalid payment method')
if (rail.type === 'crypto') {
return {
amount: data.calculation.amount,
method: 'muralpay',
method_id: data.selection.methodId!,
method_details: {
payout_details: {
type: 'blockchain',
wallet_address: data.providerData.accountDetails.walletAddress || null,
},
recipient_info: buildRecipientInfo(data.providerData.kycData),
},
}
} else if (rail.type === 'fiat') {
const fiatAndRailDetails: Record<string, any> = {
type: rail.railCode || '',
symbol: rail.currency || '',
}
for (const field of rail.fields) {
const value = data.providerData.accountDetails[field.name]
if (value !== undefined && value !== null && value !== '') {
fiatAndRailDetails[field.name] = value
}
}
if (data.providerData.accountDetails.documentNumber) {
fiatAndRailDetails.documentNumber = data.providerData.accountDetails.documentNumber
}
return {
amount: data.calculation.amount,
method: 'muralpay',
method_id: data.selection.methodId!,
method_details: {
payout_details: {
type: 'fiat',
bank_name: data.providerData.accountDetails.bankName || '',
bank_account_owner: getAccountOwnerName(data.providerData.kycData),
fiat_and_rail_details: fiatAndRailDetails,
},
recipient_info: buildRecipientInfo(data.providerData.kycData),
},
}
}
}
throw new Error('Invalid provider')
}
const STORAGE_KEY = 'modrinth_withdraw_state'
const STATE_EXPIRY_MS = 15 * 60 * 1000 // 15 minutes
export function createWithdrawContext(
balance: any,
preloadedPaymentData?: { country: string; methods: PayoutMethod[] },
): WithdrawContextValue {
const debug = useDebugLogger('CreatorWithdraw')
const currentStage = ref<WithdrawStage | undefined>()
const withdrawData = ref<WithdrawData>({
selection: {
country: null,
provider: null,
method: null,
methodId: null,
},
tax: {
skipped: false,
},
calculation: {
amount: 0,
fee: null,
exchangeRate: null,
},
providerData: {
type: null,
},
result: null,
agreedTerms: false,
stageValidation: {},
})
const balanceRef = ref(balance)
const availableMethods = ref<PayoutMethod[]>(preloadedPaymentData?.methods || [])
const preloadedCountry = ref(preloadedPaymentData?.country)
const paymentMethodsCache = ref<Record<string, PayoutMethod[]>>(
preloadedPaymentData ? { [preloadedPaymentData.country]: preloadedPaymentData.methods } : {},
)
const stages = computed<WithdrawStage[]>(() => {
const dynamicStages: WithdrawStage[] = []
const usedLimit = balance?.withdrawn_ytd ?? 0
const available = balance?.available ?? 0
const needsTaxForm =
balance?.form_completion_status !== 'complete' && usedLimit + available >= 600
debug('Tax form check:', {
usedLimit,
available,
total: usedLimit + available,
status: balance?.form_completion_status,
needsTaxForm,
})
if (needsTaxForm) {
dynamicStages.push('tax-form')
}
dynamicStages.push('method-selection')
const selectedProvider = withdrawData.value.selection.provider
if (selectedProvider === 'tremendous') {
dynamicStages.push('tremendous-details')
} else if (selectedProvider === 'muralpay') {
dynamicStages.push('muralpay-kyc')
dynamicStages.push('muralpay-details')
} else if (selectedProvider === 'paypal' || selectedProvider === 'venmo') {
dynamicStages.push('paypal-details')
}
dynamicStages.push('completion')
return dynamicStages
})
const maxWithdrawAmount = computed(() => {
const availableBalance = balance?.available ?? 0
const formCompleted = balance?.form_completion_status === 'complete'
if (formCompleted) {
return availableBalance
}
const usedLimit = balance?.withdrawn_ytd ?? 0
const remainingLimit = Math.max(0, TAX_THRESHOLD_ACTUAL - usedLimit)
return Math.min(remainingLimit, availableBalance)
})
const paymentOptions = computed<PaymentOption[]>(() => {
const methods = availableMethods.value
if (!methods || methods.length === 0) {
debug('No payment methods available')
return []
}
debug('Available methods:', methods)
const options: PaymentOption[] = []
const tremendousMethods = methods.filter((m) => m.type === 'tremendous')
const internationalPaypalMethod = tremendousMethods.find(
(m) => m.type === 'tremendous' && m.category === 'paypal',
)
// TODO: remove this US check when boris removes it from backend
if (internationalPaypalMethod && withdrawData.value.selection.country?.id != 'US') {
options.push({
value: 'paypal',
label: paymentMethodMessages.paypalInternational,
icon: PayPalColorIcon,
methodId: internationalPaypalMethod.id,
fee: '≈ 6%, max $25',
type: 'tremendous',
})
}
const merchantMethods = tremendousMethods.filter(
(m) => m.category === 'merchant_card' || m.category === 'merchant_cards',
)
if (merchantMethods.length > 0) {
options.push({
value: 'merchant_card',
label: paymentMethodMessages.giftCard,
icon: GiftIcon,
methodId: undefined,
fee: '≈ 0%',
type: 'tremendous',
})
}
const charityMethods = tremendousMethods.filter((m) => m.category === 'charity')
if (charityMethods.length > 0) {
options.push({
value: 'charity',
label: paymentMethodMessages.charity,
icon: HandHelpingIcon,
methodId: undefined,
fee: '≈ 0%',
type: 'tremendous',
})
}
const muralPayMethods = methods.filter((m) => m.type === 'muralpay')
for (const method of muralPayMethods) {
const methodId = method.id
if (methodId.startsWith('fiat_')) {
const rail = getRailConfig(methodId)
if (!rail) {
debug('Warning: No rail config found for', methodId)
continue
}
options.push({
value: methodId,
label: rail.name,
icon: LandmarkIcon,
methodId: method.id,
fee: rail.fee,
type: 'fiat',
})
} else if (methodId.startsWith('blockchain_')) {
const rail = getRailConfig(methodId)
if (!rail) {
debug('Warning: No rail config found for', methodId)
continue
}
options.push({
value: methodId,
label: rail.name,
icon: getCurrencyIcon(rail.currency) || BadgeDollarSignIcon,
methodId: method.id,
fee: rail.fee,
type: 'crypto',
})
}
}
const directPaypal = methods.find((m) => m.type === 'paypal')
if (directPaypal) {
options.push({
value: directPaypal.id,
label: paymentMethodMessages.paypal,
icon: PayPalColorIcon,
methodId: directPaypal.id,
fee: `≈ 2% + $0.25, max $1`,
type: 'paypal',
})
}
const directVenmo = methods.find((m) => m.type === 'venmo')
if (directVenmo) {
options.push({
value: directVenmo.id,
label: paymentMethodMessages.venmo,
icon: VenmoColorIcon,
methodId: directVenmo.id,
fee: `≈ 2% + $0.25, max $1`,
type: 'venmo',
})
}
const sortOrder = ['fiat', 'paypal', 'venmo', 'crypto', 'merchant_card', 'charity']
options.sort((a, b) => {
const getOrder = (item: PaymentOption) => {
let order = sortOrder.indexOf(item.type)
if (order === -1) order = sortOrder.indexOf(item.value)
return order !== -1 ? order : 999
}
return getOrder(a) - getOrder(b)
})
debug('Payment options computed:', options)
return options
})
const currentStepIndex = computed(() =>
currentStage.value ? stages.value.indexOf(currentStage.value) : -1,
)
const nextStep = computed(() => {
if (!currentStage.value) return undefined
const currentIndex = currentStepIndex.value
if (currentIndex === -1 || currentIndex >= stages.value.length - 1) return undefined
return stages.value[currentIndex + 1]
})
const previousStep = computed(() => {
if (!currentStage.value) return undefined
const currentIndex = currentStepIndex.value
if (currentIndex <= 0) return undefined
return stages.value[currentIndex - 1]
})
const canProceed = computed(() => {
return validateCurrentStage()
})
function validateCurrentStage(): boolean {
switch (currentStage.value) {
case 'tax-form': {
if (!balanceRef.value) return true
const ytd = balanceRef.value.withdrawn_ytd ?? 0
const remainingLimit = Math.max(0, TAX_THRESHOLD_ACTUAL - ytd)
const form_completion_status = balanceRef.value.form_completion_status
if (ytd < 600) return true
if (withdrawData.value.tax.skipped && remainingLimit > 0) return true
return form_completion_status === 'complete'
}
case 'method-selection':
return !!(
withdrawData.value.selection.country &&
withdrawData.value.selection.provider &&
withdrawData.value.selection.method &&
(withdrawData.value.selection.method === 'merchant_card' ||
withdrawData.value.selection.method === 'charity' ||
withdrawData.value.selection.methodId)
)
case 'tremendous-details': {
const method = withdrawData.value.selection.method
const amount = withdrawData.value.calculation.amount
const selectedMethod = availableMethods.value.find(
(m) => m.id === withdrawData.value.selection.methodId,
)
if (selectedMethod?.interval) {
if (selectedMethod.interval.standard) {
const { min, max } = selectedMethod.interval.standard
if (amount < min || amount > max) return false
}
if (selectedMethod.interval.fixed) {
if (!selectedMethod.interval.fixed.values.includes(amount)) return false
}
}
if (method === 'merchant_card' || method === 'charity') {
if (!isTremendousProvider(withdrawData.value.providerData)) return false
return !!(
withdrawData.value.selection.methodId &&
amount > 0 &&
withdrawData.value.providerData.deliveryEmail &&
withdrawData.value.agreedTerms
)
}
if (!isTremendousProvider(withdrawData.value.providerData)) return false
return !!(
amount > 0 &&
withdrawData.value.providerData.deliveryEmail &&
withdrawData.value.agreedTerms
)
}
case 'muralpay-kyc': {
if (!isMuralPayProvider(withdrawData.value.providerData)) return false
const kycData = withdrawData.value.providerData.kycData
if (!kycData) return false
const hasValidAddress = !!(
kycData.physicalAddress?.address1 &&
kycData.physicalAddress?.city &&
kycData.physicalAddress?.state &&
kycData.physicalAddress?.country &&
kycData.physicalAddress?.zip
)
if (kycData.type === 'individual') {
return !!(
kycData.firstName &&
kycData.lastName &&
kycData.email &&
kycData.dateOfBirth &&
hasValidAddress
)
} else if (kycData.type === 'business') {
return !!(kycData.name && kycData.email && hasValidAddress)
}
return false
}
case 'muralpay-details': {
if (!isMuralPayProvider(withdrawData.value.providerData)) return false
const railId = withdrawData.value.selection.method
const rail = getRailConfig(railId as string)
if (!rail) return false
if (!withdrawData.value.calculation.amount || withdrawData.value.calculation.amount <= 0)
return false
const amount = withdrawData.value.calculation.amount
const selectedMethod = availableMethods.value.find(
(m) => m.id === withdrawData.value.selection.methodId,
)
if (selectedMethod?.interval?.standard) {
const { min, max } = selectedMethod.interval.standard
if (amount < min || amount > max) return false
}
const accountDetails = withdrawData.value.providerData.accountDetails
if (!accountDetails) return false
if (rail.requiresBankName && !accountDetails.bankName) return false
const requiredFields = rail.fields.filter((f) => f.required)
const allRequiredPresent = requiredFields.every((f) => {
const value = accountDetails[f.name]
return value !== undefined && value !== null && value !== ''
})
return allRequiredPresent && withdrawData.value.agreedTerms
}
case 'paypal-details': {
const amount = withdrawData.value.calculation.amount
if (!amount || amount <= 0) return false
const selectedMethod = availableMethods.value.find(
(m) => m.id === withdrawData.value.selection.methodId,
)
if (selectedMethod?.interval?.standard) {
const { min, max } = selectedMethod.interval.standard
if (amount < min || amount > max) return false
}
return !!withdrawData.value.stageValidation?.paypalDetails
}
case 'completion':
return true
default:
return false
}
}
async function setStage(stage: WithdrawStage | undefined, skipValidation = false) {
if (!skipValidation && !canProceed.value) {
return
}
const detailsStages: WithdrawStage[] = [
'tremendous-details',
'muralpay-details',
'paypal-details',
]
const isLeavingDetails = currentStage.value && detailsStages.includes(currentStage.value)
const isGoingToMethodSelection = stage === 'method-selection'
if (isLeavingDetails && isGoingToMethodSelection) {
withdrawData.value.calculation.amount = 0
withdrawData.value.calculation.fee = null
withdrawData.value.calculation.exchangeRate = null
withdrawData.value.agreedTerms = false
withdrawData.value.stageValidation = {}
}
currentStage.value = stage
}
function resetData() {
withdrawData.value = {
selection: {
country: null,
provider: null,
method: null,
methodId: null,
},
tax: {
skipped: false,
},
calculation: {
amount: 0,
fee: null,
exchangeRate: null,
},
providerData: {
type: null,
},
result: null,
agreedTerms: false,
stageValidation: {},
}
currentStage.value = undefined
availableMethods.value = []
clearSavedState()
}
async function calculateFees(): Promise<{ fee: number | null; exchange_rate: number | null }> {
const payload = buildPayoutPayload(withdrawData.value)
const response = (await useBaseFetch('payout/fees', {
apiVersion: 3,
method: 'POST',
body: payload,
})) as { fee: number | string | null; exchange_rate: number | string | null }
const parsedFee = response.fee ? Number.parseFloat(String(response.fee)) : 0
const parsedExchangeRate = response.exchange_rate
? Number.parseFloat(String(response.exchange_rate))
: null
withdrawData.value.calculation.fee = parsedFee
withdrawData.value.calculation.exchangeRate = parsedExchangeRate
return {
fee: parsedFee,
exchange_rate: parsedExchangeRate,
}
}
async function submitWithdrawal(): Promise<void> {
const payload = buildPayoutPayload(withdrawData.value)
debug('Withdrawal payload:', payload)
await useBaseFetch('payout', {
apiVersion: 3,
method: 'POST',
body: payload,
})
withdrawData.value.result = {
created: new Date(),
amount: withdrawData.value.calculation.amount,
fee: withdrawData.value.calculation.fee || 0,
netAmount: withdrawData.value.calculation.amount - (withdrawData.value.calculation.fee || 0),
methodType: getMethodDisplayName(withdrawData.value.selection.method),
recipientDisplay: getRecipientDisplay(withdrawData.value),
}
debug('Withdrawal submitted successfully', withdrawData.value.result)
}
function saveStateToStorage(): void {
const state: SavedWithdrawState = {
timestamp: Date.now(),
stage: currentStage.value || 'method-selection',
data: withdrawData.value,
}
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}
} catch (e) {
console.warn('Failed to save withdraw state:', e)
}
}
function restoreStateFromStorage(): SavedWithdrawState | null {
try {
if (typeof localStorage === 'undefined') return null
const saved = localStorage.getItem(STORAGE_KEY)
if (!saved) return null
const state: SavedWithdrawState = JSON.parse(saved)
const age = Date.now() - state.timestamp
if (age > STATE_EXPIRY_MS) {
clearSavedState()
return null
}
return state
} catch (e) {
console.warn('Failed to restore withdraw state:', e)
clearSavedState()
return null
}
}
function clearSavedState(): void {
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(STORAGE_KEY)
}
} catch (e) {
console.warn('Failed to clear withdraw state:', e)
}
}
return {
currentStage,
stages,
canProceed,
nextStep,
previousStep,
currentStepIndex,
withdrawData,
balance: balanceRef,
maxWithdrawAmount,
availableMethods,
paymentOptions,
preloadedCountry,
paymentMethodsCache,
setStage,
validateCurrentStage,
resetData,
calculateFees,
submitWithdrawal,
saveStateToStorage,
restoreStateFromStorage,
clearSavedState,
}
}

View File

@@ -0,0 +1,34 @@
import { PolygonIcon, USDCColorIcon } from '@modrinth/assets'
import type { Component } from 'vue'
export function getCurrencyIcon(currency: string): Component | null {
const lower = currency.toLocaleLowerCase()
if (lower.includes('usdc')) return USDCColorIcon
return null
}
export function getCurrencyColor(currency: string): string {
const lower = currency.toLowerCase()
if (lower.includes('usdc')) return 'text-blue'
return 'text-contrast'
}
export function getBlockchainIcon(blockchain: string): Component | null {
const lower = blockchain.toLowerCase()
if (lower.includes('polygon')) return PolygonIcon
return null
}
export function getBlockchainColor(blockchain: string): string {
const lower = blockchain.toLowerCase()
if (lower.includes('polygon')) return 'text-purple'
return 'text-contrast'
}

View File

@@ -0,0 +1,998 @@
import { defineMessage, type MessageDescriptor } from '@vintl/vintl'
export type FieldType = 'text' | 'select' | 'email' | 'tel' | 'date'
export interface FieldConfig {
name: string
type: FieldType
label: MessageDescriptor
required: boolean
placeholder?: MessageDescriptor
helpText?: MessageDescriptor
options?: Array<{ value: string; label: MessageDescriptor }>
pattern?: string
validate?: (value: string) => string | null
autocomplete?: string
}
export interface RailConfig {
id: string
name: MessageDescriptor
currency: string
fee: string
type: 'fiat' | 'crypto'
railCode?: string
blockchain?: string
fields: FieldConfig[]
warningMessage?: MessageDescriptor
requiresBankName?: boolean
}
const DOCUMENT_TYPE_OPTIONS = [
{
value: 'NATIONAL_ID',
label: defineMessage({
id: 'muralpay.document-type.national-id',
defaultMessage: 'National ID',
}),
},
{
value: 'PASSPORT',
label: defineMessage({ id: 'muralpay.document-type.passport', defaultMessage: 'Passport' }),
},
{
value: 'RESIDENT_ID',
label: defineMessage({
id: 'muralpay.document-type.resident-id',
defaultMessage: 'Resident ID',
}),
},
{
value: 'RUC',
label: defineMessage({ id: 'muralpay.document-type.ruc', defaultMessage: 'RUC' }),
},
{
value: 'TAX_ID',
label: defineMessage({ id: 'muralpay.document-type.tax-id', defaultMessage: 'Tax ID' }),
},
]
const ACCOUNT_TYPE_OPTIONS = [
{
value: 'CHECKING',
label: defineMessage({ id: 'muralpay.account-type.checking', defaultMessage: 'Checking' }),
},
{
value: 'SAVINGS',
label: defineMessage({ id: 'muralpay.account-type.savings', defaultMessage: 'Savings' }),
},
]
export const MURALPAY_RAILS: Record<string, RailConfig> = {
fiat_usd: {
id: 'fiat_usd',
name: defineMessage({
id: 'muralpay.rail.fiat-usd.name',
defaultMessage: 'Bank Transfer (USD)',
}),
currency: 'USD',
type: 'fiat',
fee: '≈ 1.50% + $0.50',
railCode: 'usd',
requiresBankName: true,
fields: [
{
name: 'accountType',
type: 'select',
label: defineMessage({ id: 'muralpay.field.account-type', defaultMessage: 'Account Type' }),
required: true,
options: ACCOUNT_TYPE_OPTIONS,
autocomplete: 'off',
},
{
name: 'bankAccountNumber',
type: 'text',
label: defineMessage({
id: 'muralpay.field.bank-account-number',
defaultMessage: 'Account Number',
}),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.enter-account-number',
defaultMessage: 'Enter account number',
}),
autocomplete: 'off',
},
{
name: 'bankRoutingNumber',
type: 'text',
label: defineMessage({
id: 'muralpay.field.routing-number',
defaultMessage: 'Routing Number',
}),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.enter-routing-number',
defaultMessage: 'Enter 9-digit routing number',
}),
pattern: '^[0-9]{9}$',
validate: (val) => (/^\d{9}$/.test(val) ? null : 'Must be exactly 9 digits'),
autocomplete: 'off',
},
],
},
fiat_eur: {
id: 'fiat_eur',
name: defineMessage({
id: 'muralpay.rail.fiat-eur.name',
defaultMessage: 'Bank Transfer (EUR)',
}),
currency: 'EUR',
type: 'fiat',
fee: '≈ 1.60% + €1.00',
railCode: 'eur',
requiresBankName: true,
fields: [
{
name: 'iban',
type: 'text',
label: defineMessage({ id: 'muralpay.field.iban', defaultMessage: 'IBAN' }),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.enter-iban',
defaultMessage: 'Enter IBAN',
}),
helpText: defineMessage({
id: 'muralpay.help.iban',
defaultMessage: 'International Bank Account Number',
}),
autocomplete: 'iban',
},
{
name: 'swiftBic',
type: 'text',
label: defineMessage({ id: 'muralpay.field.swift-bic', defaultMessage: 'SWIFT/BIC' }),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.enter-swift-bic',
defaultMessage: 'Enter SWIFT/BIC code',
}),
helpText: defineMessage({
id: 'muralpay.help.swift-bic',
defaultMessage: 'Bank Identifier Code',
}),
autocomplete: 'swift',
},
{
name: 'country',
type: 'select',
label: defineMessage({ id: 'muralpay.field.country', defaultMessage: 'Country' }),
required: true,
autocomplete: 'off',
options: [
{
value: 'AT',
label: defineMessage({ id: 'muralpay.country.at', defaultMessage: 'Austria' }),
},
{
value: 'BE',
label: defineMessage({ id: 'muralpay.country.be', defaultMessage: 'Belgium' }),
},
{
value: 'CY',
label: defineMessage({ id: 'muralpay.country.cy', defaultMessage: 'Cyprus' }),
},
{
value: 'EE',
label: defineMessage({ id: 'muralpay.country.ee', defaultMessage: 'Estonia' }),
},
{
value: 'FI',
label: defineMessage({ id: 'muralpay.country.fi', defaultMessage: 'Finland' }),
},
{
value: 'FR',
label: defineMessage({ id: 'muralpay.country.fr', defaultMessage: 'France' }),
},
{
value: 'DE',
label: defineMessage({ id: 'muralpay.country.de', defaultMessage: 'Germany' }),
},
{
value: 'GR',
label: defineMessage({ id: 'muralpay.country.gr', defaultMessage: 'Greece' }),
},
{
value: 'IE',
label: defineMessage({ id: 'muralpay.country.ie', defaultMessage: 'Ireland' }),
},
{
value: 'IT',
label: defineMessage({ id: 'muralpay.country.it', defaultMessage: 'Italy' }),
},
{
value: 'LV',
label: defineMessage({ id: 'muralpay.country.lv', defaultMessage: 'Latvia' }),
},
{
value: 'LT',
label: defineMessage({ id: 'muralpay.country.lt', defaultMessage: 'Lithuania' }),
},
{
value: 'LU',
label: defineMessage({ id: 'muralpay.country.lu', defaultMessage: 'Luxembourg' }),
},
{
value: 'MT',
label: defineMessage({ id: 'muralpay.country.mt', defaultMessage: 'Malta' }),
},
{
value: 'NL',
label: defineMessage({ id: 'muralpay.country.nl', defaultMessage: 'Netherlands' }),
},
{
value: 'PT',
label: defineMessage({ id: 'muralpay.country.pt', defaultMessage: 'Portugal' }),
},
{
value: 'SK',
label: defineMessage({ id: 'muralpay.country.sk', defaultMessage: 'Slovakia' }),
},
{
value: 'ES',
label: defineMessage({ id: 'muralpay.country.es', defaultMessage: 'Spain' }),
},
],
},
],
},
fiat_mxn: {
id: 'fiat_mxn',
name: defineMessage({
id: 'muralpay.rail.fiat-mxn.name',
defaultMessage: 'Bank Transfer (MXN)',
}),
currency: 'MXN',
type: 'fiat',
fee: '≈ 1.90% + $0.50',
railCode: 'mxn',
requiresBankName: true,
fields: [
{
name: 'bankAccountNumber',
type: 'text',
label: defineMessage({ id: 'muralpay.field.clabe', defaultMessage: 'CLABE' }),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.enter-clabe',
defaultMessage: 'Enter 18-digit CLABE',
}),
helpText: defineMessage({
id: 'muralpay.help.clabe',
defaultMessage: 'Clave Bancaria Estandarizada (Mexican bank account number)',
}),
pattern: '^[0-9]{18}$',
validate: (val) => (/^\d{18}$/.test(val) ? null : 'CLABE must be exactly 18 digits'),
autocomplete: 'off',
},
],
},
fiat_brl: {
id: 'fiat_brl',
name: defineMessage({
id: 'muralpay.rail.fiat-brl.name',
defaultMessage: 'PIX Transfer (BRL)',
}),
currency: 'BRL',
type: 'fiat',
fee: '≈ 2.30% + $0.25',
railCode: 'brl',
requiresBankName: true,
fields: [
{
name: 'pixAccountType',
type: 'select',
label: defineMessage({ id: 'muralpay.field.pix-key-type', defaultMessage: 'PIX Key Type' }),
required: true,
autocomplete: 'off',
options: [
{
value: 'PHONE',
label: defineMessage({ id: 'muralpay.pix-type.phone', defaultMessage: 'Phone Number' }),
},
{
value: 'EMAIL',
label: defineMessage({ id: 'muralpay.pix-type.email', defaultMessage: 'Email' }),
},
{
value: 'DOCUMENT',
label: defineMessage({ id: 'muralpay.pix-type.document', defaultMessage: 'CPF/CNPJ' }),
},
{
value: 'BANK_ACCOUNT',
label: defineMessage({
id: 'muralpay.pix-type.bank-account',
defaultMessage: 'Bank Account',
}),
},
],
},
{
name: 'pixEmail',
type: 'email',
label: defineMessage({ id: 'muralpay.field.pix-email', defaultMessage: 'PIX Email' }),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.enter-pix-email',
defaultMessage: 'Enter PIX email',
}),
autocomplete: 'email',
},
{
name: 'pixPhone',
type: 'tel',
label: defineMessage({ id: 'muralpay.field.pix-phone', defaultMessage: 'PIX Phone' }),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.pix-phone',
defaultMessage: '+55...',
}),
autocomplete: 'tel',
},
{
name: 'branchCode',
type: 'text',
label: defineMessage({ id: 'muralpay.field.branch-code', defaultMessage: 'Branch Code' }),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.enter-branch-code',
defaultMessage: 'Enter branch code',
}),
autocomplete: 'off',
},
{
name: 'documentNumber',
type: 'text',
label: defineMessage({ id: 'muralpay.field.cpf-cnpj', defaultMessage: 'CPF/CNPJ' }),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.enter-cpf-cnpj',
defaultMessage: 'Enter CPF or CNPJ',
}),
helpText: defineMessage({
id: 'muralpay.help.cpf-cnpj',
defaultMessage: 'Brazilian tax identification number',
}),
autocomplete: 'off',
},
],
},
fiat_cop: {
id: 'fiat_cop',
name: defineMessage({
id: 'muralpay.rail.fiat-cop.name',
defaultMessage: 'Bank Transfer (COP)',
}),
currency: 'COP',
type: 'fiat',
fee: '≈ 1.95% + $0.35',
railCode: 'cop',
requiresBankName: true,
fields: [
{
name: 'phoneNumber',
type: 'tel',
label: defineMessage({ id: 'muralpay.field.phone-number', defaultMessage: 'Phone Number' }),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.phone-cop',
defaultMessage: '+57...',
}),
autocomplete: 'tel',
},
{
name: 'accountType',
type: 'select',
label: defineMessage({ id: 'muralpay.field.account-type', defaultMessage: 'Account Type' }),
required: true,
options: ACCOUNT_TYPE_OPTIONS,
autocomplete: 'off',
},
{
name: 'bankAccountNumber',
type: 'text',
label: defineMessage({
id: 'muralpay.field.bank-account-number',
defaultMessage: 'Account Number',
}),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.enter-account-number',
defaultMessage: 'Enter account number',
}),
autocomplete: 'off',
},
{
name: 'documentType',
type: 'select',
label: defineMessage({
id: 'muralpay.field.document-type',
defaultMessage: 'Document Type',
}),
required: true,
options: DOCUMENT_TYPE_OPTIONS,
autocomplete: 'off',
},
],
},
fiat_ars: {
id: 'fiat_ars',
name: defineMessage({
id: 'muralpay.rail.fiat-ars.name',
defaultMessage: 'Bank Transfer (ARS)',
}),
currency: 'ARS',
type: 'fiat',
fee: '≈ 1.50% + $0.00',
railCode: 'ars',
requiresBankName: true,
fields: [
{
name: 'bankAccountNumber',
type: 'text',
label: defineMessage({
id: 'muralpay.field.account-number-cbu-cvu',
defaultMessage: 'Account Number (CBU/CVU)',
}),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.cbu-cvu',
defaultMessage: 'Enter CBU or CVU',
}),
helpText: defineMessage({
id: 'muralpay.help.cbu-cvu',
defaultMessage: 'Clave Bancaria Uniforme or Clave Virtual Uniforme',
}),
autocomplete: 'off',
},
{
name: 'documentNumber',
type: 'text',
label: defineMessage({ id: 'muralpay.field.cuit-cuil', defaultMessage: 'CUIT/CUIL' }),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.cuit-cuil',
defaultMessage: 'Enter CUIT or CUIL',
}),
helpText: defineMessage({
id: 'muralpay.help.cuit-cuil',
defaultMessage: 'Argentine tax ID',
}),
autocomplete: 'off',
},
{
name: 'bankAccountNumberType',
type: 'text',
label: defineMessage({
id: 'muralpay.field.account-number-type',
defaultMessage: 'Account Number Type',
}),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.cbu-cvu-type',
defaultMessage: 'CBU or CVU',
}),
autocomplete: 'off',
},
],
},
fiat_clp: {
id: 'fiat_clp',
name: defineMessage({
id: 'muralpay.rail.fiat-clp.name',
defaultMessage: 'Bank Transfer (CLP)',
}),
currency: 'CLP',
type: 'fiat',
fee: '≈ 1.95% + $1.20',
railCode: 'clp',
requiresBankName: true,
fields: [
{
name: 'accountType',
type: 'select',
label: defineMessage({ id: 'muralpay.field.account-type', defaultMessage: 'Account Type' }),
required: true,
options: ACCOUNT_TYPE_OPTIONS,
autocomplete: 'off',
},
{
name: 'bankAccountNumber',
type: 'text',
label: defineMessage({
id: 'muralpay.field.account-number',
defaultMessage: 'Account Number',
}),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.account-number',
defaultMessage: 'Enter account number',
}),
autocomplete: 'off',
},
{
name: 'documentType',
type: 'select',
label: defineMessage({
id: 'muralpay.field.document-type',
defaultMessage: 'Document Type',
}),
required: true,
options: DOCUMENT_TYPE_OPTIONS,
autocomplete: 'off',
},
],
},
fiat_crc: {
id: 'fiat_crc',
name: defineMessage({
id: 'muralpay.rail.fiat-crc.name',
defaultMessage: 'Bank Transfer (CRC)',
}),
currency: 'CRC',
type: 'fiat',
fee: '≈ 2.05% + $0.80',
railCode: 'crc',
requiresBankName: true,
fields: [
{
name: 'iban',
type: 'text',
label: defineMessage({ id: 'muralpay.field.iban', defaultMessage: 'IBAN' }),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.iban-crc',
defaultMessage: 'Enter Costa Rican IBAN',
}),
autocomplete: 'iban',
},
{
name: 'documentType',
type: 'select',
label: defineMessage({
id: 'muralpay.field.document-type',
defaultMessage: 'Document Type',
}),
required: true,
options: DOCUMENT_TYPE_OPTIONS,
autocomplete: 'off',
},
],
},
fiat_pen: {
id: 'fiat_pen',
name: defineMessage({
id: 'muralpay.rail.fiat-pen.name',
defaultMessage: 'Bank Transfer (PEN)',
}),
currency: 'PEN',
type: 'fiat',
fee: '≈ 2.15% + $1.00',
railCode: 'pen',
requiresBankName: true,
fields: [
{
name: 'documentType',
type: 'select',
label: defineMessage({
id: 'muralpay.field.document-type',
defaultMessage: 'Document Type',
}),
required: true,
options: DOCUMENT_TYPE_OPTIONS,
autocomplete: 'off',
},
{
name: 'bankAccountNumber',
type: 'text',
label: defineMessage({
id: 'muralpay.field.account-number-cci',
defaultMessage: 'Account Number (CCI)',
}),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.cci',
defaultMessage: 'Enter 20-digit CCI',
}),
helpText: defineMessage({
id: 'muralpay.help.cci',
defaultMessage: 'Código de Cuenta Interbancaria',
}),
autocomplete: 'off',
},
{
name: 'accountType',
type: 'select',
label: defineMessage({ id: 'muralpay.field.account-type', defaultMessage: 'Account Type' }),
required: true,
options: ACCOUNT_TYPE_OPTIONS,
autocomplete: 'off',
},
],
},
// fiat_bob: {
// id: 'fiat_bob',
// name: defineMessage({
// id: 'muralpay.rail.fiat-bob.name',
// defaultMessage: 'Bank Transfer (BOB)',
// }),
// currency: 'BOB',
// type: 'fiat',
// fee: 'TBD',
// railCode: 'bob',
// requiresBankName: true,
// fields: [
// {
// name: 'bankAccountNumber',
// type: 'text',
// label: defineMessage({
// id: 'muralpay.field.account-number',
// defaultMessage: 'Account Number',
// }),
// required: true,
// placeholder: defineMessage({
// id: 'muralpay.placeholder.account-number',
// defaultMessage: 'Enter account number',
// }),
// autocomplete: 'off',
// },
// {
// name: 'documentType',
// type: 'select',
// label: defineMessage({
// id: 'muralpay.field.document-type',
// defaultMessage: 'Document Type',
// }),
// required: true,
// options: DOCUMENT_TYPE_OPTIONS,
// autocomplete: 'off',
// },
// ],
// },
fiat_zar: {
id: 'fiat_zar',
name: defineMessage({
id: 'muralpay.rail.fiat-zar.name',
defaultMessage: 'Bank Transfer (ZAR)',
}),
currency: 'ZAR',
type: 'fiat',
fee: '≈ 2.40% + $1.50',
railCode: 'zar',
requiresBankName: true,
fields: [
{
name: 'accountType',
type: 'select',
label: defineMessage({ id: 'muralpay.field.account-type', defaultMessage: 'Account Type' }),
required: true,
options: ACCOUNT_TYPE_OPTIONS,
autocomplete: 'off',
},
{
name: 'bankAccountNumber',
type: 'text',
label: defineMessage({
id: 'muralpay.field.account-number',
defaultMessage: 'Account Number',
}),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.account-number',
defaultMessage: 'Enter account number',
}),
autocomplete: 'off',
},
],
},
'fiat_usd-peru': {
id: 'fiat_usd-peru',
name: defineMessage({
id: 'muralpay.rail.fiat-usd-peru.name',
defaultMessage: 'Bank Transfer (USD - Peru)',
}),
currency: 'USD',
type: 'fiat',
fee: '≈ 1.50% + $5.00',
railCode: 'usd-peru',
requiresBankName: true,
fields: [
{
name: 'accountType',
type: 'select',
label: defineMessage({ id: 'muralpay.field.account-type', defaultMessage: 'Account Type' }),
required: true,
options: ACCOUNT_TYPE_OPTIONS,
autocomplete: 'off',
},
{
name: 'bankAccountNumber',
type: 'text',
label: defineMessage({
id: 'muralpay.field.account-number',
defaultMessage: 'Account Number',
}),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.account-number',
defaultMessage: 'Enter account number',
}),
autocomplete: 'off',
},
{
name: 'documentType',
type: 'select',
label: defineMessage({
id: 'muralpay.field.document-type',
defaultMessage: 'Document Type',
}),
required: true,
options: DOCUMENT_TYPE_OPTIONS,
autocomplete: 'off',
},
],
},
// 'fiat_usd-china': {
// id: 'fiat_usd-china',
// name: defineMessage({
// id: 'muralpay.rail.fiat-usd-china.name',
// defaultMessage: 'Bank Transfer (USD - China)',
// }),
// currency: 'USD',
// type: 'fiat',
// fee: 'TBD',
// railCode: 'usd-china',
// requiresBankName: false,
// fields: [
// {
// name: 'bankName',
// type: 'text',
// label: defineMessage({ id: 'muralpay.field.bank-name', defaultMessage: 'Bank Name' }),
// required: true,
// placeholder: defineMessage({
// id: 'muralpay.placeholder.bank-name',
// defaultMessage: 'Enter bank name',
// }),
// autocomplete: 'off',
// },
// {
// name: 'accountType',
// type: 'select',
// label: defineMessage({ id: 'muralpay.field.account-type', defaultMessage: 'Account Type' }),
// required: true,
// options: ACCOUNT_TYPE_OPTIONS,
// autocomplete: 'off',
// },
// {
// name: 'bankAccountNumber',
// type: 'text',
// label: defineMessage({
// id: 'muralpay.field.account-number',
// defaultMessage: 'Account Number',
// }),
// required: true,
// placeholder: defineMessage({
// id: 'muralpay.placeholder.account-number',
// defaultMessage: 'Enter account number',
// }),
// autocomplete: 'off',
// },
// {
// name: 'documentType',
// type: 'select',
// label: defineMessage({
// id: 'muralpay.field.document-type',
// defaultMessage: 'Document Type',
// }),
// required: true,
// options: DOCUMENT_TYPE_OPTIONS,
// autocomplete: 'off',
// },
// {
// name: 'phoneNumber',
// type: 'tel',
// label: defineMessage({ id: 'muralpay.field.phone-number', defaultMessage: 'Phone Number' }),
// required: true,
// placeholder: defineMessage({
// id: 'muralpay.placeholder.phone-china',
// defaultMessage: '+86...',
// }),
// autocomplete: 'tel',
// },
// {
// name: 'address',
// type: 'text',
// label: defineMessage({ id: 'muralpay.field.address', defaultMessage: 'Address' }),
// required: true,
// placeholder: defineMessage({
// id: 'muralpay.placeholder.address',
// defaultMessage: 'Enter address',
// }),
// autocomplete: 'street-address',
// },
// {
// name: 'swiftBic',
// type: 'text',
// label: defineMessage({ id: 'muralpay.field.swift-bic', defaultMessage: 'SWIFT/BIC' }),
// required: true,
// placeholder: defineMessage({
// id: 'muralpay.placeholder.swift-bic',
// defaultMessage: 'Enter SWIFT/BIC code',
// }),
// autocomplete: 'swift',
// },
// ],
// },
blockchain_usdc_polygon: {
id: 'blockchain_usdc_polygon',
name: defineMessage({
id: 'muralpay.rail.usdc-polygon.name',
defaultMessage: 'Crypto (USDC)',
}),
currency: 'USDC',
type: 'crypto',
fee: '≈ 1%',
blockchain: 'POLYGON',
warningMessage: defineMessage({
id: 'muralpay.warning.wallet-address',
defaultMessage:
'Double-check your wallet address. Funds sent to an incorrect address cannot be recovered.',
}),
fields: [
{
name: 'walletAddress',
type: 'text',
label: defineMessage({
id: 'muralpay.field.wallet-address',
defaultMessage: 'Wallet Address',
}),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.wallet-address-eth',
defaultMessage: '0x...',
}),
pattern: '^0x[a-fA-F0-9]{40}$',
validate: (val) =>
/^0x[a-fA-F0-9]{40}$/.test(val) ? null : 'Must be a valid Ethereum address (0x...)',
autocomplete: 'off',
},
],
},
blockchain_usdc_base: {
id: 'blockchain_usdc_base',
name: defineMessage({ id: 'muralpay.rail.usdc-base.name', defaultMessage: 'USDC (Base)' }),
currency: 'USDC',
type: 'crypto',
fee: '≈ 1%',
blockchain: 'BASE',
warningMessage: defineMessage({
id: 'muralpay.warning.wallet-address',
defaultMessage:
'Double-check your wallet address. Funds sent to an incorrect address cannot be recovered.',
}),
fields: [
{
name: 'walletAddress',
type: 'text',
label: defineMessage({
id: 'muralpay.field.wallet-address',
defaultMessage: 'Wallet Address',
}),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.wallet-address-eth',
defaultMessage: '0x...',
}),
pattern: '^0x[a-fA-F0-9]{40}$',
validate: (val) =>
/^0x[a-fA-F0-9]{40}$/.test(val) ? null : 'Must be a valid Ethereum address (0x...)',
autocomplete: 'off',
},
],
},
blockchain_usdc_ethereum: {
id: 'blockchain_usdc_ethereum',
name: defineMessage({
id: 'muralpay.rail.usdc-ethereum.name',
defaultMessage: 'USDC (Ethereum)',
}),
currency: 'USDC',
type: 'crypto',
fee: '≈ 1%',
blockchain: 'ETHEREUM',
warningMessage: defineMessage({
id: 'muralpay.warning.wallet-address',
defaultMessage:
'Double-check your wallet address. Funds sent to an incorrect address cannot be recovered.',
}),
fields: [
{
name: 'walletAddress',
type: 'text',
label: defineMessage({
id: 'muralpay.field.wallet-address',
defaultMessage: 'Wallet Address',
}),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.wallet-address-eth',
defaultMessage: '0x...',
}),
pattern: '^0x[a-fA-F0-9]{40}$',
validate: (val) =>
/^0x[a-fA-F0-9]{40}$/.test(val) ? null : 'Must be a valid Ethereum address (0x...)',
autocomplete: 'off',
},
],
},
blockchain_usdc_celo: {
id: 'blockchain_usdc_celo',
name: defineMessage({ id: 'muralpay.rail.usdc-celo.name', defaultMessage: 'USDC (Celo)' }),
currency: 'USDC',
type: 'crypto',
fee: '≈ 1%',
blockchain: 'CELO',
warningMessage: defineMessage({
id: 'muralpay.warning.wallet-address',
defaultMessage:
'Double-check your wallet address. Funds sent to an incorrect address cannot be recovered.',
}),
fields: [
{
name: 'walletAddress',
type: 'text',
label: defineMessage({
id: 'muralpay.field.wallet-address',
defaultMessage: 'Wallet Address',
}),
required: true,
placeholder: defineMessage({
id: 'muralpay.placeholder.wallet-address-eth',
defaultMessage: '0x...',
}),
pattern: '^0x[a-fA-F0-9]{40}$',
validate: (val) =>
/^0x[a-fA-F0-9]{40}$/.test(val) ? null : 'Must be a valid Ethereum address (0x...)',
autocomplete: 'off',
},
],
},
}
export function getAvailableRails(): string[] {
return Object.keys(MURALPAY_RAILS)
}
export function getRailsByType(type: 'fiat' | 'crypto'): RailConfig[] {
return Object.values(MURALPAY_RAILS).filter((rail) => rail.type === type)
}
export function getRailConfig(railId: string): RailConfig | undefined {
return MURALPAY_RAILS[railId]
}

View File

@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT created, amount\n FROM payouts_values\n WHERE user_id = $1\n AND NOW() >= date_available",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "created",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "amount",
"type_info": "Numeric"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false
]
},
"hash": "8f68ea8481d86687ef4ebd6311f8ec5437be07fab8c34a964f7864304681bb94"
}