From 40cbe92dbc33dc1c55a7dbc72171cd9a5c2b28f2 Mon Sep 17 00:00:00 2001
From: Prospector <6166773+Prospector@users.noreply.github.com>
Date: Sun, 2 Nov 2025 11:32:18 -0800
Subject: [PATCH] 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
---
apps/frontend/src/composables/affiliates.ts | 22 ++
apps/frontend/src/layouts/default.vue | 32 +-
apps/frontend/src/locales/en-US/index.json | 33 ++
apps/frontend/src/pages/admin/affiliates.vue | 281 ++++++++++++++++++
.../src/pages/admin/servers/nodes.vue | 1 +
apps/frontend/src/pages/dashboard.vue | 17 +-
.../src/pages/dashboard/affiliate-links.vue | 202 +++++++++++++
apps/frontend/src/pages/servers/index.vue | 21 +-
apps/frontend/src/pages/user/[id].vue | 120 ++++++--
packages/assets/external/facebook.svg | 1 +
packages/assets/external/instagram.svg | 1 +
packages/assets/external/reels.svg | 3 +
packages/assets/external/snapchat.svg | 1 +
packages/assets/external/threads.svg | 1 +
packages/assets/external/tiktok.svg | 1 +
packages/assets/external/twitch.svg | 1 +
packages/assets/external/youtubegaming.svg | 1 +
packages/assets/external/youtubeshorts.svg | 1 +
packages/assets/generated-icons.ts | 6 +
packages/assets/icons/affiliate.svg | 11 +
packages/assets/icons/circle-user.svg | 1 +
packages/assets/icons/user-search.svg | 1 +
packages/assets/index.ts | 20 +-
.../affiliate/AffiliateLinkCard.vue | 72 +++++
.../affiliate/AffiliateLinkCreateModal.vue | 165 ++++++++++
.../ui/src/components/base/AutoBrandIcon.vue | 153 ++++++++++
.../billing/ModrinthServersPurchaseModal.vue | 3 +
packages/ui/src/components/index.ts | 5 +
packages/ui/src/composables/stripe.ts | 9 +
packages/ui/src/locales/en-US/index.json | 39 +++
packages/ui/src/utils/billing.ts | 1 +
packages/ui/src/utils/common-messages.ts | 4 +
packages/utils/types.ts | 9 +
33 files changed, 1202 insertions(+), 37 deletions(-)
create mode 100644 apps/frontend/src/composables/affiliates.ts
create mode 100644 apps/frontend/src/pages/admin/affiliates.vue
create mode 100644 apps/frontend/src/pages/dashboard/affiliate-links.vue
create mode 100644 packages/assets/external/facebook.svg
create mode 100644 packages/assets/external/instagram.svg
create mode 100644 packages/assets/external/reels.svg
create mode 100644 packages/assets/external/snapchat.svg
create mode 100644 packages/assets/external/threads.svg
create mode 100644 packages/assets/external/tiktok.svg
create mode 100644 packages/assets/external/twitch.svg
create mode 100644 packages/assets/external/youtubegaming.svg
create mode 100644 packages/assets/external/youtubeshorts.svg
create mode 100644 packages/assets/icons/affiliate.svg
create mode 100644 packages/assets/icons/circle-user.svg
create mode 100644 packages/assets/icons/user-search.svg
create mode 100644 packages/ui/src/components/affiliate/AffiliateLinkCard.vue
create mode 100644 packages/ui/src/components/affiliate/AffiliateLinkCreateModal.vue
create mode 100644 packages/ui/src/components/base/AutoBrandIcon.vue
diff --git a/apps/frontend/src/composables/affiliates.ts b/apps/frontend/src/composables/affiliates.ts
new file mode 100644
index 000000000..3e23c3b3e
--- /dev/null
+++ b/apps/frontend/src/composables/affiliates.ts
@@ -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,
+ }
+}
diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue
index 3e75317be..abfefe743 100644
--- a/apps/frontend/src/layouts/default.vue
+++ b/apps/frontend/src/layouts/default.vue
@@ -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 @@
{{ formatMessage(messages.reports) }}
- {{ formatMessage(messages.lookupByEmail) }}
+ {{ formatMessage(messages.lookupByEmail) }}
{{ formatMessage(messages.fileLookup) }}
@@ -486,7 +492,12 @@
{{ formatMessage(messages.manageServerNotices) }}
- Server Nodes
+
+ {{ formatMessage(messages.manageAffiliates) }}
+
+
+ Credit server nodes
+
@@ -563,6 +574,10 @@
{{ formatMessage(messages.organizations) }}
+
+
+ {{ formatMessage(commonMessages.affiliateLinksButton) }}
+
{{ formatMessage(messages.revenue) }}
@@ -850,6 +865,7 @@
+
+
diff --git a/apps/frontend/src/pages/admin/servers/nodes.vue b/apps/frontend/src/pages/admin/servers/nodes.vue
index 2d0eb5270..a23a92939 100644
--- a/apps/frontend/src/pages/admin/servers/nodes.vue
+++ b/apps/frontend/src/pages/admin/servers/nodes.vue
@@ -8,6 +8,7 @@
+
don't worry there's not supposed to be anything here
diff --git a/apps/frontend/src/pages/dashboard.vue b/apps/frontend/src/pages/dashboard.vue
index 1496b6aaa..f9e434494 100644
--- a/apps/frontend/src/pages/dashboard.vue
+++ b/apps/frontend/src/pages/dashboard.vue
@@ -30,6 +30,13 @@
>
+
+
+
@@ -41,8 +48,9 @@
-
+
+
diff --git a/apps/frontend/src/pages/servers/index.vue b/apps/frontend/src/pages/servers/index.vue
index c91d6eeaf..399ef51e3 100644
--- a/apps/frontend/src/pages/servers/index.vue
+++ b/apps/frontend/src/pages/servers/index.vue
@@ -26,6 +26,7 @@
:regions="regions"
:refresh-payment-methods="fetchPaymentData"
:fetch-stock="fetchStock"
+ :affiliate-code="affiliateCode"
/>
isSmallAtCapacity.value && isMediumAtCapacity.value && isLargeAtCapacity.value,
)
diff --git a/apps/frontend/src/pages/user/[id].vue b/apps/frontend/src/pages/user/[id].vue
index 38182a890..a9491367d 100644
--- a/apps/frontend/src/pages/user/[id].vue
+++ b/apps/frontend/src/pages/user/[id].vue
@@ -109,7 +109,18 @@
- {{ user.username }}
+
+ {{ user.username }}
+
+ Affiliate
+
+
{{
@@ -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`"
>
@@ -229,6 +248,14 @@
{{ formatMessage(messages.infoButton) }}
+
+
+ {{
+ formatMessage(
+ isAffiliate ? messages.removeAffiliateButton : messages.setAffiliateButton,
+ )
+ }}
+
{{ formatMessage(messages.editRoleButton) }}
@@ -411,6 +438,7 @@
diff --git a/packages/ui/src/components/affiliate/AffiliateLinkCreateModal.vue b/packages/ui/src/components/affiliate/AffiliateLinkCreateModal.vue
new file mode 100644
index 000000000..cd77349e9
--- /dev/null
+++ b/packages/ui/src/components/affiliate/AffiliateLinkCreateModal.vue
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/base/AutoBrandIcon.vue b/packages/ui/src/components/base/AutoBrandIcon.vue
new file mode 100644
index 000000000..71cb60b14
--- /dev/null
+++ b/packages/ui/src/components/base/AutoBrandIcon.vue
@@ -0,0 +1,153 @@
+
+
+
+
+
+
diff --git a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue
index a864a7d19..a8f02fd53 100644
--- a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue
+++ b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue
@@ -58,6 +58,7 @@ const props = defineProps<{
) => Promise
onError: (err: Error) => void
onFinalizeNoPaymentChange?: () => Promise
+ affiliateCode?: string | null
}>()
const modal = useTemplateRef>('modal')
@@ -66,6 +67,7 @@ const selectedInterval = ref('quarterly')
const loading = ref(false)
const selectedRegion = ref()
const projectId = ref()
+const affiliateCode = ref(props.affiliateCode ?? null)
const {
initializeStripe,
@@ -96,6 +98,7 @@ const {
projectId,
props.initiatePayment,
props.onError,
+ affiliateCode,
)
const customServer = ref(false)
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
index 9f82bc74c..dbff9859b 100644
--- a/packages/ui/src/components/index.ts
+++ b/packages/ui/src/components/index.ts
@@ -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'
diff --git a/packages/ui/src/composables/stripe.ts b/packages/ui/src/composables/stripe.ts
index 94b8e164c..441725146 100644
--- a/packages/ui/src/composables/stripe.ts
+++ b/packages/ui/src/composables/stripe.ts
@@ -37,6 +37,7 @@ export const useStripe = (
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
) => Promise,
onError: (err: Error) => void,
+ affiliateCode?: Ref,
) => {
const stripe = ref(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) {
diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json
index d3e218e21..9aebf8112 100644
--- a/packages/ui/src/locales/en-US/index.json
+++ b/packages/ui/src/locales/en-US/index.json
@@ -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"
},
diff --git a/packages/ui/src/utils/billing.ts b/packages/ui/src/utils/billing.ts
index a84e7ef23..f2dac64d0 100644
--- a/packages/ui/src/utils/billing.ts
+++ b/packages/ui/src/utils/billing.ts
@@ -79,6 +79,7 @@ export type CreatePaymentIntentRequest = PaymentRequestType & {
type: 'pyro'
server_name?: string
server_region?: string
+ affiliate_code?: string
source:
| {
loader: Loaders
diff --git a/packages/ui/src/utils/common-messages.ts b/packages/ui/src/utils/common-messages.ts
index 46fcd35ef..4546b36dd 100644
--- a/packages/ui/src/utils/common-messages.ts
+++ b/packages/ui/src/utils/common-messages.ts
@@ -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',
diff --git a/packages/utils/types.ts b/packages/utils/types.ts
index d57ba3dd5..cac71dbd2 100644
--- a/packages/utils/types.ts
+++ b/packages/utils/types.ts
@@ -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
+}