1
0

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:
Prospector
2025-11-02 11:32:18 -08:00
committed by GitHub
parent b7f0988399
commit 40cbe92dbc
33 changed files with 1202 additions and 37 deletions

View 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,
}
}

View File

@@ -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',

View File

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

View 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>

View File

@@ -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>

View File

@@ -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({

View 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>

View File

@@ -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,
)

View File

@@ -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),