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>
@@ -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
@@ -65,3 +65,6 @@ app-playground-data/*
|
||||
|
||||
.astro
|
||||
.claude
|
||||
|
||||
# labrinth demo fixtures
|
||||
apps/labrinth/fixtures/demo
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/**',
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -53,12 +53,15 @@
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
const loading = useLoading()
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
const api = computed(() => {
|
||||
if (flags.value.demoMode) return 'prod'
|
||||
|
||||
const apiUrl = config.public.apiBaseUrl
|
||||
if (apiUrl.startsWith('https://api.modrinth.com')) {
|
||||
return 'prod'
|
||||
|
||||
@@ -90,7 +90,7 @@ defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const tags = useTags()
|
||||
const tags = useGeneratedState()
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.environment {
|
||||
|
||||
@@ -1,32 +1,134 @@
|
||||
<template>
|
||||
<nav>
|
||||
<ul>
|
||||
<slot />
|
||||
<nav :aria-label="ariaLabel" class="w-full">
|
||||
<ul class="m-0 flex list-none flex-col items-start gap-1.5 rounded-2xl bg-bg-raised p-4">
|
||||
<slot v-if="hasSlotContent" />
|
||||
|
||||
<template v-else>
|
||||
<li v-for="(item, idx) in filteredItems" :key="getKey(item, idx)" class="contents">
|
||||
<hr v-if="isSeparator(item)" class="my-1 w-full border-t border-solid" />
|
||||
|
||||
<div
|
||||
v-else-if="isHeading(item)"
|
||||
class="px-4 pb-1 pt-2 text-xs font-bold uppercase tracking-wide text-secondary"
|
||||
>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
|
||||
<NuxtLink
|
||||
v-else-if="item.link ?? item.to"
|
||||
:to="(item.link ?? item.to) as string"
|
||||
class="nav-item inline-flex w-full cursor-pointer items-center gap-2 text-nowrap rounded-xl border-none bg-transparent px-4 py-2.5 text-left text-base font-semibold leading-tight text-button-text transition-all hover:bg-button-bg hover:text-contrast active:scale-[0.97]"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
v-if="item.icon"
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 shrink-0"
|
||||
/>
|
||||
<span class="text-contrast">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.badge != null"
|
||||
class="rounded-full bg-brand-highlight px-2 text-sm font-bold text-brand"
|
||||
>
|
||||
{{ String(item.badge) }}
|
||||
</span>
|
||||
<span v-if="item.chevron" class="ml-auto"><ChevronRightIcon /></span>
|
||||
</NuxtLink>
|
||||
|
||||
<button
|
||||
v-else-if="item.action"
|
||||
class="nav-item inline-flex w-full cursor-pointer items-center gap-2 text-nowrap rounded-xl border-none bg-transparent px-4 py-2.5 text-left text-base font-semibold leading-tight text-button-text transition-all hover:bg-button-bg hover:text-contrast active:scale-[0.97]"
|
||||
:class="{ 'danger-button': item.danger }"
|
||||
@click="item.action"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
v-if="item.icon"
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 shrink-0"
|
||||
/>
|
||||
<span class="text-contrast">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.badge != null"
|
||||
class="rounded-full bg-brand-highlight px-2 text-sm font-bold text-brand"
|
||||
>
|
||||
{{ String(item.badge) }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<span v-else>You frog. 🐸</span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {}
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon } from '@modrinth/assets'
|
||||
import { type Component, computed, useSlots } from 'vue'
|
||||
|
||||
type NavStackBaseItem = {
|
||||
label: string
|
||||
icon?: Component | string
|
||||
badge?: string | number | null
|
||||
chevron?: boolean
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
type NavStackLinkItem = NavStackBaseItem & {
|
||||
type?: 'item'
|
||||
link?: string | null
|
||||
to?: string | null
|
||||
action?: (() => void) | null
|
||||
}
|
||||
|
||||
type NavStackSeparator = { type: 'separator' }
|
||||
type NavStackHeading = { type: 'heading'; label: string }
|
||||
|
||||
export type NavStackEntry = (NavStackLinkItem | NavStackSeparator | NavStackHeading) & {
|
||||
shown?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
items?: NavStackEntry[]
|
||||
ariaLabel?: string
|
||||
}>()
|
||||
|
||||
const ariaLabel = computed(() => props.ariaLabel ?? 'Section navigation')
|
||||
|
||||
const slots = useSlots()
|
||||
const hasSlotContent = computed(() => {
|
||||
const content = slots.default?.()
|
||||
return !!(content && content.length)
|
||||
})
|
||||
|
||||
function isSeparator(item: NavStackEntry): item is NavStackSeparator {
|
||||
return (item as any).type === 'separator'
|
||||
}
|
||||
|
||||
function isHeading(item: NavStackEntry): item is NavStackHeading {
|
||||
return (item as any).type === 'heading'
|
||||
}
|
||||
|
||||
function getKey(item: NavStackEntry, idx: number) {
|
||||
if (isSeparator(item)) return `sep-${idx}`
|
||||
if (isHeading(item)) return `head-${item.label}-${idx}`
|
||||
const link = (item as NavStackLinkItem).link ?? (item as NavStackLinkItem).to
|
||||
return link ? `link-${link}` : `action-${(item as NavStackLinkItem).label}-${idx}`
|
||||
}
|
||||
|
||||
const filteredItems = computed(() => props.items?.filter((x) => x.shown === undefined || x.shown))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: var(--spacing-card-xs);
|
||||
flex-wrap: wrap;
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
> :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
display: unset;
|
||||
text-align: unset;
|
||||
}
|
||||
.router-link-exact-active.nav-item {
|
||||
background: var(--color-button-bg-selected);
|
||||
color: var(--color-button-text-selected);
|
||||
}
|
||||
.router-link-exact-active.nav-item .text-contrast {
|
||||
color: var(--color-button-text-selected);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<template>
|
||||
<NuxtLink v-if="link !== null" :to="link" class="nav-item">
|
||||
<slot />
|
||||
<span>{{ label }}</span>
|
||||
<span v-if="badge" class="rounded-full bg-brand-highlight px-2 text-sm font-bold text-brand">{{
|
||||
badge
|
||||
}}</span>
|
||||
<span v-if="chevron" class="ml-auto"><ChevronRightIcon /></span>
|
||||
</NuxtLink>
|
||||
<button v-else-if="action" class="nav-item" :class="{ 'danger-button': danger }" @click="action">
|
||||
<slot />
|
||||
<span>{{ label }}</span>
|
||||
<span v-if="badge" class="rounded-full bg-brand-highlight px-2 text-sm font-bold text-brand">{{
|
||||
badge
|
||||
}}</span>
|
||||
</button>
|
||||
<span v-else>i forgor 💀</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ChevronRightIcon } from '@modrinth/assets'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChevronRightIcon,
|
||||
},
|
||||
props: {
|
||||
link: {
|
||||
default: null,
|
||||
type: String,
|
||||
},
|
||||
action: {
|
||||
default: null,
|
||||
type: Function,
|
||||
},
|
||||
label: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
badge: {
|
||||
default: null,
|
||||
type: String,
|
||||
},
|
||||
chevron: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
danger: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nav-item {
|
||||
@apply flex w-full cursor-pointer items-center gap-2 text-nowrap rounded-xl border-none bg-transparent px-4 py-2 text-left font-semibold text-button-text transition-all hover:bg-button-bg hover:text-contrast active:scale-[0.97];
|
||||
}
|
||||
|
||||
.router-link-exact-active.nav-item {
|
||||
@apply bg-button-bgSelected text-button-textSelected;
|
||||
}
|
||||
</style>
|
||||
@@ -376,7 +376,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
const tags = useTags()
|
||||
const tags = useGeneratedState()
|
||||
|
||||
const type = computed(() =>
|
||||
!props.notification.body || props.notification.body.type === 'legacy_markdown'
|
||||
|
||||
@@ -212,7 +212,7 @@ export default {
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const tags = useTags()
|
||||
const tags = useGeneratedState()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
return { tags, formatRelativeTime }
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="withdrawModal"
|
||||
:closable="currentStage !== 'completion'"
|
||||
:hide-header="currentStage === 'completion'"
|
||||
:merge-header="currentStage === 'completion'"
|
||||
:scrollable="true"
|
||||
max-content-height="72vh"
|
||||
:on-hide="onModalHide"
|
||||
>
|
||||
<template #title>
|
||||
<div v-if="shouldShowTitle" class="flex flex-wrap items-center gap-1 text-secondary">
|
||||
<template v-if="currentStage === 'tax-form'">
|
||||
<span class="text-lg font-bold text-contrast sm:text-xl">{{
|
||||
formatMessage(messages.taxFormStage)
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-else-if="currentStage === 'method-selection'">
|
||||
<span class="text-lg font-bold text-contrast sm:text-xl">{{
|
||||
formatMessage(messages.methodSelectionStage)
|
||||
}}</span>
|
||||
<ChevronRightIcon class="size-5 text-secondary" stroke-width="3" />
|
||||
<span class="text-lg text-secondary sm:text-xl">{{
|
||||
formatMessage(messages.detailsLabel)
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-else-if="isDetailsStage">
|
||||
<button
|
||||
class="active:scale-9 bg-transparent p-0 text-lg text-secondary transition-colors duration-200 hover:text-primary sm:text-xl"
|
||||
@click="goToBreadcrumbStage('method-selection')"
|
||||
>
|
||||
{{ formatMessage(messages.methodSelectionStage) }}
|
||||
</button>
|
||||
<ChevronRightIcon class="size-5 text-secondary" stroke-width="3" />
|
||||
<span class="text-lg font-bold text-contrast sm:text-xl">{{
|
||||
formatMessage(messages.detailsLabel)
|
||||
}}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full max-w-[496px] lg:min-w-[496px]">
|
||||
<TaxFormStage
|
||||
v-if="currentStage === 'tax-form'"
|
||||
:balance="balance"
|
||||
:on-show-tax-form="showTaxFormModal"
|
||||
/>
|
||||
<MethodSelectionStage
|
||||
v-else-if="currentStage === 'method-selection'"
|
||||
:on-show-tax-form="showTaxFormModal"
|
||||
@close-modal="withdrawModal?.hide()"
|
||||
/>
|
||||
<TremendousDetailsStage v-else-if="currentStage === 'tremendous-details'" />
|
||||
<MuralpayKycStage v-else-if="currentStage === 'muralpay-kyc'" />
|
||||
<MuralpayDetailsStage v-else-if="currentStage === 'muralpay-details'" />
|
||||
<LegacyPaypalDetailsStage v-else-if="currentStage === 'paypal-details'" />
|
||||
<CompletionStage v-else-if="currentStage === 'completion'" />
|
||||
<div v-else>Something went wrong</div>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div v-if="currentStage === 'completion'" class="mt-4 flex w-full gap-3">
|
||||
<ButtonStyled class="flex-1">
|
||||
<button class="w-full text-contrast" @click="handleClose">
|
||||
{{ formatMessage(messages.closeButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled class="flex-1">
|
||||
<button class="w-full text-contrast" @click="handleViewTransactions">
|
||||
{{ formatMessage(messages.transactionsButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-else class="mt-4 flex flex-col justify-end gap-2 sm:flex-row">
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
class="!border-surface-5"
|
||||
:disabled="leftButtonConfig.disabled"
|
||||
@click="leftButtonConfig.handler"
|
||||
>
|
||||
<component :is="leftButtonConfig.icon" />
|
||||
{{ leftButtonConfig.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled :color="rightButtonConfig.color">
|
||||
<button :disabled="rightButtonConfig.disabled" @click="rightButtonConfig.handler">
|
||||
<component
|
||||
:is="rightButtonConfig.icon"
|
||||
v-if="rightButtonConfig.iconPosition === 'before'"
|
||||
:class="rightButtonConfig.iconClass"
|
||||
/>
|
||||
{{ rightButtonConfig.label }}
|
||||
<component
|
||||
:is="rightButtonConfig.icon"
|
||||
v-if="rightButtonConfig.iconPosition === 'after'"
|
||||
:class="rightButtonConfig.iconClass"
|
||||
/>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
<CreatorTaxFormModal
|
||||
ref="taxFormModal"
|
||||
close-button-text="Continue"
|
||||
@success="onTaxFormSuccess"
|
||||
@cancelled="onTaxFormCancelled"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
ChevronRightIcon,
|
||||
FileTextIcon,
|
||||
LeftArrowIcon,
|
||||
RightArrowIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ButtonStyled, commonMessages, injectNotificationManager, NewModal } from '@modrinth/ui'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import {
|
||||
createWithdrawContext,
|
||||
type PayoutMethod,
|
||||
provideWithdrawContext,
|
||||
TAX_THRESHOLD_ACTUAL,
|
||||
type WithdrawStage,
|
||||
} from '@/providers/creator-withdraw.ts'
|
||||
|
||||
import CreatorTaxFormModal from './CreatorTaxFormModal.vue'
|
||||
import CompletionStage from './withdraw-stages/CompletionStage.vue'
|
||||
import LegacyPaypalDetailsStage from './withdraw-stages/LegacyPaypalDetailsStage.vue'
|
||||
import MethodSelectionStage from './withdraw-stages/MethodSelectionStage.vue'
|
||||
import MuralpayDetailsStage from './withdraw-stages/MuralpayDetailsStage.vue'
|
||||
import MuralpayKycStage from './withdraw-stages/MuralpayKycStage.vue'
|
||||
import TaxFormStage from './withdraw-stages/TaxFormStage.vue'
|
||||
import TremendousDetailsStage from './withdraw-stages/TremendousDetailsStage.vue'
|
||||
|
||||
type FormCompletionStatus = 'unknown' | 'unrequested' | 'unsigned' | 'tin-mismatch' | 'complete'
|
||||
|
||||
interface UserBalanceResponse {
|
||||
available: number
|
||||
withdrawn_lifetime: number
|
||||
withdrawn_ytd: number
|
||||
pending: number
|
||||
dates: Record<string, number>
|
||||
requested_form_type: string | null
|
||||
form_completion_status: FormCompletionStatus | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
balance: UserBalanceResponse | null
|
||||
preloadedPaymentData?: { country: string; methods: PayoutMethod[] } | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'refresh-data' | 'hide'): void
|
||||
}>()
|
||||
|
||||
const withdrawModal = useTemplateRef<InstanceType<typeof NewModal>>('withdrawModal')
|
||||
const taxFormModal = ref<InstanceType<typeof CreatorTaxFormModal> | null>(null)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
function show(preferred?: WithdrawStage) {
|
||||
if (preferred) {
|
||||
setStage(preferred, true)
|
||||
withdrawModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
const firstStage = stages.value[0]
|
||||
if (firstStage) {
|
||||
setStage(firstStage, true)
|
||||
}
|
||||
|
||||
withdrawModal.value?.show()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const withdrawContext = createWithdrawContext(
|
||||
props.balance,
|
||||
props.preloadedPaymentData || undefined,
|
||||
)
|
||||
provideWithdrawContext(withdrawContext)
|
||||
|
||||
const {
|
||||
currentStage,
|
||||
previousStep,
|
||||
nextStep,
|
||||
canProceed,
|
||||
setStage,
|
||||
withdrawData,
|
||||
resetData,
|
||||
stages,
|
||||
submitWithdrawal,
|
||||
restoreStateFromStorage,
|
||||
clearSavedState,
|
||||
} = withdrawContext
|
||||
|
||||
watch(
|
||||
() => props.balance,
|
||||
(newBalance) => {
|
||||
if (newBalance) {
|
||||
withdrawContext.balance.value = newBalance
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
if (route.query.paypal_auth_return === 'true') {
|
||||
const savedState = restoreStateFromStorage()
|
||||
|
||||
if (savedState?.data) {
|
||||
withdrawData.value = { ...savedState.data }
|
||||
|
||||
nextTick(() => {
|
||||
show(savedState.stage)
|
||||
})
|
||||
|
||||
clearSavedState()
|
||||
}
|
||||
|
||||
const query = { ...route.query }
|
||||
delete query.paypal_auth_return
|
||||
router.replace({ query })
|
||||
}
|
||||
})
|
||||
|
||||
const needsTaxForm = computed(() => {
|
||||
if (!props.balance || currentStage.value !== 'tax-form') return false
|
||||
const ytd = props.balance.withdrawn_ytd ?? 0
|
||||
const available = props.balance.available ?? 0
|
||||
const status = props.balance.form_completion_status
|
||||
return status !== 'complete' && ytd + available >= 600
|
||||
})
|
||||
|
||||
const remainingLimit = computed(() => {
|
||||
if (!props.balance) return 0
|
||||
const ytd = props.balance.withdrawn_ytd ?? 0
|
||||
const raw = TAX_THRESHOLD_ACTUAL - ytd
|
||||
if (raw <= 0) return 0
|
||||
const cents = Math.floor(raw * 100)
|
||||
return cents / 100
|
||||
})
|
||||
|
||||
const leftButtonConfig = computed(() => {
|
||||
if (previousStep.value) {
|
||||
return {
|
||||
icon: LeftArrowIcon,
|
||||
label: formatMessage(commonMessages.backButton),
|
||||
handler: () => setStage(previousStep.value, true),
|
||||
disabled: isSubmitting.value,
|
||||
}
|
||||
}
|
||||
return {
|
||||
icon: XIcon,
|
||||
label: formatMessage(commonMessages.cancelButton),
|
||||
handler: () => withdrawModal.value?.hide(),
|
||||
disabled: isSubmitting.value,
|
||||
}
|
||||
})
|
||||
|
||||
const rightButtonConfig = computed(() => {
|
||||
const stage = currentStage.value
|
||||
const isTaxFormStage = stage === 'tax-form'
|
||||
const isDetailsStage =
|
||||
stage === 'muralpay-details' || stage === 'tremendous-details' || stage === 'paypal-details'
|
||||
|
||||
if (isTaxFormStage && needsTaxForm.value && remainingLimit.value > 0) {
|
||||
return {
|
||||
icon: RightArrowIcon,
|
||||
label: formatMessage(messages.continueWithLimit),
|
||||
handler: continueWithLimit,
|
||||
disabled: false,
|
||||
color: 'standard' as const,
|
||||
iconPosition: 'after' as const,
|
||||
}
|
||||
}
|
||||
|
||||
if (isTaxFormStage && needsTaxForm.value) {
|
||||
return {
|
||||
icon: FileTextIcon,
|
||||
label: formatMessage(messages.completeTaxForm),
|
||||
handler: showTaxFormModal,
|
||||
disabled: false,
|
||||
color: 'orange' as const,
|
||||
iconPosition: 'before' as const,
|
||||
}
|
||||
}
|
||||
|
||||
if (isDetailsStage) {
|
||||
return {
|
||||
icon: isSubmitting.value ? SpinnerIcon : ArrowLeftRightIcon,
|
||||
iconClass: isSubmitting.value ? 'animate-spin' : undefined,
|
||||
label: formatMessage(messages.withdrawButton),
|
||||
handler: handleWithdraw,
|
||||
disabled: !canProceed.value || isSubmitting.value,
|
||||
color: 'brand' as const,
|
||||
iconPosition: 'before' as const,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon: RightArrowIcon,
|
||||
label: formatMessage(commonMessages.nextButton),
|
||||
handler: () => setStage(nextStep.value),
|
||||
disabled: !canProceed.value,
|
||||
color: 'standard' as const,
|
||||
iconPosition: 'after' as const,
|
||||
}
|
||||
})
|
||||
|
||||
function continueWithLimit() {
|
||||
withdrawData.value.tax.skipped = true
|
||||
setStage(nextStep.value)
|
||||
}
|
||||
|
||||
async function handleWithdraw() {
|
||||
if (isSubmitting.value) return
|
||||
|
||||
try {
|
||||
isSubmitting.value = true
|
||||
await submitWithdrawal()
|
||||
setStage('completion')
|
||||
} catch (error) {
|
||||
console.error('Withdrawal failed:', error)
|
||||
|
||||
if ((error as any)?.data?.description?.toLower?.()?.includes('Tax form')) {
|
||||
addNotification({
|
||||
title: 'Please complete tax form',
|
||||
text: 'You must complete a tax form to submit your withdrawal request.',
|
||||
type: 'error',
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
title: 'Unable to withdraw',
|
||||
text: 'We were unable to submit your withdrawal request, please check your details or contact support.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const shouldShowTitle = computed(() => {
|
||||
return currentStage.value !== 'completion'
|
||||
})
|
||||
|
||||
const isDetailsStage = computed(() => {
|
||||
const detailsStages: WithdrawStage[] = [
|
||||
'tremendous-details',
|
||||
'muralpay-kyc',
|
||||
'muralpay-details',
|
||||
'paypal-details',
|
||||
]
|
||||
const current = currentStage.value
|
||||
return current ? detailsStages.includes(current) : false
|
||||
})
|
||||
|
||||
function showTaxFormModal(e?: MouseEvent) {
|
||||
withdrawModal.value?.hide()
|
||||
taxFormModal.value?.startTaxForm(e ?? new MouseEvent('click'))
|
||||
}
|
||||
|
||||
function onTaxFormSuccess() {
|
||||
emit('refresh-data')
|
||||
nextTick(() => {
|
||||
show('method-selection')
|
||||
})
|
||||
}
|
||||
|
||||
function onTaxFormCancelled() {
|
||||
show('tax-form')
|
||||
}
|
||||
|
||||
function onModalHide() {
|
||||
resetData()
|
||||
emit('hide')
|
||||
}
|
||||
|
||||
function goToBreadcrumbStage(stage: WithdrawStage) {
|
||||
setStage(stage, true)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
withdrawModal.value?.hide()
|
||||
emit('refresh-data')
|
||||
}
|
||||
|
||||
function handleViewTransactions() {
|
||||
withdrawModal.value?.hide()
|
||||
navigateTo('/dashboard/revenue/transfers')
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
taxFormStage: {
|
||||
id: 'dashboard.creator-withdraw-modal.stage.tax-form',
|
||||
defaultMessage: 'Tax form',
|
||||
},
|
||||
methodSelectionStage: {
|
||||
id: 'dashboard.creator-withdraw-modal.stage.method-selection',
|
||||
defaultMessage: 'Method',
|
||||
},
|
||||
tremendousDetailsStage: {
|
||||
id: 'dashboard.creator-withdraw-modal.stage.tremendous-details',
|
||||
defaultMessage: 'Details',
|
||||
},
|
||||
muralpayKycStage: {
|
||||
id: 'dashboard.creator-withdraw-modal.stage.muralpay-kyc',
|
||||
defaultMessage: 'Verification',
|
||||
},
|
||||
muralpayDetailsStage: {
|
||||
id: 'dashboard.creator-withdraw-modal.stage.muralpay-details',
|
||||
defaultMessage: 'Account Details',
|
||||
},
|
||||
completionStage: {
|
||||
id: 'dashboard.creator-withdraw-modal.stage.completion',
|
||||
defaultMessage: 'Complete',
|
||||
},
|
||||
detailsLabel: {
|
||||
id: 'dashboard.creator-withdraw-modal.details-label',
|
||||
defaultMessage: 'Details',
|
||||
},
|
||||
completeTaxForm: {
|
||||
id: 'dashboard.creator-withdraw-modal.complete-tax-form',
|
||||
defaultMessage: 'Complete tax form',
|
||||
},
|
||||
continueWithLimit: {
|
||||
id: 'dashboard.creator-withdraw-modal.continue-with-limit',
|
||||
defaultMessage: 'Continue with limit',
|
||||
},
|
||||
withdrawButton: {
|
||||
id: 'dashboard.creator-withdraw-modal.withdraw-button',
|
||||
defaultMessage: 'Withdraw',
|
||||
},
|
||||
closeButton: {
|
||||
id: 'dashboard.withdraw.completion.close-button',
|
||||
defaultMessage: 'Close',
|
||||
},
|
||||
transactionsButton: {
|
||||
id: 'dashboard.withdraw.completion.transactions-button',
|
||||
defaultMessage: 'Transactions',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
149
apps/frontend/src/components/ui/dashboard/RevenueInputField.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
ref="amountInput"
|
||||
:value="modelValue"
|
||||
type="number"
|
||||
step="0.01"
|
||||
:min="minAmount"
|
||||
:max="maxAmount"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.amountPlaceholder)"
|
||||
class="w-full rounded-[14px] bg-surface-4 py-2.5 pl-4 pr-4 text-contrast placeholder:text-secondary"
|
||||
@input="handleInput"
|
||||
/>
|
||||
</div>
|
||||
<Combobox
|
||||
v-if="showCurrencySelector"
|
||||
:model-value="selectedCurrency"
|
||||
:options="currencyOptions"
|
||||
class="w-min"
|
||||
@update:model-value="$emit('update:selectedCurrency', $event)"
|
||||
>
|
||||
<template v-for="option in currencyOptions" :key="option.value" #[`option-${option.value}`]>
|
||||
<span class="font-semibold leading-tight">{{ option.label }}</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
<ButtonStyled>
|
||||
<button class="px-4 py-2" @click="setMaxAmount">
|
||||
{{ formatMessage(commonMessages.maxButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div>
|
||||
<span class="my-1 mt-0 text-secondary">{{ formatMoney(maxAmount) }} available.</span>
|
||||
<Transition name="fade">
|
||||
<span v-if="isBelowMinimum" class="text-red">
|
||||
Amount must be at least {{ formatMoney(minAmount) }}.
|
||||
</span>
|
||||
</Transition>
|
||||
<Transition name="fade">
|
||||
<span v-if="isAboveMaximum" class="text-red">
|
||||
Amount cannot exceed {{ formatMoney(maxAmount) }}.
|
||||
</span>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, Combobox, commonMessages, formFieldPlaceholders } from '@modrinth/ui'
|
||||
import { formatMoney } from '@modrinth/utils'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: number | undefined
|
||||
maxAmount: number
|
||||
minAmount?: number
|
||||
showCurrencySelector?: boolean
|
||||
selectedCurrency?: string
|
||||
currencyOptions?: Array<{ value: string; label: string }>
|
||||
}>(),
|
||||
{
|
||||
minAmount: 0.01,
|
||||
showCurrencySelector: false,
|
||||
currencyOptions: () => [],
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number | undefined]
|
||||
'update:selectedCurrency': [value: string]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const amountInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const isBelowMinimum = computed(() => {
|
||||
return (
|
||||
props.modelValue !== undefined && props.modelValue > 0 && props.modelValue < props.minAmount
|
||||
)
|
||||
})
|
||||
|
||||
const isAboveMaximum = computed(() => {
|
||||
return props.modelValue !== undefined && props.modelValue > props.maxAmount
|
||||
})
|
||||
|
||||
async function setMaxAmount() {
|
||||
const maxValue = props.maxAmount
|
||||
emit('update:modelValue', maxValue)
|
||||
|
||||
await nextTick()
|
||||
if (amountInput.value) {
|
||||
amountInput.value.value = maxValue.toFixed(2)
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const value = input.value
|
||||
|
||||
if (value && value.includes('.')) {
|
||||
const parts = value.split('.')
|
||||
if (parts[1] && parts[1].length > 2) {
|
||||
const rounded = Math.floor(parseFloat(value) * 100) / 100
|
||||
emit('update:modelValue', rounded)
|
||||
input.value = rounded.toString()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const numValue = value === '' ? undefined : parseFloat(value)
|
||||
emit('update:modelValue', numValue)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (newAmount) => {
|
||||
if (newAmount !== undefined && newAmount !== null) {
|
||||
if (newAmount > props.maxAmount) {
|
||||
emit('update:modelValue', props.maxAmount)
|
||||
await nextTick()
|
||||
if (amountInput.value) {
|
||||
amountInput.value.value = props.maxAmount.toFixed(2)
|
||||
}
|
||||
} else if (newAmount < 0) {
|
||||
emit('update:modelValue', 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active {
|
||||
transition: opacity 200ms ease-out;
|
||||
}
|
||||
|
||||
.fade-leave-active {
|
||||
transition: opacity 150ms ease-in;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
124
apps/frontend/src/components/ui/dashboard/RevenueTransaction.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="flex flex-row gap-2 md:gap-3">
|
||||
<div
|
||||
class="flex h-10 min-h-10 w-10 min-w-10 justify-center rounded-full border-[1px] border-solid border-button-bg bg-bg-raised !p-0 shadow-md md:h-12 md:min-h-12 md:w-12 md:min-w-12"
|
||||
>
|
||||
<ArrowDownIcon v-if="isIncome" class="my-auto size-6 text-secondary md:size-8" />
|
||||
<ArrowUpIcon v-else class="my-auto size-6 text-secondary md:size-8" />
|
||||
</div>
|
||||
<div class="flex w-full flex-row justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-base font-semibold text-contrast md:text-lg">{{
|
||||
isIncome
|
||||
? formatPayoutSource(transaction.payout_source)
|
||||
: formatMethodName(transaction.method_type || transaction.method)
|
||||
}}</span>
|
||||
<span class="text-xs text-secondary md:text-sm">
|
||||
<template v-if="!isIncome">
|
||||
<span
|
||||
:class="[
|
||||
transaction.status === 'cancelling' || transaction.status === 'cancelled'
|
||||
? 'text-red'
|
||||
: '',
|
||||
]"
|
||||
>{{ formatTransactionStatus(transaction.status) }} <BulletDivider
|
||||
/></span>
|
||||
</template>
|
||||
{{ $dayjs(transaction.created).format('MMM DD YYYY') }}
|
||||
<template v-if="!isIncome && transaction.fee">
|
||||
<BulletDivider /> Fee {{ formatMoney(transaction.fee) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="my-auto flex flex-row items-center gap-2">
|
||||
<span
|
||||
class="text-base font-semibold md:text-lg"
|
||||
:class="isIncome ? 'text-green' : 'text-contrast'"
|
||||
>{{ formatMoney(transaction.amount) }}</span
|
||||
>
|
||||
<template v-if="!isIncome && transaction.status === 'in-transit'">
|
||||
<Tooltip theme="dismissable-prompt" :triggers="['hover', 'focus']" no-auto-focus>
|
||||
<span class="my-auto align-middle"
|
||||
><ButtonStyled circular size="small">
|
||||
<button class="align-middle" @click="cancelPayout">
|
||||
<XIcon />
|
||||
</button> </ButtonStyled
|
||||
></span>
|
||||
<template #popper>
|
||||
<div class="font-semibold text-contrast">Cancel transaction</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowDownIcon, ArrowUpIcon, XIcon } from '@modrinth/assets'
|
||||
import { BulletDivider, ButtonStyled, injectNotificationManager } from '@modrinth/ui'
|
||||
import { capitalizeString, formatMoney } from '@modrinth/utils'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
|
||||
const props = defineProps({
|
||||
transaction: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['cancelled'])
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const auth = await useAuth()
|
||||
|
||||
const isIncome = computed(() => props.transaction.type === 'payout_available')
|
||||
|
||||
function formatTransactionStatus(status) {
|
||||
if (status === 'in-transit') return 'In Transit'
|
||||
return capitalizeString(status)
|
||||
}
|
||||
|
||||
function formatMethodName(method) {
|
||||
if (!method) return 'Unknown'
|
||||
switch (method) {
|
||||
case 'paypal':
|
||||
return 'PayPal'
|
||||
case 'venmo':
|
||||
return 'Venmo'
|
||||
case 'tremendous':
|
||||
return 'Tremendous'
|
||||
case 'muralpay':
|
||||
return 'Muralpay'
|
||||
default:
|
||||
return capitalizeString(method)
|
||||
}
|
||||
}
|
||||
|
||||
function formatPayoutSource(source) {
|
||||
if (!source) return 'Income'
|
||||
return source
|
||||
.split('_')
|
||||
.map((word) => capitalizeString(word))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
async function cancelPayout() {
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch(`payout/${props.transaction.id}`, {
|
||||
method: 'DELETE',
|
||||
apiVersion: 3,
|
||||
})
|
||||
await useAuth(auth.value.token)
|
||||
emit('cancelled')
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: 'Failed to cancel transaction',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-40"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-40"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="amount > 0" class="flex flex-col gap-2.5 rounded-[20px] bg-surface-2 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-primary">{{ formatMessage(messages.feeBreakdownAmount) }}</span>
|
||||
<span class="font-semibold text-contrast">{{ formatMoney(amount || 0) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-primary">{{ formatMessage(messages.feeBreakdownFee) }}</span>
|
||||
<span class="h-4 font-semibold text-contrast">
|
||||
<template v-if="feeLoading">
|
||||
<LoaderCircleIcon class="size-5 animate-spin !text-secondary" />
|
||||
</template>
|
||||
<template v-else>-{{ formatMoney(fee || 0) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-px bg-surface-5" />
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-primary">{{ formatMessage(messages.feeBreakdownNetAmount) }}</span>
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMoney(netAmount) }}
|
||||
<template v-if="shouldShowExchangeRate">
|
||||
<span> ({{ formattedLocalCurrency }})</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="shouldShowExchangeRate">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-primary">{{ formatMessage(messages.feeBreakdownExchangeRate) }}</span>
|
||||
<span class="text-secondary"
|
||||
>1 USD = {{ exchangeRate?.toFixed(4) }} {{ localCurrency }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LoaderCircleIcon } from '@modrinth/assets'
|
||||
import { formatMoney } from '@modrinth/utils'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
amount: number
|
||||
fee: number | null
|
||||
feeLoading: boolean
|
||||
exchangeRate?: number | null
|
||||
localCurrency?: string
|
||||
}>(),
|
||||
{
|
||||
exchangeRate: null,
|
||||
localCurrency: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const netAmount = computed(() => {
|
||||
const amount = props.amount || 0
|
||||
const fee = props.fee || 0
|
||||
return Math.max(0, amount - fee)
|
||||
})
|
||||
|
||||
const shouldShowExchangeRate = computed(() => {
|
||||
if (!props.localCurrency) return false
|
||||
if (props.localCurrency === 'USD') return false
|
||||
return props.exchangeRate !== null && props.exchangeRate !== undefined && props.exchangeRate > 0
|
||||
})
|
||||
|
||||
const netAmountInLocalCurrency = computed(() => {
|
||||
if (!shouldShowExchangeRate.value) return null
|
||||
return netAmount.value * (props.exchangeRate || 0)
|
||||
})
|
||||
|
||||
const formattedLocalCurrency = computed(() => {
|
||||
if (!shouldShowExchangeRate.value || !netAmountInLocalCurrency.value || !props.localCurrency)
|
||||
return ''
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: props.localCurrency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(netAmountInLocalCurrency.value)
|
||||
} catch {
|
||||
return `${props.localCurrency} ${netAmountInLocalCurrency.value.toFixed(2)}`
|
||||
}
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
feeBreakdownAmount: {
|
||||
id: 'dashboard.creator-withdraw-modal.fee-breakdown-amount',
|
||||
defaultMessage: 'Amount',
|
||||
},
|
||||
feeBreakdownFee: {
|
||||
id: 'dashboard.creator-withdraw-modal.fee-breakdown-fee',
|
||||
defaultMessage: 'Fee',
|
||||
},
|
||||
feeBreakdownNetAmount: {
|
||||
id: 'dashboard.creator-withdraw-modal.fee-breakdown-net-amount',
|
||||
defaultMessage: 'Net amount',
|
||||
},
|
||||
feeBreakdownExchangeRate: {
|
||||
id: 'dashboard.creator-withdraw-modal.fee-breakdown-exchange-rate',
|
||||
defaultMessage: 'FX rate',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="flex w-full items-center justify-center gap-2.5">
|
||||
<span class="text-xl font-semibold text-contrast sm:text-2xl">
|
||||
{{ formatMessage(messages.title) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-3">
|
||||
<div class="span-4 flex w-full flex-col gap-2.5 rounded-2xl bg-surface-2 p-4">
|
||||
<div
|
||||
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
|
||||
>
|
||||
<span class="text-sm font-normal text-primary sm:text-[1rem]">
|
||||
{{ formatMessage(messages.method) }}
|
||||
</span>
|
||||
<span class="break-words text-sm font-semibold text-contrast sm:text-[1rem]">
|
||||
{{ result?.methodType || 'N/A' }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
|
||||
>
|
||||
<span class="text-sm font-normal text-primary sm:text-[1rem]">
|
||||
{{ formatMessage(messages.recipient) }}
|
||||
</span>
|
||||
<span class="break-words text-sm font-semibold text-contrast sm:text-[1rem]">
|
||||
{{ result?.recipientDisplay || 'N/A' }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="destinationLabel && destinationValue"
|
||||
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
|
||||
>
|
||||
<span class="text-sm font-normal text-primary sm:text-[1rem]">
|
||||
{{ destinationLabel }}
|
||||
</span>
|
||||
<span class="break-words font-mono text-sm font-semibold text-contrast sm:text-[1rem]">
|
||||
{{ destinationValue }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
|
||||
>
|
||||
<span class="text-sm font-normal text-primary sm:text-[1rem]">
|
||||
{{ formatMessage(messages.date) }}
|
||||
</span>
|
||||
<span class="break-words text-sm font-semibold text-contrast sm:text-[1rem]">
|
||||
{{ formattedDate }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
|
||||
>
|
||||
<span class="text-sm font-normal text-primary sm:text-[1rem]">
|
||||
{{ formatMessage(messages.amount) }}
|
||||
</span>
|
||||
<span class="break-words text-sm font-semibold text-contrast sm:text-[1rem]">
|
||||
{{ formatMoney(result?.amount || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
|
||||
>
|
||||
<span class="text-sm font-normal text-primary sm:text-[1rem]">
|
||||
{{ formatMessage(messages.fee) }}
|
||||
</span>
|
||||
<span class="break-words text-sm font-semibold text-contrast sm:text-[1rem]">
|
||||
{{ formatMoney(result?.fee || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="border-b-1 h-0 w-full rounded-full border-b border-solid border-divider" />
|
||||
<div
|
||||
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
|
||||
>
|
||||
<span class="text-sm font-normal text-primary sm:text-[1rem]">
|
||||
{{ formatMessage(messages.netAmount) }}
|
||||
</span>
|
||||
<span class="break-words text-sm font-semibold text-contrast sm:text-[1rem]">
|
||||
{{ formatMoney(result?.netAmount || 0) }}
|
||||
<template v-if="shouldShowExchangeRate">
|
||||
<span> ({{ formattedLocalCurrency }})</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="shouldShowExchangeRate">
|
||||
<div
|
||||
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
|
||||
>
|
||||
<span class="text-sm font-normal text-primary sm:text-[1rem]">
|
||||
{{ formatMessage(messages.exchangeRate) }}
|
||||
</span>
|
||||
<span class="break-words text-sm font-normal text-secondary sm:text-[1rem]">
|
||||
1 USD = {{ withdrawData.calculation.exchangeRate?.toFixed(4) }}
|
||||
{{ localCurrency }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<span
|
||||
v-if="withdrawData.providerData.type === 'tremendous'"
|
||||
class="w-full break-words text-center text-sm font-normal text-primary sm:text-[1rem]"
|
||||
>
|
||||
<IntlFormatted
|
||||
:message-id="messages.emailConfirmation"
|
||||
:values="{ email: withdrawData.result?.recipientDisplay }"
|
||||
>
|
||||
<template #b="{ children }">
|
||||
<strong>
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</strong>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showConfetti"
|
||||
class="pointer-events-none fixed inset-0 z-[9999] flex items-center justify-center"
|
||||
>
|
||||
<ConfettiExplosion />
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatMoney } from '@modrinth/utils'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import ConfettiExplosion from 'vue-confetti-explosion'
|
||||
|
||||
import { type TremendousProviderData, useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||
import { getRailConfig } from '@/utils/muralpay-rails'
|
||||
import { normalizeChildren } from '@/utils/vue-children.ts'
|
||||
|
||||
const { withdrawData } = useWithdrawContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const result = computed(() => withdrawData.value.result)
|
||||
|
||||
const showConfetti = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
showConfetti.value = true
|
||||
setTimeout(() => {
|
||||
showConfetti.value = false
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!result.value?.created) return 'N/A'
|
||||
return dayjs(result.value.created).format('MMMM D, YYYY')
|
||||
})
|
||||
|
||||
const selectedRail = computed(() => {
|
||||
const railId = withdrawData.value.selection.method
|
||||
return railId ? getRailConfig(railId) : null
|
||||
})
|
||||
|
||||
const localCurrency = computed(() => {
|
||||
// Check if it's Tremendous withdrawal with currency
|
||||
if (withdrawData.value.providerData.type === 'tremendous') {
|
||||
return (withdrawData.value.providerData as TremendousProviderData).currency
|
||||
}
|
||||
|
||||
// Fall back to MuralPay rail currency
|
||||
return selectedRail.value?.currency
|
||||
})
|
||||
|
||||
const shouldShowExchangeRate = computed(() => {
|
||||
if (!localCurrency.value) return false
|
||||
if (localCurrency.value === 'USD') return false
|
||||
const exchangeRate = withdrawData.value.calculation.exchangeRate
|
||||
return exchangeRate !== null && exchangeRate !== undefined && exchangeRate > 0
|
||||
})
|
||||
|
||||
const netAmountInLocalCurrency = computed(() => {
|
||||
if (!shouldShowExchangeRate.value) return null
|
||||
const netAmount = result.value?.netAmount || 0
|
||||
const exchangeRate = withdrawData.value.calculation.exchangeRate || 0
|
||||
return netAmount * exchangeRate
|
||||
})
|
||||
|
||||
const formattedLocalCurrency = computed(() => {
|
||||
if (!shouldShowExchangeRate.value || !netAmountInLocalCurrency.value || !localCurrency.value)
|
||||
return ''
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: localCurrency.value,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(netAmountInLocalCurrency.value)
|
||||
} catch {
|
||||
return `${localCurrency.value} ${netAmountInLocalCurrency.value.toFixed(2)}`
|
||||
}
|
||||
})
|
||||
|
||||
const isMuralPayWithdrawal = computed(() => {
|
||||
return withdrawData.value.providerData.type === 'muralpay'
|
||||
})
|
||||
|
||||
const destinationLabel = computed(() => {
|
||||
if (!isMuralPayWithdrawal.value) return null
|
||||
|
||||
const rail = selectedRail.value
|
||||
if (!rail) return null
|
||||
|
||||
if (rail.type === 'crypto') {
|
||||
return formatMessage(messages.wallet)
|
||||
} else if (rail.type === 'fiat') {
|
||||
return formatMessage(messages.account)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const destinationValue = computed(() => {
|
||||
if (!isMuralPayWithdrawal.value || withdrawData.value.providerData.type !== 'muralpay') {
|
||||
return null
|
||||
}
|
||||
|
||||
const accountDetails = withdrawData.value.providerData.accountDetails
|
||||
const rail = selectedRail.value
|
||||
|
||||
if (rail?.type === 'crypto' && accountDetails.walletAddress) {
|
||||
const addr = accountDetails.walletAddress
|
||||
if (addr.length > 10) {
|
||||
return `${addr.slice(0, 6)}...${addr.slice(-4)}`
|
||||
}
|
||||
return addr
|
||||
} else if (rail?.type === 'fiat' && accountDetails.bankAccountNumber) {
|
||||
const accountType = accountDetails.accountType || 'Account'
|
||||
const last4 = accountDetails.bankAccountNumber.slice(-4)
|
||||
|
||||
const formattedType = accountType.charAt(0) + accountType.slice(1).toLowerCase()
|
||||
|
||||
return `${formattedType} (${last4})`
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'dashboard.withdraw.completion.title',
|
||||
defaultMessage: 'Withdraw complete',
|
||||
},
|
||||
method: {
|
||||
id: 'dashboard.withdraw.completion.method',
|
||||
defaultMessage: 'Method',
|
||||
},
|
||||
recipient: {
|
||||
id: 'dashboard.withdraw.completion.recipient',
|
||||
defaultMessage: 'Recipient',
|
||||
},
|
||||
wallet: {
|
||||
id: 'dashboard.withdraw.completion.wallet',
|
||||
defaultMessage: 'Wallet',
|
||||
},
|
||||
account: {
|
||||
id: 'dashboard.withdraw.completion.account',
|
||||
defaultMessage: 'Account',
|
||||
},
|
||||
date: {
|
||||
id: 'dashboard.withdraw.completion.date',
|
||||
defaultMessage: 'Date',
|
||||
},
|
||||
amount: {
|
||||
id: 'dashboard.withdraw.completion.amount',
|
||||
defaultMessage: 'Amount',
|
||||
},
|
||||
fee: {
|
||||
id: 'dashboard.withdraw.completion.fee',
|
||||
defaultMessage: 'Fee',
|
||||
},
|
||||
netAmount: {
|
||||
id: 'dashboard.withdraw.completion.net-amount',
|
||||
defaultMessage: 'Net amount',
|
||||
},
|
||||
exchangeRate: {
|
||||
id: 'dashboard.withdraw.completion.exchange-rate',
|
||||
defaultMessage: 'Exchange rate',
|
||||
},
|
||||
emailConfirmation: {
|
||||
id: 'dashboard.withdraw.completion.email-confirmation',
|
||||
defaultMessage:
|
||||
"You'll receive an email at <b>{email}</b> with instructions to redeem your withdrawal.",
|
||||
},
|
||||
closeButton: {
|
||||
id: 'dashboard.withdraw.completion.close-button',
|
||||
defaultMessage: 'Close',
|
||||
},
|
||||
transactionsButton: {
|
||||
id: 'dashboard.withdraw.completion.transactions-button',
|
||||
defaultMessage: 'Transactions',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 sm:gap-5">
|
||||
<div v-if="isPayPal" class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast"
|
||||
>{{ formatMessage(messages.paypalAccount) }} <span class="text-red">*</span></span
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<ButtonStyled v-if="!isPayPalAuthenticated" color="standard">
|
||||
<a :href="paypalAuthUrl" class="w-min" @click="handlePayPalAuth">
|
||||
<PayPalColorIcon class="size-5" />
|
||||
{{ formatMessage(messages.signInWithPaypal) }}
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button class="w-min" @click="handleDisconnectPaypal">
|
||||
<XIcon /> {{ formatMessage(messages.disconnectButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isPayPal && isPayPalAuthenticated" class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">{{
|
||||
formatMessage(messages.account)
|
||||
}}</span>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-col gap-2 rounded-2xl bg-surface-2 px-4 py-2.5">
|
||||
<span>{{ paypalEmail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isVenmo" class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast"
|
||||
>{{ formatMessage(messages.venmoHandle) }} <span class="text-red">*</span></span
|
||||
>
|
||||
</label>
|
||||
<div class="flex flex-row gap-2">
|
||||
<input
|
||||
v-model="venmoHandle"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.venmoHandlePlaceholder)"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
|
||||
/>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="!hasVenmoChanged ? 'Change the venmo username to save.' : undefined"
|
||||
:disabled="venmoSaving || !hasVenmoChanged"
|
||||
@click="saveVenmoHandle"
|
||||
>
|
||||
<CheckIcon v-if="venmoSaveSuccess" />
|
||||
<SaveIcon v-else />
|
||||
{{
|
||||
venmoSaveSuccess
|
||||
? formatMessage(messages.savedButton)
|
||||
: formatMessage(messages.saveButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span v-if="venmoSaveError" class="text-sm font-bold text-red">
|
||||
{{ venmoSaveError }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast"
|
||||
>{{ formatMessage(formFieldLabels.amount) }} <span class="text-red">*</span></span
|
||||
>
|
||||
</label>
|
||||
|
||||
<RevenueInputField
|
||||
v-model="formData.amount"
|
||||
:max-amount="effectiveMaxAmount"
|
||||
:min-amount="selectedMethodDetails?.interval?.standard?.min || 0.01"
|
||||
/>
|
||||
|
||||
<WithdrawFeeBreakdown
|
||||
:amount="formData.amount || 0"
|
||||
:fee="calculatedFee"
|
||||
:fee-loading="feeLoading"
|
||||
/>
|
||||
|
||||
<Checkbox v-model="agreedTerms">
|
||||
<span>
|
||||
<IntlFormatted :message-id="financialMessages.rewardsProgramTermsAgreement">
|
||||
<template #terms-link="{ children }">
|
||||
<nuxt-link to="/legal/cmp" class="text-link">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, PayPalColorIcon, SaveIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, Checkbox, financialMessages, formFieldLabels } from '@modrinth/ui'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue'
|
||||
import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
|
||||
import { getAuthUrl, removeAuthProvider, useAuth } from '@/composables/auth.js'
|
||||
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||
import { normalizeChildren } from '@/utils/vue-children.ts'
|
||||
|
||||
const { withdrawData, maxWithdrawAmount, availableMethods, calculateFees, saveStateToStorage } =
|
||||
useWithdrawContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
const auth = await useAuth()
|
||||
|
||||
const isPayPal = computed(() => withdrawData.value.selection.provider === 'paypal')
|
||||
const isVenmo = computed(() => withdrawData.value.selection.provider === 'venmo')
|
||||
|
||||
const selectedMethodDetails = computed(() => {
|
||||
const methodId = withdrawData.value.selection.methodId
|
||||
if (!methodId) return null
|
||||
return availableMethods.value.find((m) => m.id === methodId) || null
|
||||
})
|
||||
|
||||
const isPayPalAuthenticated = computed(() => {
|
||||
return (auth.value.user as any)?.auth_providers?.includes('paypal') || false
|
||||
})
|
||||
|
||||
const paypalEmail = computed(() => {
|
||||
return (auth.value.user as any)?.payout_data?.paypal_address || ''
|
||||
})
|
||||
|
||||
const paypalAuthUrl = computed(() => {
|
||||
const route = useRoute()
|
||||
const separator = route.fullPath.includes('?') ? '&' : '?'
|
||||
const returnUrl = `${route.fullPath}${separator}paypal_auth_return=true`
|
||||
return getAuthUrl('paypal', returnUrl)
|
||||
})
|
||||
|
||||
function handlePayPalAuth() {
|
||||
saveStateToStorage()
|
||||
}
|
||||
|
||||
async function handleDisconnectPaypal() {
|
||||
try {
|
||||
await removeAuthProvider('paypal')
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect PayPal:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const venmoHandle = ref<string>((auth.value.user as any)?.venmo_handle || '')
|
||||
const initialVenmoHandle = ref<string>((auth.value.user as any)?.venmo_handle || '')
|
||||
const venmoSaving = ref(false)
|
||||
const venmoSaveSuccess = ref(false)
|
||||
const venmoSaveError = ref<string | null>(null)
|
||||
|
||||
const hasVenmoChanged = computed(() => {
|
||||
return venmoHandle.value.trim() !== initialVenmoHandle.value.trim()
|
||||
})
|
||||
|
||||
async function saveVenmoHandle() {
|
||||
if (!venmoHandle.value.trim()) {
|
||||
venmoSaveError.value = 'Please enter a Venmo handle'
|
||||
return
|
||||
}
|
||||
|
||||
venmoSaving.value = true
|
||||
venmoSaveError.value = null
|
||||
venmoSaveSuccess.value = false
|
||||
|
||||
try {
|
||||
await useBaseFetch(`user/${(auth.value.user as any)?.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
venmo_handle: venmoHandle.value.trim(),
|
||||
},
|
||||
})
|
||||
|
||||
// @ts-expect-error auth.js is not typed
|
||||
await useAuth(auth.value.token)
|
||||
|
||||
initialVenmoHandle.value = venmoHandle.value.trim()
|
||||
venmoSaveSuccess.value = true
|
||||
|
||||
setTimeout(() => {
|
||||
venmoSaveSuccess.value = false
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
console.error('Failed to update Venmo handle:', error)
|
||||
venmoSaveError.value = 'Failed to save Venmo handle. Please try again.'
|
||||
} finally {
|
||||
venmoSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const maxAmount = computed(() => maxWithdrawAmount.value)
|
||||
const roundedMaxAmount = computed(() => Math.floor(maxAmount.value * 100) / 100)
|
||||
|
||||
const effectiveMaxAmount = computed(() => {
|
||||
const apiMax = selectedMethodDetails.value?.interval?.standard?.max
|
||||
if (apiMax) {
|
||||
return Math.min(roundedMaxAmount.value, apiMax)
|
||||
}
|
||||
return roundedMaxAmount.value
|
||||
})
|
||||
|
||||
const formData = ref<Record<string, any>>({
|
||||
amount: withdrawData.value.calculation.amount || undefined,
|
||||
})
|
||||
|
||||
const agreedTerms = computed({
|
||||
get: () => withdrawData.value.agreedTerms,
|
||||
set: (value) => {
|
||||
withdrawData.value.agreedTerms = value
|
||||
},
|
||||
})
|
||||
|
||||
const isComponentValid = computed(() => {
|
||||
const hasAmount = (formData.value.amount || 0) > 0
|
||||
const hasAgreed = agreedTerms.value
|
||||
|
||||
if (!hasAmount || !hasAgreed) return false
|
||||
|
||||
if (isPayPal.value) {
|
||||
return isPayPalAuthenticated.value
|
||||
} else if (isVenmo.value) {
|
||||
return venmoHandle.value.trim().length > 0
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const calculatedFee = ref<number>(0)
|
||||
const feeLoading = ref(false)
|
||||
|
||||
const calculateFeesDebounced = useDebounceFn(async () => {
|
||||
const amount = formData.value.amount
|
||||
if (!amount || amount <= 0) {
|
||||
calculatedFee.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
const methodId = withdrawData.value.selection.methodId
|
||||
if (!methodId) {
|
||||
calculatedFee.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
feeLoading.value = true
|
||||
try {
|
||||
await calculateFees()
|
||||
calculatedFee.value = withdrawData.value.calculation.fee ?? 0
|
||||
} catch (error) {
|
||||
console.error('Failed to calculate fees:', error)
|
||||
calculatedFee.value = 0
|
||||
} finally {
|
||||
feeLoading.value = false
|
||||
}
|
||||
}, 500)
|
||||
|
||||
watch(
|
||||
() => formData.value.amount,
|
||||
() => {
|
||||
withdrawData.value.calculation.amount = formData.value.amount ?? 0
|
||||
|
||||
if (formData.value.amount) {
|
||||
feeLoading.value = true
|
||||
calculateFeesDebounced()
|
||||
} else {
|
||||
calculatedFee.value = 0
|
||||
feeLoading.value = false
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[isComponentValid, venmoHandle, () => formData.value.amount, agreedTerms, isPayPalAuthenticated],
|
||||
() => {
|
||||
withdrawData.value.stageValidation.paypalDetails = isComponentValid.value
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
if (formData.value.amount) {
|
||||
feeLoading.value = true
|
||||
calculateFeesDebounced()
|
||||
}
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
paymentMethod: {
|
||||
id: 'dashboard.creator-withdraw-modal.paypal-details.payment-method',
|
||||
defaultMessage: 'Payment method',
|
||||
},
|
||||
paypalAccount: {
|
||||
id: 'dashboard.creator-withdraw-modal.paypal-details.paypal-account',
|
||||
defaultMessage: 'PayPal account',
|
||||
},
|
||||
account: {
|
||||
id: 'dashboard.creator-withdraw-modal.paypal-details.account',
|
||||
defaultMessage: 'Account',
|
||||
},
|
||||
signInWithPaypal: {
|
||||
id: 'dashboard.creator-withdraw-modal.paypal-details.sign-in-with-paypal',
|
||||
defaultMessage: 'Sign in with PayPal',
|
||||
},
|
||||
paypalAuthDescription: {
|
||||
id: 'dashboard.creator-withdraw-modal.paypal-details.paypal-auth-description',
|
||||
defaultMessage: 'Connect your PayPal account to receive payments directly.',
|
||||
},
|
||||
venmoHandle: {
|
||||
id: 'dashboard.creator-withdraw-modal.paypal-details.venmo-handle',
|
||||
defaultMessage: 'Venmo handle',
|
||||
},
|
||||
venmoHandlePlaceholder: {
|
||||
id: 'dashboard.creator-withdraw-modal.paypal-details.venmo-handle-placeholder',
|
||||
defaultMessage: '@username',
|
||||
},
|
||||
venmoDescription: {
|
||||
id: 'dashboard.creator-withdraw-modal.paypal-details.venmo-description',
|
||||
defaultMessage: 'Enter your Venmo handle to receive payments.',
|
||||
},
|
||||
disconnectButton: {
|
||||
id: 'dashboard.creator-withdraw-modal.paypal-details.disconnect-account',
|
||||
defaultMessage: 'Disconnect account',
|
||||
},
|
||||
saveButton: {
|
||||
id: 'dashboard.creator-withdraw-modal.paypal-details.save-button',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
savedButton: {
|
||||
id: 'dashboard.creator-withdraw-modal.paypal-details.saved-button',
|
||||
defaultMessage: 'Saved',
|
||||
},
|
||||
saveSuccess: {
|
||||
id: 'dashboard.creator-withdraw-modal.paypal-details.save-success',
|
||||
defaultMessage: 'Venmo handle saved successfully!',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Admonition v-if="shouldShowTaxLimitWarning" type="warning">
|
||||
<IntlFormatted
|
||||
:message-id="messages.taxLimitWarning"
|
||||
:values="{
|
||||
amount: formatMoney(maxWithdrawAmount),
|
||||
}"
|
||||
>
|
||||
<template #b="{ children }">
|
||||
<span class="font-semibold">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</span>
|
||||
</template>
|
||||
<template #tax-link="{ children }">
|
||||
<span class="cursor-pointer text-link" @click="onShowTaxForm">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</Admonition>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex flex-row gap-1 align-middle">
|
||||
<span class="align-middle font-semibold text-contrast">{{
|
||||
formatMessage(messages.region)
|
||||
}}</span>
|
||||
<UnknownIcon
|
||||
v-tooltip="formatMessage(messages.regionTooltip)"
|
||||
class="mt-auto size-5 text-secondary"
|
||||
/>
|
||||
</div>
|
||||
<Combobox
|
||||
:model-value="selectedCountryCode"
|
||||
:options="countries"
|
||||
:placeholder="formatMessage(messages.countryPlaceholder)"
|
||||
searchable
|
||||
:search-placeholder="formatMessage(messages.countrySearchPlaceholder)"
|
||||
:max-height="240"
|
||||
class="h-12"
|
||||
@update:model-value="handleCountryChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex flex-row gap-1 align-middle">
|
||||
<span class="align-middle font-semibold text-contrast">{{
|
||||
formatMessage(messages.selectMethod)
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="loading" class="flex min-h-[120px] items-center justify-center">
|
||||
<SpinnerIcon class="size-8 animate-spin text-contrast" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<ButtonStyled
|
||||
v-for="method in paymentOptions"
|
||||
:key="method.value"
|
||||
:color="withdrawData.selection.method === method.value ? 'green' : 'standard'"
|
||||
:highlighted="withdrawData.selection.method === method.value"
|
||||
type="chip"
|
||||
>
|
||||
<button
|
||||
class="!justify-start !gap-2 !text-left sm:!h-10"
|
||||
@click="handleMethodSelection(method)"
|
||||
>
|
||||
<component :is="method.icon" class="shrink-0" />
|
||||
<span class="flex-1 truncate text-sm sm:text-[1rem]">
|
||||
{{ typeof method.label === 'string' ? method.label : formatMessage(method.label) }}
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 text-xs font-normal text-secondary sm:text-sm">{{
|
||||
method.fee
|
||||
}}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon, UnknownIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
injectNotificationManager,
|
||||
useDebugLogger,
|
||||
} from '@modrinth/ui'
|
||||
import { formatMoney } from '@modrinth/utils'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import { useGeolocation } from '@vueuse/core'
|
||||
|
||||
import { useCountries, useFormattedCountries, useUserCountry } from '@/composables/country.ts'
|
||||
import { type PayoutMethod, useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||
import { normalizeChildren } from '@/utils/vue-children.ts'
|
||||
|
||||
const debug = useDebugLogger('MethodSelectionStage')
|
||||
const {
|
||||
withdrawData,
|
||||
availableMethods,
|
||||
paymentOptions,
|
||||
balance,
|
||||
maxWithdrawAmount,
|
||||
paymentMethodsCache,
|
||||
} = useWithdrawContext()
|
||||
const userCountry = useUserCountry()
|
||||
const allCountries = useCountries()
|
||||
const { coords } = useGeolocation()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const auth = await useAuth()
|
||||
|
||||
const messages = defineMessages({
|
||||
taxLimitWarning: {
|
||||
id: 'dashboard.creator-withdraw-modal.method-selection.tax-limit-warning',
|
||||
defaultMessage:
|
||||
'Your withdraw limit is <b>{amount}</b>, <tax-link>complete a tax form</tax-link> to withdraw more.',
|
||||
},
|
||||
region: {
|
||||
id: 'dashboard.creator-withdraw-modal.method-selection.region',
|
||||
defaultMessage: 'Region',
|
||||
},
|
||||
regionTooltip: {
|
||||
id: 'dashboard.creator-withdraw-modal.method-selection.region-tooltip',
|
||||
defaultMessage: 'Some payout methods are not available in certain regions.',
|
||||
},
|
||||
countryPlaceholder: {
|
||||
id: 'dashboard.creator-withdraw-modal.method-selection.country-placeholder',
|
||||
defaultMessage: 'Select your country',
|
||||
},
|
||||
countrySearchPlaceholder: {
|
||||
id: 'dashboard.creator-withdraw-modal.method-selection.country-search-placeholder',
|
||||
defaultMessage: 'Search countries...',
|
||||
},
|
||||
selectMethod: {
|
||||
id: 'dashboard.creator-withdraw-modal.method-selection.select-method',
|
||||
defaultMessage: 'Select withdraw method',
|
||||
},
|
||||
errorTitle: {
|
||||
id: 'dashboard.creator-withdraw-modal.method-selection.error-title',
|
||||
defaultMessage: 'Failed to load payment methods',
|
||||
},
|
||||
errorText: {
|
||||
id: 'dashboard.creator-withdraw-modal.method-selection.error-text',
|
||||
defaultMessage: 'Unable to fetch available payment methods. Please try again later.',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
onShowTaxForm: () => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close-modal'): void
|
||||
}>()
|
||||
|
||||
const countries = useFormattedCountries()
|
||||
|
||||
const selectedCountryCode = computed(() => withdrawData.value.selection.country?.id)
|
||||
|
||||
const shouldShowTaxLimitWarning = computed(() => {
|
||||
const balanceValue = balance.value
|
||||
if (!balanceValue) return false
|
||||
|
||||
const formIncomplete = balanceValue.form_completion_status !== 'complete'
|
||||
const wouldHitLimit = (balanceValue.withdrawn_ytd ?? 0) + (balanceValue.available ?? 0) >= 600
|
||||
|
||||
return formIncomplete && wouldHitLimit
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
watch(
|
||||
() => withdrawData.value.selection.country,
|
||||
async (country) => {
|
||||
debug('Watch triggered, country:', country)
|
||||
if (!country) {
|
||||
availableMethods.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (paymentMethodsCache.value[country.id]) {
|
||||
debug('Using cached methods for', country.id)
|
||||
availableMethods.value = paymentMethodsCache.value[country.id]
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
debug('Fetching payout methods for country:', country.id)
|
||||
|
||||
try {
|
||||
const methods = (await useBaseFetch('payout/methods', {
|
||||
apiVersion: 3,
|
||||
query: { country: country.id },
|
||||
})) as PayoutMethod[]
|
||||
debug('Received payout methods:', methods)
|
||||
|
||||
paymentMethodsCache.value[country.id] = methods
|
||||
availableMethods.value = methods
|
||||
} catch (e) {
|
||||
console.error('[MethodSelectionStage] Failed to fetch payout methods:', e)
|
||||
addNotification({
|
||||
title: formatMessage(messages.errorTitle),
|
||||
text: formatMessage(messages.errorText),
|
||||
type: 'error',
|
||||
})
|
||||
emit('close-modal')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function handleMethodSelection(option: {
|
||||
value: string
|
||||
methodId: string | undefined
|
||||
type: string
|
||||
}) {
|
||||
withdrawData.value.selection.method = option.value
|
||||
withdrawData.value.selection.methodId = option.methodId ?? null
|
||||
|
||||
if (option.type === 'tremendous') {
|
||||
withdrawData.value.selection.provider = 'tremendous'
|
||||
} else if (option.type === 'fiat' || option.type === 'crypto') {
|
||||
withdrawData.value.selection.provider = 'muralpay'
|
||||
} else if (option.type === 'paypal') {
|
||||
withdrawData.value.selection.provider = 'paypal'
|
||||
} else if (option.type === 'venmo') {
|
||||
withdrawData.value.selection.provider = 'venmo'
|
||||
} else {
|
||||
withdrawData.value.selection.provider = 'muralpay'
|
||||
}
|
||||
}
|
||||
|
||||
watch(paymentOptions, (newOptions) => {
|
||||
withdrawData.value.selection.method = null
|
||||
withdrawData.value.selection.methodId = null
|
||||
withdrawData.value.selection.provider = null
|
||||
|
||||
if (newOptions.length === 1) {
|
||||
const option = newOptions[0]
|
||||
handleMethodSelection(option)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => withdrawData.value.selection.provider,
|
||||
(newProvider) => {
|
||||
if (newProvider === 'tremendous') {
|
||||
const userEmail = (auth.value.user as any)?.email || ''
|
||||
withdrawData.value.providerData = {
|
||||
type: 'tremendous',
|
||||
deliveryEmail: userEmail,
|
||||
giftCardDetails: null,
|
||||
currency: undefined,
|
||||
}
|
||||
} else if (newProvider === 'muralpay') {
|
||||
withdrawData.value.providerData = {
|
||||
type: 'muralpay',
|
||||
kycData: {} as any,
|
||||
accountDetails: {},
|
||||
}
|
||||
} else if (newProvider === 'paypal' || newProvider === 'venmo') {
|
||||
withdrawData.value.providerData = {
|
||||
type: newProvider,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function handleCountryChange(countryCode: string | null) {
|
||||
debug('handleCountryChange called with:', countryCode)
|
||||
if (countryCode) {
|
||||
const normalizedCode = countryCode.toUpperCase()
|
||||
const country = allCountries.value.find((c) => c.alpha2 === normalizedCode)
|
||||
debug('Found country:', country)
|
||||
if (country) {
|
||||
withdrawData.value.selection.country = {
|
||||
id: country.alpha2,
|
||||
name: country.alpha2 === 'TW' ? 'Taiwan' : country.nameShort,
|
||||
}
|
||||
debug('Set selectedCountry to:', withdrawData.value.selection.country)
|
||||
}
|
||||
} else {
|
||||
withdrawData.value.selection.country = null
|
||||
}
|
||||
}
|
||||
|
||||
debug('Setup: userCountry.value =', userCountry.value)
|
||||
debug('Setup: current selectedCountry =', withdrawData.value.selection.country)
|
||||
|
||||
if (!withdrawData.value.selection.country) {
|
||||
const defaultCountryCode = userCountry.value || 'US'
|
||||
debug('Setup: calling handleCountryChange with', defaultCountryCode)
|
||||
handleCountryChange(defaultCountryCode)
|
||||
debug('Setup: selectedCountryCode computed =', selectedCountryCode.value)
|
||||
}
|
||||
|
||||
async function getCountryFromGeoIP(lat: number, lon: number): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lon}&localityLanguage=en`,
|
||||
)
|
||||
const data = await response.json()
|
||||
return data.countryCode || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (withdrawData.value.selection.country?.id === 'US' && !userCountry.value) {
|
||||
if (coords.value.latitude && coords.value.longitude) {
|
||||
const geoCountry = await getCountryFromGeoIP(coords.value.latitude, coords.value.longitude)
|
||||
if (geoCountry) {
|
||||
handleCountryChange(geoCountry)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,516 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 sm:gap-4">
|
||||
<Admonition
|
||||
v-if="selectedRail?.warningMessage"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.cryptoWarningHeader)"
|
||||
>
|
||||
{{ formatMessage(selectedRail.warningMessage) }}
|
||||
</Admonition>
|
||||
|
||||
<div v-if="selectedRail?.type === 'crypto'" class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.coin) }}
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
class="flex min-h-[44px] items-center gap-2 rounded-[14px] bg-surface-2 px-4 py-2.5 sm:min-h-0"
|
||||
>
|
||||
<component
|
||||
:is="getCurrencyIcon(selectedRail.currency)"
|
||||
class="size-5 shrink-0"
|
||||
:class="getCurrencyColor(selectedRail.currency)"
|
||||
/>
|
||||
<span class="text-sm font-semibold text-contrast sm:text-[1rem]">{{
|
||||
selectedRail.currency
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedRail?.type === 'fiat'" class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.accountOwner) }}
|
||||
</span>
|
||||
</label>
|
||||
<div class="w-full rounded-[14px] bg-surface-2 p-3 sm:p-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="break-words text-sm font-semibold text-contrast sm:text-[1rem]">
|
||||
{{ accountOwnerName }}
|
||||
</span>
|
||||
<span class="break-words text-xs text-primary sm:text-sm">
|
||||
{{ accountOwnerAddress }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedRail?.requiresBankName" class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(formFieldLabels.bankName) }}
|
||||
<span class="text-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Combobox
|
||||
v-if="shouldShowBankNameDropdown"
|
||||
v-model="formData.bankName"
|
||||
:options="bankNameOptions"
|
||||
:searchable="true"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.bankNamePlaceholderDropdown)"
|
||||
class="h-10"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-else
|
||||
v-model="formData.bankName"
|
||||
type="text"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.bankNamePlaceholder)"
|
||||
autocomplete="off"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-for="field in selectedRail?.fields" :key="field.name" class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(field.label) }}
|
||||
<span v-if="field.required" class="text-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<input
|
||||
v-if="['text', 'email', 'tel'].includes(field.type)"
|
||||
v-model="formData[field.name]"
|
||||
:type="field.type"
|
||||
:placeholder="field.placeholder ? formatMessage(field.placeholder) : undefined"
|
||||
:pattern="field.pattern"
|
||||
:autocomplete="field.autocomplete || 'off'"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
|
||||
/>
|
||||
|
||||
<Combobox
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="formData[field.name]"
|
||||
:options="
|
||||
(field.options || []).map((opt) => ({
|
||||
value: opt.value,
|
||||
label: formatMessage(opt.label),
|
||||
}))
|
||||
"
|
||||
:placeholder="field.placeholder ? formatMessage(field.placeholder) : undefined"
|
||||
class="h-10"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="formData[field.name]"
|
||||
type="date"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-2.5 text-contrast placeholder:text-secondary"
|
||||
/>
|
||||
|
||||
<span v-if="field.helpText" class="text-sm text-secondary">
|
||||
{{ formatMessage(field.helpText) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-40"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-40"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="dynamicDocumentNumberField" class="overflow-hidden">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ dynamicDocumentNumberField.label }}
|
||||
<span v-if="dynamicDocumentNumberField.required" class="text-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.documentNumber"
|
||||
:type="dynamicDocumentNumberField.type"
|
||||
:placeholder="dynamicDocumentNumberField.placeholder"
|
||||
autocomplete="off"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div v-if="selectedRail?.blockchain" class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.network) }}
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
class="flex min-h-[44px] items-center gap-2 rounded-[14px] bg-surface-2 px-4 py-2.5 sm:min-h-0"
|
||||
>
|
||||
<component
|
||||
:is="getBlockchainIcon(selectedRail.blockchain)"
|
||||
class="size-5 shrink-0"
|
||||
:class="getBlockchainColor(selectedRail.blockchain)"
|
||||
/>
|
||||
<span class="text-sm font-semibold text-contrast sm:text-[1rem]">{{
|
||||
selectedRail.blockchain
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(formFieldLabels.amount) }}
|
||||
<span class="text-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<RevenueInputField
|
||||
v-model="formData.amount"
|
||||
:min-amount="effectiveMinAmount"
|
||||
:max-amount="effectiveMaxAmount"
|
||||
/>
|
||||
|
||||
<WithdrawFeeBreakdown
|
||||
v-if="allRequiredFieldsFilled"
|
||||
:amount="formData.amount || 0"
|
||||
:fee="calculatedFee"
|
||||
:fee-loading="feeLoading"
|
||||
:exchange-rate="exchangeRate"
|
||||
:local-currency="selectedRail?.currency"
|
||||
/>
|
||||
|
||||
<Checkbox v-model="agreedTerms">
|
||||
<span
|
||||
><IntlFormatted :message-id="financialMessages.rewardsProgramTermsAgreement">
|
||||
<template #terms-link="{ children }">
|
||||
<nuxt-link to="/legal/cmp" class="text-link">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</nuxt-link>
|
||||
</template> </IntlFormatted
|
||||
></span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Admonition,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
financialMessages,
|
||||
formFieldLabels,
|
||||
formFieldPlaceholders,
|
||||
} from '@modrinth/ui'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue'
|
||||
import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
|
||||
import { useGeneratedState } from '@/composables/generated'
|
||||
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||
import {
|
||||
getBlockchainColor,
|
||||
getBlockchainIcon,
|
||||
getCurrencyColor,
|
||||
getCurrencyIcon,
|
||||
} from '@/utils/finance-icons.ts'
|
||||
import { getRailConfig } from '@/utils/muralpay-rails'
|
||||
import { normalizeChildren } from '@/utils/vue-children.ts'
|
||||
|
||||
const { withdrawData, maxWithdrawAmount, availableMethods, calculateFees } = useWithdrawContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
const generatedState = useGeneratedState()
|
||||
|
||||
const selectedRail = computed(() => {
|
||||
const railId = withdrawData.value.selection.method
|
||||
return railId ? getRailConfig(railId) : null
|
||||
})
|
||||
|
||||
const selectedMethodDetails = computed(() => {
|
||||
const methodId = withdrawData.value.selection.methodId
|
||||
if (!methodId) return null
|
||||
return availableMethods.value.find((m) => m.id === methodId) || null
|
||||
})
|
||||
|
||||
const maxAmount = computed(() => maxWithdrawAmount.value)
|
||||
const roundedMaxAmount = computed(() => Math.floor(maxAmount.value * 100) / 100)
|
||||
|
||||
const effectiveMinAmount = computed(
|
||||
() => selectedMethodDetails.value?.interval?.standard?.min || 0.01,
|
||||
)
|
||||
const effectiveMaxAmount = computed(() => {
|
||||
const apiMax = selectedMethodDetails.value?.interval?.standard?.max
|
||||
if (apiMax) {
|
||||
return Math.min(roundedMaxAmount.value, apiMax)
|
||||
}
|
||||
return roundedMaxAmount.value
|
||||
})
|
||||
|
||||
const availableBankNames = computed(() => {
|
||||
const rail = selectedRail.value
|
||||
if (!rail || !rail.railCode) return []
|
||||
|
||||
const bankDetails = generatedState.value.muralBankDetails?.[rail.railCode]
|
||||
return bankDetails?.bankNames || []
|
||||
})
|
||||
|
||||
const shouldShowBankNameDropdown = computed(() => {
|
||||
return availableBankNames.value.length > 0
|
||||
})
|
||||
|
||||
const bankNameOptions = computed(() => {
|
||||
return availableBankNames.value.map((name) => ({
|
||||
value: name,
|
||||
label: name,
|
||||
}))
|
||||
})
|
||||
|
||||
const providerData = withdrawData.value.providerData
|
||||
const existingAccountDetails = providerData.type === 'muralpay' ? providerData.accountDetails : {}
|
||||
const existingAmount = withdrawData.value.calculation.amount
|
||||
const formData = ref<Record<string, any>>({
|
||||
amount: existingAmount || undefined,
|
||||
bankName: existingAccountDetails?.bankName ?? '',
|
||||
...existingAccountDetails,
|
||||
})
|
||||
|
||||
const agreedTerms = computed({
|
||||
get: () => withdrawData.value.agreedTerms,
|
||||
set: (value) => {
|
||||
withdrawData.value.agreedTerms = value
|
||||
},
|
||||
})
|
||||
|
||||
const calculatedFee = ref<number | null>(null)
|
||||
const exchangeRate = ref<number | null>(null)
|
||||
const feeLoading = ref(false)
|
||||
|
||||
const hasDocumentTypeField = computed(() => {
|
||||
const rail = selectedRail.value
|
||||
if (!rail) return false
|
||||
return rail.fields.some((field) => field.name === 'documentType')
|
||||
})
|
||||
|
||||
const dynamicDocumentNumberField = computed(() => {
|
||||
if (!hasDocumentTypeField.value) return null
|
||||
|
||||
const documentType = formData.value.documentType
|
||||
if (!documentType) return null
|
||||
|
||||
const labelMap: Record<string, string> = {
|
||||
NATIONAL_ID: formatMessage(messages.documentNumberNationalId),
|
||||
PASSPORT: formatMessage(messages.documentNumberPassport),
|
||||
RESIDENT_ID: formatMessage(messages.documentNumberResidentId),
|
||||
RUC: formatMessage(messages.documentNumberRuc),
|
||||
TAX_ID: formatMessage(messages.documentNumberTaxId),
|
||||
}
|
||||
|
||||
const placeholderMap: Record<string, string> = {
|
||||
NATIONAL_ID: formatMessage(messages.documentNumberNationalIdPlaceholder),
|
||||
PASSPORT: formatMessage(messages.documentNumberPassportPlaceholder),
|
||||
RESIDENT_ID: formatMessage(messages.documentNumberResidentIdPlaceholder),
|
||||
RUC: formatMessage(messages.documentNumberRucPlaceholder),
|
||||
TAX_ID: formatMessage(messages.documentNumberTaxIdPlaceholder),
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'documentNumber',
|
||||
type: 'text' as const,
|
||||
label: labelMap[documentType] || 'Document Number',
|
||||
placeholder: placeholderMap[documentType] || 'Enter document number',
|
||||
required: true,
|
||||
}
|
||||
})
|
||||
|
||||
const accountOwnerName = computed(() => {
|
||||
const providerDataValue = withdrawData.value.providerData
|
||||
if (providerDataValue.type !== 'muralpay') return ''
|
||||
const kycData = providerDataValue.kycData
|
||||
if (!kycData) return ''
|
||||
|
||||
if (kycData.type === 'individual') {
|
||||
return `${kycData.firstName} ${kycData.lastName}`
|
||||
} else if (kycData.type === 'business') {
|
||||
return kycData.name
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const accountOwnerAddress = computed(() => {
|
||||
const providerDataValue = withdrawData.value.providerData
|
||||
if (providerDataValue.type !== 'muralpay') return ''
|
||||
const kycData = providerDataValue.kycData
|
||||
if (!kycData || !kycData.physicalAddress) return ''
|
||||
|
||||
const addr = kycData.physicalAddress
|
||||
const parts = [
|
||||
addr.address1,
|
||||
addr.address2,
|
||||
addr.city,
|
||||
addr.state,
|
||||
addr.zip,
|
||||
addr.country,
|
||||
].filter(Boolean)
|
||||
|
||||
return parts.join(', ')
|
||||
})
|
||||
|
||||
const allRequiredFieldsFilled = computed(() => {
|
||||
const rail = selectedRail.value
|
||||
if (!rail) return false
|
||||
|
||||
const amount = formData.value.amount
|
||||
if (!amount || amount <= 0) return false
|
||||
|
||||
if (rail.requiresBankName && !formData.value.bankName) return false
|
||||
|
||||
const requiredFields = rail.fields.filter((f) => f.required)
|
||||
const allRequiredPresent = requiredFields.every((f) => {
|
||||
const value = formData.value[f.name]
|
||||
return value !== undefined && value !== null && value !== ''
|
||||
})
|
||||
|
||||
if (!allRequiredPresent) return false
|
||||
|
||||
if (dynamicDocumentNumberField.value?.required && !formData.value.documentNumber) return false
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
const calculateFeesDebounced = useDebounceFn(async () => {
|
||||
const amount = formData.value.amount
|
||||
const rail = selectedRail.value
|
||||
const providerDataValue = withdrawData.value.providerData
|
||||
const kycData = providerDataValue.type === 'muralpay' ? providerDataValue.kycData : null
|
||||
|
||||
if (!amount || amount <= 0 || !rail || !kycData) {
|
||||
calculatedFee.value = null
|
||||
exchangeRate.value = null
|
||||
return
|
||||
}
|
||||
|
||||
feeLoading.value = true
|
||||
try {
|
||||
await calculateFees()
|
||||
calculatedFee.value = withdrawData.value.calculation.fee
|
||||
exchangeRate.value = withdrawData.value.calculation.exchangeRate
|
||||
} catch (error) {
|
||||
console.error('Failed to calculate fees:', error)
|
||||
calculatedFee.value = 0
|
||||
exchangeRate.value = null
|
||||
} finally {
|
||||
feeLoading.value = false
|
||||
}
|
||||
}, 500)
|
||||
|
||||
watch(
|
||||
formData,
|
||||
() => {
|
||||
withdrawData.value.calculation.amount = formData.value.amount ?? 0
|
||||
if (withdrawData.value.providerData.type === 'muralpay') {
|
||||
withdrawData.value.providerData.accountDetails = { ...formData.value }
|
||||
}
|
||||
|
||||
if (allRequiredFieldsFilled.value) {
|
||||
feeLoading.value = true
|
||||
calculateFeesDebounced()
|
||||
} else {
|
||||
calculatedFee.value = null
|
||||
exchangeRate.value = null
|
||||
feeLoading.value = false
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
if (allRequiredFieldsFilled.value) {
|
||||
feeLoading.value = true
|
||||
calculateFeesDebounced()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => withdrawData.value.selection.method,
|
||||
(newMethod, oldMethod) => {
|
||||
if (oldMethod && newMethod !== oldMethod) {
|
||||
formData.value = {
|
||||
amount: undefined,
|
||||
bankName: '',
|
||||
}
|
||||
if (withdrawData.value.providerData.type === 'muralpay') {
|
||||
withdrawData.value.providerData.accountDetails = {}
|
||||
}
|
||||
withdrawData.value.calculation.amount = 0
|
||||
calculatedFee.value = null
|
||||
exchangeRate.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
accountOwner: {
|
||||
id: 'dashboard.creator-withdraw-modal.muralpay-details.account-owner',
|
||||
defaultMessage: 'Account owner',
|
||||
},
|
||||
cryptoWarningHeader: {
|
||||
id: 'dashboard.creator-withdraw-modal.muralpay-details.crypto-warning-header',
|
||||
defaultMessage: 'Confirm your wallet address',
|
||||
},
|
||||
coin: {
|
||||
id: 'dashboard.creator-withdraw-modal.muralpay-details.coin',
|
||||
defaultMessage: 'Coin',
|
||||
},
|
||||
network: {
|
||||
id: 'dashboard.creator-withdraw-modal.muralpay-details.network',
|
||||
defaultMessage: 'Network',
|
||||
},
|
||||
documentNumberNationalId: {
|
||||
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-national-id',
|
||||
defaultMessage: 'National ID Number',
|
||||
},
|
||||
documentNumberPassport: {
|
||||
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-passport',
|
||||
defaultMessage: 'Passport Number',
|
||||
},
|
||||
documentNumberResidentId: {
|
||||
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-resident-id',
|
||||
defaultMessage: 'Resident ID Number',
|
||||
},
|
||||
documentNumberRuc: {
|
||||
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-ruc',
|
||||
defaultMessage: 'RUC Number',
|
||||
},
|
||||
documentNumberTaxId: {
|
||||
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-tax-id',
|
||||
defaultMessage: 'Tax ID Number',
|
||||
},
|
||||
documentNumberNationalIdPlaceholder: {
|
||||
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-national-id-placeholder',
|
||||
defaultMessage: 'Enter national ID number',
|
||||
},
|
||||
documentNumberPassportPlaceholder: {
|
||||
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-passport-placeholder',
|
||||
defaultMessage: 'Enter passport number',
|
||||
},
|
||||
documentNumberResidentIdPlaceholder: {
|
||||
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-resident-id-placeholder',
|
||||
defaultMessage: 'Enter resident ID number',
|
||||
},
|
||||
documentNumberRucPlaceholder: {
|
||||
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-ruc-placeholder',
|
||||
defaultMessage: 'Enter RUC number',
|
||||
},
|
||||
documentNumberTaxIdPlaceholder: {
|
||||
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-tax-id-placeholder',
|
||||
defaultMessage: 'Enter tax ID number',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 sm:gap-4">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.entityQuestion) }}
|
||||
<span class="text-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<Chips
|
||||
v-model="entityType"
|
||||
:items="['individual', 'business']"
|
||||
:format-label="
|
||||
(item: string) =>
|
||||
item === 'individual'
|
||||
? formatMessage(messages.privateIndividual)
|
||||
: formatMessage(messages.businessEntity)
|
||||
"
|
||||
:never-empty="false"
|
||||
:capitalize="false"
|
||||
/>
|
||||
<span class="leading-tight text-primary">
|
||||
{{ formatMessage(messages.entityDescription) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="entityType" class="flex flex-col gap-4">
|
||||
<div v-if="entityType === 'business'" class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(formFieldLabels.businessName) }}
|
||||
<span class="text-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.businessName"
|
||||
type="text"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.businessNamePlaceholder)"
|
||||
autocomplete="organization"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-2.5 text-contrast placeholder:text-secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(formFieldLabels.email) }}
|
||||
<span class="text-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.email"
|
||||
type="email"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.emailPlaceholder)"
|
||||
autocomplete="email"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-2.5 text-contrast placeholder:text-secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="entityType === 'individual'" class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:gap-4">
|
||||
<div class="flex flex-1 flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(formFieldLabels.firstName) }}
|
||||
<span class="text-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.firstName"
|
||||
type="text"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.firstNamePlaceholder)"
|
||||
autocomplete="given-name"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(formFieldLabels.lastName) }}
|
||||
<span class="text-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.lastName"
|
||||
type="text"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.lastNamePlaceholder)"
|
||||
autocomplete="family-name"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(formFieldLabels.dateOfBirth) }}
|
||||
<span class="text-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.dateOfBirth"
|
||||
type="date"
|
||||
:max="maxDate"
|
||||
autocomplete="bday"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-2.5 text-contrast placeholder:text-secondary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(formFieldLabels.addressLine) }}
|
||||
<span class="text-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.physicalAddress.address1"
|
||||
type="text"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.addressPlaceholder)"
|
||||
autocomplete="address-line1"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-2.5 text-contrast placeholder:text-secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(formFieldLabels.addressLine2) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.physicalAddress.address2"
|
||||
type="text"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.address2Placeholder)"
|
||||
autocomplete="address-line2"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-2.5 text-contrast placeholder:text-secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:gap-4">
|
||||
<div class="flex flex-1 flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(formFieldLabels.city) }}
|
||||
<span class="text-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.physicalAddress.city"
|
||||
type="text"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.cityPlaceholder)"
|
||||
autocomplete="address-level2"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(formFieldLabels.stateProvince) }}
|
||||
<span class="text-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<Combobox
|
||||
v-if="subdivisionOptions.length > 0"
|
||||
v-model="formData.physicalAddress.state"
|
||||
:options="subdivisionOptions"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.statePlaceholder)"
|
||||
searchable
|
||||
search-placeholder="Search subdivisions..."
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
v-model="formData.physicalAddress.state"
|
||||
type="text"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.statePlaceholder)"
|
||||
autocomplete="address-level1"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:gap-4">
|
||||
<div class="flex flex-1 flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(formFieldLabels.postalCode) }}
|
||||
<span class="text-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.physicalAddress.zip"
|
||||
type="text"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.postalCodePlaceholder)"
|
||||
autocomplete="postal-code"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(formFieldLabels.country) }}
|
||||
<span class="text-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<Combobox
|
||||
v-model="formData.physicalAddress.country"
|
||||
:options="countryOptions"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.countryPlaceholder)"
|
||||
searchable
|
||||
search-placeholder="Search countries..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Chips, Combobox, formFieldLabels, formFieldPlaceholders } from '@modrinth/ui'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
|
||||
import { useFormattedCountries } from '@/composables/country.ts'
|
||||
import { useGeneratedState } from '@/composables/generated.ts'
|
||||
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||
|
||||
const { withdrawData } = useWithdrawContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
const generatedState = useGeneratedState()
|
||||
|
||||
const providerData = withdrawData.value.providerData
|
||||
const existingKycData = providerData.type === 'muralpay' ? providerData.kycData : null
|
||||
|
||||
const entityType = ref<'individual' | 'business' | null>(existingKycData?.type ?? null)
|
||||
|
||||
interface PayoutRecipientInfoMerged {
|
||||
email: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
dateOfBirth?: string
|
||||
businessName?: string
|
||||
physicalAddress: {
|
||||
address1: string
|
||||
address2: string | null
|
||||
country: string
|
||||
state: string
|
||||
city: string
|
||||
zip: string
|
||||
}
|
||||
}
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
const formData = ref<PayoutRecipientInfoMerged>({
|
||||
email: existingKycData?.email ?? `${(auth.value.user as any)?.email}`,
|
||||
firstName: existingKycData?.type === 'individual' ? existingKycData.firstName : '',
|
||||
lastName: existingKycData?.type === 'individual' ? existingKycData.lastName : '',
|
||||
dateOfBirth: existingKycData?.type === 'individual' ? existingKycData.dateOfBirth : '',
|
||||
businessName: existingKycData?.type === 'business' ? existingKycData.name : '',
|
||||
physicalAddress: {
|
||||
address1: existingKycData?.physicalAddress?.address1 ?? '',
|
||||
address2: existingKycData?.physicalAddress?.address2 ?? null,
|
||||
country:
|
||||
existingKycData?.physicalAddress?.country ?? withdrawData.value.selection.country?.id ?? '',
|
||||
state: existingKycData?.physicalAddress?.state ?? '',
|
||||
city: existingKycData?.physicalAddress?.city ?? '',
|
||||
zip: existingKycData?.physicalAddress?.zip ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
const maxDate = computed(() => {
|
||||
const today = new Date()
|
||||
const year = today.getFullYear() - 18
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(today.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
})
|
||||
|
||||
const countryOptions = useFormattedCountries()
|
||||
|
||||
const subdivisionOptions = computed(() => {
|
||||
const selectedCountry = formData.value.physicalAddress.country
|
||||
if (!selectedCountry) return []
|
||||
|
||||
const subdivisions = generatedState.value.subdivisions?.[selectedCountry] ?? []
|
||||
|
||||
return subdivisions.map((sub) => ({
|
||||
value: sub.code.includes('-') ? sub.code.split('-')[1] : sub.code,
|
||||
label: sub.localVariant || sub.name,
|
||||
}))
|
||||
})
|
||||
|
||||
watch(
|
||||
[entityType, formData],
|
||||
() => {
|
||||
if (!entityType.value) {
|
||||
if (withdrawData.value.providerData.type === 'muralpay') {
|
||||
withdrawData.value.providerData.kycData = null as any
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (withdrawData.value.providerData.type !== 'muralpay') return
|
||||
|
||||
if (entityType.value === 'individual') {
|
||||
if (formData.value.dateOfBirth) {
|
||||
withdrawData.value.providerData.kycData = {
|
||||
type: 'individual',
|
||||
firstName: formData.value.firstName || '',
|
||||
lastName: formData.value.lastName || '',
|
||||
email: formData.value.email,
|
||||
dateOfBirth: formData.value.dateOfBirth,
|
||||
physicalAddress: {
|
||||
address1: formData.value.physicalAddress.address1,
|
||||
address2: formData.value.physicalAddress.address2 || undefined,
|
||||
country: formData.value.physicalAddress.country,
|
||||
state: formData.value.physicalAddress.state,
|
||||
city: formData.value.physicalAddress.city,
|
||||
zip: formData.value.physicalAddress.zip,
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withdrawData.value.providerData.kycData = {
|
||||
type: 'business',
|
||||
name: formData.value.businessName || '',
|
||||
email: formData.value.email,
|
||||
physicalAddress: {
|
||||
address1: formData.value.physicalAddress.address1,
|
||||
address2: formData.value.physicalAddress.address2 || undefined,
|
||||
country: formData.value.physicalAddress.country,
|
||||
state: formData.value.physicalAddress.state,
|
||||
city: formData.value.physicalAddress.city,
|
||||
zip: formData.value.physicalAddress.zip,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
entityQuestion: {
|
||||
id: 'dashboard.creator-withdraw-modal.kyc.entity-question',
|
||||
defaultMessage: 'Are you a withdrawing as an individual or business?',
|
||||
},
|
||||
entityDescription: {
|
||||
id: 'dashboard.creator-withdraw-modal.kyc.entity-description',
|
||||
defaultMessage:
|
||||
'A business entity refers to a registered organization such as a corporation, partnership, or LLC.',
|
||||
},
|
||||
privateIndividual: {
|
||||
id: 'dashboard.creator-withdraw-modal.kyc.private-individual',
|
||||
defaultMessage: 'Private individual',
|
||||
},
|
||||
businessEntity: {
|
||||
id: 'dashboard.creator-withdraw-modal.kyc.business-entity',
|
||||
defaultMessage: 'Business entity',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5 sm:gap-3">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex w-full flex-col gap-1 sm:flex-row sm:justify-between sm:gap-0">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.withdrawLimit) }}</span>
|
||||
<div>
|
||||
<span class="text-orange">{{ formatMoney(usedLimit) }}</span> /
|
||||
<span class="text-contrast">{{ formatMoney(600) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-2.5 w-full overflow-hidden rounded-full bg-surface-2">
|
||||
<div
|
||||
v-if="usedLimit > 0"
|
||||
class="gradient-border bg-orange"
|
||||
:style="{ width: `${(usedLimit / 600) * 100}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="remainingLimit > 0">
|
||||
<span>
|
||||
<IntlFormatted
|
||||
:message-id="messages.nearingThreshold"
|
||||
:values="{
|
||||
amountRemaining: formatMoney(remainingLimit),
|
||||
}"
|
||||
>
|
||||
<template #b="{ children }">
|
||||
<span class="font-medium">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
<Admonition
|
||||
type="warning"
|
||||
show-actions-underneath
|
||||
:header="formatMessage(messages.taxFormRequiredHeader)"
|
||||
>
|
||||
<span class="text-sm font-normal md:text-base">
|
||||
{{
|
||||
formatMessage(messages.taxFormRequiredBodyWithLimit, {
|
||||
limit: formatMoney(remainingLimit),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<template #icon="{ iconClass }">
|
||||
<FileTextIcon :class="iconClass" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="showTaxFormModal">
|
||||
{{ formatMessage(messages.completeTaxForm) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>
|
||||
<IntlFormatted
|
||||
:message-id="messages.withdrawLimitUsed"
|
||||
:values="{ withdrawLimit: formatMoney(600) }"
|
||||
>
|
||||
<template #b="{ children }">
|
||||
<b>
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</b>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FileTextIcon } from '@modrinth/assets'
|
||||
import { Admonition, ButtonStyled } from '@modrinth/ui'
|
||||
import { formatMoney } from '@modrinth/utils'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { TAX_THRESHOLD_ACTUAL } from '@/providers/creator-withdraw.ts'
|
||||
import { normalizeChildren } from '@/utils/vue-children.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
balance: any
|
||||
onShowTaxForm: () => void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const usedLimit = computed(() => props.balance?.withdrawn_ytd ?? 0)
|
||||
const remainingLimit = computed(() => {
|
||||
const raw = TAX_THRESHOLD_ACTUAL - usedLimit.value
|
||||
if (raw <= 0) return 0
|
||||
const cents = Math.floor(raw * 100)
|
||||
return cents / 100
|
||||
})
|
||||
|
||||
function showTaxFormModal() {
|
||||
props.onShowTaxForm()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
withdrawLimit: {
|
||||
id: 'dashboard.creator-withdraw-modal.withdraw-limit',
|
||||
defaultMessage: 'Withdraw limit',
|
||||
},
|
||||
nearingThreshold: {
|
||||
id: 'dashboard.creator-withdraw-modal.nearing-threshold',
|
||||
defaultMessage:
|
||||
"You're nearing the withdraw threshold. You can withdraw <b>{amountRemaining}</b> now, but a tax form is required for more.",
|
||||
},
|
||||
taxFormRequiredHeader: {
|
||||
id: 'dashboard.creator-withdraw-modal.tax-form-required.header',
|
||||
defaultMessage: 'Tax form required',
|
||||
},
|
||||
taxFormRequiredBody: {
|
||||
id: 'dashboard.creator-withdraw-modal.tax-form-required.body',
|
||||
defaultMessage:
|
||||
'To withdraw your full <b>{available}</b> available balance please complete the form below. It is required for tax reporting and only needs to be done once.',
|
||||
},
|
||||
taxFormRequiredBodyWithLimit: {
|
||||
id: 'dashboard.creator-withdraw-modal.tax-form-required.body-with-limit',
|
||||
defaultMessage:
|
||||
"You must complete a W-9 or W-8 form for Modrinth's tax records so we remain compliant with tax regulations.",
|
||||
},
|
||||
completeTaxForm: {
|
||||
id: 'dashboard.creator-withdraw-modal.complete-tax-form',
|
||||
defaultMessage: 'Complete tax form',
|
||||
},
|
||||
withdrawLimitUsed: {
|
||||
id: 'dashboard.creator-withdraw-modal.withdraw-limit-used',
|
||||
defaultMessage:
|
||||
"You've used up your <b>{withdrawLimit}</b> withdrawal limit. You must complete a tax form to withdraw more.",
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.gradient-border {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.3), transparent);
|
||||
border-radius: inherit;
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: xor;
|
||||
padding: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,649 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 sm:gap-5">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-40"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-40"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="isUnverifiedEmail" class="overflow-hidden">
|
||||
<Admonition type="warning" :header="formatMessage(messages.unverifiedEmailHeader)">
|
||||
{{ formatMessage(messages.unverifiedEmailMessage) }}
|
||||
</Admonition>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-40"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-40"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="shouldShowUsdWarning" class="overflow-hidden">
|
||||
<Admonition type="warning" :header="formatMessage(messages.usdPaypalWarningHeader)">
|
||||
<IntlFormatted :message-id="messages.usdPaypalWarningMessage">
|
||||
<template #direct-paypal-link="{ children }">
|
||||
<span class="cursor-pointer text-link" @click="switchToDirectPaypal">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</Admonition>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div v-if="!showGiftCardSelector && selectedMethodDisplay" class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.paymentMethod) }}
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
class="flex min-h-[44px] items-center gap-2 rounded-[14px] bg-surface-2 px-4 py-2.5 sm:min-h-0"
|
||||
>
|
||||
<component :is="selectedMethodDisplay.icon" class="size-5 shrink-0" />
|
||||
<span class="break-words text-sm font-semibold text-contrast sm:text-[1rem]">{{
|
||||
typeof selectedMethodDisplay.label === 'string'
|
||||
? selectedMethodDisplay.label
|
||||
: formatMessage(selectedMethodDisplay.label)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast"
|
||||
>{{ formatMessage(formFieldLabels.email) }} <span class="text-red">*</span></span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
v-model="deliveryEmail"
|
||||
type="email"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.emailPlaceholder)"
|
||||
autocomplete="email"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="showGiftCardSelector" class="flex flex-col gap-1">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast"
|
||||
>{{ categoryLabel }} <span class="text-red">*</span></span
|
||||
>
|
||||
</label>
|
||||
<Combobox
|
||||
v-model="selectedGiftCardId"
|
||||
:options="rewardOptions"
|
||||
:placeholder="`Select ${categoryLabel.toLowerCase()}`"
|
||||
searchable
|
||||
:search-placeholder="`Search ${categoryLabelPlural.toLowerCase()}...`"
|
||||
class="h-10"
|
||||
>
|
||||
<template #selected>
|
||||
<div v-if="selectedRewardOption" class="flex items-center gap-2">
|
||||
<img
|
||||
v-if="selectedRewardOption.imageUrl"
|
||||
:src="selectedRewardOption.imageUrl"
|
||||
:alt="selectedRewardOption.label"
|
||||
class="size-5 rounded-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span class="font-semibold leading-tight">{{ selectedRewardOption.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-for="option in rewardOptions" :key="option.value" #[`option-${option.value}`]>
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
v-if="option.imageUrl"
|
||||
:src="option.imageUrl"
|
||||
:alt="option.label"
|
||||
class="size-5 rounded-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span class="font-semibold leading-tight">{{ option.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
<span v-if="selectedMethodDetails" class="text-secondary">
|
||||
{{ formatMoney(effectiveMinAmount) }} min,
|
||||
{{ formatMoney(selectedMethodDetails.interval?.standard?.max ?? effectiveMaxAmount) }}
|
||||
max withdrawal amount.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast"
|
||||
>{{ formatMessage(formFieldLabels.amount) }} <span class="text-red">*</span></span
|
||||
>
|
||||
</label>
|
||||
|
||||
<div v-if="showGiftCardSelector && useFixedDenominations" class="flex flex-col gap-2.5">
|
||||
<Chips
|
||||
v-model="selectedDenomination"
|
||||
:items="denominationOptions"
|
||||
:format-label="(amt: number) => formatMoney(amt)"
|
||||
:never-empty="false"
|
||||
:capitalize="false"
|
||||
/>
|
||||
<span v-if="denominationOptions.length === 0" class="text-error text-sm">
|
||||
No denominations available for your current balance
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<RevenueInputField
|
||||
v-model="formData.amount"
|
||||
v-model:selected-currency="selectedCurrency"
|
||||
:max-amount="effectiveMaxAmount"
|
||||
:min-amount="effectiveMinAmount"
|
||||
:show-currency-selector="showPayPalCurrencySelector"
|
||||
:currency-options="currencyOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<WithdrawFeeBreakdown
|
||||
v-if="allRequiredFieldsFilled"
|
||||
:amount="formData.amount || 0"
|
||||
:fee="calculatedFee"
|
||||
:fee-loading="feeLoading"
|
||||
:exchange-rate="exchangeRate"
|
||||
:local-currency="showPayPalCurrencySelector ? selectedCurrency : undefined"
|
||||
/>
|
||||
|
||||
<Checkbox v-model="agreedTerms">
|
||||
<span>
|
||||
<IntlFormatted :message-id="financialMessages.rewardsProgramTermsAgreement">
|
||||
<template #terms-link="{ children }">
|
||||
<nuxt-link to="/legal/cmp" class="text-link">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Admonition,
|
||||
Checkbox,
|
||||
Chips,
|
||||
Combobox,
|
||||
financialMessages,
|
||||
formFieldLabels,
|
||||
formFieldPlaceholders,
|
||||
paymentMethodMessages,
|
||||
useDebugLogger,
|
||||
} from '@modrinth/ui'
|
||||
import { formatMoney } from '@modrinth/utils'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue'
|
||||
import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
|
||||
import { useAuth } from '@/composables/auth.js'
|
||||
import { useBaseFetch } from '@/composables/fetch.js'
|
||||
import { type PayoutMethod, useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||
import { normalizeChildren } from '@/utils/vue-children.ts'
|
||||
|
||||
const debug = useDebugLogger('TremendousDetailsStage')
|
||||
const {
|
||||
withdrawData,
|
||||
maxWithdrawAmount,
|
||||
availableMethods,
|
||||
paymentOptions,
|
||||
calculateFees,
|
||||
setStage,
|
||||
paymentMethodsCache,
|
||||
} = useWithdrawContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
const auth = await useAuth()
|
||||
|
||||
const userEmail = computed(() => {
|
||||
return (auth.value.user as any)?.email || ''
|
||||
})
|
||||
|
||||
const providerData = withdrawData.value.providerData
|
||||
const initialDeliveryEmail =
|
||||
providerData.type === 'tremendous'
|
||||
? providerData.deliveryEmail || userEmail.value || ''
|
||||
: userEmail.value || ''
|
||||
const deliveryEmail = ref<string>(initialDeliveryEmail)
|
||||
|
||||
const showGiftCardSelector = computed(() => {
|
||||
const method = withdrawData.value.selection.method
|
||||
return method === 'merchant_card' || method === 'charity'
|
||||
})
|
||||
|
||||
const showPayPalCurrencySelector = computed(() => {
|
||||
const method = withdrawData.value.selection.method
|
||||
return method === 'paypal'
|
||||
})
|
||||
|
||||
const shouldShowUsdWarning = computed(() => {
|
||||
const method = withdrawData.value.selection.method
|
||||
const currency = selectedCurrency.value
|
||||
return method === 'paypal' && currency === 'USD'
|
||||
})
|
||||
|
||||
const selectedMethodDisplay = computed(() => {
|
||||
const method = withdrawData.value.selection.method
|
||||
if (!method) return null
|
||||
return paymentOptions.value.find((m) => m.value === method) || null
|
||||
})
|
||||
|
||||
const categoryLabel = computed(() => {
|
||||
const method = withdrawData.value.selection.method
|
||||
switch (method) {
|
||||
case 'visa_card':
|
||||
return formatMessage(paymentMethodMessages.virtualVisa)
|
||||
case 'merchant_card':
|
||||
return formatMessage(paymentMethodMessages.giftCard)
|
||||
case 'charity':
|
||||
return formatMessage(paymentMethodMessages.charity)
|
||||
default:
|
||||
return formatMessage(messages.reward)
|
||||
}
|
||||
})
|
||||
|
||||
const categoryLabelPlural = computed(() => {
|
||||
const method = withdrawData.value.selection.method
|
||||
switch (method) {
|
||||
case 'visa_card':
|
||||
return formatMessage(paymentMethodMessages.virtualVisaPlural)
|
||||
case 'merchant_card':
|
||||
return formatMessage(paymentMethodMessages.giftCardPlural)
|
||||
case 'charity':
|
||||
return formatMessage(paymentMethodMessages.charityPlural)
|
||||
default:
|
||||
return formatMessage(messages.rewardPlural)
|
||||
}
|
||||
})
|
||||
|
||||
const isUnverifiedEmail = computed(() => {
|
||||
if (!deliveryEmail.value || !userEmail.value) return false
|
||||
return deliveryEmail.value.toLowerCase() !== userEmail.value.toLowerCase()
|
||||
})
|
||||
|
||||
const maxAmount = computed(() => maxWithdrawAmount.value)
|
||||
const roundedMaxAmount = computed(() => Math.floor(maxAmount.value * 100) / 100)
|
||||
|
||||
const formData = ref<Record<string, any>>({
|
||||
amount: withdrawData.value.calculation.amount || undefined,
|
||||
})
|
||||
|
||||
const selectedGiftCardId = ref<string | null>(withdrawData.value.selection.methodId || null)
|
||||
|
||||
const currencyOptions = [
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'AUD', label: 'AUD' },
|
||||
{ value: 'CAD', label: 'CAD' },
|
||||
{ value: 'CHF', label: 'CHF' },
|
||||
{ value: 'CZK', label: 'CZK' },
|
||||
{ value: 'DKK', label: 'DKK' },
|
||||
{ value: 'EUR', label: 'EUR' },
|
||||
{ value: 'GBP', label: 'GBP' },
|
||||
{ value: 'MXN', label: 'MXN' },
|
||||
{ value: 'NOK', label: 'NOK' },
|
||||
{ value: 'NZD', label: 'NZD' },
|
||||
{ value: 'PLN', label: 'PLN' },
|
||||
{ value: 'SEK', label: 'SEK' },
|
||||
{ value: 'SGD', label: 'SGD' },
|
||||
]
|
||||
|
||||
function getCurrencyFromCountryCode(countryCode: string | undefined): string {
|
||||
if (!countryCode) return 'USD'
|
||||
|
||||
const code = countryCode.toUpperCase()
|
||||
|
||||
const countryToCurrency: Record<string, string> = {
|
||||
US: 'USD', // United States
|
||||
GB: 'GBP', // UK
|
||||
CA: 'CAD', // Canada
|
||||
AU: 'AUD', // Australia
|
||||
CH: 'CHF', // Switzerland
|
||||
CZ: 'CZK', // Czech Republic
|
||||
DK: 'DKK', // Denmark
|
||||
MX: 'MXN', // Mexico
|
||||
NO: 'NOK', // Norway
|
||||
NZ: 'NZD', // New Zealand
|
||||
PL: 'PLN', // Poland
|
||||
SE: 'SEK', // Sweden
|
||||
SG: 'SGD', // Singapore
|
||||
|
||||
// Eurozone countries
|
||||
AT: 'EUR', // Austria
|
||||
BE: 'EUR', // Belgium
|
||||
CY: 'EUR', // Cyprus
|
||||
EE: 'EUR', // Estonia
|
||||
FI: 'EUR', // Finland
|
||||
FR: 'EUR', // France
|
||||
DE: 'EUR', // Germany
|
||||
GR: 'EUR', // Greece
|
||||
IE: 'EUR', // Ireland
|
||||
IT: 'EUR', // Italy
|
||||
LV: 'EUR', // Latvia
|
||||
LT: 'EUR', // Lithuania
|
||||
LU: 'EUR', // Luxembourg
|
||||
MT: 'EUR', // Malta
|
||||
NL: 'EUR', // Netherlands
|
||||
PT: 'EUR', // Portugal
|
||||
SK: 'EUR', // Slovakia
|
||||
SI: 'EUR', // Slovenia
|
||||
ES: 'EUR', // Spain
|
||||
}
|
||||
|
||||
return countryToCurrency[code] || 'USD'
|
||||
}
|
||||
|
||||
const initialCurrency = getCurrencyFromCountryCode(withdrawData.value.selection.country?.id)
|
||||
const selectedCurrency = ref<string>(initialCurrency)
|
||||
|
||||
const agreedTerms = computed({
|
||||
get: () => withdrawData.value.agreedTerms,
|
||||
set: (value) => {
|
||||
withdrawData.value.agreedTerms = value
|
||||
},
|
||||
})
|
||||
|
||||
const calculatedFee = ref<number>(0)
|
||||
const exchangeRate = ref<number | null>(null)
|
||||
const feeLoading = ref(false)
|
||||
|
||||
const rewardOptions = ref<
|
||||
Array<{
|
||||
value: string
|
||||
label: string
|
||||
imageUrl?: string
|
||||
methodDetails?: {
|
||||
id: string
|
||||
name: string
|
||||
interval?: {
|
||||
fixed?: { values: number[] }
|
||||
standard?: { min: number; max: number }
|
||||
}
|
||||
}
|
||||
}>
|
||||
>([])
|
||||
|
||||
const selectedRewardOption = computed(() => {
|
||||
if (!selectedGiftCardId.value) return null
|
||||
return rewardOptions.value.find((opt) => opt.value === selectedGiftCardId.value) || null
|
||||
})
|
||||
|
||||
const selectedMethodDetails = computed(() => {
|
||||
console.log(rewardOptions.value, selectedGiftCardId.value)
|
||||
if (!selectedGiftCardId.value) return null
|
||||
const option = rewardOptions.value.find((opt) => opt.value === selectedGiftCardId.value)
|
||||
debug('Selected method details:', option?.methodDetails)
|
||||
return option?.methodDetails || null
|
||||
})
|
||||
|
||||
const useFixedDenominations = computed(() => {
|
||||
const hasFixed = !!selectedMethodDetails.value?.interval?.fixed?.values
|
||||
debug('Use fixed denominations:', hasFixed, selectedMethodDetails.value?.interval)
|
||||
return hasFixed
|
||||
})
|
||||
|
||||
const denominationOptions = computed(() => {
|
||||
const fixedValues = selectedMethodDetails.value?.interval?.fixed?.values
|
||||
if (!fixedValues) return []
|
||||
|
||||
const filtered = fixedValues
|
||||
.filter((amount) => amount <= roundedMaxAmount.value)
|
||||
.sort((a, b) => a - b)
|
||||
debug(
|
||||
'Denomination options (filtered by max):',
|
||||
filtered,
|
||||
'from',
|
||||
fixedValues,
|
||||
'max:',
|
||||
roundedMaxAmount.value,
|
||||
)
|
||||
return filtered
|
||||
})
|
||||
|
||||
const effectiveMinAmount = computed(() => {
|
||||
return selectedMethodDetails.value?.interval?.standard?.min || 0.01
|
||||
})
|
||||
|
||||
const effectiveMaxAmount = computed(() => {
|
||||
const methodMax = selectedMethodDetails.value?.interval?.standard?.max
|
||||
if (methodMax !== undefined && methodMax !== null) {
|
||||
return Math.min(roundedMaxAmount.value, methodMax)
|
||||
}
|
||||
return roundedMaxAmount.value
|
||||
})
|
||||
|
||||
const selectedDenomination = computed({
|
||||
get: () => formData.value.amount,
|
||||
set: (value) => {
|
||||
formData.value.amount = value
|
||||
},
|
||||
})
|
||||
|
||||
const allRequiredFieldsFilled = computed(() => {
|
||||
const amount = formData.value.amount
|
||||
if (!amount || amount <= 0) return false
|
||||
|
||||
if (!deliveryEmail.value) return false
|
||||
|
||||
if (showGiftCardSelector.value && !selectedGiftCardId.value) return false
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
const calculateFeesDebounced = useDebounceFn(async () => {
|
||||
const amount = formData.value.amount
|
||||
if (!amount || amount <= 0) {
|
||||
calculatedFee.value = 0
|
||||
exchangeRate.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const methodId = showGiftCardSelector.value
|
||||
? selectedGiftCardId.value
|
||||
: withdrawData.value.selection.methodId
|
||||
|
||||
if (!methodId) {
|
||||
calculatedFee.value = 0
|
||||
exchangeRate.value = null
|
||||
return
|
||||
}
|
||||
|
||||
feeLoading.value = true
|
||||
try {
|
||||
await calculateFees()
|
||||
calculatedFee.value = withdrawData.value.calculation.fee ?? 0
|
||||
exchangeRate.value = withdrawData.value.calculation.exchangeRate
|
||||
} catch (error) {
|
||||
console.error('Failed to calculate fees:', error)
|
||||
calculatedFee.value = 0
|
||||
exchangeRate.value = null
|
||||
} finally {
|
||||
feeLoading.value = false
|
||||
}
|
||||
}, 500)
|
||||
|
||||
watch(deliveryEmail, (newEmail) => {
|
||||
if (withdrawData.value.providerData.type === 'tremendous') {
|
||||
withdrawData.value.providerData.deliveryEmail = newEmail
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
selectedCurrency,
|
||||
(newCurrency) => {
|
||||
if (withdrawData.value.providerData.type === 'tremendous') {
|
||||
;(withdrawData.value.providerData as any).currency = newCurrency
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => withdrawData.value.selection.country?.id,
|
||||
(newCountryId) => {
|
||||
if (showPayPalCurrencySelector.value && newCountryId) {
|
||||
const detectedCurrency = getCurrencyFromCountryCode(newCountryId)
|
||||
selectedCurrency.value = detectedCurrency
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => formData.value.amount, selectedGiftCardId, deliveryEmail, selectedCurrency],
|
||||
() => {
|
||||
withdrawData.value.calculation.amount = formData.value.amount ?? 0
|
||||
|
||||
if (showGiftCardSelector.value && selectedGiftCardId.value) {
|
||||
withdrawData.value.selection.methodId = selectedGiftCardId.value
|
||||
}
|
||||
|
||||
if (allRequiredFieldsFilled.value) {
|
||||
feeLoading.value = true
|
||||
calculateFeesDebounced()
|
||||
} else {
|
||||
calculatedFee.value = 0
|
||||
exchangeRate.value = null
|
||||
feeLoading.value = false
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const methods = availableMethods.value
|
||||
const selectedMethod = withdrawData.value.selection.method
|
||||
|
||||
rewardOptions.value = methods
|
||||
.filter((m) => m.type === 'tremendous')
|
||||
.filter((m) => m.category === selectedMethod)
|
||||
.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.name,
|
||||
imageUrl: m.image_url || m.image_logo_url || undefined,
|
||||
methodDetails: {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
interval: m.interval,
|
||||
},
|
||||
}))
|
||||
|
||||
debug('Loaded reward options:', rewardOptions.value.length, 'methods')
|
||||
debug('Sample method with interval:', rewardOptions.value[0]?.methodDetails)
|
||||
|
||||
if (allRequiredFieldsFilled.value) {
|
||||
feeLoading.value = true
|
||||
calculateFeesDebounced()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => withdrawData.value.selection.method,
|
||||
(newMethod, oldMethod) => {
|
||||
if (oldMethod && newMethod !== oldMethod) {
|
||||
formData.value = {
|
||||
amount: undefined,
|
||||
}
|
||||
selectedGiftCardId.value = null
|
||||
calculatedFee.value = 0
|
||||
exchangeRate.value = null
|
||||
|
||||
// Clear currency when switching away from PayPal International
|
||||
if (newMethod !== 'paypal' && withdrawData.value.providerData.type === 'tremendous') {
|
||||
;(withdrawData.value.providerData as any).currency = undefined
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function switchToDirectPaypal() {
|
||||
withdrawData.value.selection.country = {
|
||||
id: 'US',
|
||||
name: 'United States',
|
||||
}
|
||||
|
||||
let usMethods = paymentMethodsCache.value['US']
|
||||
|
||||
if (!usMethods) {
|
||||
try {
|
||||
usMethods = (await useBaseFetch('payout/methods', {
|
||||
apiVersion: 3,
|
||||
query: { country: 'US' },
|
||||
})) as PayoutMethod[]
|
||||
|
||||
paymentMethodsCache.value['US'] = usMethods
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch US payment methods:', error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
availableMethods.value = usMethods
|
||||
|
||||
const directPaypal = usMethods.find((m) => m.type === 'paypal')
|
||||
|
||||
if (directPaypal) {
|
||||
withdrawData.value.selection.provider = 'paypal'
|
||||
withdrawData.value.selection.method = directPaypal.id
|
||||
withdrawData.value.selection.methodId = directPaypal.id
|
||||
|
||||
withdrawData.value.providerData = {
|
||||
type: 'paypal',
|
||||
}
|
||||
|
||||
await setStage('paypal-details', true)
|
||||
} else {
|
||||
console.error('An error occured - no paypal in US region??')
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
unverifiedEmailHeader: {
|
||||
id: 'dashboard.creator-withdraw-modal.tremendous-details.unverified-email-header',
|
||||
defaultMessage: 'Unverified email',
|
||||
},
|
||||
unverifiedEmailMessage: {
|
||||
id: 'dashboard.creator-withdraw-modal.tremendous-details.unverified-email-message',
|
||||
defaultMessage:
|
||||
'The delivery email you have entered is not associated with your Modrinth account. Modrinth cannot recover rewards sent to an incorrect email address.',
|
||||
},
|
||||
paymentMethod: {
|
||||
id: 'dashboard.creator-withdraw-modal.tremendous-details.payment-method',
|
||||
defaultMessage: 'Payment method',
|
||||
},
|
||||
reward: {
|
||||
id: 'dashboard.creator-withdraw-modal.tremendous-details.reward',
|
||||
defaultMessage: 'Reward',
|
||||
},
|
||||
rewardPlaceholder: {
|
||||
id: 'dashboard.creator-withdraw-modal.tremendous-details.reward-placeholder',
|
||||
defaultMessage: 'Select reward',
|
||||
},
|
||||
rewardPlural: {
|
||||
id: 'dashboard.creator-withdraw-modal.tremendous-details.reward-plural',
|
||||
defaultMessage: 'Rewards',
|
||||
},
|
||||
usdPaypalWarningHeader: {
|
||||
id: 'dashboard.creator-withdraw-modal.tremendous-details.usd-paypal-warning-header',
|
||||
defaultMessage: 'Lower fees available',
|
||||
},
|
||||
usdPaypalWarningMessage: {
|
||||
id: 'dashboard.creator-withdraw-modal.tremendous-details.usd-paypal-warning-message',
|
||||
defaultMessage:
|
||||
'You selected USD for PayPal International. <direct-paypal-link>Switch to direct PayPal</direct-paypal-link> for better fees (≈2% instead of ≈6%).',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -26,7 +26,7 @@ export default {
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const tags = useTags()
|
||||
const tags = useGeneratedState()
|
||||
|
||||
return { tags }
|
||||
},
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TeleportDropdownMenu
|
||||
<Combobox
|
||||
:id="'interval-field'"
|
||||
v-model="backupIntervalsLabel"
|
||||
:disabled="!autoBackupEnabled || isSaving"
|
||||
name="interval"
|
||||
:options="Object.keys(backupIntervals)"
|
||||
placeholder="Backup interval"
|
||||
:options="Object.keys(backupIntervals).map((k) => ({ value: k, label: k }))"
|
||||
:display-value="backupIntervalsLabel"
|
||||
/>
|
||||
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
@@ -57,12 +57,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SaveIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
TeleportDropdownMenu,
|
||||
} from '@modrinth/ui'
|
||||
import { ButtonStyled, Combobox, injectNotificationManager, NewModal } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
@@ -60,16 +60,24 @@
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<TeleportDropdownMenu
|
||||
<Combobox
|
||||
v-model="selectedVersion"
|
||||
name="Project"
|
||||
:options="filteredVersions"
|
||||
placeholder="No valid versions found"
|
||||
:options="
|
||||
filteredVersions.map((v) => ({
|
||||
value: v,
|
||||
label: typeof v === 'object' ? v.version_number : String(v),
|
||||
}))
|
||||
"
|
||||
:display-value="
|
||||
selectedVersion
|
||||
? typeof selectedVersion === 'object'
|
||||
? selectedVersion.version_number
|
||||
: String(selectedVersion)
|
||||
: 'No valid versions found'
|
||||
"
|
||||
class="!min-w-full"
|
||||
:disabled="filteredVersions.length === 0"
|
||||
:display-name="
|
||||
(version) => (typeof version === 'object' ? version?.version_number : version)
|
||||
"
|
||||
/>
|
||||
<Checkbox v-model="showBetaAlphaReleases"> Show any beta and alpha releases </Checkbox>
|
||||
</template>
|
||||
@@ -237,14 +245,7 @@ import {
|
||||
LockOpenIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
CopyCode,
|
||||
NewModal,
|
||||
TeleportDropdownMenu,
|
||||
} from '@modrinth/ui'
|
||||
import { Admonition, Avatar, ButtonStyled, Combobox, CopyCode, NewModal } from '@modrinth/ui'
|
||||
import TagItem from '@modrinth/ui/src/components/base/TagItem.vue'
|
||||
import { formatCategory, formatVersionsForDisplay, type Mod, type Version } from '@modrinth/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
@@ -282,7 +283,7 @@ const versionsError = ref('')
|
||||
const showBetaAlphaReleases = ref(false)
|
||||
const unlockFilterAccordion = ref()
|
||||
const versionFilter = ref(true)
|
||||
const tags = useTags()
|
||||
const tags = useGeneratedState()
|
||||
const noCompatibleVersions = ref(false)
|
||||
|
||||
const { pluginLoaders, modLoaders } = tags.value.loaders.reduce(
|
||||
|
||||
@@ -17,10 +17,11 @@
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<TeleportDropdownMenu
|
||||
<Combobox
|
||||
v-if="props.versions?.length"
|
||||
v-model="selectedVersion"
|
||||
:options="versionOptions"
|
||||
:options="versionOptions.map((v) => ({ value: v, label: v }))"
|
||||
:display-value="selectedVersion || 'Select version...'"
|
||||
placeholder="Select version..."
|
||||
name="version"
|
||||
class="w-full max-w-full"
|
||||
@@ -68,12 +69,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
TeleportDropdownMenu,
|
||||
} from '@modrinth/ui'
|
||||
import { ButtonStyled, Combobox, injectNotificationManager, NewModal } from '@modrinth/ui'
|
||||
import { ModrinthServersFetchError } from '@modrinth/utils'
|
||||
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
@@ -96,7 +92,7 @@ const emit = defineEmits<{
|
||||
const modal = ref()
|
||||
const hardReset = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const selectedVersion = ref('')
|
||||
const selectedVersion = ref(props.currentVersion?.version_number || '')
|
||||
|
||||
const versionOptions = computed(() => props.versions?.map((v) => v.version_number) || [])
|
||||
|
||||
|
||||
@@ -54,11 +54,12 @@
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="text-lg font-bold text-contrast">Minecraft version</div>
|
||||
<TeleportDropdownMenu
|
||||
<Combobox
|
||||
v-model="selectedMCVersion"
|
||||
name="mcVersion"
|
||||
:options="mcVersions"
|
||||
class="w-full max-w-[100%]"
|
||||
:options="mcVersions.map((v) => ({ value: v, label: v }))"
|
||||
:display-value="selectedMCVersion || 'Select Minecraft version...'"
|
||||
class="!w-full"
|
||||
placeholder="Select Minecraft version..."
|
||||
/>
|
||||
<div class="mt-2 flex items-center justify-between gap-2">
|
||||
@@ -108,10 +109,17 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="selectedLoaderVersions.length > 0">
|
||||
<TeleportDropdownMenu
|
||||
<Combobox
|
||||
v-model="selectedLoaderVersion"
|
||||
name="loaderVersion"
|
||||
:options="selectedLoaderVersions"
|
||||
:options="selectedLoaderVersions.map((v) => ({ value: v, label: v }))"
|
||||
:display-value="
|
||||
selectedLoaderVersion ||
|
||||
(selectedLoader.toLowerCase() === 'paper' ||
|
||||
selectedLoader.toLowerCase() === 'purpur'
|
||||
? 'Select build number...'
|
||||
: 'Select loader version...')
|
||||
"
|
||||
class="w-full max-w-[100%]"
|
||||
:placeholder="
|
||||
selectedLoader.toLowerCase() === 'paper' ||
|
||||
@@ -201,9 +209,9 @@ import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from '@modrinth/asset
|
||||
import {
|
||||
BackupWarning,
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
TeleportDropdownMenu,
|
||||
Toggle,
|
||||
} from '@modrinth/ui'
|
||||
import { type Loaders, ModrinthServersFetchError } from '@modrinth/utils'
|
||||
@@ -433,7 +441,7 @@ onMounted(() => {
|
||||
fetchLoaderVersions()
|
||||
})
|
||||
|
||||
const tags = useTags()
|
||||
const tags = useGeneratedState()
|
||||
const mcVersions = computed(() =>
|
||||
tags.value.gameVersions
|
||||
.filter((x) =>
|
||||
|
||||
@@ -1,458 +0,0 @@
|
||||
<template>
|
||||
<div class="relative inline-block h-9 w-full max-w-80">
|
||||
<button
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="dropdownVisible"
|
||||
:aria-controls="listboxId"
|
||||
:aria-labelledby="listboxId"
|
||||
class="duration-50 flex h-full w-full cursor-pointer select-none appearance-none items-center justify-between gap-4 rounded-xl border-none bg-button-bg px-4 py-2 shadow-sm !outline-none transition-all ease-in-out"
|
||||
:class="triggerClasses"
|
||||
@click="toggleDropdown"
|
||||
@keydown="handleTriggerKeyDown"
|
||||
>
|
||||
<span>{{ selectedOption }}</span>
|
||||
<DropdownIcon
|
||||
class="transition-transform duration-200 ease-in-out"
|
||||
:class="{ 'rotate-180': dropdownVisible }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<transition
|
||||
enter-active-class="transition-opacity duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="dropdownVisible"
|
||||
:id="listboxId"
|
||||
ref="optionsContainer"
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
:aria-activedescendant="activeDescendant"
|
||||
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg outline-none"
|
||||
:class="{
|
||||
'rounded-b-xl': !isRenderingUp,
|
||||
'rounded-t-xl': isRenderingUp,
|
||||
}"
|
||||
:style="positionStyle"
|
||||
@keydown="handleListboxKeyDown"
|
||||
>
|
||||
<div
|
||||
class="overflow-y-auto"
|
||||
:style="{ height: `${virtualListHeight}px` }"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
|
||||
<div
|
||||
v-for="item in visibleOptions"
|
||||
:key="item.index"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: `translateY(${item.index * ITEM_HEIGHT}px)`,
|
||||
width: '100%',
|
||||
height: `${ITEM_HEIGHT}px`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:id="`${listboxId}-option-${item.index}`"
|
||||
role="option"
|
||||
:aria-selected="selectedValue === item.option"
|
||||
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out"
|
||||
:class="{
|
||||
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
|
||||
'bg-bg-raised': focusedOptionIndex === item.index,
|
||||
'rounded-b-xl': item.index === props.options.length - 1 && !isRenderingUp,
|
||||
'rounded-t-xl': item.index === 0 && isRenderingUp,
|
||||
}"
|
||||
@click="selectOption(item.option, item.index)"
|
||||
@mousemove="focusedOptionIndex = item.index"
|
||||
>
|
||||
{{ displayName(item.option) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const ITEM_HEIGHT = 44
|
||||
const BUFFER_ITEMS = 5
|
||||
|
||||
type OptionValue = string | number | Record<string, any>
|
||||
|
||||
interface Props {
|
||||
options: OptionValue[]
|
||||
name: string
|
||||
defaultValue?: OptionValue | null
|
||||
placeholder?: string | number | null
|
||||
modelValue?: OptionValue | null
|
||||
renderUp?: boolean
|
||||
disabled?: boolean
|
||||
displayName?: (option: OptionValue) => string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
defaultValue: null,
|
||||
placeholder: null,
|
||||
modelValue: null,
|
||||
renderUp: false,
|
||||
disabled: false,
|
||||
displayName: (option: OptionValue) => String(option),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'input' | 'update:modelValue', value: OptionValue): void
|
||||
(e: 'change', value: { option: OptionValue; index: number }): void
|
||||
}>()
|
||||
|
||||
const dropdownVisible = ref(false)
|
||||
const selectedValue = ref<OptionValue | null>(props.modelValue || props.defaultValue)
|
||||
const focusedOptionIndex = ref<number | null>(null)
|
||||
const optionsContainer = ref<HTMLElement | null>(null)
|
||||
const scrollTop = ref(0)
|
||||
const isRenderingUp = ref(false)
|
||||
const virtualListHeight = ref(300)
|
||||
const isOpen = ref(false)
|
||||
const openDropdownCount = ref(0)
|
||||
const listboxId = `pyro-listbox-${Math.random().toString(36).substring(2, 11)}`
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
const positionStyle = ref<CSSProperties>({
|
||||
position: 'fixed',
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
width: '0px',
|
||||
zIndex: 999,
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => props.options.length * ITEM_HEIGHT)
|
||||
|
||||
const visibleOptions = computed(() => {
|
||||
const startIndex = Math.floor(scrollTop.value / ITEM_HEIGHT) - BUFFER_ITEMS
|
||||
const visibleCount = Math.ceil(virtualListHeight.value / ITEM_HEIGHT) + 2 * BUFFER_ITEMS
|
||||
|
||||
return Array.from({ length: visibleCount }, (_, i) => {
|
||||
const index = startIndex + i
|
||||
if (index >= 0 && index < props.options.length) {
|
||||
return {
|
||||
index,
|
||||
option: props.options[index],
|
||||
}
|
||||
}
|
||||
return null
|
||||
}).filter((item): item is { index: number; option: OptionValue } => item !== null)
|
||||
})
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
if (selectedValue.value !== null && selectedValue.value !== undefined) {
|
||||
return props.displayName(selectedValue.value as OptionValue)
|
||||
}
|
||||
return props.placeholder || 'Select an option'
|
||||
})
|
||||
|
||||
const radioValue = computed<OptionValue>({
|
||||
get() {
|
||||
return props.modelValue ?? selectedValue.value ?? ''
|
||||
},
|
||||
set(newValue: OptionValue) {
|
||||
emit('update:modelValue', newValue)
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
})
|
||||
|
||||
const triggerClasses = computed(() => ({
|
||||
'!cursor-not-allowed opacity-50 grayscale': props.disabled,
|
||||
'rounded-b-none': dropdownVisible.value && !isRenderingUp.value && !props.disabled,
|
||||
'rounded-t-none': dropdownVisible.value && isRenderingUp.value && !props.disabled,
|
||||
}))
|
||||
|
||||
const updatePosition = async () => {
|
||||
if (!triggerRef.value) return
|
||||
|
||||
await nextTick()
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const margin = 8
|
||||
|
||||
const contentHeight = props.options.length * ITEM_HEIGHT
|
||||
const preferredHeight = Math.min(contentHeight, 300)
|
||||
|
||||
const spaceBelow = viewportHeight - triggerRect.bottom
|
||||
const spaceAbove = triggerRect.top
|
||||
|
||||
isRenderingUp.value = spaceBelow < preferredHeight && spaceAbove > spaceBelow
|
||||
|
||||
virtualListHeight.value = isRenderingUp.value
|
||||
? Math.min(spaceAbove - margin, preferredHeight)
|
||||
: Math.min(spaceBelow - margin, preferredHeight)
|
||||
|
||||
positionStyle.value = {
|
||||
position: 'fixed',
|
||||
left: `${triggerRect.left}px`,
|
||||
width: `${triggerRect.width}px`,
|
||||
zIndex: 999,
|
||||
...(isRenderingUp.value
|
||||
? { bottom: `${viewportHeight - triggerRect.top}px`, top: 'auto' }
|
||||
: { top: `${triggerRect.bottom}px`, bottom: 'auto' }),
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!props.disabled) {
|
||||
if (dropdownVisible.value) {
|
||||
closeDropdown()
|
||||
} else {
|
||||
openDropdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
if (dropdownVisible.value) {
|
||||
requestAnimationFrame(() => {
|
||||
updatePosition()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
scrollTop.value = target.scrollTop
|
||||
}
|
||||
|
||||
const closeAllDropdowns = () => {
|
||||
const event = new CustomEvent('close-all-dropdowns')
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
|
||||
const selectOption = (option: OptionValue, index: number) => {
|
||||
radioValue.value = option
|
||||
emit('change', { option, index })
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
const focusNextOption = () => {
|
||||
if (focusedOptionIndex.value === null) {
|
||||
focusedOptionIndex.value = 0
|
||||
} else {
|
||||
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length
|
||||
}
|
||||
scrollToFocused()
|
||||
}
|
||||
|
||||
const focusPreviousOption = () => {
|
||||
if (focusedOptionIndex.value === null) {
|
||||
focusedOptionIndex.value = props.options.length - 1
|
||||
} else {
|
||||
focusedOptionIndex.value =
|
||||
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length
|
||||
}
|
||||
scrollToFocused()
|
||||
}
|
||||
|
||||
const scrollToFocused = () => {
|
||||
if (focusedOptionIndex.value === null) return
|
||||
|
||||
const optionsElement = optionsContainer.value?.querySelector('.overflow-y-auto')
|
||||
if (!optionsElement) return
|
||||
|
||||
const targetScrollTop = focusedOptionIndex.value * ITEM_HEIGHT
|
||||
const scrollBottom = optionsElement.clientHeight
|
||||
|
||||
if (targetScrollTop < optionsElement.scrollTop) {
|
||||
optionsElement.scrollTop = targetScrollTop
|
||||
} else if (targetScrollTop + ITEM_HEIGHT > optionsElement.scrollTop + scrollBottom) {
|
||||
optionsElement.scrollTop = targetScrollTop - scrollBottom + ITEM_HEIGHT
|
||||
}
|
||||
}
|
||||
|
||||
const openDropdown = async () => {
|
||||
if (!props.disabled) {
|
||||
closeAllDropdowns()
|
||||
dropdownVisible.value = true
|
||||
isOpen.value = true
|
||||
openDropdownCount.value++
|
||||
document.body.style.overflow = 'hidden'
|
||||
await updatePosition()
|
||||
|
||||
nextTick(() => {
|
||||
optionsContainer.value?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const closeDropdown = () => {
|
||||
if (isOpen.value) {
|
||||
dropdownVisible.value = false
|
||||
isOpen.value = false
|
||||
openDropdownCount.value--
|
||||
if (openDropdownCount.value === 0) {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
focusedOptionIndex.value = null
|
||||
triggerRef.value?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleTriggerKeyDown = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
if (!dropdownVisible.value) {
|
||||
openDropdown()
|
||||
focusedOptionIndex.value = event.key === 'ArrowUp' ? props.options.length - 1 : 0
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
focusNextOption()
|
||||
} else {
|
||||
focusPreviousOption()
|
||||
}
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault()
|
||||
if (!dropdownVisible.value) {
|
||||
openDropdown()
|
||||
focusedOptionIndex.value = 0
|
||||
} else if (focusedOptionIndex.value !== null) {
|
||||
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
closeDropdown()
|
||||
break
|
||||
case 'Tab':
|
||||
if (dropdownVisible.value) {
|
||||
event.preventDefault()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleListboxKeyDown = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault()
|
||||
if (focusedOptionIndex.value !== null) {
|
||||
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value)
|
||||
}
|
||||
break
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
focusNextOption()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
focusPreviousOption()
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
closeDropdown()
|
||||
break
|
||||
case 'Tab':
|
||||
event.preventDefault()
|
||||
break
|
||||
case 'Home':
|
||||
event.preventDefault()
|
||||
focusedOptionIndex.value = 0
|
||||
scrollToFocused()
|
||||
break
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
focusedOptionIndex.value = props.options.length - 1
|
||||
scrollToFocused()
|
||||
break
|
||||
default:
|
||||
if (event.key.length === 1) {
|
||||
const char = event.key.toLowerCase()
|
||||
const index = props.options.findIndex((option) =>
|
||||
props.displayName(option).toLowerCase().startsWith(char),
|
||||
)
|
||||
if (index !== -1) {
|
||||
focusedOptionIndex.value = index
|
||||
scrollToFocused()
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('scroll', handleResize, true)
|
||||
window.addEventListener('click', (event) => {
|
||||
if (!isChildOfDropdown(event.target as HTMLElement)) {
|
||||
closeDropdown()
|
||||
}
|
||||
})
|
||||
window.addEventListener('close-all-dropdowns', closeDropdown)
|
||||
|
||||
if (selectedValue.value) {
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('scroll', handleResize, true)
|
||||
window.removeEventListener('click', (event) => {
|
||||
if (!isChildOfDropdown(event.target as HTMLElement)) {
|
||||
closeDropdown()
|
||||
}
|
||||
})
|
||||
window.removeEventListener('close-all-dropdowns', closeDropdown)
|
||||
|
||||
if (isOpen.value) {
|
||||
openDropdownCount.value--
|
||||
if (openDropdownCount.value === 0) {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
)
|
||||
|
||||
watch(dropdownVisible, async (newValue) => {
|
||||
if (newValue) {
|
||||
await updatePosition()
|
||||
scrollTop.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
const activeDescendant = computed(() =>
|
||||
focusedOptionIndex.value !== null ? `${listboxId}-option-${focusedOptionIndex.value}` : undefined,
|
||||
)
|
||||
|
||||
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
|
||||
let currentNode: HTMLElement | null = element
|
||||
while (currentNode) {
|
||||
if (currentNode === triggerRef.value || currentNode === optionsContainer.value) {
|
||||
return true
|
||||
}
|
||||
currentNode = currentNode.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
146
apps/frontend/src/composables/generated.ts
Normal 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,
|
||||
}))
|
||||
@@ -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'],
|
||||
}))
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -300,7 +300,7 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const tags = useTags()
|
||||
const tags = useGeneratedState()
|
||||
const router = useNativeRouter()
|
||||
|
||||
const name = ref(props.project.title)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -161,7 +161,7 @@ interface Props {
|
||||
patchProject?: (data: any) => void
|
||||
}
|
||||
|
||||
const tags = useTags()
|
||||
const tags = useGeneratedState()
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
allMembers: () => [],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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('-')
|
||||
|
||||
@@ -226,7 +226,7 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const tags = useTags()
|
||||
const tags = useGeneratedState()
|
||||
const flags = useFeatureFlags()
|
||||
const auth = await useAuth()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 }>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -345,7 +345,7 @@ const toggleFeatures = defineMessages({
|
||||
|
||||
const cosmetics = useCosmetics()
|
||||
const flags = useFeatureFlags()
|
||||
const tags = useTags()
|
||||
const tags = useGeneratedState()
|
||||
|
||||
const theme = useTheme()
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
917
apps/frontend/src/providers/creator-withdraw.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
34
apps/frontend/src/utils/finance-icons.ts
Normal 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'
|
||||
}
|
||||
998
apps/frontend/src/utils/muralpay-rails.ts
Normal 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]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 989 B After Width: | Height: | Size: 989 B |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 967 B After Width: | Height: | Size: 967 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 321 B After Width: | Height: | Size: 321 B |
13
packages/assets/external/color/paypal.svg
vendored
Normal 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 |
|
Before Width: | Height: | Size: 839 B After Width: | Height: | Size: 839 B |
10
packages/assets/external/color/usdc.svg
vendored
Normal 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 |
8
packages/assets/external/color/venmo.svg
vendored
Normal 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 |
6
packages/assets/external/paypal.svg
vendored
@@ -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
@@ -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 |
6
packages/assets/external/tumblr.svg
vendored
@@ -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
@@ -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
@@ -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 |
@@ -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
|
||||
|
||||
5
packages/assets/icons/arrow-down.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-icon lucide-arrow-down">
|
||||
<path d="M12 5v14" />
|
||||
<path d="m19 12-7 7-7-7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 275 B |
1
packages/assets/icons/arrow-left-right.svg
Normal 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 |
15
packages/assets/icons/arrow-up-right.svg
Normal 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 |
15
packages/assets/icons/arrow-up.svg
Normal 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 |
7
packages/assets/icons/badge-dollar-sign.svg
Normal 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 |
1
packages/assets/icons/gift.svg
Normal 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 |
6
packages/assets/icons/hand-helping.svg
Normal 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 |
9
packages/assets/icons/landmark.svg
Normal 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 |
1
packages/assets/icons/loader-circle.svg
Normal 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 |
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
5
packages/ui/src/components/base/BulletDivider.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
539
packages/ui/src/components/base/Combobox.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||