From 713bb5e2af480ce497a38249e8b79c3828e3f44e Mon Sep 17 00:00:00 2001 From: tdgao Date: Tue, 31 Mar 2026 15:43:57 -0600 Subject: [PATCH] implement servers guest plan modal and signin which redirects back to modal's next step --- .../billing/ModrinthServersPurchaseModal.vue | 3 +- .../billing/ServersGuestPlanModal.vue | 85 ++++++++++ packages/ui/src/components/billing/index.ts | 1 + .../layouts/wrapped/hosting/manage/index.vue | 152 ++++++++++++++++-- 4 files changed, 229 insertions(+), 12 deletions(-) create mode 100644 packages/ui/src/components/billing/ServersGuestPlanModal.vue diff --git a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue index 487f3e4f9d..dd0fb34a72 100644 --- a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue +++ b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue @@ -264,7 +264,8 @@ function begin( selectedInterval.value = interval customServer.value = !selectedPlan.value selectedPaymentMethod.value = undefined - currentStep.value = steps[0] + const skipPlanStep = props.planStage && plan !== undefined + currentStep.value = skipPlanStep ? (steps[1] ?? steps[0]) : steps[0] skipPaymentMethods.value = true projectId.value = project modal.value?.show() diff --git a/packages/ui/src/components/billing/ServersGuestPlanModal.vue b/packages/ui/src/components/billing/ServersGuestPlanModal.vue new file mode 100644 index 0000000000..7d7ca60ed5 --- /dev/null +++ b/packages/ui/src/components/billing/ServersGuestPlanModal.vue @@ -0,0 +1,85 @@ + + + diff --git a/packages/ui/src/components/billing/index.ts b/packages/ui/src/components/billing/index.ts index 2f96020354..4475573997 100644 --- a/packages/ui/src/components/billing/index.ts +++ b/packages/ui/src/components/billing/index.ts @@ -2,5 +2,6 @@ export { default as AddPaymentMethodModal } from './AddPaymentMethodModal.vue' export { default as ModrinthServersPurchaseModal } from './ModrinthServersPurchaseModal.vue' export { default as PurchaseModal } from './PurchaseModal.vue' export { default as ResubscribeModal } from './ResubscribeModal.vue' +export { default as ServersGuestPlanModal } from './ServersGuestPlanModal.vue' export { default as ServersSpecs } from './ServersSpecs.vue' export { default as ServersUpgradeModalWrapper } from './ServersUpgradeModalWrapper.vue' diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue index cb2dc5affc..3a846be7b2 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue @@ -4,9 +4,14 @@ class="experimental-styles-within relative mx-auto mb-6 flex w-full max-w-[1280px] flex-col px-6" :class="serverList.length ? 'min-h-screen' : 'min-h-[calc(100vh-4.5rem)]'" > + New server - @@ -198,6 +203,7 @@ import { ModrinthServersPurchaseModal, ResubscribeModal, ServerListEmpty, + ServersGuestPlanModal, StyledInput, } from '@modrinth/ui' import type { ModrinthServersFetchError } from '@modrinth/utils' @@ -225,6 +231,17 @@ const client = injectModrinthClient() const loggedIn = computed(() => !!auth.user.value) const isNuxt = computed(() => client instanceof NuxtModrinthClient) +const PURCHASE_INTENT_STORAGE_KEY = 'modrinth:servers-purchase-intent' +const PURCHASE_INTENT_SOURCE = 'hosting-manage' + +type ServerBillingInterval = 'monthly' | 'quarterly' | 'yearly' + +type PendingPurchaseIntent = { + interval: ServerBillingInterval + planId: string | null + source: string + ts: number +} const isPollingForNewServers = ref(false) const pollingState = ref({ @@ -233,10 +250,14 @@ const pollingState = ref({ initialServers: [] as Archon.Servers.v0.Server[], }) +const guestPlanModal = ref | null>(null) const purchaseModal = ref | null>(null) const resubscribeModal = ref | null>(null) const affiliateCode = ref(null) const selectedCurrency = ref('USD') +const lastSelectedInterval = ref('quarterly') +const lastSelectedPlanId = ref(undefined) +const pendingResumeIntent = ref(null) const regionPings = ref< { region: string @@ -256,6 +277,40 @@ const pyroProducts = computed(() => { }) }) +function readPendingPurchaseIntent(): PendingPurchaseIntent | null { + if (typeof window === 'undefined') return null + const rawIntent = window.sessionStorage.getItem(PURCHASE_INTENT_STORAGE_KEY) + if (!rawIntent) return null + + try { + const parsedIntent = JSON.parse(rawIntent) as Partial + if ( + (parsedIntent.interval === 'monthly' || + parsedIntent.interval === 'quarterly' || + parsedIntent.interval === 'yearly') && + (parsedIntent.planId === null || typeof parsedIntent.planId === 'string') && + typeof parsedIntent.source === 'string' && + typeof parsedIntent.ts === 'number' + ) { + return parsedIntent as PendingPurchaseIntent + } + } catch { + return null + } + + return null +} + +function writePendingPurchaseIntent(intent: PendingPurchaseIntent) { + if (typeof window === 'undefined') return + window.sessionStorage.setItem(PURCHASE_INTENT_STORAGE_KEY, JSON.stringify(intent)) +} + +function clearPendingPurchaseIntent() { + if (typeof window === 'undefined') return + window.sessionStorage.removeItem(PURCHASE_INTENT_STORAGE_KEY) +} + const { data: customer, refetch: refetchCustomer, @@ -505,6 +560,25 @@ const canOpenPurchaseModal = computed(() => { ) }) +function openCheckoutFromIntent(intent: PendingPurchaseIntent): boolean { + console.log('Attempting to open checkout from intent', intent) + if (!purchaseModal.value || !canOpenPurchaseModal.value) return false + + lastSelectedInterval.value = intent.interval + lastSelectedPlanId.value = intent.planId + const defaultPlan = + pyroProducts.value.find((p) => p?.metadata?.type === 'pyro' && p.metadata.ram === 6144) ?? + pyroProducts.value.find((p) => p?.metadata?.type === 'pyro') ?? + pyroProducts.value[0] + const selectedPlan = + intent.planId === null + ? null + : (pyroProducts.value.find((product) => product.id === intent.planId) ?? defaultPlan) + + purchaseModal.value.show(intent.interval, selectedPlan) + return true +} + function handleError(err: unknown) { const error = err as Error & { data?: { description?: string } } addNotification({ @@ -515,22 +589,78 @@ function handleError(err: unknown) { } 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', - }) + guestPlanModal.value?.show(lastSelectedInterval.value, lastSelectedPlanId.value) +} + +function handleGuestPlanContinue(payload: { + interval: ServerBillingInterval + planId: string | null +}) { + const intent: PendingPurchaseIntent = { + interval: payload.interval, + planId: payload.planId, + source: PURCHASE_INTENT_SOURCE, + ts: Date.now(), + } + + lastSelectedInterval.value = payload.interval + lastSelectedPlanId.value = payload.planId + + if (!loggedIn.value) { + writePendingPurchaseIntent(intent) + void auth.requestSignIn('/hosting/manage') return } - purchaseModal.value.show('quarterly') + pendingResumeIntent.value = intent + if (openCheckoutFromIntent(intent)) { + pendingResumeIntent.value = null + } else { + addNotification({ + title: 'Purchase unavailable', + text: 'Payment information is still loading. Opening checkout as soon as it is ready.', + type: 'info', + }) + } } function handleSignIn() { void auth.requestSignIn('/hosting/manage') } +watch( + loggedIn, + (isLoggedIn) => { + if (!isLoggedIn) return + + const pendingIntent = readPendingPurchaseIntent() + if (!pendingIntent || pendingIntent.source !== PURCHASE_INTENT_SOURCE) return + + clearPendingPurchaseIntent() + pendingResumeIntent.value = pendingIntent + lastSelectedInterval.value = pendingIntent.interval + lastSelectedPlanId.value = pendingIntent.planId + }, + { immediate: true }, +) + +watch( + () => + [ + loggedIn.value, + canOpenPurchaseModal.value, + pendingResumeIntent.value, + purchaseModal.value, + ] as const, + ([isLoggedIn, canOpen, pendingIntent]) => { + if (!isLoggedIn || !canOpen || !pendingIntent) return + if (openCheckoutFromIntent(pendingIntent)) { + pendingResumeIntent.value = null + } + }, + { immediate: true }, +) + const { data: subscriptions } = useQuery({ queryKey: ['billing', 'subscriptions'], queryFn: () => client.labrinth.billing_internal.getSubscriptions(),