forked from didirus/AstralRinth
Affiliates frontend (#4380)
* Begin affiliates frontend * Significant work on hooking up affiliates ui * Clean up server nodes menu * affiliates work * update affiliate time * oops * fix local import * fix local import x2 * remove line in dashboard * lint
This commit is contained in:
22
apps/frontend/src/composables/affiliates.ts
Normal file
22
apps/frontend/src/composables/affiliates.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const useAffiliates = () => {
|
||||
const affiliateCookie = useCookie('mrs_afl', {
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
httpOnly: false,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
const setAffiliateCode = (code: string) => {
|
||||
affiliateCookie.value = code
|
||||
}
|
||||
|
||||
const getAffiliateCode = (): string | undefined => {
|
||||
return affiliateCookie.value || undefined
|
||||
}
|
||||
|
||||
return {
|
||||
setAffiliateCode,
|
||||
getAffiliateCode,
|
||||
}
|
||||
}
|
||||
@@ -455,6 +455,12 @@
|
||||
link: '/admin/user_email',
|
||||
shown: isAdmin(auth.user),
|
||||
},
|
||||
{
|
||||
id: 'affiliates',
|
||||
color: 'primary',
|
||||
link: '/admin/affiliates',
|
||||
shown: isAdmin(auth.user),
|
||||
},
|
||||
{
|
||||
id: 'servers-notices',
|
||||
color: 'primary',
|
||||
@@ -478,7 +484,7 @@
|
||||
<ReportIcon aria-hidden="true" /> {{ formatMessage(messages.reports) }}
|
||||
</template>
|
||||
<template #user-lookup>
|
||||
<UserIcon aria-hidden="true" /> {{ formatMessage(messages.lookupByEmail) }}
|
||||
<UserSearchIcon aria-hidden="true" /> {{ formatMessage(messages.lookupByEmail) }}
|
||||
</template>
|
||||
<template #file-lookup>
|
||||
<FileIcon aria-hidden="true" /> {{ formatMessage(messages.fileLookup) }}
|
||||
@@ -486,7 +492,12 @@
|
||||
<template #servers-notices>
|
||||
<IssuesIcon aria-hidden="true" /> {{ formatMessage(messages.manageServerNotices) }}
|
||||
</template>
|
||||
<template #servers-nodes> <ServerIcon aria-hidden="true" /> Server Nodes </template>
|
||||
<template #affiliates>
|
||||
<AffiliateIcon aria-hidden="true" /> {{ formatMessage(messages.manageAffiliates) }}
|
||||
</template>
|
||||
<template #servers-nodes>
|
||||
<ServerIcon aria-hidden="true" /> Credit server nodes
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
@@ -563,6 +574,10 @@
|
||||
<template #organizations>
|
||||
<OrganizationIcon aria-hidden="true" /> {{ formatMessage(messages.organizations) }}
|
||||
</template>
|
||||
<template #affiliate-links>
|
||||
<AffiliateIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.affiliateLinksButton) }}
|
||||
</template>
|
||||
<template #revenue>
|
||||
<CurrencyIcon aria-hidden="true" /> {{ formatMessage(messages.revenue) }}
|
||||
</template>
|
||||
@@ -850,6 +865,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
AffiliateIcon,
|
||||
ArrowBigUpDashIcon,
|
||||
BellIcon,
|
||||
BlueskyIcon,
|
||||
@@ -891,6 +907,7 @@ import {
|
||||
SunIcon,
|
||||
TwitterIcon,
|
||||
UserIcon,
|
||||
UserSearchIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
@@ -903,7 +920,7 @@ import {
|
||||
OverflowMenu,
|
||||
PagewideBanner,
|
||||
} from '@modrinth/ui'
|
||||
import { isAdmin, isStaff } from '@modrinth/utils'
|
||||
import { isAdmin, isStaff, UserBadge } from '@modrinth/utils'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
|
||||
import TextLogo from '~/components/brand/TextLogo.vue'
|
||||
@@ -1159,6 +1176,10 @@ const messages = defineMessages({
|
||||
id: 'layout.action.manage-server-notices',
|
||||
defaultMessage: 'Manage server notices',
|
||||
},
|
||||
manageAffiliates: {
|
||||
id: 'layout.action.manage-affiliates',
|
||||
defaultMessage: 'Manage affiliate links',
|
||||
},
|
||||
newProject: {
|
||||
id: 'layout.action.new-project',
|
||||
defaultMessage: 'New project',
|
||||
@@ -1336,6 +1357,11 @@ const userMenuOptions = computed(() => {
|
||||
id: 'organizations',
|
||||
link: '/dashboard/organizations',
|
||||
},
|
||||
{
|
||||
id: 'affiliate-links',
|
||||
link: '/dashboard/affiliate-links',
|
||||
shown: auth.value.user.badges & UserBadge.AFFILIATE,
|
||||
},
|
||||
{
|
||||
id: 'revenue',
|
||||
link: '/dashboard/revenue',
|
||||
|
||||
@@ -566,6 +566,27 @@
|
||||
"create.project.visibility-unlisted": {
|
||||
"message": "Unlisted"
|
||||
},
|
||||
"dashboard.affiliate-links.create.button": {
|
||||
"message": "Create affiliate link"
|
||||
},
|
||||
"dashboard.affiliate-links.error.title": {
|
||||
"message": "Error loading affiliate links"
|
||||
},
|
||||
"dashboard.affiliate-links.header": {
|
||||
"message": "Your affiliate links"
|
||||
},
|
||||
"dashboard.affiliate-links.revoke-confirm.body": {
|
||||
"message": "This will permanently revoke the affiliate code `{id}` and any existing links with this code that have been shared will no longer be valid."
|
||||
},
|
||||
"dashboard.affiliate-links.revoke-confirm.button": {
|
||||
"message": "Revoke"
|
||||
},
|
||||
"dashboard.affiliate-links.revoke-confirm.title": {
|
||||
"message": "Are you sure you want to revoke your ''{title}'' affiliate link?"
|
||||
},
|
||||
"dashboard.affiliate-links.search": {
|
||||
"message": "Search affiliate links..."
|
||||
},
|
||||
"dashboard.collections.button.create-new": {
|
||||
"message": "Create new"
|
||||
},
|
||||
@@ -869,6 +890,9 @@
|
||||
"layout.action.lookup-by-email": {
|
||||
"message": "Lookup by email"
|
||||
},
|
||||
"layout.action.manage-affiliates": {
|
||||
"message": "Manage affiliate links"
|
||||
},
|
||||
"layout.action.manage-server-notices": {
|
||||
"message": "Manage server notices"
|
||||
},
|
||||
@@ -1130,6 +1154,12 @@
|
||||
"profile.button.manage-projects": {
|
||||
"message": "Manage projects"
|
||||
},
|
||||
"profile.button.remove-affiliate": {
|
||||
"message": "Remove as affiliate"
|
||||
},
|
||||
"profile.button.set-affiliate": {
|
||||
"message": "Set as affiliate"
|
||||
},
|
||||
"profile.details.label.auth-providers": {
|
||||
"message": "Auth providers"
|
||||
},
|
||||
@@ -1154,6 +1184,9 @@
|
||||
"profile.error.not-found": {
|
||||
"message": "User not found"
|
||||
},
|
||||
"profile.label.affiliate": {
|
||||
"message": "Affiliate"
|
||||
},
|
||||
"profile.label.badges": {
|
||||
"message": "Badges"
|
||||
},
|
||||
|
||||
281
apps/frontend/src/pages/admin/affiliates.vue
Normal file
281
apps/frontend/src/pages/admin/affiliates.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<AffiliateLinkCreateModal
|
||||
ref="createModal"
|
||||
:show-user-field="true"
|
||||
:creating-link="creatingLink"
|
||||
@create="createAffiliateCode"
|
||||
/>
|
||||
<ConfirmModal
|
||||
ref="revokeModal"
|
||||
:title="`Are you sure you want to revoke ${revokingAffiliateUsername}'s affiliate code?`"
|
||||
:description="`This will permanently revoke the affiliate code \`${revokingAffiliateId}\` and make any links that this user has shared invalid.`"
|
||||
:proceed-icon="XCircleIcon"
|
||||
:proceed-label="`Revoke`"
|
||||
@proceed="confirmRevokeAffiliateCode"
|
||||
/>
|
||||
<div class="page">
|
||||
<div
|
||||
class="mb-6 flex items-center gap-6 border-0 border-b-[1px] border-solid border-divider pb-6"
|
||||
>
|
||||
<h1 class="m-0 grow text-2xl font-extrabold">Manage affiliate links</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="iconified-input">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input
|
||||
v-model="filterQuery"
|
||||
class="card-shadow"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
:placeholder="`Search affiliates...`"
|
||||
/>
|
||||
<Button v-if="filterQuery" class="r-btn" @click="() => (filterQuery = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="createModal?.show">
|
||||
<PlusIcon />
|
||||
Create affiliate code
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<Admonition v-if="error" type="critical">
|
||||
<template #header> Error loading affiliate links </template>
|
||||
{{ error }}
|
||||
</Admonition>
|
||||
<div v-else-if="groupedAffiliates.length === 0" class="py-8 text-center">
|
||||
<p class="text-secondary">No affiliate codes found.</p>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<Accordion
|
||||
v-for="(userGroup, index) in filteredGroupedAffiliates"
|
||||
:key="userGroup.user.id"
|
||||
open-by-default
|
||||
:class="{
|
||||
'border-0 border-b-[1px] border-solid border-divider pb-4':
|
||||
index < filteredGroupedAffiliates.length - 1,
|
||||
}"
|
||||
:button-class="`flex flex-col w-full gap-2 bg-transparent m-0 p-0 border-none`"
|
||||
>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar :src="userGroup.user.avatar_url" circle size="48px" />
|
||||
<div class="flex flex-col items-start">
|
||||
<span class="text-lg font-bold text-contrast">
|
||||
{{ userGroup.user.username }}
|
||||
</span>
|
||||
<span class="text-sm text-secondary">
|
||||
{{ userGroup.affiliates.length }} affiliate code{{
|
||||
userGroup.affiliates.length === 1 ? '' : 's'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="mt-4 space-y-3">
|
||||
<AffiliateLinkCard
|
||||
v-for="affiliate in userGroup.affiliates"
|
||||
:key="affiliate.id"
|
||||
:affiliate="affiliate"
|
||||
:created-by="getCreatedByUsername(affiliate.created_by)"
|
||||
@revoke="revokeAffiliateCode"
|
||||
/>
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, SearchIcon, XCircleIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
Admonition,
|
||||
AffiliateLinkCard,
|
||||
AffiliateLinkCreateModal,
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonStyled,
|
||||
ConfirmModal,
|
||||
injectNotificationManager,
|
||||
} from '@modrinth/ui'
|
||||
import type { AffiliateLink, User } from '@modrinth/utils'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
type UserGroup = {
|
||||
user: User
|
||||
affiliates: AffiliateLink[]
|
||||
}
|
||||
|
||||
const createModal = useTemplateRef<typeof AffiliateLinkCreateModal>('createModal')
|
||||
const revokeModal = useTemplateRef<typeof ConfirmModal>('revokeModal')
|
||||
|
||||
const {
|
||||
data: affiliateCodes,
|
||||
error,
|
||||
refresh,
|
||||
} = await useAsyncData(
|
||||
'AffiliateLinks',
|
||||
() => useBaseFetch('affiliate', { method: 'GET', internal: true }) as Promise<AffiliateLink[]>,
|
||||
)
|
||||
|
||||
const filterQuery = ref('')
|
||||
const creatingLink = ref(false)
|
||||
|
||||
const userIds = computed(() => {
|
||||
if (!affiliateCodes.value) {
|
||||
return []
|
||||
}
|
||||
const ids = new Set<string>()
|
||||
affiliateCodes.value.forEach((code) => {
|
||||
ids.add(code.affiliate)
|
||||
ids.add(code.created_by)
|
||||
})
|
||||
return Array.from(ids)
|
||||
})
|
||||
|
||||
const { data: users } = await useAsyncData(
|
||||
'admin-affiliates-bulk-users',
|
||||
() => {
|
||||
if (userIds.value.length === 0) return Promise.resolve([])
|
||||
return useBaseFetch(`users?ids=${JSON.stringify(userIds.value)}`) as Promise<User[]>
|
||||
},
|
||||
{
|
||||
watch: [userIds],
|
||||
},
|
||||
)
|
||||
|
||||
const userMap = computed(() => {
|
||||
if (!users.value) {
|
||||
return new Map()
|
||||
}
|
||||
return new Map(users.value.map((user) => [user.id, user]))
|
||||
})
|
||||
|
||||
const groupedAffiliates = computed((): UserGroup[] => {
|
||||
if (!affiliateCodes.value || !users.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
const groups = new Map<string, UserGroup>()
|
||||
|
||||
affiliateCodes.value.forEach((code) => {
|
||||
const user = userMap.value.get(code.affiliate)
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!groups.has(user.id)) {
|
||||
groups.set(user.id, {
|
||||
user,
|
||||
affiliates: [],
|
||||
})
|
||||
}
|
||||
|
||||
groups.get(user.id)!.affiliates.push(code)
|
||||
})
|
||||
|
||||
return Array.from(groups.values()).sort((a, b) => a.user.username.localeCompare(b.user.username))
|
||||
})
|
||||
|
||||
const filteredGroupedAffiliates = computed(() => {
|
||||
if (!filterQuery.value.trim()) {
|
||||
return groupedAffiliates.value
|
||||
}
|
||||
|
||||
const query = filterQuery.value.trim().toLowerCase()
|
||||
return groupedAffiliates.value.filter(
|
||||
(group) =>
|
||||
group.user.username.toLowerCase().includes(query) ||
|
||||
group.affiliates.some((affiliate) => affiliate.source_name.toLowerCase().includes(query)),
|
||||
)
|
||||
})
|
||||
|
||||
function getCreatedByUsername(createdBy: string): string {
|
||||
const user = userMap.value.get(createdBy)
|
||||
return user?.username || 'Unknown'
|
||||
}
|
||||
|
||||
async function createAffiliateCode(data: { sourceName: string; username?: string }) {
|
||||
creatingLink.value = true
|
||||
|
||||
try {
|
||||
if (!data.username) {
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error('Username is required')
|
||||
}
|
||||
|
||||
let user = users.value?.find((u) => u.username === data.username)
|
||||
|
||||
if (!user) {
|
||||
try {
|
||||
user = (await useBaseFetch(`user/${data.username}`)) as User
|
||||
|
||||
if (users.value) {
|
||||
users.value.push(user)
|
||||
}
|
||||
} catch {
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error('User not found')
|
||||
}
|
||||
}
|
||||
|
||||
await useBaseFetch('affiliate', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
affiliate: user.id,
|
||||
source_name: data.sourceName,
|
||||
},
|
||||
internal: true,
|
||||
})
|
||||
|
||||
await refresh()
|
||||
createModal.value?.close()
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
} finally {
|
||||
creatingLink.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const revokingAffiliateUsername = ref<string | null>(null)
|
||||
const revokingAffiliateId = ref<string | null>(null)
|
||||
|
||||
function revokeAffiliateCode(affiliate: AffiliateLink) {
|
||||
const user = userMap.value.get(affiliate.affiliate)
|
||||
revokingAffiliateUsername.value = user?.username || 'Unknown'
|
||||
revokingAffiliateId.value = affiliate.id
|
||||
revokeModal.value?.show()
|
||||
}
|
||||
|
||||
async function confirmRevokeAffiliateCode() {
|
||||
if (!revokingAffiliateId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await useBaseFetch(`affiliate/${revokingAffiliateId.value}`, {
|
||||
method: 'DELETE',
|
||||
internal: true,
|
||||
})
|
||||
|
||||
await refresh()
|
||||
revokeModal.value?.hide()
|
||||
revokingAffiliateUsername.value = null
|
||||
revokingAffiliateId.value = null
|
||||
} catch (err) {
|
||||
console.error('Failed to revoke affiliate code:', err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
padding: 1rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 78.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@
|
||||
<button @click="openBatchModal"><PlusIcon /> Batch credit</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div>don't worry there's not supposed to be anything here</div>
|
||||
|
||||
<NewModal ref="batchModal">
|
||||
<template #title>
|
||||
|
||||
@@ -30,6 +30,13 @@
|
||||
>
|
||||
<LibraryIcon aria-hidden="true" />
|
||||
</NavStackItem>
|
||||
<NavStackItem
|
||||
v-if="isAffiliate"
|
||||
link="/dashboard/affiliate-links"
|
||||
:label="formatMessage(commonMessages.affiliateLinksButton)"
|
||||
>
|
||||
<AffiliateIcon aria-hidden="true" />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/dashboard/revenue" label="Revenue">
|
||||
<CurrencyIcon aria-hidden="true" />
|
||||
</NavStackItem>
|
||||
@@ -41,8 +48,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AffiliateIcon,
|
||||
BellIcon as NotificationsIcon,
|
||||
ChartIcon,
|
||||
CurrencyIcon,
|
||||
@@ -53,10 +61,17 @@ import {
|
||||
ReportIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { commonMessages } from '@modrinth/ui'
|
||||
import { type User, UserBadge } from '@modrinth/utils'
|
||||
|
||||
import NavStack from '~/components/ui/NavStack.vue'
|
||||
import NavStackItem from '~/components/ui/NavStackItem.vue'
|
||||
|
||||
const auth = (await useAuth()) as Ref<{ user: User | null }>
|
||||
|
||||
const isAffiliate = computed(() => {
|
||||
return auth.value.user && auth.value.user.badges & UserBadge.AFFILIATE
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
definePageMeta({
|
||||
|
||||
202
apps/frontend/src/pages/dashboard/affiliate-links.vue
Normal file
202
apps/frontend/src/pages/dashboard/affiliate-links.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<AffiliateLinkCreateModal
|
||||
ref="createModal"
|
||||
:creating-link="creatingLink"
|
||||
@create="createAffiliateCode"
|
||||
/>
|
||||
<ConfirmModal
|
||||
ref="revokeModal"
|
||||
:title="formatMessage(messages.revokeConfirmTitle, { title: revokingTitle })"
|
||||
:description="formatMessage(messages.revokeConfirmBody, { id: revokingId })"
|
||||
:proceed-icon="XCircleIcon"
|
||||
:proceed-label="formatMessage(messages.revokeConfirmButton)"
|
||||
@proceed="confirmRevokeAffiliateLink"
|
||||
/>
|
||||
<div class="page">
|
||||
<div class="mb-6 flex items-center gap-6">
|
||||
<h1 class="m-0 grow text-2xl font-extrabold">
|
||||
{{ formatMessage(messages.yourAffiliateLinks) }}
|
||||
</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="iconified-input">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input
|
||||
v-model="filterQuery"
|
||||
class="card-shadow"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.searchAffiliateLinks)"
|
||||
/>
|
||||
<Button v-if="filterQuery" class="r-btn" @click="() => (filterQuery = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="createModal?.show">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.createButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<Admonition v-if="error" type="critical">
|
||||
<template #header>
|
||||
{{ formatMessage(messages.errorTitle) }}
|
||||
</template>
|
||||
{{ error }}
|
||||
</Admonition>
|
||||
<div
|
||||
v-else-if="!filteredAffiliates || filteredAffiliates.length === 0"
|
||||
class="py-8 text-center"
|
||||
>
|
||||
<p class="text-secondary">No affiliate codes found.</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<AffiliateLinkCard
|
||||
v-for="affiliate in filteredAffiliates"
|
||||
:key="`affiliate-${affiliate.id}`"
|
||||
:affiliate="affiliate"
|
||||
@revoke="revokeAffiliateLink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, SearchIcon, XCircleIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
AffiliateLinkCard,
|
||||
AffiliateLinkCreateModal,
|
||||
Button,
|
||||
ButtonStyled,
|
||||
ConfirmModal,
|
||||
injectNotificationManager,
|
||||
} from '@modrinth/ui'
|
||||
import type { AffiliateLink } from '@modrinth/utils'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
|
||||
const createModal = useTemplateRef<typeof AffiliateLinkCreateModal>('createModal')
|
||||
const revokeModal = useTemplateRef<typeof ConfirmModal>('revokeModal')
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const {
|
||||
data: affiliateLinks,
|
||||
error,
|
||||
refresh,
|
||||
} = await useAsyncData(
|
||||
'affiliateLinks',
|
||||
() => useBaseFetch('affiliate', { method: 'GET', internal: true }) as Promise<AffiliateLink[]>,
|
||||
)
|
||||
|
||||
const filterQuery = ref('')
|
||||
const creatingLink = ref(false)
|
||||
|
||||
const filteredAffiliates = computed(() =>
|
||||
affiliateLinks
|
||||
? affiliateLinks.value?.filter(
|
||||
(link: AffiliateLink) =>
|
||||
link.affiliate === auth.value?.user?.id &&
|
||||
(filterQuery.value.trim()
|
||||
? link.source_name.trim().toLowerCase().includes(filterQuery.value.trim().toLowerCase())
|
||||
: true),
|
||||
)
|
||||
: [],
|
||||
)
|
||||
|
||||
async function createAffiliateCode(data: { sourceName: string }) {
|
||||
creatingLink.value = true
|
||||
|
||||
try {
|
||||
await useBaseFetch('affiliate', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
source_name: data.sourceName,
|
||||
},
|
||||
internal: true,
|
||||
})
|
||||
|
||||
await refresh()
|
||||
createModal.value?.close()
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
} finally {
|
||||
creatingLink.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const revokingTitle = ref<string | null>(null)
|
||||
const revokingId = ref<string | null>(null)
|
||||
|
||||
function revokeAffiliateLink(affiliate: AffiliateLink) {
|
||||
revokingTitle.value = affiliate.source_name
|
||||
revokingId.value = affiliate.id
|
||||
revokeModal.value?.show()
|
||||
}
|
||||
|
||||
async function confirmRevokeAffiliateLink() {
|
||||
if (!revokingId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await useBaseFetch(`affiliate/${revokingId.value}`, {
|
||||
method: 'DELETE',
|
||||
internal: true,
|
||||
})
|
||||
|
||||
await refresh()
|
||||
revokeModal.value?.hide()
|
||||
revokingTitle.value = null
|
||||
revokingId.value = null
|
||||
} catch (err) {
|
||||
console.error('Failed to revoke affiliate code:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
createButton: {
|
||||
id: 'dashboard.affiliate-links.create.button',
|
||||
defaultMessage: 'Create affiliate link',
|
||||
},
|
||||
yourAffiliateLinks: {
|
||||
id: 'dashboard.affiliate-links.header',
|
||||
defaultMessage: 'Your affiliate links',
|
||||
},
|
||||
searchAffiliateLinks: {
|
||||
id: 'dashboard.affiliate-links.search',
|
||||
defaultMessage: 'Search affiliate links...',
|
||||
},
|
||||
errorTitle: {
|
||||
id: 'dashboard.affiliate-links.error.title',
|
||||
defaultMessage: 'Error loading affiliate links',
|
||||
},
|
||||
revokeConfirmButton: {
|
||||
id: 'dashboard.affiliate-links.revoke-confirm.button',
|
||||
defaultMessage: 'Revoke',
|
||||
},
|
||||
revokeConfirmTitle: {
|
||||
id: 'dashboard.affiliate-links.revoke-confirm.title',
|
||||
defaultMessage: "Are you sure you want to revoke your ''{title}'' affiliate link?",
|
||||
},
|
||||
revokeConfirmBody: {
|
||||
id: 'dashboard.affiliate-links.revoke-confirm.body',
|
||||
defaultMessage:
|
||||
'This will permanently revoke the affiliate code `{id}` and any existing links with this code that have been shared will no longer be valid.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
padding: 1rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 78.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -26,6 +26,7 @@
|
||||
:regions="regions"
|
||||
:refresh-payment-methods="fetchPaymentData"
|
||||
:fetch-stock="fetchStock"
|
||||
:affiliate-code="affiliateCode"
|
||||
/>
|
||||
|
||||
<section
|
||||
@@ -664,6 +665,25 @@ import ServerPlanSelector from '~/components/ui/servers/marketing/ServerPlanSele
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
import { products } from '~/generated/state.json'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const { setAffiliateCode, getAffiliateCode } = useAffiliates()
|
||||
|
||||
const affiliateCode = ref(route.query.afl ?? null)
|
||||
|
||||
if (affiliateCode.value) {
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
afl: undefined,
|
||||
},
|
||||
})
|
||||
setAffiliateCode(affiliateCode.value)
|
||||
} else {
|
||||
affiliateCode.value = getAffiliateCode()
|
||||
}
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { locale, formatMessage } = useVIntl()
|
||||
const flags = useFeatureFlags()
|
||||
@@ -853,7 +873,6 @@ async function fetchPaymentData() {
|
||||
|
||||
const selectedProjectId = ref()
|
||||
|
||||
const route = useRoute()
|
||||
const isAtCapacity = computed(
|
||||
() => isSmallAtCapacity.value && isMediumAtCapacity.value && isLargeAtCapacity.value,
|
||||
)
|
||||
|
||||
@@ -109,7 +109,18 @@
|
||||
<Avatar :src="user.avatar_url" :alt="user.username" size="96px" circle />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ user.username }}
|
||||
<span class="flex items-center gap-2">
|
||||
{{ user.username }}
|
||||
<TagItem
|
||||
v-if="isAdminViewing && isAffiliate"
|
||||
:style="{
|
||||
'--_color': 'var(--color-brand)',
|
||||
'--_bg-color': 'var(--color-brand-highlight)',
|
||||
}"
|
||||
>
|
||||
<AffiliateIcon /> Affiliate
|
||||
</TagItem>
|
||||
</span>
|
||||
</template>
|
||||
<template #summary>
|
||||
{{
|
||||
@@ -191,6 +202,13 @@
|
||||
action: () => navigateTo(`/admin/billing/${user.id}`),
|
||||
shown: auth.user && isStaff(auth.user),
|
||||
},
|
||||
{
|
||||
id: 'toggle-affiliate',
|
||||
action: () => toggleAffiliate(user.id),
|
||||
shown: isAdminViewing,
|
||||
remainOnClick: true,
|
||||
color: isAffiliate ? 'red' : 'orange',
|
||||
},
|
||||
{
|
||||
id: 'open-info',
|
||||
action: () => $refs.userDetailsModal.show(),
|
||||
@@ -203,6 +221,7 @@
|
||||
},
|
||||
]"
|
||||
aria-label="More options"
|
||||
:dropdown-id="`${baseId}-more-options`"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #manage-projects>
|
||||
@@ -229,6 +248,14 @@
|
||||
<InfoIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.infoButton) }}
|
||||
</template>
|
||||
<template #toggle-affiliate>
|
||||
<AffiliateIcon aria-hidden="true" />
|
||||
{{
|
||||
formatMessage(
|
||||
isAffiliate ? messages.removeAffiliateButton : messages.setAffiliateButton,
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
<template #edit-role>
|
||||
<EditIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.editRoleButton) }}
|
||||
@@ -411,6 +438,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
AffiliateIcon,
|
||||
BoxIcon,
|
||||
CalendarIcon,
|
||||
CheckIcon,
|
||||
@@ -437,10 +465,11 @@ import {
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
OverflowMenu,
|
||||
TagItem,
|
||||
TeleportDropdownMenu,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import { isAdmin } from '@modrinth/utils'
|
||||
import { isAdmin, UserBadge } from '@modrinth/utils'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
|
||||
import TenMClubBadge from '~/assets/images/badges/10m-club.svg?component'
|
||||
@@ -475,6 +504,8 @@ const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const baseId = useId()
|
||||
|
||||
const messages = defineMessages({
|
||||
profileProjectsLabel: {
|
||||
id: 'profile.label.projects',
|
||||
@@ -599,6 +630,18 @@ const messages = defineMessages({
|
||||
id: 'profile.button.info',
|
||||
defaultMessage: 'View user details',
|
||||
},
|
||||
setAffiliateButton: {
|
||||
id: 'profile.button.set-affiliate',
|
||||
defaultMessage: 'Set as affiliate',
|
||||
},
|
||||
removeAffiliateButton: {
|
||||
id: 'profile.button.remove-affiliate',
|
||||
defaultMessage: 'Remove as affiliate',
|
||||
},
|
||||
affiliateLabel: {
|
||||
id: 'profile.label.affiliate',
|
||||
defaultMessage: 'Affiliate',
|
||||
},
|
||||
editRoleButton: {
|
||||
id: 'profile.button.edit-role',
|
||||
defaultMessage: 'Edit role',
|
||||
@@ -609,38 +652,42 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
let user, projects, organizations, collections
|
||||
let user, projects, organizations, collections, refreshUser
|
||||
try {
|
||||
;[{ data: user }, { data: projects }, { data: organizations }, { data: collections }] =
|
||||
await Promise.all([
|
||||
useAsyncData(`user/${route.params.id}`, () => useBaseFetch(`user/${route.params.id}`)),
|
||||
useAsyncData(
|
||||
`user/${route.params.id}/projects`,
|
||||
() => useBaseFetch(`user/${route.params.id}/projects`),
|
||||
{
|
||||
transform: (projects) => {
|
||||
for (const project of projects) {
|
||||
project.categories = project.categories.concat(project.loaders)
|
||||
project.project_type = data.$getProjectTypeForUrl(
|
||||
project.project_type,
|
||||
project.categories,
|
||||
tags.value,
|
||||
)
|
||||
}
|
||||
;[
|
||||
{ data: user, refresh: refreshUser },
|
||||
{ data: projects },
|
||||
{ data: organizations },
|
||||
{ data: collections },
|
||||
] = await Promise.all([
|
||||
useAsyncData(`user/${route.params.id}`, () => useBaseFetch(`user/${route.params.id}`)),
|
||||
useAsyncData(
|
||||
`user/${route.params.id}/projects`,
|
||||
() => useBaseFetch(`user/${route.params.id}/projects`),
|
||||
{
|
||||
transform: (projects) => {
|
||||
for (const project of projects) {
|
||||
project.categories = project.categories.concat(project.loaders)
|
||||
project.project_type = data.$getProjectTypeForUrl(
|
||||
project.project_type,
|
||||
project.categories,
|
||||
tags.value,
|
||||
)
|
||||
}
|
||||
|
||||
return projects
|
||||
},
|
||||
return projects
|
||||
},
|
||||
),
|
||||
useAsyncData(`user/${route.params.id}/organizations`, () =>
|
||||
useBaseFetch(`user/${route.params.id}/organizations`, {
|
||||
apiVersion: 3,
|
||||
}),
|
||||
),
|
||||
useAsyncData(`user/${route.params.id}/collections`, () =>
|
||||
useBaseFetch(`user/${route.params.id}/collections`, { apiVersion: 3 }),
|
||||
),
|
||||
])
|
||||
},
|
||||
),
|
||||
useAsyncData(`user/${route.params.id}/organizations`, () =>
|
||||
useBaseFetch(`user/${route.params.id}/organizations`, {
|
||||
apiVersion: 3,
|
||||
}),
|
||||
),
|
||||
useAsyncData(`user/${route.params.id}/collections`, () =>
|
||||
useBaseFetch(`user/${route.params.id}/collections`, { apiVersion: 3 }),
|
||||
),
|
||||
])
|
||||
} catch {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
@@ -764,6 +811,17 @@ async function copyPermalink() {
|
||||
await navigator.clipboard.writeText(`${config.public.siteUrl}/user/${user.value.id}`)
|
||||
}
|
||||
|
||||
const isAffiliate = computed(() => user.value.badges & UserBadge.AFFILIATE)
|
||||
const isAdminViewing = computed(() => isAdmin(auth.value.user))
|
||||
|
||||
async function toggleAffiliate(id) {
|
||||
await useBaseFetch(`user/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: { badges: user.value.badges ^ (1 << 7) },
|
||||
})
|
||||
refreshUser()
|
||||
}
|
||||
|
||||
const navLinks = computed(() => [
|
||||
{
|
||||
label: formatMessage(commonMessages.allProjectType),
|
||||
|
||||
Reference in New Issue
Block a user