forked from didirus/AstralRinth
Affiliates frontend (#4380)
* Begin affiliates frontend * Significant work on hooking up affiliates ui * Clean up server nodes menu * affiliates work * update affiliate time * oops * fix local import * fix local import x2 * remove line in dashboard * lint
This commit is contained in:
72
packages/ui/src/components/affiliate/AffiliateLinkCard.vue
Normal file
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
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',
|
||||
|
||||
Reference in New Issue
Block a user