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
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
@@ -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
@@ -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),
|
||||
|
||||
1
packages/assets/external/facebook.svg
vendored
Normal 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 |
1
packages/assets/external/instagram.svg
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 |
1
packages/assets/external/youtubegaming.svg
vendored
Normal 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 |
1
packages/assets/external/youtubeshorts.svg
vendored
Normal 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 |
@@ -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
|
||||
|
||||
11
packages/assets/icons/affiliate.svg
Normal 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 |
1
packages/assets/icons/circle-user.svg
Normal 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 |
1
packages/assets/icons/user-search.svg
Normal 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 |
@@ -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'
|
||||
|
||||
72
packages/ui/src/components/affiliate/AffiliateLinkCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
153
packages/ui/src/components/base/AutoBrandIcon.vue
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -79,6 +79,7 @@ export type CreatePaymentIntentRequest = PaymentRequestType & {
|
||||
type: 'pyro'
|
||||
server_name?: string
|
||||
server_region?: string
|
||||
affiliate_code?: string
|
||||
source:
|
||||
| {
|
||||
loader: Loaders
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||