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

1
packages/assets/external/facebook.svg vendored Normal file
View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><title>Facebook</title><path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"/></svg>

After

Width:  |  Height:  |  Size: 563 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><title>Instagram</title><path d="M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

3
packages/assets/external/reels.svg vendored Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 122.14 122.88" fill="currentColor">
<path d="M35.14 0H87c9.65 0 18.43 3.96 24.8 10.32 6.38 6.37 10.34 15.16 10.34 24.82v52.61c0 9.64-3.96 18.42-10.32 24.79l-.02.02c-6.38 6.37-15.16 10.32-24.79 10.32H35.14c-9.66 0-18.45-3.96-24.82-10.32l-.24-.27C3.86 105.95 0 97.27 0 87.74v-52.6c0-9.67 3.95-18.45 10.32-24.82S25.47 0 35.14 0zm56.37 31.02.07.11h21.6c-.87-5.68-3.58-10.78-7.48-14.69-4.8-4.8-11.42-7.78-18.7-7.78h-8.87l13.38 22.36zm-9.99.11L68.07 8.66h-29.5l13.61 22.47h29.34zm-39.41 0L28.95 9.39a26.446 26.446 0 0 0-12.51 7.05c-3.9 3.9-6.6 9.01-7.48 14.69h33.15zm71.37 8.66H8.66v47.96c0 7.17 2.89 13.7 7.56 18.48l.22.21c4.8 4.8 11.43 7.79 18.7 7.79H87c7.28 0 13.9-2.98 18.69-7.77l.02-.02c4.79-4.79 7.77-11.41 7.77-18.69V39.79zM50.95 54.95 77.78 72.4c.43.28.82.64 1.13 1.08a3.9 3.9 0 0 1-1 5.42L51.19 94.67c-.67.55-1.53.88-2.48.88a3.91 3.91 0 0 1-3.91-3.91V58.15h.02a3.902 3.902 0 0 1 6.13-3.2z"/>
</svg>

After

Width:  |  Height:  |  Size: 978 B

1
packages/assets/external/snapchat.svg vendored Normal file
View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><title>Snapchat</title><path d="M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.055-.225-.015-.243.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.332-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.09-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
packages/assets/external/threads.svg vendored Normal file
View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><title>Threads</title><path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 0 1 3.02.142c-.126-.742-.375-1.332-.75-1.757-.513-.586-1.308-.883-2.359-.89h-.029c-.844 0-1.992.232-2.721 1.32L7.734 7.847c.98-1.454 2.568-2.256 4.478-2.256h.044c3.194.02 5.097 1.975 5.287 5.388.108.046.216.094.321.142 1.49.7 2.58 1.761 3.154 3.07.797 1.82.871 4.79-1.548 7.158-1.85 1.81-4.094 2.628-7.277 2.65Zm1.003-11.69c-.242 0-.487.007-.739.021-1.836.103-2.98.946-2.916 2.143.067 1.256 1.452 1.839 2.784 1.767 1.224-.065 2.818-.543 3.086-3.71a10.5 10.5 0 0 0-2.215-.221z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
packages/assets/external/tiktok.svg vendored Normal file
View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><title>TikTok</title><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>

After

Width:  |  Height:  |  Size: 728 B

1
packages/assets/external/twitch.svg vendored Normal file
View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><title>Twitch</title><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/></svg>

After

Width:  |  Height:  |  Size: 313 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><title>YouTube Gaming</title><path d="M24 13.2v-6l-6-3.6-6 3.6-6-3.6-6 3.6v6l12 7.2zM8.4 10.8H6v2.4H4.8v-2.4H2.4V9.6h2.4V7.2H6v2.4h2.4zm7.2 2.4a1.2 1.2 0 01-1.2-1.2c0-.66.54-1.2 1.2-1.2.66 0 1.2.54 1.2 1.2 0 .66-.54 1.2-1.2 1.2zm3.6-2.4A1.2 1.2 0 0118 9.6c0-.66.54-1.2 1.2-1.2.66 0 1.2.54 1.2 1.2 0 .66-.54 1.2-1.2 1.2Z"/></svg>

After

Width:  |  Height:  |  Size: 420 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><title>YouTube Shorts</title><path d="m18.931 9.99-1.441-.601 1.717-.913a4.48 4.48 0 0 0 1.874-6.078 4.506 4.506 0 0 0-6.09-1.874L4.792 5.929a4.504 4.504 0 0 0-2.402 4.193 4.521 4.521 0 0 0 2.666 3.904c.036.012 1.442.6 1.442.6l-1.706.901a4.51 4.51 0 0 0-2.369 3.967A4.528 4.528 0 0 0 6.93 24c.725 0 1.437-.174 2.08-.508l10.21-5.406a4.494 4.494 0 0 0 2.39-4.192 4.525 4.525 0 0 0-2.678-3.904ZM9.597 15.19V8.824l6.007 3.184z"/></svg>

After

Width:  |  Height:  |  Size: 523 B

View File

@@ -1,6 +1,7 @@
// Auto-generated icon imports and exports
// Do not edit this file manually - run 'pnpm run fix' to regenerate
import _AffiliateIcon from './icons/affiliate.svg?component'
import _AlignLeftIcon from './icons/align-left.svg?component'
import _ArchiveIcon from './icons/archive.svg?component'
import _ArrowBigRightDashIcon from './icons/arrow-big-right-dash.svg?component'
@@ -30,6 +31,7 @@ import _CheckCheckIcon from './icons/check-check.svg?component'
import _CheckCircleIcon from './icons/check-circle.svg?component'
import _ChevronLeftIcon from './icons/chevron-left.svg?component'
import _ChevronRightIcon from './icons/chevron-right.svg?component'
import _CircleUserIcon from './icons/circle-user.svg?component'
import _ClearIcon from './icons/clear.svg?component'
import _ClientIcon from './icons/client.svg?component'
import _ClipboardCopyIcon from './icons/clipboard-copy.svg?component'
@@ -190,6 +192,7 @@ import _UploadIcon from './icons/upload.svg?component'
import _UserIcon from './icons/user.svg?component'
import _UserCogIcon from './icons/user-cog.svg?component'
import _UserPlusIcon from './icons/user-plus.svg?component'
import _UserSearchIcon from './icons/user-search.svg?component'
import _UserXIcon from './icons/user-x.svg?component'
import _UsersIcon from './icons/users.svg?component'
import _VersionIcon from './icons/version.svg?component'
@@ -202,6 +205,7 @@ import _XCircleIcon from './icons/x-circle.svg?component'
import _ZoomInIcon from './icons/zoom-in.svg?component'
import _ZoomOutIcon from './icons/zoom-out.svg?component'
export const AffiliateIcon = _AffiliateIcon
export const AlignLeftIcon = _AlignLeftIcon
export const ArchiveIcon = _ArchiveIcon
export const ArrowBigRightDashIcon = _ArrowBigRightDashIcon
@@ -231,6 +235,7 @@ export const CheckCircleIcon = _CheckCircleIcon
export const CheckIcon = _CheckIcon
export const ChevronLeftIcon = _ChevronLeftIcon
export const ChevronRightIcon = _ChevronRightIcon
export const CircleUserIcon = _CircleUserIcon
export const ClearIcon = _ClearIcon
export const ClientIcon = _ClientIcon
export const ClipboardCopyIcon = _ClipboardCopyIcon
@@ -390,6 +395,7 @@ export const UpdatedIcon = _UpdatedIcon
export const UploadIcon = _UploadIcon
export const UserCogIcon = _UserCogIcon
export const UserPlusIcon = _UserPlusIcon
export const UserSearchIcon = _UserSearchIcon
export const UserXIcon = _UserXIcon
export const UserIcon = _UserIcon
export const UsersIcon = _UsersIcon

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<g>
<circle cx="12" cy="12" r="7"/>
<circle cx="12" cy="10.6" r="2.1"/>
<circle cx="3.7" cy="3.7" r="1.8"/>
<circle cx="20.3" cy="3.7" r="1.8"/>
<circle cx="3.7" cy="20.3" r="1.8"/>
<circle cx="20.3" cy="20.3" r="1.8"/>
<path d="M8.5 18.1v-1.2c0-.8.6-1.4 1.4-1.4h4.2c.8 0 1.4.6 1.4 1.4v1.2M17 7l2-2M5 19l2-2M17 17l2 2M5 5l2 2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 532 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-user-icon lucide-circle-user"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg>

After

Width:  |  Height:  |  Size: 368 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-search-icon lucide-user-search"><circle cx="10" cy="7" r="4"/><path d="M10.3 15H7a4 4 0 0 0-4 4v2"/><circle cx="17" cy="17" r="3"/><path d="m21 21-1.9-1.9"/></svg>

After

Width:  |  Height:  |  Size: 370 B

View File

@@ -23,29 +23,38 @@ import _SleepingRinthbot from './branding/rinthbot/sleeping.webp'
import _SobbingRinthbot from './branding/rinthbot/sobbing.webp'
import _ThinkingRinthbot from './branding/rinthbot/thinking.webp'
import _WavingRinthbot from './branding/rinthbot/waving.webp'
// External Icons
import _AppleIcon from './external/apple.svg?component'
import _BlueskyIcon from './external/bluesky.svg?component'
import _BuyMeACoffeeIcon from './external/bmac.svg?component'
import _CurseForgeIcon from './external/curseforge.svg?component'
import _DiscordIcon from './external/discord.svg?component'
import _FacebookIcon from './external/facebook.svg?component'
import _GithubIcon from './external/github.svg?component'
import _InstagramIcon from './external/instagram.svg?component'
import _KoFiIcon from './external/kofi.svg?component'
import _MastodonIcon from './external/mastodon.svg?component'
import _OpenCollectiveIcon from './external/opencollective.svg?component'
import _PatreonIcon from './external/patreon.svg?component'
import _PayPalIcon from './external/paypal.svg?component'
import _RedditIcon from './external/reddit.svg?component'
// External Icons
import _ReelsIcon from './external/reels.svg?component'
import _SnapchatIcon from './external/snapchat.svg?component'
import _SSODiscordIcon from './external/sso/discord.svg?component'
import _SSOGitHubIcon from './external/sso/github.svg?component'
import _SSOGitLabIcon from './external/sso/gitlab.svg?component'
import _SSOGoogleIcon from './external/sso/google.svg?component'
import _SSOMicrosoftIcon from './external/sso/microsoft.svg?component'
import _SSOSteamIcon from './external/sso/steam.svg?component'
import _ThreadsIcon from './external/threads.svg?component'
import _TikTokIcon from './external/tiktok.svg?component'
import _TumblrIcon from './external/tumblr.svg?component'
import _TwitchIcon from './external/twitch.svg?component'
import _TwitterIcon from './external/twitter.svg?component'
import _WindowsIcon from './external/windows.svg?component'
import _YouTubeIcon from './external/youtube.svg?component'
import _YouTubeGaming from './external/youtubegaming.svg?component'
import _YouTubeShortsIcon from './external/youtubeshorts.svg?component'
export const ModrinthIcon = _ModrinthIcon
export const BrowserWindowSuccessIllustration = _BrowserWindowSuccessIllustration
@@ -73,6 +82,13 @@ export const BuyMeACoffeeIcon = _BuyMeACoffeeIcon
export const GithubIcon = _GithubIcon
export const CurseForgeIcon = _CurseForgeIcon
export const DiscordIcon = _DiscordIcon
export const FacebookIcon = _FacebookIcon
export const InstagramIcon = _InstagramIcon
export const SnapchatIcon = _SnapchatIcon
export const ReelsIcon = _ReelsIcon
export const TikTokIcon = _TikTokIcon
export const TwitchIcon = _TwitchIcon
export const ThreadsIcon = _ThreadsIcon
export const KoFiIcon = _KoFiIcon
export const MastodonIcon = _MastodonIcon
export const OpenCollectiveIcon = _OpenCollectiveIcon
@@ -83,6 +99,8 @@ export const TumblrIcon = _TumblrIcon
export const TwitterIcon = _TwitterIcon
export const WindowsIcon = _WindowsIcon
export const YouTubeIcon = _YouTubeIcon
export const YouTubeGaming = _YouTubeGaming
export const YouTubeShortsIcon = _YouTubeShortsIcon
export * from './generated-icons'
export { default as ClassicPlayerModel } from './models/classic-player.gltf?url'

View File

@@ -0,0 +1,72 @@
<template>
<div class="card-shadow flex flex-col gap-4 rounded-2xl bg-bg-raised p-4">
<div class="flex items-center gap-4">
<div
class="flex items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg p-2"
>
<AutoBrandIcon :keyword="affiliate.source_name" class="h-6 w-6">
<AffiliateIcon />
</AutoBrandIcon>
</div>
<div class="flex flex-col">
<span class="w-fit text-lg font-bold text-contrast">
{{ affiliate.source_name }}
</span>
<span v-if="createdBy" class="text-sm text-secondary">
{{ formatMessage(messages.createdBy, { user: createdBy }) }}
</span>
</div>
<div class="ml-auto flex items-center gap-2">
<slot />
<ButtonStyled v-if="showRevoke" color="red" color-fill="text">
<button @click="emit('revoke', affiliate)">
<XCircleIcon />
{{ formatMessage(messages.revokeAffiliateLink) }}
</button>
</ButtonStyled>
</div>
</div>
<CopyCode :text="`https://modrinth.gg?afl=${affiliate.id}`" />
</div>
</template>
<script setup lang="ts">
import { AffiliateIcon, XCircleIcon } from '@modrinth/assets'
import type { AffiliateLink } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { AutoBrandIcon, ButtonStyled, CopyCode } from '../index.ts'
withDefaults(
defineProps<{
affiliate: AffiliateLink
showRevoke?: boolean
createdBy?: string
}>(),
{
showRevoke: true,
createdBy: undefined,
},
)
const emit = defineEmits<{
(e: 'revoke', affiliate: AffiliateLink): void
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
viewAnalytics: {
id: 'affiliate.viewAnalytics',
defaultMessage: 'View analytics',
},
revokeAffiliateLink: {
id: 'affiliate.revoke',
defaultMessage: 'Revoke affiliate link',
},
createdBy: {
id: 'affiliate.createdBy',
defaultMessage: 'Created by {user}',
},
})
</script>

View File

@@ -0,0 +1,165 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.createHeader)">
<div class="flex flex-col">
<label v-if="showUserField" class="contents" for="create-affiliate-user-input">
<span class="text-lg font-semibold text-contrast mb-1">
{{ formatMessage(messages.createUserLabel) }}
</span>
<span class="text-secondary mb-2">{{ formatMessage(messages.createUserDescription) }}</span>
</label>
<div v-if="showUserField" class="mb-4">
<div class="iconified-input">
<UserIcon aria-hidden="true" />
<input
id="create-affiliate-user-input"
v-model="affiliateUsername"
class="card-shadow"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.createUserPlaceholder)"
/>
<Button v-if="affiliateUsername" class="r-btn" @click="() => (affiliateUsername = '')">
<XIcon />
</Button>
</div>
</div>
<label class="contents" for="create-affiliate-title-input">
<span class="text-lg font-semibold text-contrast mb-1">
{{ formatMessage(messages.createTitleLabel) }}
</span>
<span class="text-secondary mb-2">{{
formatMessage(messages.createTitleDescription)
}}</span>
</label>
<div class="flex items-center gap-2">
<div class="iconified-input">
<AutoBrandIcon :keyword="affiliateLinkTitle" aria-hidden="true">
<AffiliateIcon />
</AutoBrandIcon>
<input
id="create-affiliate-title-input"
v-model="affiliateLinkTitle"
class="card-shadow"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.createTitlePlaceholder)"
/>
<Button v-if="affiliateLinkTitle" class="r-btn" @click="() => (affiliateLinkTitle = '')">
<XIcon />
</Button>
</div>
<ButtonStyled color="brand">
<button :disabled="creatingLink || !canCreate" @click="createAffiliateLink">
<SpinnerIcon v-if="creatingLink" class="animate-spin" />
<PlusIcon v-else />
{{ formatMessage(creatingLink ? messages.creatingButton : messages.createButton) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script lang="ts"></script>
<script setup lang="ts">
import { AffiliateIcon, PlusIcon, SpinnerIcon, UserIcon, XIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, ref, useTemplateRef } from 'vue'
import { AutoBrandIcon, Button, ButtonStyled, NewModal } from '../index.ts'
export type CreateAffiliateProps = { sourceName: string; username?: string }
const props = withDefaults(
defineProps<{
showUserField?: boolean
creatingLink?: boolean
}>(),
{
showUserField: false,
creatingLink: false,
},
)
const emit = defineEmits<{
(e: 'create', data: CreateAffiliateProps): void
}>()
const modal = useTemplateRef<typeof NewModal>('modal')
const { formatMessage } = useVIntl()
const affiliateLinkTitle = ref('')
const affiliateUsername = ref('')
const canCreate = computed(() => {
if (props.showUserField) {
return affiliateLinkTitle.value.trim() && affiliateUsername.value.trim()
}
return affiliateLinkTitle.value.trim()
})
function createAffiliateLink() {
if (!canCreate.value) {
return
}
emit('create', {
sourceName: affiliateLinkTitle.value,
username: props.showUserField ? affiliateUsername.value : undefined,
})
}
function close() {
modal.value?.hide()
affiliateLinkTitle.value = ''
affiliateUsername.value = ''
}
function show() {
modal.value?.show()
}
defineExpose({
show,
close,
})
const messages = defineMessages({
createHeader: {
id: 'affiliate.create.header',
defaultMessage: 'Creating new affiliate code',
},
createTitleLabel: {
id: 'affiliate.create.title.label',
defaultMessage: 'Title of affiliate link',
},
createTitleDescription: {
id: 'affiliate.create.title.description',
defaultMessage: 'Give your affiliate link a name so you know where people are coming from!',
},
createTitlePlaceholder: {
id: 'affiliate.create.title.placeholder',
defaultMessage: 'e.g. YouTube',
},
createUserLabel: {
id: 'affiliate.create.user.label',
defaultMessage: 'Username',
},
createUserDescription: {
id: 'affiliate.create.user.description',
defaultMessage: 'The username of the user to create the affiliate code for',
},
createUserPlaceholder: {
id: 'affiliate.create.user.placeholder',
defaultMessage: 'Enter username...',
},
createButton: {
id: 'affiliate.create.button',
defaultMessage: 'Create affiliate link',
},
creatingButton: {
id: 'affiliate.creating.button',
defaultMessage: 'Creating affiliate link...',
},
})
</script>

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import {
AppleIcon,
BlueskyIcon,
BuyMeACoffeeIcon,
CurseForgeIcon,
DiscordIcon,
FacebookIcon,
GithubIcon,
InstagramIcon,
KoFiIcon,
MastodonIcon,
ModrinthIcon,
OpenCollectiveIcon,
PatreonIcon,
PayPalIcon,
RedditIcon,
ReelsIcon,
SnapchatIcon,
ThreadsIcon,
TikTokIcon,
TumblrIcon,
TwitchIcon,
TwitterIcon,
WindowsIcon,
YouTubeGaming,
YouTubeIcon,
YouTubeShortsIcon,
} from '@modrinth/assets'
import { computed } from 'vue'
const props = defineProps<{
keyword: string
}>()
const services = [
{
icon: AppleIcon,
keywords: ['apple'],
},
{
icon: BlueskyIcon,
keywords: ['bluesky', 'bsky', 'blue sky'],
},
{
icon: BuyMeACoffeeIcon,
keywords: ['buymeacoffee', 'bmac', 'buy me a coffee'],
},
{
icon: DiscordIcon,
keywords: ['discord'],
},
{
icon: FacebookIcon,
keywords: ['facebook', 'fb', 'face book'],
},
{
icon: GithubIcon,
keywords: ['github', 'gh', 'git hub'],
},
{
icon: ThreadsIcon,
keywords: ['threads'],
},
{
icon: InstagramIcon,
keywords: ['instagram', 'ig', 'insta'],
},
{
icon: KoFiIcon,
keywords: ['ko-fi', 'kofi', 'ko fi'],
},
{
icon: MastodonIcon,
keywords: ['mastodon'],
},
{
icon: OpenCollectiveIcon,
keywords: ['opencollective', 'open collective'],
},
{
icon: PatreonIcon,
keywords: ['patreon'],
},
{
icon: PayPalIcon,
keywords: ['paypal', 'pay pal'],
},
{
icon: RedditIcon,
keywords: ['reddit'],
},
{
icon: ReelsIcon,
keywords: ['reels', 'instagram reels', 'facebook reels'],
},
{
icon: SnapchatIcon,
keywords: ['snapchat'],
},
{
icon: TikTokIcon,
keywords: ['tiktok', 'tik', 'tok'],
},
{
icon: TumblrIcon,
keywords: ['tumblr'],
},
{
icon: TwitchIcon,
keywords: ['twitch', 'twitch.tv'],
},
{
icon: WindowsIcon,
keywords: ['windows', 'microsoft'],
},
{
icon: YouTubeIcon,
keywords: ['youtube', 'yt'],
},
{
icon: YouTubeShortsIcon,
keywords: ['shorts', 'youtube shorts'],
},
{
icon: YouTubeGaming,
keywords: ['youtube gaming'],
},
{
icon: CurseForgeIcon,
keywords: ['curseforge', 'cf', 'curse', 'curse forge'],
},
{
icon: ModrinthIcon,
keywords: ['modrinth', 'mod rinth', 'modrith', 'mr'],
},
{
icon: TwitterIcon,
keywords: ['twitter', 'x.com', 'x'],
},
]
const selectedService = computed(() =>
services.find((service) =>
service.keywords.some((keyword) => props.keyword.toLowerCase().includes(keyword)),
),
)
</script>
<template>
<component :is="selectedService?.icon" v-if="selectedService" />
<slot v-else />
</template>

View File

@@ -58,6 +58,7 @@ const props = defineProps<{
) => Promise<UpdatePaymentIntentResponse | CreatePaymentIntentResponse | null>
onError: (err: Error) => void
onFinalizeNoPaymentChange?: () => Promise<void>
affiliateCode?: string | null
}>()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
@@ -66,6 +67,7 @@ const selectedInterval = ref<ServerBillingInterval>('quarterly')
const loading = ref(false)
const selectedRegion = ref<string>()
const projectId = ref<string>()
const affiliateCode = ref(props.affiliateCode ?? null)
const {
initializeStripe,
@@ -96,6 +98,7 @@ const {
projectId,
props.initiatePayment,
props.onError,
affiliateCode,
)
const customServer = ref<boolean>(false)

View File

@@ -2,6 +2,7 @@
export { default as Accordion } from './base/Accordion.vue'
export { default as Admonition } from './base/Admonition.vue'
export { default as AppearingProgressBar } from './base/AppearingProgressBar.vue'
export { default as AutoBrandIcon } from './base/AutoBrandIcon.vue'
export { default as AutoLink } from './base/AutoLink.vue'
export { default as Avatar } from './base/Avatar.vue'
export { default as Badge } from './base/Badge.vue'
@@ -98,6 +99,10 @@ export { default as SearchFilterControl } from './search/SearchFilterControl.vue
export { default as SearchFilterOption } from './search/SearchFilterOption.vue'
export { default as SearchSidebarFilter } from './search/SearchSidebarFilter.vue'
// Affiliate
export { default as AffiliateLinkCard } from './affiliate/AffiliateLinkCard.vue'
export { default as AffiliateLinkCreateModal } from './affiliate/AffiliateLinkCreateModal.vue'
// Billing
export { default as AddPaymentMethodModal } from './billing/AddPaymentMethodModal.vue'
export { default as ModrinthServersPurchaseModal } from './billing/ModrinthServersPurchaseModal.vue'

View File

@@ -37,6 +37,7 @@ export const useStripe = (
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
) => Promise<CreatePaymentIntentResponse | UpdatePaymentIntentResponse | null>,
onError: (err: Error) => void,
affiliateCode?: Ref<string | null>,
) => {
const stripe = ref<StripeJs | null>(null)
@@ -229,6 +230,13 @@ export const useStripe = (
let result: BasePaymentIntentResponse | null = null
const affiliateMetadata =
affiliateCode && affiliateCode.value
? {
affiliate_code: affiliateCode.value,
}
: {}
const metadata: CreatePaymentIntentRequest['metadata'] = {
type: 'pyro',
server_region: region.value,
@@ -237,6 +245,7 @@ export const useStripe = (
project_id: project.value,
}
: {},
...affiliateMetadata,
}
if (paymentIntentId.value) {

View File

@@ -1,4 +1,40 @@
{
"affiliate.create.button": {
"defaultMessage": "Create affiliate link"
},
"affiliate.create.header": {
"defaultMessage": "Creating new affiliate code"
},
"affiliate.create.title.description": {
"defaultMessage": "Give your affiliate link a name so you know where people are coming from!"
},
"affiliate.create.title.label": {
"defaultMessage": "Title of affiliate link"
},
"affiliate.create.title.placeholder": {
"defaultMessage": "e.g. YouTube"
},
"affiliate.create.user.description": {
"defaultMessage": "The username of the user to create the affiliate code for"
},
"affiliate.create.user.label": {
"defaultMessage": "Username"
},
"affiliate.create.user.placeholder": {
"defaultMessage": "Enter username..."
},
"affiliate.createdBy": {
"defaultMessage": "Created by {user}"
},
"affiliate.creating.button": {
"defaultMessage": "Creating affiliate link..."
},
"affiliate.revoke": {
"defaultMessage": "Revoke affiliate link"
},
"affiliate.viewAnalytics": {
"defaultMessage": "View analytics"
},
"badge.beta": {
"defaultMessage": "Beta"
},
@@ -8,6 +44,9 @@
"badge.new": {
"defaultMessage": "New"
},
"button.affiliate-links": {
"defaultMessage": "Affiliate links"
},
"button.analytics": {
"defaultMessage": "Analytics"
},

View File

@@ -79,6 +79,7 @@ export type CreatePaymentIntentRequest = PaymentRequestType & {
type: 'pyro'
server_name?: string
server_region?: string
affiliate_code?: string
source:
| {
loader: Loaders

View File

@@ -1,6 +1,10 @@
import { defineMessages } from '@vintl/vintl'
export const commonMessages = defineMessages({
affiliateLinksButton: {
id: 'button.affiliate-links',
defaultMessage: 'Affiliate links',
},
analyticsButton: {
id: 'button.analytics',
defaultMessage: 'Analytics',

View File

@@ -334,6 +334,7 @@ export enum UserBadge {
ALPHA_TESTER = 1 << 4,
CONTRIBUTOR = 1 << 5,
TRANSLATOR = 1 << 6,
AFFILIATE = 1 << 7,
}
export type UserBadges = number
@@ -597,3 +598,11 @@ export interface DelphiReport {
status: 'pending' | 'approved' | 'rejected'
content?: string
}
export type AffiliateLink = {
id: string
created_at: string
created_by: string
affiliate: string
source_name: string
}