You've already forked AstralRinth
forked from didirus/AstralRinth
refactor: migrate to common eslint+prettier configs (#4168)
* refactor: migrate to common eslint+prettier configs * fix: prettier frontend * feat: config changes * fix: lint issues * fix: lint * fix: type imports * fix: cyclical import issue * fix: lockfile * fix: missing dep * fix: switch to tabs * fix: continue switch to tabs * fix: rustfmt parity * fix: moderation lint issue * fix: lint issues * fix: ui intl * fix: lint issues * Revert "fix: rustfmt parity" This reverts commit cb99d2376c321d813d4b7fc7e2a213bb30a54711. * feat: revert last rs
This commit is contained in:
@@ -1,24 +1,24 @@
|
||||
<template>
|
||||
<div>
|
||||
<ChartDisplay :projects="projects ?? undefined" :personal="true" />
|
||||
</div>
|
||||
<div>
|
||||
<ChartDisplay :projects="projects ?? undefined" :personal="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ChartDisplay from "~/components/ui/charts/ChartDisplay.vue";
|
||||
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth",
|
||||
});
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: "Analytics - Modrinth",
|
||||
});
|
||||
title: 'Analytics - Modrinth',
|
||||
})
|
||||
|
||||
const auth = await useAuth();
|
||||
const id = auth.value?.user?.id;
|
||||
const auth = await useAuth()
|
||||
const id = auth.value?.user?.id
|
||||
|
||||
const { data: projects } = await useAsyncData(`user/${id}/projects`, () =>
|
||||
useBaseFetch(`user/${id}/projects`),
|
||||
);
|
||||
useBaseFetch(`user/${id}/projects`),
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,251 +1,252 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<CollectionCreateModal ref="modal_creation" />
|
||||
<h2 class="text-2xl">{{ formatMessage(commonMessages.collectionsLabel) }}</h2>
|
||||
<div class="search-row">
|
||||
<div class="iconified-input">
|
||||
<label for="search-input" hidden>{{ formatMessage(messages.searchInputLabel) }}</label>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input id="search-input" v-model="filterQuery" type="text" />
|
||||
<Button
|
||||
v-if="filterQuery"
|
||||
class="r-btn"
|
||||
aria-label="Clear search"
|
||||
@click="() => (filterQuery = '')"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button color="primary" @click="(event) => $refs.modal_creation.show(event)">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.createNewButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="collections-grid">
|
||||
<nuxt-link
|
||||
v-if="'followed projects'.includes(filterQuery.toLowerCase())"
|
||||
:to="`/collection/following`"
|
||||
class="universal-card recessed collection"
|
||||
>
|
||||
<Avatar src="https://cdn.modrinth.com/follow-collection.png" class="icon" />
|
||||
<div class="details">
|
||||
<span class="title">{{ formatMessage(commonMessages.followedProjectsLabel) }}</span>
|
||||
<span class="description">
|
||||
{{ formatMessage(messages.followingCollectionDescription) }}
|
||||
</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stats">
|
||||
<BoxIcon aria-hidden="true" />
|
||||
{{
|
||||
formatMessage(messages.projectsCountLabel, {
|
||||
count: formatCompactNumber(user ? user.follows.length : 0),
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="stats">
|
||||
<LockIcon aria-hidden="true" />
|
||||
<span> {{ formatMessage(commonMessages.privateLabel) }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<nuxt-link
|
||||
v-for="collection in orderedCollections.sort(
|
||||
(a, b) => new Date(b.created) - new Date(a.created),
|
||||
)"
|
||||
:key="collection.id"
|
||||
:to="`/collection/${collection.id}`"
|
||||
class="universal-card recessed collection"
|
||||
>
|
||||
<Avatar :src="collection.icon_url" class="icon" />
|
||||
<div class="details">
|
||||
<span class="title">{{ collection.name }}</span>
|
||||
<span class="description">
|
||||
{{ collection.description }}
|
||||
</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stats">
|
||||
<BoxIcon aria-hidden="true" />
|
||||
{{
|
||||
formatMessage(messages.projectsCountLabel, {
|
||||
count: formatCompactNumber(collection.projects?.length || 0),
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="stats">
|
||||
<template v-if="collection.status === 'listed'">
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
<span> {{ formatMessage(commonMessages.publicLabel) }} </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'unlisted'">
|
||||
<LinkIcon aria-hidden="true" />
|
||||
<span> {{ formatMessage(commonMessages.unlistedLabel) }} </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'private'">
|
||||
<LockIcon aria-hidden="true" />
|
||||
<span> {{ formatMessage(commonMessages.privateLabel) }} </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'rejected'">
|
||||
<XIcon aria-hidden="true" />
|
||||
<span> {{ formatMessage(commonMessages.rejectedLabel) }} </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="universal-card">
|
||||
<CollectionCreateModal ref="modal_creation" />
|
||||
<h2 class="text-2xl">{{ formatMessage(commonMessages.collectionsLabel) }}</h2>
|
||||
<div class="search-row">
|
||||
<div class="iconified-input">
|
||||
<label for="search-input" hidden>{{ formatMessage(messages.searchInputLabel) }}</label>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input id="search-input" v-model="filterQuery" type="text" />
|
||||
<Button
|
||||
v-if="filterQuery"
|
||||
class="r-btn"
|
||||
aria-label="Clear search"
|
||||
@click="() => (filterQuery = '')"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button color="primary" @click="(event) => $refs.modal_creation.show(event)">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.createNewButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="collections-grid">
|
||||
<nuxt-link
|
||||
v-if="'followed projects'.includes(filterQuery.toLowerCase())"
|
||||
:to="`/collection/following`"
|
||||
class="universal-card recessed collection"
|
||||
>
|
||||
<Avatar src="https://cdn.modrinth.com/follow-collection.png" class="icon" />
|
||||
<div class="details">
|
||||
<span class="title">{{ formatMessage(commonMessages.followedProjectsLabel) }}</span>
|
||||
<span class="description">
|
||||
{{ formatMessage(messages.followingCollectionDescription) }}
|
||||
</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stats">
|
||||
<BoxIcon aria-hidden="true" />
|
||||
{{
|
||||
formatMessage(messages.projectsCountLabel, {
|
||||
count: formatCompactNumber(user ? user.follows.length : 0),
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="stats">
|
||||
<LockIcon aria-hidden="true" />
|
||||
<span> {{ formatMessage(commonMessages.privateLabel) }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<nuxt-link
|
||||
v-for="collection in orderedCollections.sort(
|
||||
(a, b) => new Date(b.created) - new Date(a.created),
|
||||
)"
|
||||
:key="collection.id"
|
||||
:to="`/collection/${collection.id}`"
|
||||
class="universal-card recessed collection"
|
||||
>
|
||||
<Avatar :src="collection.icon_url" class="icon" />
|
||||
<div class="details">
|
||||
<span class="title">{{ collection.name }}</span>
|
||||
<span class="description">
|
||||
{{ collection.description }}
|
||||
</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stats">
|
||||
<BoxIcon aria-hidden="true" />
|
||||
{{
|
||||
formatMessage(messages.projectsCountLabel, {
|
||||
count: formatCompactNumber(collection.projects?.length || 0),
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="stats">
|
||||
<template v-if="collection.status === 'listed'">
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
<span> {{ formatMessage(commonMessages.publicLabel) }} </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'unlisted'">
|
||||
<LinkIcon aria-hidden="true" />
|
||||
<span> {{ formatMessage(commonMessages.unlistedLabel) }} </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'private'">
|
||||
<LockIcon aria-hidden="true" />
|
||||
<span> {{ formatMessage(commonMessages.privateLabel) }} </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'rejected'">
|
||||
<XIcon aria-hidden="true" />
|
||||
<span> {{ formatMessage(commonMessages.rejectedLabel) }} </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
BoxIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
PlusIcon,
|
||||
LinkIcon,
|
||||
LockIcon,
|
||||
GlobeIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { Avatar, Button, commonMessages } from "@modrinth/ui";
|
||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||
BoxIcon,
|
||||
GlobeIcon,
|
||||
LinkIcon,
|
||||
LockIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, Button, commonMessages } from '@modrinth/ui'
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
const formatCompactNumber = useCompactNumber();
|
||||
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatCompactNumber = useCompactNumber()
|
||||
|
||||
const messages = defineMessages({
|
||||
createNewButton: {
|
||||
id: "dashboard.collections.button.create-new",
|
||||
defaultMessage: "Create new",
|
||||
},
|
||||
collectionsLongTitle: {
|
||||
id: "dashboard.collections.long-title",
|
||||
defaultMessage: "Your collections",
|
||||
},
|
||||
followingCollectionDescription: {
|
||||
id: "collection.description.following",
|
||||
defaultMessage: "Auto-generated collection of all the projects you're following.",
|
||||
},
|
||||
projectsCountLabel: {
|
||||
id: "dashboard.collections.label.projects-count",
|
||||
defaultMessage: "{count, plural, one {{count} project} other {{count} projects}}",
|
||||
},
|
||||
searchInputLabel: {
|
||||
id: "dashboard.collections.label.search-input",
|
||||
defaultMessage: "Search your collections",
|
||||
},
|
||||
});
|
||||
createNewButton: {
|
||||
id: 'dashboard.collections.button.create-new',
|
||||
defaultMessage: 'Create new',
|
||||
},
|
||||
collectionsLongTitle: {
|
||||
id: 'dashboard.collections.long-title',
|
||||
defaultMessage: 'Your collections',
|
||||
},
|
||||
followingCollectionDescription: {
|
||||
id: 'collection.description.following',
|
||||
defaultMessage: "Auto-generated collection of all the projects you're following.",
|
||||
},
|
||||
projectsCountLabel: {
|
||||
id: 'dashboard.collections.label.projects-count',
|
||||
defaultMessage: '{count, plural, one {{count} project} other {{count} projects}}',
|
||||
},
|
||||
searchInputLabel: {
|
||||
id: 'dashboard.collections.label.search-input',
|
||||
defaultMessage: 'Search your collections',
|
||||
},
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth",
|
||||
});
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: () => `${formatMessage(messages.collectionsLongTitle)} - Modrinth`,
|
||||
});
|
||||
title: () => `${formatMessage(messages.collectionsLongTitle)} - Modrinth`,
|
||||
})
|
||||
|
||||
const auth = await useAuth();
|
||||
const user = await useUser();
|
||||
const auth = await useAuth()
|
||||
const user = await useUser()
|
||||
|
||||
if (import.meta.client) {
|
||||
await initUserFollows();
|
||||
await initUserFollows()
|
||||
}
|
||||
|
||||
const filterQuery = ref("");
|
||||
const filterQuery = ref('')
|
||||
|
||||
const { data: collections } = await useAsyncData(`user/${auth.value.user.id}/collections`, () =>
|
||||
useBaseFetch(`user/${auth.value.user.id}/collections`, { apiVersion: 3 }),
|
||||
);
|
||||
useBaseFetch(`user/${auth.value.user.id}/collections`, { apiVersion: 3 }),
|
||||
)
|
||||
|
||||
const orderedCollections = computed(() => {
|
||||
if (!collections.value) return [];
|
||||
return collections.value
|
||||
.sort((a, b) => {
|
||||
const aUpdated = new Date(a.updated);
|
||||
const bUpdated = new Date(b.updated);
|
||||
return bUpdated - aUpdated;
|
||||
})
|
||||
.filter((collection) => {
|
||||
if (!filterQuery.value) return true;
|
||||
return collection.name.toLowerCase().includes(filterQuery.value.toLowerCase());
|
||||
});
|
||||
});
|
||||
if (!collections.value) return []
|
||||
return [...collections.value] // copy to avoid in-place mutation (no side effects)
|
||||
.sort((a, b) => {
|
||||
const aUpdated = new Date(a.updated)
|
||||
const bUpdated = new Date(b.updated)
|
||||
return bUpdated - aUpdated
|
||||
})
|
||||
.filter((collection) => {
|
||||
if (!filterQuery.value) return true
|
||||
return collection.name.toLowerCase().includes(filterQuery.value.toLowerCase())
|
||||
})
|
||||
})
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.collections-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
}
|
||||
@media screen and (max-width: 800px) {
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
}
|
||||
|
||||
gap: var(--gap-md);
|
||||
gap: var(--gap-md);
|
||||
|
||||
.collection {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--gap-md);
|
||||
margin-bottom: 0;
|
||||
.collection {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--gap-md);
|
||||
margin-bottom: 0;
|
||||
|
||||
.icon {
|
||||
width: 100% !important;
|
||||
height: 6rem !important;
|
||||
max-width: unset !important;
|
||||
max-height: unset !important;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
.icon {
|
||||
width: 100% !important;
|
||||
height: 6rem !important;
|
||||
max-width: unset !important;
|
||||
max-height: unset !important;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.title {
|
||||
color: var(--color-contrast);
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
.title {
|
||||
color: var(--color-contrast);
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--color-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.description {
|
||||
color: var(--color-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.stat-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
margin-top: auto;
|
||||
}
|
||||
.stat-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
|
||||
svg {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
svg {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
margin-bottom: var(--gap-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-lg) var(--gap-sm);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--gap-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-lg) var(--gap-sm);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
.iconified-input {
|
||||
flex-grow: 1;
|
||||
.iconified-input {
|
||||
flex-grow: 1;
|
||||
|
||||
input {
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
input {
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,212 +1,210 @@
|
||||
<template>
|
||||
<div class="dashboard-overview">
|
||||
<section class="universal-card dashboard-header">
|
||||
<Avatar :src="auth.user.avatar_url" size="md" circle :alt="auth.user.username" />
|
||||
<div class="username">
|
||||
<h1>
|
||||
{{ auth.user.username }}
|
||||
</h1>
|
||||
<NuxtLink class="goto-link" :to="`/user/${auth.user.username}`">
|
||||
Visit your profile
|
||||
<ChevronRightIcon class="featured-header-chevron" aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
<div class="dashboard-notifications">
|
||||
<section class="universal-card">
|
||||
<div class="header__row">
|
||||
<h2 class="header__title text-2xl">Notifications</h2>
|
||||
<nuxt-link
|
||||
v-if="notifications.length > 0"
|
||||
class="goto-link"
|
||||
to="/dashboard/notifications"
|
||||
>
|
||||
See all
|
||||
<ChevronRightIcon />
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<template v-if="notifications.length > 0">
|
||||
<NotificationItem
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:notifications="notifications"
|
||||
class="universal-card recessed"
|
||||
:notification="notification"
|
||||
:auth="auth"
|
||||
raised
|
||||
compact
|
||||
@update:notifications="() => refresh()"
|
||||
/>
|
||||
<nuxt-link
|
||||
v-if="extraNotifs > 0"
|
||||
class="goto-link view-more-notifs mt-4"
|
||||
to="/dashboard/notifications"
|
||||
>
|
||||
View {{ extraNotifs }} more notification{{ extraNotifs === 1 ? "" : "s" }}
|
||||
<ChevronRightIcon />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
<div v-else class="universal-body">
|
||||
<p>You have no unread notifications.</p>
|
||||
<nuxt-link class="iconified-button !mt-4" to="/dashboard/notifications/history">
|
||||
<HistoryIcon />
|
||||
View notification history
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="dashboard-overview">
|
||||
<section class="universal-card dashboard-header">
|
||||
<Avatar :src="auth.user.avatar_url" size="md" circle :alt="auth.user.username" />
|
||||
<div class="username">
|
||||
<h1>
|
||||
{{ auth.user.username }}
|
||||
</h1>
|
||||
<NuxtLink class="goto-link" :to="`/user/${auth.user.username}`">
|
||||
Visit your profile
|
||||
<ChevronRightIcon class="featured-header-chevron" aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
<div class="dashboard-notifications">
|
||||
<section class="universal-card">
|
||||
<div class="header__row">
|
||||
<h2 class="header__title text-2xl">Notifications</h2>
|
||||
<nuxt-link
|
||||
v-if="notifications.length > 0"
|
||||
class="goto-link"
|
||||
to="/dashboard/notifications"
|
||||
>
|
||||
See all
|
||||
<ChevronRightIcon />
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<template v-if="notifications.length > 0">
|
||||
<NotificationItem
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:notifications="notifications"
|
||||
class="universal-card recessed"
|
||||
:notification="notification"
|
||||
:auth="auth"
|
||||
raised
|
||||
compact
|
||||
@update:notifications="() => refresh()"
|
||||
/>
|
||||
<nuxt-link
|
||||
v-if="extraNotifs > 0"
|
||||
class="goto-link view-more-notifs mt-4"
|
||||
to="/dashboard/notifications"
|
||||
>
|
||||
View {{ extraNotifs }} more notification{{ extraNotifs === 1 ? '' : 's' }}
|
||||
<ChevronRightIcon />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
<div v-else class="universal-body">
|
||||
<p>You have no unread notifications.</p>
|
||||
<nuxt-link class="iconified-button !mt-4" to="/dashboard/notifications/history">
|
||||
<HistoryIcon />
|
||||
View notification history
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-analytics">
|
||||
<section class="universal-card">
|
||||
<h2>Analytics</h2>
|
||||
<div class="grid-display">
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Total downloads</div>
|
||||
<div class="value">
|
||||
{{ $formatNumber(projects.reduce((agg, x) => agg + x.downloads, 0)) }}
|
||||
</div>
|
||||
<span
|
||||
>from
|
||||
{{ downloadsProjectCount }}
|
||||
project{{ downloadsProjectCount === 1 ? "" : "s" }}</span
|
||||
>
|
||||
<!-- <NuxtLink class="goto-link" to="/dashboard/analytics"-->
|
||||
<!-- >View breakdown-->
|
||||
<!-- <ChevronRightIcon-->
|
||||
<!-- class="featured-header-chevron"-->
|
||||
<!-- aria-hidden="true"-->
|
||||
<!-- /></NuxtLink>-->
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Total followers</div>
|
||||
<div class="value">
|
||||
{{ $formatNumber(projects.reduce((agg, x) => agg + x.followers, 0)) }}
|
||||
</div>
|
||||
<span>
|
||||
<span
|
||||
>from {{ followersProjectCount }} project{{
|
||||
followersProjectCount === 1 ? "" : "s"
|
||||
}}</span
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-analytics">
|
||||
<section class="universal-card">
|
||||
<h2>Analytics</h2>
|
||||
<div class="grid-display">
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Total downloads</div>
|
||||
<div class="value">
|
||||
{{ $formatNumber(projects.reduce((agg, x) => agg + x.downloads, 0)) }}
|
||||
</div>
|
||||
<span
|
||||
>from
|
||||
{{ downloadsProjectCount }}
|
||||
project{{ downloadsProjectCount === 1 ? '' : 's' }}</span
|
||||
>
|
||||
<!-- <NuxtLink class="goto-link" to="/dashboard/analytics"-->
|
||||
<!-- >View breakdown-->
|
||||
<!-- <ChevronRightIcon-->
|
||||
<!-- class="featured-header-chevron"-->
|
||||
<!-- aria-hidden="true"-->
|
||||
<!-- /></NuxtLink>-->
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Total followers</div>
|
||||
<div class="value">
|
||||
{{ $formatNumber(projects.reduce((agg, x) => agg + x.followers, 0)) }}
|
||||
</div>
|
||||
<span>
|
||||
<span
|
||||
>from {{ followersProjectCount }} project{{
|
||||
followersProjectCount === 1 ? '' : 's'
|
||||
}}</span
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ChevronRightIcon, HistoryIcon } from "@modrinth/assets";
|
||||
import { Avatar } from "@modrinth/ui";
|
||||
import NotificationItem from "~/components/ui/NotificationItem.vue";
|
||||
import {
|
||||
fetchExtraNotificationData,
|
||||
groupNotifications,
|
||||
} from "~/helpers/platform-notifications.ts";
|
||||
import { ChevronRightIcon, HistoryIcon } from '@modrinth/assets'
|
||||
import { Avatar } from '@modrinth/ui'
|
||||
|
||||
import NotificationItem from '~/components/ui/NotificationItem.vue'
|
||||
import { fetchExtraNotificationData, groupNotifications } from '~/helpers/platform-notifications.ts'
|
||||
|
||||
useHead({
|
||||
title: "Dashboard - Modrinth",
|
||||
});
|
||||
title: 'Dashboard - Modrinth',
|
||||
})
|
||||
|
||||
const auth = await useAuth();
|
||||
const auth = await useAuth()
|
||||
|
||||
const [{ data: projects }] = await Promise.all([
|
||||
useAsyncData(`user/${auth.value.user.id}/projects`, () =>
|
||||
useBaseFetch(`user/${auth.value.user.id}/projects`),
|
||||
),
|
||||
]);
|
||||
useAsyncData(`user/${auth.value.user.id}/projects`, () =>
|
||||
useBaseFetch(`user/${auth.value.user.id}/projects`),
|
||||
),
|
||||
])
|
||||
|
||||
const downloadsProjectCount = computed(
|
||||
() => projects.value.filter((project) => project.downloads > 0).length,
|
||||
);
|
||||
() => projects.value.filter((project) => project.downloads > 0).length,
|
||||
)
|
||||
const followersProjectCount = computed(
|
||||
() => projects.value.filter((project) => project.followers > 0).length,
|
||||
);
|
||||
() => projects.value.filter((project) => project.followers > 0).length,
|
||||
)
|
||||
|
||||
const { data, refresh } = await useAsyncData(async () => {
|
||||
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`);
|
||||
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`)
|
||||
|
||||
const filteredNotifications = notifications.filter((notif) => !notif.read);
|
||||
const slice = filteredNotifications.slice(0, 30); // send first 30 notifs to be grouped before trimming to 3
|
||||
const filteredNotifications = notifications.filter((notif) => !notif.read)
|
||||
const slice = filteredNotifications.slice(0, 30) // send first 30 notifs to be grouped before trimming to 3
|
||||
|
||||
return fetchExtraNotificationData(slice).then((notifications) => {
|
||||
notifications = groupNotifications(notifications).slice(0, 3);
|
||||
return { notifications, extraNotifs: filteredNotifications.length - slice.length };
|
||||
});
|
||||
});
|
||||
return fetchExtraNotificationData(slice).then((notifications) => {
|
||||
notifications = groupNotifications(notifications).slice(0, 3)
|
||||
return { notifications, extraNotifs: filteredNotifications.length - slice.length }
|
||||
})
|
||||
})
|
||||
|
||||
const notifications = computed(() => {
|
||||
if (data.value === null) {
|
||||
return [];
|
||||
}
|
||||
return data.value.notifications;
|
||||
});
|
||||
if (data.value === null) {
|
||||
return []
|
||||
}
|
||||
return data.value.notifications
|
||||
})
|
||||
|
||||
const extraNotifs = computed(() => (data.value ? data.value.extraNotifs : 0));
|
||||
const extraNotifs = computed(() => (data.value ? data.value.extraNotifs : 0))
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.dashboard-overview {
|
||||
display: grid;
|
||||
grid-template:
|
||||
"header header"
|
||||
"notifications analytics" / 1fr auto;
|
||||
gap: var(--spacing-card-md);
|
||||
display: grid;
|
||||
grid-template:
|
||||
'header header'
|
||||
'notifications analytics' / 1fr auto;
|
||||
gap: var(--spacing-card-md);
|
||||
|
||||
> .universal-card {
|
||||
margin: 0;
|
||||
}
|
||||
> .universal-card {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@media screen and (max-width: 750px) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-notifications {
|
||||
grid-area: notifications;
|
||||
//display: flex;
|
||||
//flex-direction: column;
|
||||
//gap: var(--spacing-card-md);
|
||||
grid-area: notifications;
|
||||
//display: flex;
|
||||
//flex-direction: column;
|
||||
//gap: var(--spacing-card-md);
|
||||
|
||||
a.view-more-notifs {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
margin-left: auto;
|
||||
}
|
||||
a.view-more-notifs {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-analytics {
|
||||
grid-area: analytics;
|
||||
grid-area: analytics;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
gap: var(--spacing-card-bg);
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
gap: var(--spacing-card-bg);
|
||||
grid-area: header;
|
||||
|
||||
.username {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-card-sm);
|
||||
justify-content: center;
|
||||
word-break: break-word;
|
||||
.username {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-card-sm);
|
||||
justify-content: center;
|
||||
word-break: break-word;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.avatar {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
}
|
||||
@media screen and (max-width: 650px) {
|
||||
.avatar {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.username {
|
||||
h1 {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
}
|
||||
}
|
||||
.username {
|
||||
h1 {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,156 +1,157 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<Breadcrumbs
|
||||
v-if="history"
|
||||
current-title="History"
|
||||
:link-stack="[{ href: `/dashboard/notifications`, label: 'Notifications' }]"
|
||||
/>
|
||||
<div class="header__row">
|
||||
<div class="header__title">
|
||||
<h2 v-if="history" class="text-2xl">Notification history</h2>
|
||||
<h2 v-else class="text-2xl">Notifications</h2>
|
||||
</div>
|
||||
<template v-if="!history">
|
||||
<Button v-if="data.hasRead" @click="updateRoute()">
|
||||
<HistoryIcon />
|
||||
View history
|
||||
</Button>
|
||||
<Button v-if="notifications.length > 0" color="danger" @click="readAll()">
|
||||
<CheckCheckIcon />
|
||||
Mark all as read
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
<Chips
|
||||
v-if="notifTypes.length > 1"
|
||||
v-model="selectedType"
|
||||
:items="notifTypes"
|
||||
:format-label="(x) => (x === 'all' ? 'All' : formatProjectType(x).replace('_', ' ') + 's')"
|
||||
:capitalize="false"
|
||||
/>
|
||||
<p v-if="pending">Loading notifications...</p>
|
||||
<template v-else-if="error">
|
||||
<p>Error loading notifications:</p>
|
||||
<pre>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<Breadcrumbs
|
||||
v-if="history"
|
||||
current-title="History"
|
||||
:link-stack="[{ href: `/dashboard/notifications`, label: 'Notifications' }]"
|
||||
/>
|
||||
<div class="header__row">
|
||||
<div class="header__title">
|
||||
<h2 v-if="history" class="text-2xl">Notification history</h2>
|
||||
<h2 v-else class="text-2xl">Notifications</h2>
|
||||
</div>
|
||||
<template v-if="!history">
|
||||
<Button v-if="data.hasRead" @click="updateRoute()">
|
||||
<HistoryIcon />
|
||||
View history
|
||||
</Button>
|
||||
<Button v-if="notifications.length > 0" color="danger" @click="readAll()">
|
||||
<CheckCheckIcon />
|
||||
Mark all as read
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
<Chips
|
||||
v-if="notifTypes.length > 1"
|
||||
v-model="selectedType"
|
||||
:items="notifTypes"
|
||||
:format-label="(x) => (x === 'all' ? 'All' : formatProjectType(x).replace('_', ' ') + 's')"
|
||||
:capitalize="false"
|
||||
/>
|
||||
<p v-if="pending">Loading notifications...</p>
|
||||
<template v-else-if="error">
|
||||
<p>Error loading notifications:</p>
|
||||
<pre>
|
||||
{{ error }}
|
||||
</pre>
|
||||
</template>
|
||||
<template v-else-if="notifications && notifications.length > 0">
|
||||
<NotificationItem
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:notifications="notifications"
|
||||
class="universal-card recessed"
|
||||
:notification="notification"
|
||||
:auth="auth"
|
||||
raised
|
||||
@update:notifications="() => refresh()"
|
||||
/>
|
||||
</template>
|
||||
<p v-else>You don't have any unread notifications.</p>
|
||||
<div class="flex justify-end">
|
||||
<Pagination :page="page" :count="pages" @switch-page="changePage" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="notifications && notifications.length > 0">
|
||||
<NotificationItem
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:notifications="notifications"
|
||||
class="universal-card recessed"
|
||||
:notification="notification"
|
||||
:auth="auth"
|
||||
raised
|
||||
@update:notifications="() => refresh()"
|
||||
/>
|
||||
</template>
|
||||
<p v-else>You don't have any unread notifications.</p>
|
||||
<div class="flex justify-end">
|
||||
<Pagination :page="page" :count="pages" @switch-page="changePage" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { CheckCheckIcon, HistoryIcon } from "@modrinth/assets";
|
||||
import { Button, Chips, Pagination } from "@modrinth/ui";
|
||||
import { formatProjectType } from "@modrinth/utils";
|
||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||
import NotificationItem from "~/components/ui/NotificationItem.vue";
|
||||
import { CheckCheckIcon, HistoryIcon } from '@modrinth/assets'
|
||||
import { Button, Chips, Pagination } from '@modrinth/ui'
|
||||
import { formatProjectType } from '@modrinth/utils'
|
||||
|
||||
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
|
||||
import NotificationItem from '~/components/ui/NotificationItem.vue'
|
||||
import {
|
||||
fetchExtraNotificationData,
|
||||
groupNotifications,
|
||||
markAsRead,
|
||||
} from "~/helpers/platform-notifications.ts";
|
||||
fetchExtraNotificationData,
|
||||
groupNotifications,
|
||||
markAsRead,
|
||||
} from '~/helpers/platform-notifications.ts'
|
||||
|
||||
useHead({
|
||||
title: "Notifications - Modrinth",
|
||||
});
|
||||
title: 'Notifications - Modrinth',
|
||||
})
|
||||
|
||||
const auth = await useAuth();
|
||||
const route = useNativeRoute();
|
||||
const router = useNativeRouter();
|
||||
const auth = await useAuth()
|
||||
const route = useNativeRoute()
|
||||
const router = useNativeRouter()
|
||||
|
||||
const history = computed(() => route.name === "dashboard-notifications-history");
|
||||
const selectedType = ref("all");
|
||||
const page = ref(1);
|
||||
const perPage = ref(50);
|
||||
const history = computed(() => route.name === 'dashboard-notifications-history')
|
||||
const selectedType = ref('all')
|
||||
const page = ref(1)
|
||||
const perPage = ref(50)
|
||||
|
||||
const { data, pending, error, refresh } = await useAsyncData(
|
||||
async () => {
|
||||
const pageNum = page.value - 1;
|
||||
const showRead = history.value;
|
||||
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`);
|
||||
async () => {
|
||||
const pageNum = page.value - 1
|
||||
const showRead = history.value
|
||||
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`)
|
||||
|
||||
const typesInFeed = [
|
||||
...new Set(notifications.filter((n) => showRead || !n.read).map((n) => n.type)),
|
||||
];
|
||||
const typesInFeed = [
|
||||
...new Set(notifications.filter((n) => showRead || !n.read).map((n) => n.type)),
|
||||
]
|
||||
|
||||
const filtered = notifications.filter(
|
||||
(n) =>
|
||||
(selectedType.value === "all" || n.type === selectedType.value) && (showRead || !n.read),
|
||||
);
|
||||
const filtered = notifications.filter(
|
||||
(n) =>
|
||||
(selectedType.value === 'all' || n.type === selectedType.value) && (showRead || !n.read),
|
||||
)
|
||||
|
||||
const pages = Math.max(1, Math.ceil(filtered.length / perPage.value));
|
||||
const pages = Math.max(1, Math.ceil(filtered.length / perPage.value))
|
||||
|
||||
return fetchExtraNotificationData(
|
||||
filtered.slice(pageNum * perPage.value, pageNum * perPage.value + perPage.value),
|
||||
).then((notifs) => ({
|
||||
notifications: notifs,
|
||||
notifTypes: typesInFeed.length > 1 ? ["all", ...typesInFeed] : typesInFeed,
|
||||
pages,
|
||||
hasRead: notifications.some((n) => n.read),
|
||||
}));
|
||||
},
|
||||
{ watch: [page, history, selectedType] },
|
||||
);
|
||||
return fetchExtraNotificationData(
|
||||
filtered.slice(pageNum * perPage.value, pageNum * perPage.value + perPage.value),
|
||||
).then((notifs) => ({
|
||||
notifications: notifs,
|
||||
notifTypes: typesInFeed.length > 1 ? ['all', ...typesInFeed] : typesInFeed,
|
||||
pages,
|
||||
hasRead: notifications.some((n) => n.read),
|
||||
}))
|
||||
},
|
||||
{ watch: [page, history, selectedType] },
|
||||
)
|
||||
|
||||
const notifications = computed(() =>
|
||||
data.value ? groupNotifications(data.value.notifications, history.value) : [],
|
||||
);
|
||||
data.value ? groupNotifications(data.value.notifications, history.value) : [],
|
||||
)
|
||||
|
||||
const notifTypes = computed(() => data.value?.notifTypes || []);
|
||||
const pages = computed(() => data.value?.pages ?? 1);
|
||||
const notifTypes = computed(() => data.value?.notifTypes || [])
|
||||
const pages = computed(() => data.value?.pages ?? 1)
|
||||
|
||||
function updateRoute() {
|
||||
router.push(history.value ? "/dashboard/notifications" : "/dashboard/notifications/history");
|
||||
selectedType.value = "all";
|
||||
page.value = 1;
|
||||
router.push(history.value ? '/dashboard/notifications' : '/dashboard/notifications/history')
|
||||
selectedType.value = 'all'
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
async function readAll() {
|
||||
const ids = notifications.value.flatMap((n) => [
|
||||
n.id,
|
||||
...(n.grouped_notifs ? n.grouped_notifs.map((g) => g.id) : []),
|
||||
]);
|
||||
const ids = notifications.value.flatMap((n) => [
|
||||
n.id,
|
||||
...(n.grouped_notifs ? n.grouped_notifs.map((g) => g.id) : []),
|
||||
])
|
||||
|
||||
await markAsRead(ids);
|
||||
await refresh();
|
||||
await markAsRead(ids)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
function changePage(newPage) {
|
||||
page.value = newPage;
|
||||
if (import.meta.client) window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
page.value = newPage
|
||||
if (import.meta.client) window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.read-toggle-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-card-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-card-md);
|
||||
|
||||
.label__title {
|
||||
margin: 0;
|
||||
}
|
||||
.label__title {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header__title {
|
||||
h2 {
|
||||
margin: 0 auto 0 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 auto 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,206 +1,209 @@
|
||||
<template>
|
||||
<div>
|
||||
<OrganizationCreateModal ref="createOrgModal" />
|
||||
<section class="universal-card">
|
||||
<div class="header__row">
|
||||
<h2 class="header__title text-2xl">Organizations</h2>
|
||||
<div class="input-group">
|
||||
<button class="iconified-button brand-button" @click="openCreateOrgModal">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Create organization
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="orgs?.length > 0">
|
||||
<div class="orgs-grid">
|
||||
<nuxt-link
|
||||
v-for="org in sortedOrgs"
|
||||
:key="org.id"
|
||||
:to="`/organization/${org.slug}`"
|
||||
class="universal-card button-base recessed org"
|
||||
:class="{ 'is-disabled': onlyAcceptedMembers(org.members).length === 0 }"
|
||||
>
|
||||
<Avatar :src="org.icon_url" :alt="org.name" class="icon" />
|
||||
<div class="details">
|
||||
<div class="title">
|
||||
{{ org.name }}
|
||||
</div>
|
||||
<div class="description">
|
||||
{{ org.description }}
|
||||
</div>
|
||||
<span class="stat-bar">
|
||||
<div class="stats">
|
||||
<UsersIcon aria-hidden="true" />
|
||||
<span>
|
||||
{{ onlyAcceptedMembers(org.members).length }} member<template
|
||||
v-if="onlyAcceptedMembers(org.members).length !== 1"
|
||||
>s</template
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else> Make an organization! </template>
|
||||
</section>
|
||||
</div>
|
||||
<div>
|
||||
<OrganizationCreateModal ref="createOrgModal" />
|
||||
<section class="universal-card">
|
||||
<div class="header__row">
|
||||
<h2 class="header__title text-2xl">Organizations</h2>
|
||||
<div class="input-group">
|
||||
<button class="iconified-button brand-button" @click="openCreateOrgModal">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Create organization
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="orgs?.length > 0">
|
||||
<div class="orgs-grid">
|
||||
<nuxt-link
|
||||
v-for="org in sortedOrgs"
|
||||
:key="org.id"
|
||||
:to="`/organization/${org.slug}`"
|
||||
class="universal-card button-base recessed org"
|
||||
:class="{ 'is-disabled': onlyAcceptedMembers(org.members).length === 0 }"
|
||||
>
|
||||
<Avatar :src="org.icon_url" :alt="org.name" class="icon" />
|
||||
<div class="details">
|
||||
<div class="title">
|
||||
{{ org.name }}
|
||||
</div>
|
||||
<div class="description">
|
||||
{{ org.description }}
|
||||
</div>
|
||||
<span class="stat-bar">
|
||||
<div class="stats">
|
||||
<UsersIcon aria-hidden="true" />
|
||||
<span>
|
||||
{{ onlyAcceptedMembers(org.members).length }}
|
||||
member<template v-if="onlyAcceptedMembers(org.members).length !== 1"
|
||||
>s</template
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else> Make an organization! </template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { PlusIcon, UsersIcon } from "@modrinth/assets";
|
||||
import { Avatar } from "@modrinth/ui";
|
||||
import { useAuth } from "~/composables/auth.js";
|
||||
import OrganizationCreateModal from "~/components/ui/OrganizationCreateModal.vue";
|
||||
import { PlusIcon, UsersIcon } from '@modrinth/assets'
|
||||
import { Avatar } from '@modrinth/ui'
|
||||
|
||||
const createOrgModal = ref(null);
|
||||
import OrganizationCreateModal from '~/components/ui/OrganizationCreateModal.vue'
|
||||
import { useAuth } from '~/composables/auth.js'
|
||||
|
||||
const auth = await useAuth();
|
||||
const uid = computed(() => auth.value.user?.id || null);
|
||||
const createOrgModal = ref(null)
|
||||
|
||||
const { data: orgs, error } = useAsyncData("organizations", () => {
|
||||
if (!uid.value) return Promise.resolve(null);
|
||||
const auth = await useAuth()
|
||||
const uid = computed(() => auth.value.user?.id || null)
|
||||
|
||||
return useBaseFetch("user/" + uid.value + "/organizations", {
|
||||
apiVersion: 3,
|
||||
});
|
||||
});
|
||||
const { data: orgs, error } = useAsyncData('organizations', () => {
|
||||
if (!uid.value) return Promise.resolve(null)
|
||||
|
||||
const sortedOrgs = computed(() => orgs.value.sort((a, b) => a.name.localeCompare(b.name)));
|
||||
return useBaseFetch('user/' + uid.value + '/organizations', {
|
||||
apiVersion: 3,
|
||||
})
|
||||
})
|
||||
|
||||
const onlyAcceptedMembers = (members) => members.filter((member) => member?.accepted);
|
||||
const sortedOrgs = computed(() =>
|
||||
orgs.value ? [...orgs.value].sort((a, b) => a.name.localeCompare(b.name)) : [],
|
||||
)
|
||||
|
||||
const onlyAcceptedMembers = (members) => members.filter((member) => member?.accepted)
|
||||
|
||||
if (error.value) {
|
||||
createError({
|
||||
statusCode: 500,
|
||||
message: "Failed to fetch organizations",
|
||||
});
|
||||
createError({
|
||||
statusCode: 500,
|
||||
message: 'Failed to fetch organizations',
|
||||
})
|
||||
}
|
||||
|
||||
const openCreateOrgModal = (event) => {
|
||||
createOrgModal.value?.show(event);
|
||||
};
|
||||
createOrgModal.value?.show(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.project-meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
padding: var(--spacing-card-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
padding: var(--spacing-card-sm);
|
||||
|
||||
.project-title {
|
||||
margin-bottom: var(--spacing-card-sm);
|
||||
}
|
||||
.project-title {
|
||||
margin-bottom: var(--spacing-card-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.orgs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
}
|
||||
@media screen and (max-width: 750px) {
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
}
|
||||
|
||||
gap: var(--gap-md);
|
||||
gap: var(--gap-md);
|
||||
|
||||
.org {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: var(--gap-md);
|
||||
margin-bottom: 0;
|
||||
.org {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: var(--gap-md);
|
||||
margin-bottom: 0;
|
||||
|
||||
.icon {
|
||||
width: 100% !important;
|
||||
height: min(6rem, 20vw) !important;
|
||||
max-width: unset !important;
|
||||
max-height: unset !important;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
.icon {
|
||||
width: 100% !important;
|
||||
height: min(6rem, 20vw) !important;
|
||||
max-width: unset !important;
|
||||
max-height: unset !important;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.title {
|
||||
color: var(--color-contrast);
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
.title {
|
||||
color: var(--color-contrast);
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--color-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.description {
|
||||
color: var(--color-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.stat-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
margin-top: auto;
|
||||
}
|
||||
.stat-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
|
||||
svg {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
svg {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.grid-table {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
min-content min-content minmax(min-content, 2fr)
|
||||
minmax(min-content, 1fr) minmax(min-content, 1fr) min-content;
|
||||
border-radius: var(--size-rounded-sm);
|
||||
overflow: hidden;
|
||||
margin-top: var(--spacing-card-md);
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
min-content min-content minmax(min-content, 2fr)
|
||||
minmax(min-content, 1fr) minmax(min-content, 1fr) min-content;
|
||||
border-radius: var(--size-rounded-sm);
|
||||
overflow: hidden;
|
||||
margin-top: var(--spacing-card-md);
|
||||
|
||||
.grid-table__row {
|
||||
display: contents;
|
||||
.grid-table__row {
|
||||
display: contents;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: var(--spacing-card-sm);
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: var(--spacing-card-sm);
|
||||
|
||||
// Left edge of table
|
||||
&:first-child {
|
||||
padding-left: var(--spacing-card-bg);
|
||||
}
|
||||
// Left edge of table
|
||||
&:first-child {
|
||||
padding-left: var(--spacing-card-bg);
|
||||
}
|
||||
|
||||
// Right edge of table
|
||||
&:last-child {
|
||||
padding-right: var(--spacing-card-bg);
|
||||
}
|
||||
}
|
||||
// Right edge of table
|
||||
&:last-child {
|
||||
padding-right: var(--spacing-card-bg);
|
||||
}
|
||||
}
|
||||
|
||||
&:nth-child(2n + 1) > div {
|
||||
background-color: var(--color-table-alternate-row);
|
||||
}
|
||||
&:nth-child(2n + 1) > div {
|
||||
background-color: var(--color-table-alternate-row);
|
||||
}
|
||||
|
||||
&.grid-table__header > div {
|
||||
background-color: var(--color-bg);
|
||||
font-weight: bold;
|
||||
color: var(--color-text-dark);
|
||||
padding-top: var(--spacing-card-bg);
|
||||
padding-bottom: var(--spacing-card-bg);
|
||||
}
|
||||
}
|
||||
&.grid-table__header > div {
|
||||
background-color: var(--color-bg);
|
||||
font-weight: bold;
|
||||
color: var(--color-text-dark);
|
||||
padding-top: var(--spacing-card-bg);
|
||||
padding-bottom: var(--spacing-card-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hover-link:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<ReportView
|
||||
:auth="auth"
|
||||
:report-id="route.params.id"
|
||||
:breadcrumbs-stack="[{ href: '/dashboard/reports', label: 'Active reports' }]"
|
||||
/>
|
||||
<ReportView
|
||||
:auth="auth"
|
||||
:report-id="route.params.id"
|
||||
:breadcrumbs-stack="[{ href: '/dashboard/reports', label: 'Active reports' }]"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import ReportView from "~/components/ui/report/ReportView.vue";
|
||||
import ReportView from '~/components/ui/report/ReportView.vue'
|
||||
|
||||
const route = useNativeRoute();
|
||||
const auth = await useAuth();
|
||||
const route = useNativeRoute()
|
||||
const auth = await useAuth()
|
||||
|
||||
useHead({
|
||||
title: `Report ${route.params.id} - Modrinth`,
|
||||
});
|
||||
title: `Report ${route.params.id} - Modrinth`,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2 class="text-2xl">Reports</h2>
|
||||
<ReportsList :auth="auth" />
|
||||
</section>
|
||||
</div>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2 class="text-2xl">Reports</h2>
|
||||
<ReportsList :auth="auth" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import ReportsList from "~/components/ui/report/ReportsList.vue";
|
||||
import ReportsList from '~/components/ui/report/ReportsList.vue'
|
||||
|
||||
const auth = await useAuth();
|
||||
const auth = await useAuth()
|
||||
useHead({
|
||||
title: "Active reports - Modrinth",
|
||||
});
|
||||
title: 'Active reports - Modrinth',
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,232 +1,232 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="input-group mt-4">
|
||||
<span :class="{ 'disabled-cursor-wrapper': userBalance.available < minWithdraw }">
|
||||
<nuxt-link
|
||||
:aria-disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
|
||||
:class="{ 'disabled-link': userBalance.available < minWithdraw }"
|
||||
:disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
|
||||
:tabindex="userBalance.available < minWithdraw ? -1 : undefined"
|
||||
class="iconified-button brand-button"
|
||||
to="/dashboard/revenue/withdraw"
|
||||
>
|
||||
<TransferIcon /> Withdraw
|
||||
</nuxt-link>
|
||||
</span>
|
||||
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
|
||||
<HistoryIcon />
|
||||
View transfer history
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<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>
|
||||
<button class="btn mt-4" @click="removeAuthProvider('paypal')">
|
||||
<XIcon />
|
||||
Disconnect account
|
||||
</button>
|
||||
</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"
|
||||
class="mt-4"
|
||||
name="search"
|
||||
placeholder="@example"
|
||||
type="search"
|
||||
/>
|
||||
<button class="btn btn-secondary" @click="updateVenmo">
|
||||
<SaveIcon />
|
||||
Save information
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="input-group mt-4">
|
||||
<span :class="{ 'disabled-cursor-wrapper': userBalance.available < minWithdraw }">
|
||||
<nuxt-link
|
||||
:aria-disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
|
||||
:class="{ 'disabled-link': userBalance.available < minWithdraw }"
|
||||
:disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
|
||||
:tabindex="userBalance.available < minWithdraw ? -1 : undefined"
|
||||
class="iconified-button brand-button"
|
||||
to="/dashboard/revenue/withdraw"
|
||||
>
|
||||
<TransferIcon /> Withdraw
|
||||
</nuxt-link>
|
||||
</span>
|
||||
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
|
||||
<HistoryIcon />
|
||||
View transfer history
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<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>
|
||||
<button class="btn mt-4" @click="removeAuthProvider('paypal')">
|
||||
<XIcon />
|
||||
Disconnect account
|
||||
</button>
|
||||
</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"
|
||||
class="mt-4"
|
||||
name="search"
|
||||
placeholder="@example"
|
||||
type="search"
|
||||
/>
|
||||
<button class="btn btn-secondary" @click="updateVenmo">
|
||||
<SaveIcon />
|
||||
Save information
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
HistoryIcon,
|
||||
InProgressIcon,
|
||||
PayPalIcon,
|
||||
SaveIcon,
|
||||
TransferIcon,
|
||||
UnknownIcon,
|
||||
XIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { injectNotificationManager } from "@modrinth/ui";
|
||||
import { formatDate } from "@modrinth/utils";
|
||||
import dayjs from "dayjs";
|
||||
import { computed } from "vue";
|
||||
HistoryIcon,
|
||||
InProgressIcon,
|
||||
PayPalIcon,
|
||||
SaveIcon,
|
||||
TransferIcon,
|
||||
UnknownIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { injectNotificationManager } from '@modrinth/ui'
|
||||
import { formatDate } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager();
|
||||
const auth = await useAuth();
|
||||
const minWithdraw = ref(0.01);
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const auth = await useAuth()
|
||||
const minWithdraw = ref(0.01)
|
||||
|
||||
const { data: userBalance } = await useAsyncData(`payout/balance`, () =>
|
||||
useBaseFetch(`payout/balance`, { apiVersion: 3 }),
|
||||
);
|
||||
useBaseFetch(`payout/balance`, { apiVersion: 3 }),
|
||||
)
|
||||
|
||||
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;
|
||||
});
|
||||
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 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)));
|
||||
// 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)))
|
||||
|
||||
return dates.reduce((acc, date) => {
|
||||
acc[date] = userBalance.value.dates[date];
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
return dates.reduce((acc, date) => {
|
||||
acc[date] = userBalance.value.dates[date]
|
||||
return acc
|
||||
}, {})
|
||||
})
|
||||
|
||||
const availableSoonDateKeys = computed(() => Object.keys(availableSoonDates.value));
|
||||
const availableSoonDateKeys = computed(() => Object.keys(availableSoonDates.value))
|
||||
|
||||
async function updateVenmo() {
|
||||
startLoading();
|
||||
try {
|
||||
const data = {
|
||||
venmo_handle: auth.value.user.payout_data.venmo_handle ?? null,
|
||||
};
|
||||
startLoading()
|
||||
try {
|
||||
const data = {
|
||||
venmo_handle: auth.value.user.payout_data.venmo_handle ?? null,
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
stopLoading();
|
||||
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',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
strong {
|
||||
color: var(--color-text-dark);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.disabled-cursor-wrapper {
|
||||
cursor: not-allowed;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.disabled-link {
|
||||
pointer-events: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-display {
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,225 +1,228 @@
|
||||
<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
|
||||
v-for="payout in filteredPayouts"
|
||||
:key="payout.id"
|
||||
class="universal-card recessed payout"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<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
|
||||
v-for="payout in filteredPayouts"
|
||||
:key="payout.id"
|
||||
class="universal-card recessed payout"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</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 dayjs from "dayjs";
|
||||
import TremendousIcon from "~/assets/images/external/tremendous.svg?component";
|
||||
import VenmoIcon from "~/assets/images/external/venmo-small.svg?component";
|
||||
import { PayPalIcon, UnknownIcon, XIcon } from '@modrinth/assets'
|
||||
import { Badge, Breadcrumbs, DropdownSelect, injectNotificationManager } from '@modrinth/ui'
|
||||
import { capitalizeString, formatWallet } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const { addNotification } = injectNotificationManager();
|
||||
const vintl = useVIntl();
|
||||
const { formatMessage } = vintl;
|
||||
import TremendousIcon from '~/assets/images/external/tremendous.svg?component'
|
||||
import VenmoIcon from '~/assets/images/external/venmo-small.svg?component'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const vintl = useVIntl()
|
||||
const { formatMessage } = vintl
|
||||
|
||||
useHead({
|
||||
title: "Transfer history - Modrinth",
|
||||
});
|
||||
title: 'Transfer history - Modrinth',
|
||||
})
|
||||
|
||||
const auth = await useAuth();
|
||||
const auth = await useAuth()
|
||||
|
||||
const { data: payouts, refresh } = await useAsyncData(`payout`, () =>
|
||||
useBaseFetch(`payout`, {
|
||||
apiVersion: 3,
|
||||
}),
|
||||
);
|
||||
useBaseFetch(`payout`, {
|
||||
apiVersion: 3,
|
||||
}),
|
||||
)
|
||||
|
||||
const sortedPayouts = computed(() =>
|
||||
payouts.value.sort((a, b) => dayjs(b.created) - dayjs(a.created)),
|
||||
);
|
||||
(payouts.value ? [...payouts.value] : []).sort((a, b) => dayjs(b.created) - dayjs(a.created)),
|
||||
)
|
||||
|
||||
const years = computed(() => {
|
||||
const values = sortedPayouts.value.map((x) => dayjs(x.created).year());
|
||||
return ["all", ...new Set(values)];
|
||||
});
|
||||
const values = sortedPayouts.value.map((x) => dayjs(x.created).year())
|
||||
return ['all', ...new Set(values)]
|
||||
})
|
||||
|
||||
const selectedYear = ref("all");
|
||||
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 values = sortedPayouts.value.filter((x) => x.method).map((x) => x.method)
|
||||
return ['all', ...new Set(values)]
|
||||
})
|
||||
|
||||
const selectedMethod = ref("all");
|
||||
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),
|
||||
);
|
||||
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),
|
||||
);
|
||||
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",
|
||||
});
|
||||
}
|
||||
stopLoading();
|
||||
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',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
transfersTotal: {
|
||||
id: "revenue.transfers.total",
|
||||
defaultMessage: "You have withdrawn {amount} in total.",
|
||||
},
|
||||
transfersTotalYear: {
|
||||
id: "revenue.transfers.total.year",
|
||||
defaultMessage: "You have withdrawn {amount} in {year}.",
|
||||
},
|
||||
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}.",
|
||||
},
|
||||
});
|
||||
transfersTotal: {
|
||||
id: 'revenue.transfers.total',
|
||||
defaultMessage: 'You have withdrawn {amount} in total.',
|
||||
},
|
||||
transfersTotalYear: {
|
||||
id: 'revenue.transfers.total.year',
|
||||
defaultMessage: 'You have withdrawn {amount} in {year}.',
|
||||
},
|
||||
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}.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.payout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
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;
|
||||
.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;
|
||||
}
|
||||
}
|
||||
svg {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.payout-status {
|
||||
display: flex;
|
||||
gap: 0.5ch;
|
||||
}
|
||||
.payout-status {
|
||||
display: flex;
|
||||
gap: 0.5ch;
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: var(--color-heading);
|
||||
font-weight: 500;
|
||||
}
|
||||
.amount {
|
||||
color: var(--color-heading);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.input-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
.input-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,536 +1,538 @@
|
||||
<template>
|
||||
<section class="universal-card">
|
||||
<Breadcrumbs
|
||||
current-title="Withdraw"
|
||||
:link-stack="[{ href: '/dashboard/revenue', label: 'Revenue' }]"
|
||||
/>
|
||||
<section class="universal-card">
|
||||
<Breadcrumbs
|
||||
current-title="Withdraw"
|
||||
:link-stack="[{ href: '/dashboard/revenue', label: 'Revenue' }]"
|
||||
/>
|
||||
|
||||
<h2>Withdraw</h2>
|
||||
<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>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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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)
|
||||
"
|
||||
class="iconified-button brand-button"
|
||||
@click="withdraw"
|
||||
>
|
||||
<TransferIcon />
|
||||
Withdraw
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<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)
|
||||
"
|
||||
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";
|
||||
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'
|
||||
|
||||
const { addNotification } = injectNotificationManager();
|
||||
const auth = await useAuth();
|
||||
const data = useNuxtApp();
|
||||
import VenmoIcon from '~/assets/images/external/venmo.svg?component'
|
||||
|
||||
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("");
|
||||
all().map((x) => ({
|
||||
id: x.alpha2,
|
||||
name: x.alpha2 === 'TW' ? 'Taiwan' : x.country,
|
||||
})),
|
||||
)
|
||||
const search = ref('')
|
||||
|
||||
const amount = ref("");
|
||||
const amount = ref('')
|
||||
const country = ref(
|
||||
countries.value.find((x) => x.id === (auth.value.user.payout_data.paypal_region ?? "US")),
|
||||
);
|
||||
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`, () => useBaseFetch(`payout/balance`, { apiVersion: 3 })),
|
||||
useAsyncData(`payout/methods?country=${country.value.id}`, () =>
|
||||
useBaseFetch(`payout/methods?country=${country.value.id}`, { apiVersion: 3 }),
|
||||
),
|
||||
]);
|
||||
await Promise.all([
|
||||
useAsyncData(`payout/balance`, () => useBaseFetch(`payout/balance`, { apiVersion: 3 })),
|
||||
useAsyncData(`payout/methods?country=${country.value.id}`, () =>
|
||||
useBaseFetch(`payout/methods?country=${country.value.id}`, { apiVersion: 3 }),
|
||||
),
|
||||
])
|
||||
|
||||
const selectedMethodId = ref(payoutMethods.value[0].id);
|
||||
const selectedMethodId = ref(payoutMethods.value[0].id)
|
||||
const selectedMethod = computed(() =>
|
||||
payoutMethods.value.find((x) => x.id === selectedMethodId.value),
|
||||
);
|
||||
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 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,
|
||||
);
|
||||
});
|
||||
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 [];
|
||||
}
|
||||
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];
|
||||
}
|
||||
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];
|
||||
};
|
||||
return min === max ? [min] : [min, max]
|
||||
}
|
||||
|
||||
const getRangeOfMethod = (method) => {
|
||||
return getIntervalRange(method.interval?.fixed || method.interval?.standard);
|
||||
};
|
||||
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 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 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;
|
||||
}
|
||||
});
|
||||
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.");
|
||||
}
|
||||
}
|
||||
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)}`);
|
||||
}
|
||||
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;
|
||||
});
|
||||
return errors
|
||||
})
|
||||
|
||||
const agreedTransfer = ref(false);
|
||||
const agreedFees = ref(false);
|
||||
const agreedTerms = ref(false);
|
||||
const agreedTransfer = ref(false)
|
||||
const agreedFees = ref(false)
|
||||
const agreedTerms = ref(false)
|
||||
|
||||
watch(country, async () => {
|
||||
await refreshPayoutMethods();
|
||||
if (payoutMethods.value && payoutMethods.value[0]) {
|
||||
selectedMethodId.value = payoutMethods.value[0].id;
|
||||
}
|
||||
});
|
||||
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;
|
||||
});
|
||||
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
|
||||
})
|
||||
|
||||
async function withdraw() {
|
||||
startLoading();
|
||||
try {
|
||||
const auth = await useAuth();
|
||||
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();
|
||||
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()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.withdraw-options-scroll {
|
||||
max-height: 460px;
|
||||
overflow-y: auto;
|
||||
max-height: 460px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: var(--gap-md);
|
||||
border: 3px solid var(--color-bg);
|
||||
}
|
||||
&::-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-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);
|
||||
}
|
||||
&::-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;
|
||||
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: 300px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 600px) {
|
||||
grid-template-columns: repeat(3, 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);
|
||||
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);
|
||||
&.selected {
|
||||
color: var(--color-contrast);
|
||||
|
||||
.label svg {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
.label svg {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
aspect-ratio: 30 / 19;
|
||||
position: relative;
|
||||
.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);
|
||||
.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);
|
||||
}
|
||||
}
|
||||
.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);
|
||||
}
|
||||
&.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;
|
||||
}
|
||||
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);
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
|
||||
svg {
|
||||
width: 2rem;
|
||||
height: auto;
|
||||
}
|
||||
svg {
|
||||
width: 2rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: 2rem;
|
||||
font-style: italic;
|
||||
}
|
||||
span {
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: 2rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.enlarge {
|
||||
width: auto;
|
||||
height: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
.enlarge {
|
||||
width: auto;
|
||||
height: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--gap-md) var(--gap-lg);
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--gap-md) var(--gap-lg);
|
||||
|
||||
svg {
|
||||
min-height: 1rem;
|
||||
min-width: 1rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
svg {
|
||||
min-height: 1rem;
|
||||
min-width: 1rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
span {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
span {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invalid {
|
||||
color: var(--color-red);
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.confirm-text {
|
||||
margin: var(--spacing-card-md) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-card-sm);
|
||||
margin: var(--spacing-card-md) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-card-sm);
|
||||
}
|
||||
|
||||
.iconified-input {
|
||||
margin-bottom: var(--spacing-card-md);
|
||||
margin-bottom: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
.country-multiselect,
|
||||
.iconified-input {
|
||||
max-width: 16rem;
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
.rewards-checkbox {
|
||||
a {
|
||||
margin-left: 0.5ch;
|
||||
}
|
||||
a {
|
||||
margin-left: 0.5ch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user