You've already forked AstralRinth
forked from didirus/AstralRinth
feat: creator revenue page overhaul (#4204)
* feat: start on tax compliance * feat: avarala1099 composable * fix: shouldShow should be managed on the page itself * refactor: move show logic to revenue page * feat: security practices rather than info * feat: withdraw page lock * fix: empty modal bug & lint issues * feat: hide behind feature flag * Use standard admonition components, make casing consistent * modal title * lint * feat: withdrawal check * feat: tax cap on withdrawals warning * feat: start on revenue page overhaul * feat: segment generation for bar * feat: tooltips and links * fix: tooltip border * feat: finish initial layout, start on withdraw modal * feat: start on withdrawal limit stage * feat: shade support for primary colors * feat: start on withdraw details stage * fix: convert swatches to hex * feat: payout method/region dropdown temporarily using multiselect * feat: fix modal open issues and use teleport dropdowns * feat: hide transactions section if there are no transactions * refactor: NavStack surfaces * feat: new dropdown component * feat: remove teleport dropdown modal in favour of new combobox component * fix: lint * refactor: dashboard sidebar layout * feat: cleanup * fix: niche bugs * fix: ComboBox styling * feat: first part of qa * feat: animate flash rather than tooltip * fix: lint * feat: qa border gradient * fix: seg hover flashes * feat: i18n * feat: i18n and final QA * fix: lint * feat: QA * fix: lint * fix: merge conflicts * fix: intl * fix: blue hover * fix: transfers page * feat: surface variables & gradients * feat: text vars * fix: lint * fix: intl * feat: stages * fix: lint * feat: region selection * feat: method selection btns * fix: flex col on transactions * feat: hook up method selection to ctx * feat: muralpay kyc stage info * wip: muralpay integration * Basic Mural Pay API bindings * Fix clippy * use dotenvy in muralpay example * Refactor payout creation code * wip: muralpay payout requests * Mural Pay payouts work * Fix clippy * feat: progress * fix: broken tax form stage logic * polish: tax form stage and method selection stage layout * add mural pay fees API * Work on payout fee API * Fees API for more payment methods * Fix CI * polish: muralpay qa * refactor: clean up combobox component * polish: change from critical -> warning admonition in MuralpayDetailsStage * Temporarily disable Venmo and PayPal methods from frontend * polish: clean up transaction component & page * polish: navbar qa, text color-contrast in chips type buttonstyled, mb on rev/index.vue page * fix: incorrectly using available balance as tax form withdraw limit after tax forms submitted * wip: counterparties * Start on counterparties and payment methods API * polish: combobox component * polish: fix broken scroll logic using a composable & web:fix * fix: lint * polish: various QA fixes * feat: hook up with backend (wip) * feat: draft muralpay rails dynamic logic * polish: modify rails to support backend changes * Mural Pay multiple methods when fetching * Don't send supported_countries to frontend * Mural Pay multiple methods when fetching * Don't send supported_countries to frontend * feat: fees & methods endpoint hookup * chore: remove duplicates fix * polish: qa changes + figma match * Add countries to muralpay fiat methods * Compile fix * Add exchange rate info to fees endpoint * Add fees to premium Tremendous options * polish: i18n and better document type dropdown -> id input labels * feat: tremendous * fix: lint & i18n * feat: reintroduce tin mismatch logic to index.vue * polish: qa * fix: i18n * feat: remove teleport dropdown menu - combobox should be used * fix: lint * fix: jsdoc * feat: checkbox for reward program terms * Add delivery email field to Tremendous payouts * Add Tremendous product category to payout methods * Add bank details API to muralpay * Fix CI * Fix CI * polish: qa changes * feat: i18n pass * feat: deduplicate methods endpoint & fix i18n issues * chore: deduplicate i18n strings into common-messages.ts * fix: lint * fix: i18n * feat: estimates * polish: more QA * Remove prepaid visa, compute fees properly for Tremendous methods * Add more details to Tremendous errors * feat: withdraw endpoint impl & internals refactor * Add more details to Tremendous errors * feat: completion stage * Add fees to Mural * feat: transactions page match figma * fix: i18n * polish: QA changes * polish: qa * Payout history route and bank details * polish: autofill and requirements checks * fix: i18n + lint * fix: fiat rail fees * polish: move scroll fade stuff into NewModal rather than just CreatorWithdrawModal * feat: simplify action btn logic & tax form error * fix: tax -> Tax form * Re-add legacy PayPal/Venmo options for US * feat: mobile responsiveness fixes for modal * fix: responsiveness issues * feat: navstack responsiveness * fix: responsiveness * move the mural bank details route * fix: generated state cleanup & bank details input * fix: lint & i18n * Add utoipa support to payout endpoints * address some PR comments * polish: qa * add CORS to new utoipa routes * feat: legacy paypal/venmo stage * polish: reset amount on back qa * revert: navstack mr changes * polish: loading indicator on method selection stage * fix: paypal modal doesnt reopen after auth * fix: lint & i18n * fix: paypal flow * polish: qa changes * fix: gitignore * polish: qa fixes * fix: payouts_available in payouts.rs * fix: bug when limit is zero * polish: qa changes * fix: qa stuff & muralpay sub-division fix * Immediately approve mural payouts * Add currency support to Tremendous payouts * Currency forex * add forex to tremendous fee request * polish: qa & currency support for paypal tremendous * polish: fx qa * feat: demo mode flag * fix: i18n & padding issues * polish: qa changes * fix: ml * Add Mural balance to bank balance info * polish: show warning for paypal international USD withdrawals + more currencies * Add more Tremendous currencies support * fix: colors on balance bars * fix: empty states * fix: pl-8 mobile issue * fix: hide see all * Transaction payouts available use the correct date * Address my own review comment * Address PR comments * Change Mural withdrawal limit to 3k * fix: empty state + paypal warning * maybe fix tremendous gift cards * Change how Mural minimum withdrawals are calculated * Tweak min/max withdrawal values * fix: segment brightness * fix: min & max for muralpay & legacy paypal * Fix some icon issues * more issues * fix user menu * fix: remove + network --------- Signed-off-by: Calum H. <contact@cal.engineer> Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com> Co-authored-by: aecsocket <aecsocket@tutanota.com> Co-authored-by: Alejandro González <me@alegon.dev>
This commit is contained in:
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user