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

@@ -9,6 +9,7 @@ Please follow these rules precisely:
1. Identify translatable strings
- Scan the <template> for all user-visible strings (inner text, alt attributes, placeholders, button labels, etc.). Do not extract dynamic expressions (like {{ user.name }}) or HTML tags. Only extract static human-readable text.
- There may be strings within the <script> block, e.g dropdown option labels, notifications etc.
2. Create message definitions

3
.gitignore vendored
View File

@@ -65,3 +65,6 @@ app-playground-data/*
.astro
.claude
# labrinth demo fixtures
apps/labrinth/fixtures/demo

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"
}

View File

Before

Width:  |  Height:  |  Size: 989 B

After

Width:  |  Height:  |  Size: 989 B

View File

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 982 B

View File

Before

Width:  |  Height:  |  Size: 967 B

After

Width:  |  Height:  |  Size: 967 B

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 321 B

After

Width:  |  Height:  |  Size: 321 B

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7.056000232696533 3 37.35095977783203 45">
<g xmlns="http://www.w3.org/2000/svg" clip-path="url(#a)">
<path fill="#002991"
d="M38.914 13.35c0 5.574-5.144 12.15-12.927 12.15H18.49l-.368 2.322L16.373 39H7.056l5.605-36h15.095c5.083 0 9.082 2.833 10.555 6.77a9.687 9.687 0 0 1 .603 3.58z">
</path>
<path fill="#60CDFF"
d="M44.284 23.7A12.894 12.894 0 0 1 31.53 34.5h-5.206L24.157 48H14.89l1.483-9 1.75-11.178.367-2.322h7.497c7.773 0 12.927-6.576 12.927-12.15 3.825 1.974 6.055 5.963 5.37 10.35z">
</path>
<path fill="#008CFF"
d="M38.914 13.35C37.31 12.511 35.365 12 33.248 12h-12.64L18.49 25.5h7.497c7.773 0 12.927-6.576 12.927-12.15z">
</path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 725 B

View File

Before

Width:  |  Height:  |  Size: 839 B

After

Width:  |  Height:  |  Size: 839 B

10
packages/assets/external/color/usdc.svg vendored Normal file
View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" data-name="86977684-12db-4850-8f30-233a7c267d11" viewBox="0 0 2000 2000">
<path d="M1000 2000c554.17 0 1000-445.83 1000-1000S1554.17 0 1000 0 0 445.83 0 1000s445.83 1000 1000 1000z"
fill="#2775ca" />
<path
d="M1275 1158.33c0-145.83-87.5-195.83-262.5-216.66-125-16.67-150-50-150-108.34s41.67-95.83 125-95.83c75 0 116.67 25 137.5 87.5 4.17 12.5 16.67 20.83 29.17 20.83h66.66c16.67 0 29.17-12.5 29.17-29.16v-4.17c-16.67-91.67-91.67-162.5-187.5-170.83v-100c0-16.67-12.5-29.17-33.33-33.34h-62.5c-16.67 0-29.17 12.5-33.34 33.34v95.83c-125 16.67-204.16 100-204.16 204.17 0 137.5 83.33 191.66 258.33 212.5 116.67 20.83 154.17 45.83 154.17 112.5s-58.34 112.5-137.5 112.5c-108.34 0-145.84-45.84-158.34-108.34-4.16-16.66-16.66-25-29.16-25h-70.84c-16.66 0-29.16 12.5-29.16 29.17v4.17c16.66 104.16 83.33 179.16 220.83 200v100c0 16.66 12.5 29.16 33.33 33.33h62.5c16.67 0 29.17-12.5 33.34-33.33v-100c125-20.84 208.33-108.34 208.33-220.84z"
fill="#fff" />
<path
d="M787.5 1595.83c-325-116.66-491.67-479.16-370.83-800 62.5-175 200-308.33 370.83-370.83 16.67-8.33 25-20.83 25-41.67V325c0-16.67-8.33-29.17-25-33.33-4.17 0-12.5 0-16.67 4.16-395.83 125-612.5 545.84-487.5 941.67 75 233.33 254.17 412.5 487.5 487.5 16.67 8.33 33.34 0 37.5-16.67 4.17-4.16 4.17-8.33 4.17-16.66v-58.34c0-12.5-12.5-29.16-25-37.5zM1229.17 295.83c-16.67-8.33-33.34 0-37.5 16.67-4.17 4.17-4.17 8.33-4.17 16.67v58.33c0 16.67 12.5 33.33 25 41.67 325 116.66 491.67 479.16 370.83 800-62.5 175-200 308.33-370.83 370.83-16.67 8.33-25 20.83-25 41.67V1700c0 16.67 8.33 29.17 25 33.33 4.17 0 12.5 0 16.67-4.16 395.83-125 612.5-545.84 487.5-941.67-75-237.5-258.34-416.67-487.5-491.67z"
fill="#fff" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<g transform="matrix(.124031 0 0 .124031 -.000001 56.062016)">
<rect y="-452" rx="61" height="516" width="516" fill="#3396cd" />
<path
d="M385.16-347c11.1 18.3 16.08 37.17 16.08 61 0 76-64.87 174.7-117.52 244H163.5l-48.2-288.35 105.3-10 25.6 205.17C270-174 299.43-235 299.43-276.56c0-22.77-3.9-38.25-10-51z"
fill="#fff" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 408 B

View File

@@ -1 +1,5 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>PayPal</title><path fill="currentColor" d="M7.016 19.198h-4.2a.562.562 0 0 1-.555-.65L5.093.584A.692.692 0 0 1 5.776 0h7.222c3.417 0 5.904 2.488 5.846 5.5-.006.25-.027.5-.066.747A6.794 6.794 0 0 1 12.071 12H8.743a.69.69 0 0 0-.682.583l-.325 2.056-.013.083-.692 4.39-.015.087zM19.79 6.142c-.01.087-.01.175-.023.261a7.76 7.76 0 0 1-7.695 6.598H9.007l-.283 1.795-.013.083-.692 4.39-.134.843-.014.088H6.86l-.497 3.15a.562.562 0 0 0 .555.65h3.612c.34 0 .63-.249.683-.585l.952-6.031a.692.692 0 0 1 .683-.584h2.126a6.793 6.793 0 0 0 6.707-5.752c.306-1.95-.466-3.744-1.89-4.906z"/></svg>
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>PayPal</title>
<path fill="currentColor"
d="M15.607 4.653H8.941L6.645 19.251H1.82L4.862 0h7.995c3.754 0 6.375 2.294 6.473 5.513-.648-.478-2.105-.86-3.722-.86m6.57 5.546c0 3.41-3.01 6.853-6.958 6.853h-2.493L11.595 24H6.74l1.845-11.538h3.592c4.208 0 7.346-3.634 7.153-6.949a5.24 5.24 0 0 1 2.848 4.686M9.653 5.546h6.408c.907 0 1.942.222 2.363.541-.195 2.741-2.655 5.483-6.441 5.483H8.714Z" />
</svg>

Before

Width:  |  Height:  |  Size: 658 B

After

Width:  |  Height:  |  Size: 481 B

5
packages/assets/external/polygon.svg vendored Normal file
View File

@@ -0,0 +1,5 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Polygon</title>
<path fill="currentColor"
d="m17.82 16.342 5.692-3.287A.98.98 0 0 0 24 12.21V5.635a.98.98 0 0 0-.488-.846l-5.693-3.286a.98.98 0 0 0-.977 0L11.15 4.789a.98.98 0 0 0-.489.846v11.747L6.67 19.686l-3.992-2.304v-4.61l3.992-2.304 2.633 1.52V8.896L7.158 7.658a.98.98 0 0 0-.977 0L.488 10.945a.98.98 0 0 0-.488.846v6.573a.98.98 0 0 0 .488.847l5.693 3.286a.981.981 0 0 0 .977 0l5.692-3.286a.98.98 0 0 0 .489-.846V6.618l.072-.041 3.92-2.263 3.99 2.305v4.609l-3.99 2.304-2.63-1.517v3.092l2.14 1.236a.981.981 0 0 0 .978 0v-.001Z" />
</svg>

After

Width:  |  Height:  |  Size: 626 B

View File

@@ -1 +1,5 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Tumblr</title><path fill="currentColor" d="M14.563 24c-5.093 0-7.031-3.756-7.031-6.411V9.747H5.116V6.648c3.63-1.313 4.512-4.596 4.71-6.469C9.84.051 9.941 0 9.999 0h3.517v6.114h4.801v3.633h-4.82v7.47c.016 1.001.375 2.371 2.207 2.371h.09c.631-.02 1.486-.205 1.936-.419l1.156 3.425c-.436.636-2.4 1.374-4.156 1.404h-.178l.011.002z"/></svg>
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Tumblr</title>
<path fill="currentColor"
d="M14.563 24c-5.093 0-7.031-3.756-7.031-6.411V9.747H5.116V6.648c3.63-1.313 4.512-4.596 4.71-6.469C9.84.051 9.941 0 9.999 0h3.517v6.114h4.801v3.633h-4.82v7.47c.016 1.001.375 2.371 2.207 2.371h.09c.631-.02 1.486-.205 1.936-.419l1.156 3.425c-.436.636-2.4 1.374-4.156 1.404h-.178l.011.002z" />
</svg>

Before

Width:  |  Height:  |  Size: 414 B

After

Width:  |  Height:  |  Size: 422 B

5
packages/assets/external/venmo.svg vendored Normal file
View File

@@ -0,0 +1,5 @@
<svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M56.4346 0C60.6127 0.000251497 63.9998 3.38727 64 7.56543V56.4346C63.9997 60.6127 60.6127 63.9997 56.4346 64H7.56543C3.38727 63.9998 0.0002515 60.6127 0 56.4346V7.56543C0.000249178 3.38727 3.38727 0.000249181 7.56543 0H56.4346ZM35.8984 15.4346C36.655 17.0159 37.1386 18.9358 37.1387 21.7598C37.1387 26.9145 33.4881 34.481 30.5361 39.2959L27.3613 13.8486L14.3008 15.0889L20.2793 50.8525H35.1904C41.7206 42.2572 49.7666 30.0151 49.7666 20.5889C49.7665 17.6334 49.1482 15.2931 47.7715 13.0234L35.8984 15.4346Z"
fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 625 B

5
packages/assets/external/visa.svg vendored Normal file
View File

@@ -0,0 +1,5 @@
<svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M24.2987 22.0321L15.92 42.0214H10.4533L6.33067 26.0667C6.08 25.0854 5.864 24.7254 5.10133 24.3121C3.85867 23.6374 1.80533 23.0054 0 22.6107L0.122667 22.0321H8.92267C9.49773 22.0315 10.0541 22.2365 10.4912 22.6101C10.9284 22.9837 11.2176 23.5013 11.3067 24.0694L13.4853 35.6374L18.8667 22.0321H24.2987ZM45.72 35.4961C45.7413 30.2187 38.424 29.9281 38.4747 27.5707C38.4907 26.8534 39.1733 26.0907 40.6667 25.8961C42.4168 25.7299 44.1793 26.0394 45.768 26.7921L46.6747 22.5521C45.1279 21.9706 43.4898 21.6699 41.8373 21.6641C36.7253 21.6641 33.128 24.3841 33.096 28.2747C33.064 31.1521 35.664 32.7547 37.624 33.7147C39.64 34.6934 40.3173 35.3227 40.3067 36.1974C40.2933 37.5414 38.7013 38.1307 37.2133 38.1547C34.6133 38.1947 33.1067 37.4534 31.9013 36.8934L30.9653 41.2721C32.1733 41.8267 34.4027 42.3121 36.7147 42.3334C42.1467 42.3334 45.7013 39.6507 45.72 35.4961ZM59.216 42.0214H64L59.8267 22.0321H55.4107C54.9388 22.0277 54.4766 22.1652 54.0838 22.4267C53.6911 22.6882 53.3859 23.0617 53.208 23.4987L45.4507 42.0214H50.88L51.96 39.0347H58.5947L59.216 42.0214ZM53.448 34.9387L56.168 27.4321L57.736 34.9387H53.448ZM31.688 22.0321L27.4133 42.0214H22.24L26.52 22.0321H31.688Z"
fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -6,8 +6,13 @@ import _AlignLeftIcon from './icons/align-left.svg?component'
import _ArchiveIcon from './icons/archive.svg?component'
import _ArrowBigRightDashIcon from './icons/arrow-big-right-dash.svg?component'
import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component'
import _ArrowDownIcon from './icons/arrow-down.svg?component'
import _ArrowLeftRightIcon from './icons/arrow-left-right.svg?component'
import _ArrowUpIcon from './icons/arrow-up.svg?component'
import _ArrowUpRightIcon from './icons/arrow-up-right.svg?component'
import _AsteriskIcon from './icons/asterisk.svg?component'
import _BadgeCheckIcon from './icons/badge-check.svg?component'
import _BadgeDollarSignIcon from './icons/badge-dollar-sign.svg?component'
import _BanIcon from './icons/ban.svg?component'
import _BellIcon from './icons/bell.svg?component'
import _BellRingIcon from './icons/bell-ring.svg?component'
@@ -72,12 +77,14 @@ import _FolderSearchIcon from './icons/folder-search.svg?component'
import _GameIcon from './icons/game.svg?component'
import _GapIcon from './icons/gap.svg?component'
import _GaugeIcon from './icons/gauge.svg?component'
import _GiftIcon from './icons/gift.svg?component'
import _GitGraphIcon from './icons/git-graph.svg?component'
import _GlassesIcon from './icons/glasses.svg?component'
import _GlobeIcon from './icons/globe.svg?component'
import _GridIcon from './icons/grid.svg?component'
import _HamburgerIcon from './icons/hamburger.svg?component'
import _HammerIcon from './icons/hammer.svg?component'
import _HandHelpingIcon from './icons/hand-helping.svg?component'
import _HashIcon from './icons/hash.svg?component'
import _Heading1Icon from './icons/heading-1.svg?component'
import _Heading2Icon from './icons/heading-2.svg?component'
@@ -94,6 +101,7 @@ import _IssuesIcon from './icons/issues.svg?component'
import _ItalicIcon from './icons/italic.svg?component'
import _KeyIcon from './icons/key.svg?component'
import _KeyboardIcon from './icons/keyboard.svg?component'
import _LandmarkIcon from './icons/landmark.svg?component'
import _LanguagesIcon from './icons/languages.svg?component'
import _LeftArrowIcon from './icons/left-arrow.svg?component'
import _LibraryIcon from './icons/library.svg?component'
@@ -104,6 +112,7 @@ import _ListBulletedIcon from './icons/list-bulleted.svg?component'
import _ListEndIcon from './icons/list-end.svg?component'
import _ListOrderedIcon from './icons/list-ordered.svg?component'
import _LoaderIcon from './icons/loader.svg?component'
import _LoaderCircleIcon from './icons/loader-circle.svg?component'
import _LockIcon from './icons/lock.svg?component'
import _LockOpenIcon from './icons/lock-open.svg?component'
import _LogInIcon from './icons/log-in.svg?component'
@@ -210,8 +219,13 @@ export const AlignLeftIcon = _AlignLeftIcon
export const ArchiveIcon = _ArchiveIcon
export const ArrowBigRightDashIcon = _ArrowBigRightDashIcon
export const ArrowBigUpDashIcon = _ArrowBigUpDashIcon
export const ArrowDownIcon = _ArrowDownIcon
export const ArrowLeftRightIcon = _ArrowLeftRightIcon
export const ArrowUpRightIcon = _ArrowUpRightIcon
export const ArrowUpIcon = _ArrowUpIcon
export const AsteriskIcon = _AsteriskIcon
export const BadgeCheckIcon = _BadgeCheckIcon
export const BadgeDollarSignIcon = _BadgeDollarSignIcon
export const BanIcon = _BanIcon
export const BellRingIcon = _BellRingIcon
export const BellIcon = _BellIcon
@@ -276,12 +290,14 @@ export const FolderSearchIcon = _FolderSearchIcon
export const GameIcon = _GameIcon
export const GapIcon = _GapIcon
export const GaugeIcon = _GaugeIcon
export const GiftIcon = _GiftIcon
export const GitGraphIcon = _GitGraphIcon
export const GlassesIcon = _GlassesIcon
export const GlobeIcon = _GlobeIcon
export const GridIcon = _GridIcon
export const HamburgerIcon = _HamburgerIcon
export const HammerIcon = _HammerIcon
export const HandHelpingIcon = _HandHelpingIcon
export const HashIcon = _HashIcon
export const Heading1Icon = _Heading1Icon
export const Heading2Icon = _Heading2Icon
@@ -298,6 +314,7 @@ export const IssuesIcon = _IssuesIcon
export const ItalicIcon = _ItalicIcon
export const KeyIcon = _KeyIcon
export const KeyboardIcon = _KeyboardIcon
export const LandmarkIcon = _LandmarkIcon
export const LanguagesIcon = _LanguagesIcon
export const LeftArrowIcon = _LeftArrowIcon
export const LibraryIcon = _LibraryIcon
@@ -307,6 +324,7 @@ export const ListBulletedIcon = _ListBulletedIcon
export const ListEndIcon = _ListEndIcon
export const ListOrderedIcon = _ListOrderedIcon
export const ListIcon = _ListIcon
export const LoaderCircleIcon = _LoaderCircleIcon
export const LoaderIcon = _LoaderIcon
export const LockOpenIcon = _LockOpenIcon
export const LockIcon = _LockIcon

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-icon lucide-arrow-down">
<path d="M12 5v14" />
<path d="m19 12-7 7-7-7" />
</svg>

After

Width:  |  Height:  |  Size: 275 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left-right-icon lucide-arrow-left-right"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/></svg>

After

Width:  |  Height:  |  Size: 344 B

View File

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-arrow-up-right-icon lucide-arrow-up-right"
>
<path d="M7 7h10v10" />
<path d="M7 17 17 7" />
</svg>

After

Width:  |  Height:  |  Size: 314 B

View File

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-arrow-up-icon lucide-arrow-up"
>
<path d="m5 12 7-7 7 7" />
<path d="M12 19V5" />
</svg>

After

Width:  |  Height:  |  Size: 303 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-dollar-sign-icon lucide-badge-dollar-sign">
<path
d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z" />
<path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8" />
<path d="M12 18V6" />
</svg>

After

Width:  |  Height:  |  Size: 491 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-gift-icon lucide-gift"><rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13"/><path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"/><path d="M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5"/></svg>

After

Width:  |  Height:  |  Size: 441 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hand-helping-icon lucide-hand-helping">
<path d="M11 12h2a2 2 0 1 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 14" />
<path d="m7 18 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91l-4.2 3.9" />
<path d="m2 13 6 6" />
</svg>

After

Width:  |  Height:  |  Size: 420 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-landmark-icon lucide-landmark">
<path d="M10 18v-7" />
<path d="M11.12 2.198a2 2 0 0 1 1.76.006l7.866 3.847c.476.233.31.949-.22.949H3.474c-.53 0-.695-.716-.22-.949z" />
<path d="M14 18v-7" />
<path d="M18 18v-7" />
<path d="M3 22h18" />
<path d="M6 18v-7" />
</svg>

After

Width:  |  Height:  |  Size: 475 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-loader-circle-icon lucide-loader-circle"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>

After

Width:  |  Height:  |  Size: 288 B

View File

@@ -27,6 +27,15 @@ import _WavingRinthbot from './branding/rinthbot/waving.webp'
import _AppleIcon from './external/apple.svg?component'
import _BlueskyIcon from './external/bluesky.svg?component'
import _BuyMeACoffeeIcon from './external/bmac.svg?component'
import _DiscordColorIcon from './external/color/discord.svg?component'
import _GitHubColorIcon from './external/color/github.svg?component'
import _GitLabColorIcon from './external/color/gitlab.svg?component'
import _GoogleColorIcon from './external/color/google.svg?component'
import _MicrosoftColorIcon from './external/color/microsoft.svg?component'
import _PayPalColorIcon from './external/color/paypal.svg?component'
import _SteamColorIcon from './external/color/steam.svg?component'
import _USDCColorIcon from './external/color/usdc.svg?component'
import _VenmoColorIcon from './external/color/venmo.svg?component'
import _CurseForgeIcon from './external/curseforge.svg?component'
import _DiscordIcon from './external/discord.svg?component'
import _FacebookIcon from './external/facebook.svg?component'
@@ -37,20 +46,17 @@ import _MastodonIcon from './external/mastodon.svg?component'
import _OpenCollectiveIcon from './external/opencollective.svg?component'
import _PatreonIcon from './external/patreon.svg?component'
import _PayPalIcon from './external/paypal.svg?component'
import _PolygonIcon from './external/polygon.svg?component'
import _RedditIcon from './external/reddit.svg?component'
import _ReelsIcon from './external/reels.svg?component'
import _SnapchatIcon from './external/snapchat.svg?component'
import _SSODiscordIcon from './external/sso/discord.svg?component'
import _SSOGitHubIcon from './external/sso/github.svg?component'
import _SSOGitLabIcon from './external/sso/gitlab.svg?component'
import _SSOGoogleIcon from './external/sso/google.svg?component'
import _SSOMicrosoftIcon from './external/sso/microsoft.svg?component'
import _SSOSteamIcon from './external/sso/steam.svg?component'
import _ThreadsIcon from './external/threads.svg?component'
import _TikTokIcon from './external/tiktok.svg?component'
import _TumblrIcon from './external/tumblr.svg?component'
import _TwitchIcon from './external/twitch.svg?component'
import _TwitterIcon from './external/twitter.svg?component'
import _VenmoIcon from './external/venmo.svg?component'
import _VisaIcon from './external/visa.svg?component'
import _WindowsIcon from './external/windows.svg?component'
import _YouTubeIcon from './external/youtube.svg?component'
import _YouTubeGaming from './external/youtubegaming.svg?component'
@@ -70,12 +76,14 @@ export const SleepingRinthbot = _SleepingRinthbot
export const SobbingRinthbot = _SobbingRinthbot
export const ThinkingRinthbot = _ThinkingRinthbot
export const WavingRinthbot = _WavingRinthbot
export const SSODiscordIcon = _SSODiscordIcon
export const SSOGitHubIcon = _SSOGitHubIcon
export const SSOGitLabIcon = _SSOGitLabIcon
export const SSOGoogleIcon = _SSOGoogleIcon
export const SSOMicrosoftIcon = _SSOMicrosoftIcon
export const SSOSteamIcon = _SSOSteamIcon
export const PayPalColorIcon = _PayPalColorIcon
export const VenmoColorIcon = _VenmoColorIcon
export const DiscordColorIcon = _DiscordColorIcon
export const GitHubColorIcon = _GitHubColorIcon
export const GitLabColorIcon = _GitLabColorIcon
export const GoogleColorIcon = _GoogleColorIcon
export const MicrosoftColorIcon = _MicrosoftColorIcon
export const SteamColorIcon = _SteamColorIcon
export const AppleIcon = _AppleIcon
export const BlueskyIcon = _BlueskyIcon
export const BuyMeACoffeeIcon = _BuyMeACoffeeIcon
@@ -101,6 +109,10 @@ export const WindowsIcon = _WindowsIcon
export const YouTubeIcon = _YouTubeIcon
export const YouTubeGaming = _YouTubeGaming
export const YouTubeShortsIcon = _YouTubeShortsIcon
export const VenmoIcon = _VenmoIcon
export const PolygonIcon = _PolygonIcon
export const USDCColorIcon = _USDCColorIcon
export const VisaIcon = _VisaIcon
export * from './generated-icons'
export { default as ClassicPlayerModel } from './models/classic-player.gltf?url'

View File

@@ -1,23 +1,24 @@
<template>
<div
:class="[
'flex rounded-2xl border-2 border-solid p-4 gap-4 font-semibold text-contrast',
'flex flex-col rounded-2xl border-[1px] border-solid p-4 gap-3 text-contrast',
typeClasses[type],
]"
>
<component
:is="icons[type]"
:class="['hidden h-8 w-8 flex-none sm:block', iconClasses[type]]"
/>
<div class="flex flex-col gap-2">
<div class="font-semibold flex justify-between gap-4">
<slot name="header">{{ header }}</slot>
<div :class="['flex gap-2 items-start', (header || $slots.header) && 'flex-col']">
<div class="flex gap-2 items-start" :class="header || $slots.header ? 'w-full' : 'contents'">
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
<component :is="icons[type]" :class="['h-6 w-6 flex-none', iconClasses[type]]" />
</slot>
<div v-if="header || $slots.header" class="font-semibold text-base">
<slot name="header">{{ header }}</slot>
</div>
</div>
<div class="font-normal text-sm sm:text-base">
<div class="font-normal text-base" :class="!(header || $slots.header) && 'flex-1'">
<slot>{{ body }}</slot>
</div>
</div>
<div class="ml-auto w-fit">
<div v-if="showActionsUnderneath || $slots.actions">
<slot name="actions" />
</div>
</div>
@@ -26,20 +27,20 @@
<script setup lang="ts">
import { InfoIcon, IssuesIcon, XCircleIcon } from '@modrinth/assets'
defineProps({
type: {
type: String as () => 'info' | 'warning' | 'critical',
default: 'info',
withDefaults(
defineProps<{
type?: 'info' | 'warning' | 'critical'
header?: string
body?: string
showActionsUnderneath?: boolean
}>(),
{
type: 'info',
header: '',
body: '',
showActionsUnderneath: false,
},
header: {
type: String,
default: '',
},
body: {
type: String,
default: '',
},
})
)
const typeClasses = {
info: 'border-brand-blue bg-bg-blue',

View File

@@ -0,0 +1,5 @@
<template>
<div
class="min-w-1.5 min-h-1.5 max-h-1.5 max-w-1.5 mx-0.5 rounded-full bg-surface-5 inline-block my-auto align-middle"
></div>
</template>

View File

@@ -6,7 +6,7 @@ const props = withDefaults(
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'medal-promo'
size?: 'standard' | 'large' | 'small'
circular?: boolean
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text'
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text' | 'chip'
colorFill?: 'auto' | 'background' | 'text' | 'none'
hoverColorFill?: 'auto' | 'background' | 'text' | 'none'
highlightedStyle?: 'main-nav-primary' | 'main-nav-secondary'
@@ -172,12 +172,16 @@ const colorVariables = computed(() => {
: 'var(--color-button-bg)',
text: 'var(--color-contrast)',
icon:
props.highlightedStyle === 'main-nav-primary'
? 'var(--color-brand)'
: 'var(--color-contrast)',
props.type === 'chip'
? 'var(--color-contrast)'
: props.highlightedStyle === 'main-nav-primary'
? 'var(--color-brand)'
: 'var(--color-contrast)',
}
const hoverColors = JSON.parse(JSON.stringify(colors))
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_icon: ${colors.icon}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_hover-icon: ${hoverColors.icon};`
const boxShadow =
props.type === 'chip' && colorVar.value ? `0 0 0 2px ${colorVar.value}` : 'none'
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_icon: ${colors.icon}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_hover-icon: ${hoverColors.icon}; --_box-shadow: ${boxShadow};`
}
let colors = {
@@ -197,6 +201,14 @@ const colorVariables = computed(() => {
hoverColors,
props.hoverColorFill === 'auto' ? 'text' : props.hoverColorFill,
)
} else if (props.type === 'chip') {
// Chip type uses highlight-colored-text styling when colored
if (colorVar.value && highlightedColorVar.value) {
colors.bg = highlightedColorVar.value
colors.text = colorVar.value
hoverColors.bg = highlightedColorVar.value
hoverColors.text = colorVar.value
}
} else {
colors = setColorFill(colors, props.colorFill === 'auto' ? 'background' : props.colorFill)
hoverColors = setColorFill(
@@ -205,7 +217,8 @@ const colorVariables = computed(() => {
)
}
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text};`
const boxShadow = props.type === 'chip' && colorVar.value ? `0 0 0 2px ${colorVar.value}` : 'none'
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_box-shadow: ${boxShadow};`
})
const fontSize = computed(() => {
@@ -219,7 +232,7 @@ const fontSize = computed(() => {
<template>
<div
class="btn-wrapper"
:class="[{ outline: type === 'outlined' }, fontSize]"
:class="[{ outline: type === 'outlined', chip: type === 'chip' }, fontSize]"
:style="`${colorVariables}--_height:${height};--_width:${width};--_radius: ${radius};--_padding-x:${paddingX};--_padding-y:${paddingY};--_gap:${gap};--_font-weight:${fontWeight};--_icon-size:${iconSize};`"
>
<slot />
@@ -242,10 +255,12 @@ const fontSize = computed(() => {
> *:first-child
> :is(button, a, .button-like):first-child {
@apply flex cursor-pointer flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight] whitespace-nowrap;
box-shadow: var(--_box-shadow, inset 0 0 0 transparent);
transition:
scale 0.125s ease-in-out,
background-color 0.25s ease-in-out,
color 0.25s ease-in-out;
color 0.25s ease-in-out,
filter 0.25s ease-in-out;
svg:first-child {
color: var(--_icon, var(--_text));
@@ -267,7 +282,7 @@ const fontSize = computed(() => {
}
&:not([disabled]):not([disabled='true']):not(.disabled) {
@apply active:scale-95 hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text];
@apply hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text];
&:hover svg:first-child,
&:focus-visible svg:first-child {
@@ -276,6 +291,20 @@ const fontSize = computed(() => {
}
}
.btn-wrapper:not(.chip) :deep(:is(button, a, .button-like):first-child),
.btn-wrapper:not(.chip) :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper:not(.chip) :slotted(*) > :is(button, a, .button-like):first-child,
.btn-wrapper:not(.chip) :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
.btn-wrapper:not(.chip)
:slotted(*)
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child {
&:not([disabled]):not([disabled='true']):not(.disabled) {
@apply active:scale-95;
}
}
.btn-wrapper.outline :deep(:is(button, a, .button-like):first-child),
.btn-wrapper.outline :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper.outline :slotted(*) > :is(button, a, .button-like):first-child,

View File

@@ -0,0 +1,539 @@
<template>
<div ref="containerRef" class="relative inline-block w-full">
<span
ref="triggerRef"
role="button"
tabindex="0"
class="max-h-[36px] relative cursor-pointer flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-button-bg px-4 py-2.5 text-left transition-all duration-200 text-button-text hover:bg-button-bgHover active:bg-button-bgActive"
:class="[
triggerClasses,
{
'z-[9999]': isOpen,
'rounded-b-none': shouldRoundBottomCorners,
'rounded-t-none': shouldRoundTopCorners,
'cursor-not-allowed opacity-50': disabled,
},
]"
:aria-expanded="isOpen"
:aria-haspopup="listbox ? 'listbox' : 'menu'"
:aria-disabled="disabled || undefined"
@click="handleTriggerClick"
@keydown="handleTriggerKeydown"
>
<div class="flex items-center gap-2">
<slot name="prefix"></slot>
<span class="text-primary font-semibold leading-tight">
<slot name="selected">{{ triggerText }}</slot>
</span>
</div>
<div class="flex items-center gap-1">
<slot name="suffix"></slot>
<ChevronLeftIcon
v-if="showChevron"
class="size-5 shrink-0 transition-transform duration-300"
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
/>
</div>
</span>
<Teleport to="#teleports">
<div
v-if="isOpen"
ref="dropdownRef"
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 !border-solid border-0 shadow-2xl"
:class="[
shouldRoundBottomCorners
? 'rounded-t-none !border-t-[1px] !border-t-surface-5'
: 'rounded-b-none !border-b-[1px] !border-b-surface-5',
]"
:style="dropdownStyle"
:role="listbox ? 'listbox' : 'menu'"
@mousedown.stop
@keydown="handleDropdownKeydown"
>
<div v-if="searchable" class="p-4">
<div class="iconified-input w-full border-surface-5 border-[1px] border-solid rounded-xl">
<SearchIcon aria-hidden="true" />
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
:placeholder="searchPlaceholder"
class=""
@keydown.stop="handleSearchKeydown"
/>
</div>
</div>
<div v-if="searchable && filteredOptions.length > 0" class="h-px bg-surface-5"></div>
<div
v-if="filteredOptions.length > 0"
ref="optionsContainerRef"
class="flex flex-col gap-2 overflow-y-auto p-3"
:style="{ maxHeight: `${maxHeight}px` }"
>
<template v-for="(item, index) in filteredOptions" :key="item.key">
<div v-if="item.type === 'divider'" class="h-px bg-surface-5"></div>
<component
:is="item.type === 'link' ? 'a' : 'span'"
v-else
:ref="(el: HTMLElement) => setOptionRef(el as HTMLElement, index)"
:href="item.type === 'link' && !item.disabled ? item.href : undefined"
:target="item.type === 'link' && !item.disabled ? item.target : undefined"
:role="listbox ? 'option' : 'menuitem'"
:aria-selected="listbox && item.value === modelValue"
:aria-disabled="item.disabled || undefined"
:data-focused="focusedIndex === index"
class="flex items-center gap-2.5 cursor-pointer rounded-xl p-3 text-left transition-colors duration-150 text-contrast hover:bg-surface-5 focus:bg-surface-5"
:class="getOptionClasses(item, index)"
tabindex="-1"
@click="handleOptionClick(item, index)"
@mouseenter="!item.disabled && (focusedIndex = index)"
>
<slot :name="`option-${item.value}`" :item="item">
<div class="flex items-center gap-2">
<component :is="item.icon" v-if="item.icon" class="h-5 w-5" />
<span
class="font-semibold leading-tight"
:class="item.value === modelValue ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
</div>
</slot>
</component>
</template>
</div>
<div v-else-if="searchQuery" class="p-4 mb-2 text-center text-sm text-secondary">
No results found
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts" generic="T">
import { ChevronLeftIcon, SearchIcon } from '@modrinth/assets'
import { onClickOutside } from '@vueuse/core'
import {
type Component,
computed,
nextTick,
onMounted,
onUnmounted,
ref,
useSlots,
watch,
} from 'vue'
export interface DropdownOption<T> {
value: T
label: string
icon?: Component
disabled?: boolean
class?: string
type?: 'button' | 'link' | 'divider'
href?: string
target?: string
action?: () => void
}
const DROPDOWN_VIEWPORT_MARGIN = 8
const DEFAULT_MAX_HEIGHT = 300
function isDropdownOption<T>(
opt: DropdownOption<T> | { type: 'divider' },
): opt is DropdownOption<T> {
return 'value' in opt
}
function isDivider<T>(opt: DropdownOption<T> | { type: 'divider' }): opt is { type: 'divider' } {
return opt.type === 'divider'
}
const props = withDefaults(
defineProps<{
modelValue?: T
options: (DropdownOption<T> | { type: 'divider' })[]
placeholder?: string
disabled?: boolean
searchable?: boolean
searchPlaceholder?: string
listbox?: boolean
showChevron?: boolean
maxHeight?: number
displayValue?: string
extraPosition?: 'top' | 'bottom'
triggerClass?: string
forceDirection?: 'up' | 'down'
}>(),
{
placeholder: 'Select an option',
disabled: false,
searchable: false,
searchPlaceholder: 'Search...',
listbox: true,
showChevron: true,
maxHeight: DEFAULT_MAX_HEIGHT,
extraPosition: 'bottom',
},
)
const emit = defineEmits<{
'update:modelValue': [value: T]
select: [option: DropdownOption<T>]
open: []
close: []
}>()
const slots = useSlots()
const isOpen = ref(false)
const searchQuery = ref('')
const focusedIndex = ref(-1)
const containerRef = ref<HTMLElement>()
const triggerRef = ref<HTMLElement>()
const dropdownRef = ref<HTMLElement>()
const searchInputRef = ref<HTMLInputElement>()
const optionsContainerRef = ref<HTMLElement>()
const optionRefs = ref<(HTMLElement | null)[]>([])
const dropdownStyle = ref({
top: '0px',
left: '0px',
width: '0px',
})
const openDirection = ref<'down' | 'up'>('down')
const triggerClasses = computed(() => {
const classes = [props.triggerClass]
if (isOpen.value) {
if (props.extraPosition === 'bottom' && slots?.extra) {
classes.push('!rounded-b-none')
} else if (props.extraPosition === 'top' && slots?.extra) {
classes.push('!rounded-t-none')
}
}
return classes
})
const selectedOption = computed<DropdownOption<T> | undefined>(() => {
return props.options.find(
(opt): opt is DropdownOption<T> => isDropdownOption(opt) && opt.value === props.modelValue,
)
})
const triggerText = computed(() => {
if (props.displayValue !== undefined) return props.displayValue
if (selectedOption.value) return selectedOption.value.label
return props.placeholder
})
const optionsWithKeys = computed(() => {
return props.options.map((opt, index) => ({
...opt,
key: isDivider(opt) ? `divider-${index}` : `option-${opt.value}`,
}))
})
const filteredOptions = computed(() => {
if (!searchQuery.value || !props.searchable) {
return optionsWithKeys.value
}
const query = searchQuery.value.toLowerCase()
return optionsWithKeys.value.filter((opt) => {
if (isDivider(opt)) return false
return opt.label.toLowerCase().includes(query)
})
})
const shouldRoundBottomCorners = computed(() => isOpen.value && openDirection.value === 'down')
const shouldRoundTopCorners = computed(() => isOpen.value && openDirection.value === 'up')
function getOptionClasses(item: DropdownOption<T> & { key: string }, index: number) {
return [
item.class,
{
'bg-surface-5':
(props.listbox && item.value === props.modelValue) ||
(focusedIndex.value === index && !(props.listbox && item.value === props.modelValue)),
'cursor-not-allowed opacity-50 pointer-events-none': item.disabled,
},
]
}
function setOptionRef(el: HTMLElement | null, index: number) {
optionRefs.value[index] = el
}
function setInitialFocus() {
focusedIndex.value = props.listbox
? props.options.findIndex((opt) => isDropdownOption(opt) && opt.value === props.modelValue)
: -1
if (focusedIndex.value >= 0 && optionRefs.value[focusedIndex.value]) {
optionRefs.value[focusedIndex.value]?.scrollIntoView({ block: 'center' })
}
}
function focusSearchInput() {
if (props.searchable && searchInputRef.value) {
searchInputRef.value.focus()
}
}
function determineOpenDirection(
triggerRect: DOMRect,
dropdownRect: DOMRect,
viewportHeight: number,
): 'up' | 'down' {
if (props.forceDirection) {
return props.forceDirection
}
const hasSpaceBelow =
triggerRect.bottom + dropdownRect.height + DROPDOWN_VIEWPORT_MARGIN <= viewportHeight
const hasSpaceAbove = triggerRect.top - dropdownRect.height - DROPDOWN_VIEWPORT_MARGIN > 0
return !hasSpaceBelow && hasSpaceAbove ? 'up' : 'down'
}
function calculateVerticalPosition(
triggerRect: DOMRect,
dropdownRect: DOMRect,
direction: 'up' | 'down',
): number {
return direction === 'up' ? triggerRect.top - dropdownRect.height : triggerRect.bottom
}
function calculateHorizontalPosition(
triggerRect: DOMRect,
dropdownRect: DOMRect,
viewportWidth: number,
): number {
let left = triggerRect.left
if (left + dropdownRect.width > viewportWidth - DROPDOWN_VIEWPORT_MARGIN) {
left = Math.max(
DROPDOWN_VIEWPORT_MARGIN,
viewportWidth - dropdownRect.width - DROPDOWN_VIEWPORT_MARGIN,
)
}
return left
}
async function updateDropdownPosition() {
if (!triggerRef.value || !dropdownRef.value) return
await nextTick()
const triggerRect = triggerRef.value.getBoundingClientRect()
const dropdownRect = dropdownRef.value.getBoundingClientRect()
const viewportHeight = window.innerHeight
const viewportWidth = window.innerWidth
const direction = determineOpenDirection(triggerRect, dropdownRect, viewportHeight)
const top = calculateVerticalPosition(triggerRect, dropdownRect, direction)
const left = calculateHorizontalPosition(triggerRect, dropdownRect, viewportWidth)
dropdownStyle.value = {
top: `${top}px`,
left: `${left}px`,
width: `${triggerRect.width}px`,
}
openDirection.value = direction
}
async function openDropdown() {
if (props.disabled || isOpen.value) return
isOpen.value = true
searchQuery.value = ''
emit('open')
await nextTick()
await updateDropdownPosition()
setInitialFocus()
focusSearchInput()
}
function closeDropdown() {
if (!isOpen.value) return
isOpen.value = false
searchQuery.value = ''
focusedIndex.value = -1
emit('close')
nextTick(() => {
triggerRef.value?.focus()
})
}
function handleTriggerClick() {
if (isOpen.value) {
closeDropdown()
} else {
openDropdown()
}
}
function handleOptionClick(option: DropdownOption<T>, index: number) {
if (option.disabled || option.type === 'divider') return
focusedIndex.value = index
if (option.action) {
option.action()
}
if (props.listbox && option.value !== undefined) {
emit('update:modelValue', option.value)
}
emit('select', option)
if (option.type !== 'link') {
closeDropdown()
}
}
function findNextFocusableOption(currentIndex: number, direction: 'next' | 'previous'): number {
const length = filteredOptions.value.length
let index = currentIndex
let option
do {
index = direction === 'next' ? (index + 1) % length : (index - 1 + length) % length
option = filteredOptions.value[index]
} while (isDivider(option) || option.disabled)
return index
}
function focusOption(index: number) {
if (index < 0 || index >= filteredOptions.value.length) return
const option = filteredOptions.value[index]
if (isDivider(option) || option.disabled) return
focusedIndex.value = index
optionRefs.value[index]?.focus()
optionRefs.value[index]?.scrollIntoView({ block: 'nearest' })
}
function focusNextOption() {
const nextIndex = findNextFocusableOption(focusedIndex.value, 'next')
focusOption(nextIndex)
}
function focusPreviousOption() {
const prevIndex = findNextFocusableOption(focusedIndex.value, 'previous')
focusOption(prevIndex)
}
function handleTriggerKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'Enter':
case ' ':
case 'ArrowDown':
event.preventDefault()
openDropdown()
break
case 'ArrowUp':
event.preventDefault()
openDropdown()
break
}
}
function handleDropdownKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'Escape':
event.preventDefault()
closeDropdown()
break
case 'ArrowDown':
event.preventDefault()
focusNextOption()
break
case 'ArrowUp':
event.preventDefault()
focusPreviousOption()
break
case 'Enter':
case ' ':
event.preventDefault()
if (focusedIndex.value >= 0) {
const option = filteredOptions.value[focusedIndex.value]
if (!isDivider(option)) {
handleOptionClick(option, focusedIndex.value)
}
}
break
case 'Tab':
event.preventDefault()
if (event.shiftKey) {
focusPreviousOption()
} else {
focusNextOption()
}
break
}
}
function handleSearchKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
closeDropdown()
} else if (event.key === 'ArrowDown') {
event.preventDefault()
focusNextOption()
} else if (event.key === 'ArrowUp') {
event.preventDefault()
focusPreviousOption()
}
}
function handleWindowResize() {
if (isOpen.value) {
updateDropdownPosition()
}
}
onClickOutside(
dropdownRef,
() => {
closeDropdown()
},
{ ignore: [triggerRef] },
)
onMounted(() => {
window.addEventListener('resize', handleWindowResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleWindowResize)
})
watch(isOpen, (value) => {
if (value) {
updateDropdownPosition()
}
})
watch(filteredOptions, () => {
if (isOpen.value) {
updateDropdownPosition()
}
})
</script>

View File

@@ -1,441 +0,0 @@
<template>
<div
ref="dropdown"
data-pyro-dropdown
tabindex="0"
role="combobox"
:aria-expanded="dropdownVisible"
class="relative inline-block h-9 w-full max-w-80"
@focus="onFocus"
@blur="onBlur"
@mousedown.prevent
@keydown="handleKeyDown"
>
<div
data-pyro-dropdown-trigger
class="duration-50 flex h-full w-full cursor-pointer select-none items-center justify-between gap-4 rounded-xl bg-button-bg px-4 py-2 shadow-sm transition-all ease-in-out"
:class="triggerClasses"
@click="toggleDropdown"
>
<span>{{ selectedOption }}</span>
<DropdownIcon
class="transition-transform duration-200 ease-in-out"
:class="{ 'rotate-180': dropdownVisible }"
/>
</div>
<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"
ref="optionsContainer"
data-pyro-dropdown-options
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg"
:class="{
'rounded-b-xl': !isRenderingUp,
'rounded-t-xl': isRenderingUp,
}"
:style="positionStyle"
@keydown.stop="handleDropdownKeyDown"
>
<div
class="overflow-y-auto"
:style="{ height: `${virtualListHeight}px` }"
data-pyro-dropdown-options-virtual-scroller
@scroll="handleScroll"
>
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
<div
v-for="item in visibleOptions"
:key="item.index"
data-pyro-dropdown-option
:style="{
position: 'absolute',
top: 0,
transform: `translateY(${item.index * ITEM_HEIGHT}px)`,
width: '100%',
height: `${ITEM_HEIGHT}px`,
}"
>
<div
:ref="(el) => handleOptionRef(el as HTMLElement, item.index)"
role="option"
:tabindex="focusedOptionIndex === item.index ? 0 : -1"
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out focus:border-none focus:outline-none"
:class="{
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
'bg-bg-raised': focusedOptionIndex === item.index,
}"
:aria-selected="selectedValue === item.option"
@click="selectOption(item.option, item.index)"
@mouseover="focusedOptionIndex = item.index"
@focus="focusedOptionIndex = item.index"
>
<input
:id="`${name}-${item.index}`"
v-model="radioValue"
type="radio"
:value="item.option"
:name="name"
class="hidden"
/>
<label :for="`${name}-${item.index}`" class="w-full cursor-pointer">
{{ displayName(item.option) }}
</label>
</div>
</div>
</div>
</div>
</div>
</transition>
</Teleport>
</div>
</template>
<script setup lang="ts" generic="OptionValue extends string | number | Record<string, any>">
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
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 focusedOptionRef = ref<HTMLElement | null>(null)
const dropdown = ref<HTMLElement | null>(null)
const optionsContainer = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const isRenderingUp = ref(false)
const virtualListHeight = ref(300)
const lastFocusedElement = ref<HTMLElement | null>(null)
const positionStyle = ref<CSSProperties>({
position: 'fixed',
top: '0px',
left: '0px',
width: '0px',
zIndex: 999,
})
const handleOptionRef = (el: HTMLElement | null, index: number) => {
if (focusedOptionIndex.value === index) {
focusedOptionRef.value = el
}
}
const onFocus = async () => {
if (!props.disabled) {
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
lastFocusedElement.value = document.activeElement as HTMLElement
dropdownVisible.value = true
await updatePosition()
nextTick(() => {
dropdown.value?.focus()
})
}
}
const onBlur = (event: FocusEvent) => {
if (!isChildOfDropdown(event.relatedTarget as HTMLElement | null)) {
closeDropdown()
}
}
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
let currentNode: HTMLElement | null = element
while (currentNode) {
if (currentNode === dropdown.value || currentNode === optionsContainer.value) {
return true
}
currentNode = currentNode.parentElement
}
return false
}
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 (!dropdown.value) return
await nextTick()
const triggerRect = dropdown.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 openDropdown = async () => {
if (!props.disabled) {
closeAllDropdowns()
dropdownVisible.value = true
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
lastFocusedElement.value = document.activeElement as HTMLElement
await updatePosition()
requestAnimationFrame(() => {
updatePosition()
})
}
}
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 handleKeyDown = (event: KeyboardEvent) => {
if (!dropdownVisible.value) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
lastFocusedElement.value = document.activeElement as HTMLElement
toggleDropdown()
}
} else {
handleDropdownKeyDown(event)
}
}
const handleDropdownKeyDown = (event: KeyboardEvent) => {
event.stopPropagation()
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
focusNextOption()
break
case 'ArrowUp':
event.preventDefault()
focusPreviousOption()
break
case 'Enter':
event.preventDefault()
if (focusedOptionIndex.value !== null) {
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value)
}
break
case 'Escape':
event.preventDefault()
event.stopPropagation()
closeDropdown()
break
case 'Tab':
event.preventDefault()
if (event.shiftKey) {
focusPreviousOption()
} else {
focusNextOption()
}
break
}
}
const closeDropdown = () => {
dropdownVisible.value = false
focusedOptionIndex.value = null
if (lastFocusedElement.value) {
lastFocusedElement.value.focus()
lastFocusedElement.value = null
}
}
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()
nextTick(() => {
focusedOptionRef.value?.focus()
})
}
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()
nextTick(() => {
focusedOptionRef.value?.focus()
})
}
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
}
}
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)
})
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)
lastFocusedElement.value = null
})
watch(
() => props.modelValue,
(newValue) => {
selectedValue.value = newValue
},
)
watch(dropdownVisible, async (newValue) => {
if (newValue) {
await updatePosition()
scrollTop.value = 0
}
})
</script>

View File

@@ -6,6 +6,7 @@ export { default as AutoBrandIcon } from './base/AutoBrandIcon.vue'
export { default as AutoLink } from './base/AutoLink.vue'
export { default as Avatar } from './base/Avatar.vue'
export { default as Badge } from './base/Badge.vue'
export { default as BulletDivider } from './base/BulletDivider.vue'
export { default as Button } from './base/Button.vue'
export { default as ButtonStyled } from './base/ButtonStyled.vue'
export { default as Card } from './base/Card.vue'
@@ -13,6 +14,7 @@ export { default as Checkbox } from './base/Checkbox.vue'
export { default as Chips } from './base/Chips.vue'
export { default as Collapsible } from './base/Collapsible.vue'
export { default as CollapsibleRegion } from './base/CollapsibleRegion.vue'
export { default as Combobox } from './base/Combobox.vue'
export { default as ContentPageHeader } from './base/ContentPageHeader.vue'
export { default as CopyCode } from './base/CopyCode.vue'
export { default as DoubleIcon } from './base/DoubleIcon.vue'
@@ -50,7 +52,6 @@ export { default as Slider } from './base/Slider.vue'
export { default as SmartClickable } from './base/SmartClickable.vue'
export { default as StatItem } from './base/StatItem.vue'
export { default as TagItem } from './base/TagItem.vue'
export { default as TeleportDropdownMenu } from './base/TeleportDropdownMenu.vue'
export { default as Timeline } from './base/Timeline.vue'
export { default as Toggle } from './base/Toggle.vue'
export { default as UnsavedChangesPopup } from './base/UnsavedChangesPopup.vue'

Some files were not shown because too many files have changed in this diff Show More