Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/frontend/src/locales/en-US/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -1460,6 +1460,24 @@
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
"message": "You've used up your <b>{withdrawLimit}</b> withdrawal limit. You must complete a tax form to withdraw more."
},
"dashboard.discord-roles.banner.body": {
"message": "You're eligible for {roles}. Link your Discord account through Modrinth and we'll sync them automatically."
},
"dashboard.discord-roles.banner.cta": {
"message": "Link Discord"
},
"dashboard.discord-roles.banner.title": {
"message": "Claim your Discord roles"
},
"dashboard.discord-roles.role.big-creator": {
"message": "1M+ Downloads"
},
"dashboard.discord-roles.role.creator": {
"message": "Creator"
},
"dashboard.discord-roles.role.pride": {
"message": "Pride 2026"
},
"dashboard.head-title": {
"message": "Dashboard"
},
Expand Down
128 changes: 125 additions & 3 deletions apps/frontend/src/pages/dashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,28 +48,69 @@
/>
</div>
<div class="normal-page__content mt-4 lg:!mt-0">
<Admonition
v-if="showDiscordRoleBanner"
class="mb-3"
type="info"
:header="formatMessage(messages.discordRoleBannerTitle)"
show-actions-underneath
dismissible
@dismiss="dismissDiscordRoleBanner"
>
<div class="text-primary">
{{
formatMessage(messages.discordRoleBannerBody, {
roles: eligibleDiscordRolesLabel,
})
}}
</div>
<template #actions>
<ButtonStyled color="blue">
<NuxtLink to="/discord/link" class="w-fit !px-4">
<ExternalIcon />
{{ formatMessage(messages.discordRoleBannerCta) }}
</NuxtLink>
</ButtonStyled>
</template>
</Admonition>
<NuxtPage :route="route" />
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
AffiliateIcon,
BellIcon as NotificationsIcon,
ChartIcon,
CurrencyIcon,
DashboardIcon,
ExternalIcon,
LibraryIcon,
ListIcon,
OrganizationIcon,
ReportIcon,
} from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { type User, UserBadge } from '@modrinth/utils'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
injectModrinthClient,
useVIntl,
} from '@modrinth/ui'
import { UserBadge } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { useLocalStorage } from '@vueuse/core'

import NavStack from '~/components/ui/NavStack.vue'

const auth = (await useAuth()) as Ref<{ user: User | null }>
const auth = (await useAuth()) as Ref<{ user: Labrinth.Users.v3.User | null }>
const client = injectModrinthClient()
const dismissedDiscordRoleBannerUsers = useLocalStorage<string[]>(
'dashboard-discord-role-banner-dismissed-users',
[],
)

const isAffiliate = computed(() => {
return auth.value.user && auth.value.user.badges & UserBadge.AFFILIATE
Expand Down Expand Up @@ -114,6 +155,31 @@ const messages = defineMessages({
id: 'dashboard.sidebar.label.revenue',
defaultMessage: 'Revenue',
},
discordRoleBannerTitle: {
id: 'dashboard.discord-roles.banner.title',
defaultMessage: 'Claim your Discord roles',
},
discordRoleBannerBody: {
id: 'dashboard.discord-roles.banner.body',
defaultMessage:
"You're eligible for {roles}. Link your Discord account through Modrinth and we'll sync them automatically.",
},
discordRoleBannerCta: {
id: 'dashboard.discord-roles.banner.cta',
defaultMessage: 'Link Discord',
},
discordRolePride: {
id: 'dashboard.discord-roles.role.pride',
defaultMessage: 'Pride 2026',
},
discordRoleCreator: {
id: 'dashboard.discord-roles.role.creator',
defaultMessage: 'Creator',
},
discordRoleBigCreator: {
id: 'dashboard.discord-roles.role.big-creator',
defaultMessage: '1M+ Downloads',
},
})

definePageMeta({
Expand All @@ -125,4 +191,60 @@ useSeoMeta({
})

const route = useNativeRoute()

const { data: projects } = useQuery({
queryKey: computed(() => ['dashboard-discord-role-eligibility', auth.value.user?.id, 'projects']),
queryFn: () => {
const userId = auth.value.user?.id
if (!userId) return []

return client.labrinth.users_v2.getProjects(userId)
},
enabled: computed(() => !!auth.value.user?.id),
})

const totalProjectDownloads = computed(() =>
(projects.value ?? []).reduce((total, project) => total + (project.downloads ?? 0), 0),
)

const eligibleDiscordRoles = computed(() => {
const roles = []

if (auth.value.user?.campaigns?.pride_26?.has_badge === true) {
roles.push(formatMessage(messages.discordRolePride))
}

if (totalProjectDownloads.value >= 20_000) {
roles.push(formatMessage(messages.discordRoleCreator))
}

if (totalProjectDownloads.value >= 1_000_000) {
roles.push(formatMessage(messages.discordRoleBigCreator))
}

return roles
})

const roleListFormatter = new Intl.ListFormat(undefined, {
style: 'long',
type: 'conjunction',
})

const eligibleDiscordRolesLabel = computed(() =>
roleListFormatter.format(eligibleDiscordRoles.value),
)

const hasDismissedDiscordRoleBanner = computed(() =>
dismissedDiscordRoleBannerUsers.value.includes(auth.value.user?.id ?? ''),
)
const showDiscordRoleBanner = computed(
() => eligibleDiscordRoles.value.length > 0 && !hasDismissedDiscordRoleBanner.value,
)

function dismissDiscordRoleBanner() {
const userId = auth.value.user?.id
if (!userId || dismissedDiscordRoleBannerUsers.value.includes(userId)) return

dismissedDiscordRoleBannerUsers.value = [...dismissedDiscordRoleBannerUsers.value, userId]
}
</script>
62 changes: 62 additions & 0 deletions apps/frontend/src/pages/discord/link.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { injectModrinthClient } from '@modrinth/ui'

import { getAuthUrl } from '~/composables/auth.js'

definePageMeta({
layout: 'empty',
middleware: 'auth',
})

const route = useRoute()
const auth = await useAuth()
const client = injectModrinthClient()
const error = ref<unknown>(null)
const isLinkedCallback = computed(() => route.query.callback === 'linked')

onMounted(async () => {
if (isLinkedCallback.value) return

try {
if (!auth.value.user?.auth_providers?.includes('discord')) {
window.location.href = `${getAuthUrl('discord', '/discord/link')}&token=${auth.value.token}`
return
}

const res = await client.labrinth.auth_internal.createDiscordCommunityLink()
window.location.href = res.url
} catch (err) {
error.value = err
}
})
</script>

<template>
<section class="discord-link-container universal-card">
<h1>{{ isLinkedCallback ? 'Modrinth account linked' : 'Linking Discord' }}</h1>
<p v-if="isLinkedCallback">Your Modrinth account has been linked to the Discord server.</p>
<p v-else-if="!error">Connecting your Modrinth account to the Discord server...</p>
<p v-else>Discord linking failed. Please try again later.</p>
</section>
</template>

<style scoped>
.discord-link-container {
width: 26rem;
max-width: calc(100% - 2rem);
margin: 1rem auto;
display: flex;
flex-direction: column;
gap: 2rem;
}

.discord-link-container h1 {
font-size: var(--font-size-xl);
margin: 0 0 -1rem 0;
color: var(--color-contrast);
}

.discord-link-container p {
margin: 0;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { Button, Heading, Section, Text } from '@vue-email/components'

import StyledEmail from '../shared/StyledEmail.vue'
</script>

<template>
<StyledEmail
title="You're invited to the Creator Club"
:manual-links="[{ link: '{discord.link_url}', label: 'Link Discord' }]"
>
<Heading as="h1" class="mb-2 text-2xl font-bold">You're invited to the Creator Club</Heading>

<Text class="text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>

<Text class="text-base">
Your projects have passed 20,000 total downloads, congratulations!
</Text>

<Text class="text-base">
The Creator Club role in the Modrinth Discord is for creators like you. Link your Discord
account through Modrinth and we'll grant it automatically.
</Text>

<Text class="text-base">
Creator Club gives you access to dedicated Discord spaces: a feedback channel where you can
share input on creator features directly with the team, and a creator chat for talking with
other creators and Modrinth staff.
</Text>

<Section class="mb-4 mt-4">
<Button
href="{discord.link_url}"
target="_blank"
class="text-accentContrast inline-block rounded-[12px] bg-brand pb-3 pl-4 pr-4 pt-3 text-[14px] font-bold"
>
Join the Creator Club
</Button>
</Section>
</StyledEmail>
</template>
3 changes: 3 additions & 0 deletions apps/frontend/src/templates/emails/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export default {
'server-invited': () => import('./server/ServerInvited.vue'),
'server-invited-no-account': () => import('./server/ServerInvitedNoAccount.vue'),

// Discord
'discord-role-creator-club': () => import('./discord/DiscordRoleCreatorClub.vue'),

// Organizations
'organization-invited': () => import('./organization/OrganizationInvited.vue'),
} as Record<string, () => Promise<{ default: Component }>>
80 changes: 80 additions & 0 deletions apps/labrinth/fixtures/hgwxdegw-badges-project.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
-- Fixture for user HGwXDEgw.
-- User 60829878552966 = HGwXDEgw
-- Team 930000000000001 = 4G5AdLiy1
-- Team member 930000000000002 = 4G5AdLiy2
-- Project 930000000000003 = 4G5AdLiy3
-- Thread 930000000000004 = 4G5AdLiy4
-- Pride donation 930000000000005 = 4G5AdLiy5

INSERT INTO users (
id, username, email, role, badges, balance, email_verified
)
VALUES (
60829878552966, 'fixture_hgwxdegw', 'admin@modrinth.invalid',
'developer', 15, 0, TRUE
)
ON CONFLICT (id) DO UPDATE SET
badges = users.badges | EXCLUDED.badges,
email = COALESCE(users.email, EXCLUDED.email),
email_verified = TRUE;

INSERT INTO teams (id)
VALUES (930000000000001)
ON CONFLICT (id) DO NOTHING;

INSERT INTO team_members (
id, team_id, user_id, role, permissions, accepted, payouts_split, ordering,
organization_permissions, is_owner
)
VALUES (
930000000000002, 930000000000001, 60829878552966, 'Owner',
1023, TRUE, 100, 0, NULL, TRUE
)
ON CONFLICT (id) DO UPDATE SET
team_id = EXCLUDED.team_id,
user_id = EXCLUDED.user_id,
permissions = EXCLUDED.permissions,
accepted = EXCLUDED.accepted,
is_owner = EXCLUDED.is_owner;

INSERT INTO mods (
id, team_id, name, summary, downloads, slug, description, follows,
license, status, requested_status, monetization_status,
side_types_migration_review_status, components
)
VALUES (
930000000000003, 930000000000001, 'HGwXDEgw Million Download Fixture',
'Project used to exercise badges and high download counts.', 1000000,
'hgwxdegw-million-download-fixture', '', 0,
'LicenseRef-All-Rights-Reserved', 'approved', 'approved',
'monetized', 'reviewed', '{}'::jsonb
)
ON CONFLICT (id) DO UPDATE SET
team_id = EXCLUDED.team_id,
name = EXCLUDED.name,
summary = EXCLUDED.summary,
downloads = EXCLUDED.downloads,
slug = EXCLUDED.slug,
status = EXCLUDED.status,
requested_status = EXCLUDED.requested_status,
monetization_status = EXCLUDED.monetization_status,
side_types_migration_review_status = EXCLUDED.side_types_migration_review_status,
components = EXCLUDED.components;

INSERT INTO threads (id, thread_type, mod_id)
VALUES (930000000000004, 'project', 930000000000003)
ON CONFLICT (id) DO UPDATE SET
thread_type = EXCLUDED.thread_type,
mod_id = EXCLUDED.mod_id;

INSERT INTO campaign_donations (
id, tiltify_event_id, raw_data, donated_at, amount_usd, user_id
)
VALUES (
930000000000005, '00000000-0000-4000-8000-000000000005',
'{"fixture": "hgwxdegw-badges-project"}'::jsonb,
'2026-06-01T00:00:00Z', 5, 60829878552966
)
ON CONFLICT (id) DO UPDATE SET
amount_usd = EXCLUDED.amount_usd,
user_id = EXCLUDED.user_id;
Loading
Loading