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