From 345929eeffd9d885808f8ed18ea9d4587fbaba6a Mon Sep 17 00:00:00 2001 From: tdgao Date: Fri, 20 Mar 2026 15:20:37 -0600 Subject: [PATCH] hook up purchase server modal in app --- apps/app-frontend/src/config.ts | 7 + apps/app-frontend/src/pages/Servers.vue | 26 ++ apps/app-frontend/src/pages/index.js | 3 +- apps/app-frontend/src/routes.js | 3 +- apps/app-frontend/tsconfig.app.json | 2 + apps/app/tauri.conf.json | 6 +- .../billing/ModrinthServersPurchaseModal.vue | 2 +- .../billing/ServersPurchase3Review.vue | 11 +- .../layouts/wrapped/hosting/manage/index.vue | 245 ++++++++++++++++-- 9 files changed, 265 insertions(+), 40 deletions(-) create mode 100644 apps/app-frontend/src/config.ts create mode 100644 apps/app-frontend/src/pages/Servers.vue diff --git a/apps/app-frontend/src/config.ts b/apps/app-frontend/src/config.ts new file mode 100644 index 0000000000..bc98d33109 --- /dev/null +++ b/apps/app-frontend/src/config.ts @@ -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', +} diff --git a/apps/app-frontend/src/pages/Servers.vue b/apps/app-frontend/src/pages/Servers.vue new file mode 100644 index 0000000000..d2cc730f73 --- /dev/null +++ b/apps/app-frontend/src/pages/Servers.vue @@ -0,0 +1,26 @@ + + + diff --git a/apps/app-frontend/src/pages/index.js b/apps/app-frontend/src/pages/index.js index d08a3fbeb6..5a4294937a 100644 --- a/apps/app-frontend/src/pages/index.js +++ b/apps/app-frontend/src/pages/index.js @@ -1,6 +1,7 @@ import Browse from './Browse.vue' import Index from './Index.vue' +import Servers from './Servers.vue' import Skins from './Skins.vue' import Worlds from './Worlds.vue' -export { Browse, Index, Skins, Worlds } +export { Browse, Index, Servers, Skins, Worlds } diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index 0c7b641e54..11b8842dfd 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -1,4 +1,3 @@ -import { ServersManagePageIndex } from '@modrinth/ui' import { createRouter, createWebHistory } from 'vue-router' import * as Pages from '@/pages' @@ -31,7 +30,7 @@ export default new createRouter({ { path: '/hosting/manage/', name: 'Servers', - component: ServersManagePageIndex, + component: Pages.Servers, meta: { breadcrumb: [{ name: 'Servers' }], }, diff --git a/apps/app-frontend/tsconfig.app.json b/apps/app-frontend/tsconfig.app.json index 8d5b455fe5..f723e2026f 100644 --- a/apps/app-frontend/tsconfig.app.json +++ b/apps/app-frontend/tsconfig.app.json @@ -16,6 +16,8 @@ "strict": true, + "types": ["vite/client"], + "paths": { "@/*": ["./src/*"] } diff --git a/apps/app/tauri.conf.json b/apps/app/tauri.conf.json index 986347dc30..bc06fa6405 100644 --- a/apps/app/tauri.conf.json +++ b/apps/app/tauri.conf.json @@ -87,12 +87,12 @@ "capabilities": ["ads", "core", "plugins"], "csp": { "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/"], "img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:", "style-src": "'unsafe-inline' 'self'", - "script-src": "https://*.posthog.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'", + "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/ https://js.stripe.com https://hooks.stripe.com 'self'", "media-src": "https://*.githubusercontent.com" } } diff --git a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue index edfd64ab1d..323d9f3df4 100644 --- a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue +++ b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue @@ -374,7 +374,7 @@ function goToBreadcrumbStep(id: string) { :ping="currentPing" :loading="paymentMethodLoading" :selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod" - :has-payment-method="hasPaymentMethod" + :has-payment-method="!!hasPaymentMethod" :tax="tax" :total="total" :no-payment-required="noPaymentRequired" diff --git a/packages/ui/src/components/billing/ServersPurchase3Review.vue b/packages/ui/src/components/billing/ServersPurchase3Review.vue index bcd83a5269..bfe6096c8d 100644 --- a/packages/ui/src/components/billing/ServersPurchase3Review.vue +++ b/packages/ui/src/components/billing/ServersPurchase3Review.vue @@ -179,7 +179,7 @@ function setInterval(newInterval: ServerBillingInterval) { -
+
No payment required. Your downgrade will apply at the end of the current billing period.
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue index a089206ccc..ec85dbc2a2 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue @@ -3,12 +3,26 @@ 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" > -
You don't have any servers yet!

Modrinth Hosting is a new way to play modded Minecraft with your friends.

- Create a server + Create a server +
@@ -116,11 +133,15 @@ placeholder="Search servers..." wrapper-class="w-full md:w-72" /> - - + + New server + @@ -152,7 +173,7 @@ v-for="server in filteredData.filter((s) => s.is_medal)" :key="server.server_id" v-bind="server" - @upgrade="openUpgradeModal(server.server_id)" + @upgrade="openPurchaseModal" /> () const router = useRouter() @@ -214,6 +235,155 @@ const pollingState = ref({ initialServers: [] as Archon.Servers.v0.Server[], }) +const purchaseModal = ref | null>(null) +const affiliateCode = ref(null) +const selectedCurrency = ref('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, +}) + +const { + data: paymentMethods, + refetch: refetchPaymentMethods, + isLoading: paymentMethodsLoading, +} = useQuery({ + queryKey: ['billing', 'payment-methods'], + queryFn: () => + client.labrinth.billing_internal.getPaymentMethods() as Promise, +}) + +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 { + 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 { data: serverResponse, error: fetchError, @@ -309,18 +479,43 @@ watch(serverResponse, (response) => { } }) -type ServersUpgradeModalWrapperRef = ComponentPublicInstance<{ - open: (id: string) => void | Promise -}> - -const upgradeModal = ref(null) -function openUpgradeModal(serverId: string) { - upgradeModal.value?.open(serverId) -} - const { addNotification } = injectNotificationManager() 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({ queryKey: ['billing', 'subscriptions'], queryFn: () => client.labrinth.billing_internal.getSubscriptions(),