hook up purchase server modal in app

This commit is contained in:
tdgao
2026-03-20 15:20:37 -06:00
parent 83202a3761
commit 345929eeff
9 changed files with 265 additions and 40 deletions
+7
View File
@@ -0,0 +1,7 @@
// src/config.ts
export const config = {
siteUrl: import.meta.env.VITE_SITE_URL,
stripePublishableKey:
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ||
'pk_test_51JbFxJJygY5LJFfKV50mnXzz3YLvBVe2Gd1jn7ljWAkaBlRz3VQdxN9mXcPSrFbSqxwAb0svte9yhnsmm7qHfcWn00R611Ce7b',
}
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { injectModrinthClient, ServersManagePageIndex } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { config } from '../config'
const stripePublishableKey = (config.stripePublishableKey as string) || ''
const client = injectModrinthClient()
const { data: products } = useQuery({
queryKey: ['billing', 'products'],
queryFn: () => client.labrinth.billing_internal.getProducts(),
})
const resolvedProducts = computed<Labrinth.Billing.Internal.Product[]>(() => products.value ?? [])
</script>
<template>
<ServersManagePageIndex
:stripe-publishable-key="stripePublishableKey"
:site-url="'https://modrinth.com'"
:products="resolvedProducts"
/>
</template>
+2 -1
View File
@@ -1,6 +1,7 @@
import Browse from './Browse.vue' import Browse from './Browse.vue'
import Index from './Index.vue' import Index from './Index.vue'
import Servers from './Servers.vue'
import Skins from './Skins.vue' import Skins from './Skins.vue'
import Worlds from './Worlds.vue' import Worlds from './Worlds.vue'
export { Browse, Index, Skins, Worlds } export { Browse, Index, Servers, Skins, Worlds }
+1 -2
View File
@@ -1,4 +1,3 @@
import { ServersManagePageIndex } from '@modrinth/ui'
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import * as Pages from '@/pages' import * as Pages from '@/pages'
@@ -31,7 +30,7 @@ export default new createRouter({
{ {
path: '/hosting/manage/', path: '/hosting/manage/',
name: 'Servers', name: 'Servers',
component: ServersManagePageIndex, component: Pages.Servers,
meta: { meta: {
breadcrumb: [{ name: 'Servers' }], breadcrumb: [{ name: 'Servers' }],
}, },
+2
View File
@@ -16,6 +16,8 @@
"strict": true, "strict": true,
"types": ["vite/client"],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
+3 -3
View File
@@ -87,12 +87,12 @@
"capabilities": ["ads", "core", "plugins"], "capabilities": ["ads", "core", "plugins"],
"csp": { "csp": {
"default-src": "'self' customprotocol: asset:", "default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net 'self' data: blob:", "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com wss://*.nodes.modrinth.com 'self' data: blob:",
"font-src": ["https://cdn-raw.modrinth.com/fonts/"], "font-src": ["https://cdn-raw.modrinth.com/fonts/"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:", "img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'", "style-src": "'unsafe-inline' 'self'",
"script-src": "https://*.posthog.com https://tally.so/widgets/embed.js 'self'", "script-src": "https://*.posthog.com https://posthog.modrinth.com https://js.stripe.com https://tally.so/widgets/embed.js 'self'",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ 'self'", "frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ https://js.stripe.com https://hooks.stripe.com 'self'",
"media-src": "https://*.githubusercontent.com" "media-src": "https://*.githubusercontent.com"
} }
} }
@@ -374,7 +374,7 @@ function goToBreadcrumbStep(id: string) {
:ping="currentPing" :ping="currentPing"
:loading="paymentMethodLoading" :loading="paymentMethodLoading"
:selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod" :selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod"
:has-payment-method="hasPaymentMethod" :has-payment-method="!!hasPaymentMethod"
:tax="tax" :tax="tax"
:total="total" :total="total"
:no-payment-required="noPaymentRequired" :no-payment-required="noPaymentRequired"
@@ -179,7 +179,7 @@ function setInterval(newInterval: ServerBillingInterval) {
<template> <template>
<div class="grid sm:grid-cols-[3fr_2fr] gap-4"> <div class="grid sm:grid-cols-[3fr_2fr] gap-4">
<div class="bg-table-alternateRow p-4 rounded-2xl"> <div class="bg-surface-2 p-4 rounded-2xl">
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
<ModrinthServersIcon class="flex h-5 w-fit" /> <ModrinthServersIcon class="flex h-5 w-fit" />
<TagItem>{{ planName }}</TagItem> <TagItem>{{ planName }}</TagItem>
@@ -194,9 +194,7 @@ function setInterval(newInterval: ServerBillingInterval) {
/> />
</div> </div>
</div> </div>
<div <div class="bg-surface-2 p-4 rounded-2xl flex flex-col gap-2 items-center justify-center">
class="bg-table-alternateRow p-4 rounded-2xl flex flex-col gap-2 items-center justify-center"
>
<img <img
v-if="flag" v-if="flag"
class="aspect-[16/10] max-w-12 w-full object-cover rounded-md border-1 border-button-border border-solid" class="aspect-[16/10] max-w-12 w-full object-cover rounded-md border-1 border-button-border border-solid"
@@ -304,10 +302,7 @@ function setInterval(newInterval: ServerBillingInterval) {
" "
/> />
</template> </template>
<div <div v-else class="p-4 rounded-2xl bg-surface-2 text-sm text-secondary leading-relaxed">
v-else
class="p-4 rounded-2xl bg-table-alternateRow text-sm text-secondary leading-relaxed"
>
No payment required. Your downgrade will apply at the end of the current billing period. No payment required. Your downgrade will apply at the end of the current billing period.
</div> </div>
</div> </div>
@@ -3,12 +3,26 @@
data-pyro-server-list-root data-pyro-server-list-root
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6" class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
> >
<ServersUpgradeModalWrapper <ModrinthServersPurchaseModal
v-if="isNuxt" v-if="customer && regions"
ref="upgradeModal" :key="`purchase-modal-${customer.id}`"
:stripe-publishable-key ref="purchaseModal"
:site-url :publishable-key="props.stripePublishableKey"
:products :initiate-payment="
async (body) => await client.labrinth.billing_internal.initiatePayment(body)
"
:available-products="pyroProducts"
:on-error="handleError"
:customer="customer"
:payment-methods="paymentMethods ?? []"
:currency="selectedCurrency"
:return-url="`${props.siteUrl}/hosting/manage`"
:pings="regionPings"
:regions="regions"
:refresh-payment-methods="fetchPaymentData"
:fetch-stock="fetchStock"
:affiliate-code="affiliateCode"
plan-stage
/> />
<div <div
@@ -98,7 +112,10 @@
<h1 class="m-0 text-contrast">You don't have any servers yet!</h1> <h1 class="m-0 text-contrast">You don't have any servers yet!</h1>
<p class="m-0">Modrinth Hosting is a new way to play modded Minecraft with your friends.</p> <p class="m-0">Modrinth Hosting is a new way to play modded Minecraft with your friends.</p>
<ButtonStyled size="large" type="standard" color="brand"> <ButtonStyled size="large" type="standard" color="brand">
<AutoLink to="/servers#plan">Create a server</AutoLink> <AutoLink v-if="isNuxt" to="/servers#plan">Create a server</AutoLink>
<button v-else :disabled="!canOpenPurchaseModal" @click="openPurchaseModal">
Create a server
</button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@@ -116,11 +133,15 @@
placeholder="Search servers..." placeholder="Search servers..."
wrapper-class="w-full md:w-72" wrapper-class="w-full md:w-72"
/> />
<ButtonStyled v-if="isNuxt" type="standard"> <ButtonStyled type="standard">
<AutoLink :to="{ path: '/servers', hash: '#plan' }"> <AutoLink v-if="isNuxt" :to="{ path: '/servers', hash: '#plan' }">
<PlusIcon /> <PlusIcon />
New server New server
</AutoLink> </AutoLink>
<button v-else :disabled="!canOpenPurchaseModal" @click="openPurchaseModal">
<PlusIcon />
New server
</button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</div> </div>
@@ -152,7 +173,7 @@
v-for="server in filteredData.filter((s) => s.is_medal)" v-for="server in filteredData.filter((s) => s.is_medal)"
:key="server.server_id" :key="server.server_id"
v-bind="server" v-bind="server"
@upgrade="openUpgradeModal(server.server_id)" @upgrade="openPurchaseModal"
/> />
<ServerListing <ServerListing
v-for="server in filteredData.filter((s) => !s.is_medal)" v-for="server in filteredData.filter((s) => !s.is_medal)"
@@ -180,24 +201,24 @@ import {
CopyCode, CopyCode,
injectModrinthClient, injectModrinthClient,
injectNotificationManager, injectNotificationManager,
ModrinthServersPurchaseModal,
StyledInput, StyledInput,
} from '@modrinth/ui' } from '@modrinth/ui'
import type { ModrinthServersFetchError } from '@modrinth/utils' import type { ModrinthServersFetchError } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query' import { useQuery, useQueryClient } from '@tanstack/vue-query'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
import type { ComponentPublicInstance } from 'vue' import type Stripe from 'stripe'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import ServersUpgradeModalWrapper from '#ui/components/billing/ServersUpgradeModalWrapper.vue'
import MedalServerListing from '#ui/components/servers/marketing/MedalServerListing.vue' import MedalServerListing from '#ui/components/servers/marketing/MedalServerListing.vue'
import ServerListing from '#ui/components/servers/ServerListing.vue' import ServerListing from '#ui/components/servers/ServerListing.vue'
defineProps<{ const props = defineProps<{
stripePublishableKey?: string stripePublishableKey: string
siteUrl?: string siteUrl: string
products?: Labrinth.Billing.Internal.Product[] products: Labrinth.Billing.Internal.Product[]
}>() }>()
const router = useRouter() const router = useRouter()
@@ -214,6 +235,155 @@ const pollingState = ref({
initialServers: [] as Archon.Servers.v0.Server[], initialServers: [] as Archon.Servers.v0.Server[],
}) })
const purchaseModal = ref<InstanceType<typeof ModrinthServersPurchaseModal> | null>(null)
const affiliateCode = ref<string | null>(null)
const selectedCurrency = ref<string>('USD')
const regionPings = ref<
{
region: string
ping: number
}[]
>([])
const pyroProducts = computed(() => {
return [...props.products]
.filter((p) => p?.metadata?.type === 'pyro' || p?.metadata?.type === 'medal')
.sort((a, b) => {
const aRam =
a?.metadata?.type === 'pyro' || a?.metadata?.type === 'medal' ? a.metadata.ram : 0
const bRam =
b?.metadata?.type === 'pyro' || b?.metadata?.type === 'medal' ? b.metadata.ram : 0
return aRam - bRam
})
})
const {
data: customer,
refetch: refetchCustomer,
isLoading: customerLoading,
} = useQuery({
queryKey: ['billing', 'customer'],
queryFn: () => client.labrinth.billing_internal.getCustomer() as Promise<Stripe.Customer>,
})
const {
data: paymentMethods,
refetch: refetchPaymentMethods,
isLoading: paymentMethodsLoading,
} = useQuery({
queryKey: ['billing', 'payment-methods'],
queryFn: () =>
client.labrinth.billing_internal.getPaymentMethods() as Promise<Stripe.PaymentMethod[]>,
})
const { data: regions, isLoading: regionsLoading } = useQuery({
queryKey: ['servers', 'regions'],
queryFn: () => client.archon.servers_v1.getRegions(),
})
watch(
regions,
(newRegions) => {
regionPings.value = []
if (newRegions) {
newRegions.forEach((region) => {
runPingTest(region)
})
}
},
{ immediate: true },
)
async function fetchPaymentData() {
await Promise.all([refetchCustomer(), refetchPaymentMethods()])
}
async function fetchStock(
region: Archon.Servers.v1.Region,
request: Archon.Servers.v0.StockRequest,
): Promise<number> {
const result = await client.archon.servers_v0.checkStock(region.shortcode, request)
return result.available
}
const PING_COUNT = 20
const PING_INTERVAL = 200
const MAX_PING_TIME = 1000
function runPingTest(region: Archon.Servers.v1.Region, index = 1) {
if (index > 10) {
regionPings.value = regionPings.value.filter((entry) => entry.region !== region.shortcode)
regionPings.value.push({
region: region.shortcode,
ping: -1,
})
return
}
const wsUrl = `wss://${region.shortcode}${index}.${region.zone}/pingtest`
try {
const socket = new WebSocket(wsUrl)
const pings: number[] = []
let finalized = false
const finalize = (ping: number) => {
if (finalized) return
finalized = true
clearTimeout(connectTimeout)
regionPings.value = regionPings.value.filter((entry) => entry.region !== region.shortcode)
regionPings.value.push({
region: region.shortcode,
ping,
})
socket.close()
}
const retryNext = () => {
if (finalized) return
finalized = true
clearTimeout(connectTimeout)
socket.close()
runPingTest(region, index + 1)
}
// Prevent hangs where the socket never opens or errors.
const connectTimeout = setTimeout(() => {
retryNext()
}, 3000)
socket.onopen = () => {
clearTimeout(connectTimeout)
for (let i = 0; i < PING_COUNT; i++) {
setTimeout(() => {
socket.send(String(performance.now()))
}, i * PING_INTERVAL)
}
setTimeout(
() => {
const median =
pings.length > 0
? Math.round([...pings].sort((a, b) => a - b)[Math.floor(pings.length / 2)])
: -1
finalize(median)
},
PING_COUNT * PING_INTERVAL + MAX_PING_TIME,
)
}
socket.onmessage = (event) => {
const start = Number(event.data)
pings.push(performance.now() - start)
}
socket.onerror = () => {
retryNext()
}
} catch {
runPingTest(region, index + 1)
}
}
const { const {
data: serverResponse, data: serverResponse,
error: fetchError, error: fetchError,
@@ -309,18 +479,43 @@ watch(serverResponse, (response) => {
} }
}) })
type ServersUpgradeModalWrapperRef = ComponentPublicInstance<{
open: (id: string) => void | Promise<void>
}>
const upgradeModal = ref<ServersUpgradeModalWrapperRef | null>(null)
function openUpgradeModal(serverId: string) {
upgradeModal.value?.open(serverId)
}
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const canOpenPurchaseModal = computed(() => {
return (
Boolean(props.stripePublishableKey) &&
Boolean(customer.value) &&
paymentMethods.value !== undefined &&
Boolean(regions.value) &&
!customerLoading.value &&
!paymentMethodsLoading.value &&
!regionsLoading.value
)
})
function handleError(err: unknown) {
const error = err as Error & { data?: { description?: string } }
addNotification({
title: 'An error occurred',
type: 'error',
text: error?.message ?? error?.data?.description ?? String(err),
})
}
function openPurchaseModal() {
if (!canOpenPurchaseModal.value || !purchaseModal.value) {
addNotification({
title: 'Purchase unavailable',
text: 'Payment information is still loading. Please try again in a moment.',
type: 'warning',
})
return
}
purchaseModal.value.show('quarterly')
}
const { data: subscriptions } = useQuery({ const { data: subscriptions } = useQuery({
queryKey: ['billing', 'subscriptions'], queryKey: ['billing', 'subscriptions'],
queryFn: () => client.labrinth.billing_internal.getSubscriptions(), queryFn: () => client.labrinth.billing_internal.getSubscriptions(),