implement servers guest plan modal and signin which redirects back to modal's next step

This commit is contained in:
tdgao
2026-03-31 15:43:57 -06:00
parent 2c993fb4fb
commit 713bb5e2af
4 changed files with 229 additions and 12 deletions
@@ -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()
@@ -0,0 +1,85 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { computed, ref, useTemplateRef } from 'vue'
import { ChevronRightIcon } from '@modrinth/assets'
import NewModal from '../modal/NewModal.vue'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import PlanSelector from './ServersPurchase0Plan.vue'
const props = defineProps<{
availableProducts: Labrinth.Billing.Internal.Product[]
currency: string
}>()
const emit = defineEmits<{
(e: 'continue', payload: { interval: ServerBillingInterval; planId: string | null }): void
}>()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const selectedPlan = ref<Labrinth.Billing.Internal.Product>()
const selectedInterval = ref<ServerBillingInterval>('quarterly')
const defaultPlan = computed<Labrinth.Billing.Internal.Product | undefined>(() => {
return (
props.availableProducts.find((p) => p?.metadata?.type === 'pyro' && p.metadata.ram === 6144) ??
props.availableProducts.find((p) => p?.metadata?.type === 'pyro') ??
props.availableProducts[0]
)
})
function continueWithSelection() {
emit('continue', {
interval: selectedInterval.value,
planId: selectedPlan.value?.id ?? null,
})
modal.value?.hide()
}
function chooseCustom() {
selectedPlan.value = undefined
}
function show(initialInterval?: ServerBillingInterval, initialPlanId?: string | null) {
selectedInterval.value = initialInterval ?? 'quarterly'
if (initialPlanId === null) {
selectedPlan.value = undefined
} else if (initialPlanId) {
selectedPlan.value = props.availableProducts.find((product) => product.id === initialPlanId)
} else {
selectedPlan.value = defaultPlan.value
}
modal.value?.show()
}
defineExpose({
show,
})
</script>
<template>
<NewModal ref="modal">
<template #title>
<div class="flex items-center gap-1 font-bold text-secondary">
<span class="text-contrast">Plan</span>
<ChevronRightIcon class="h-5 w-5 text-secondary" stroke-width="3" />
<span class=""> Region </span>
<ChevronRightIcon class="h-5 w-5 text-secondary" stroke-width="3" />
<span class=""> Payment method </span>
<ChevronRightIcon class="h-5 w-5 text-secondary" stroke-width="3" />
<span class=""> Review </span>
</div>
</template>
<div class="w-[56rem] max-w-full">
<PlanSelector
v-model:plan="selectedPlan"
v-model:interval="selectedInterval"
:available-products="availableProducts"
:currency="currency"
@choose-custom="chooseCustom"
@proceed="continueWithSelection"
/>
</div>
</NewModal>
</template>
@@ -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'
@@ -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)]'"
>
<ServersGuestPlanModal
ref="guestPlanModal"
:available-products="pyroProducts"
:currency="selectedCurrency"
@continue="handleGuestPlanContinue"
/>
<ModrinthServersPurchaseModal
v-if="customer && regions"
:key="`purchase-modal-${customer.id}`"
v-if="customer && paymentMethods && regions"
ref="purchaseModal"
:publishable-key="props.stripePublishableKey"
:initiate-payment="
@@ -15,7 +20,7 @@
:available-products="pyroProducts"
:on-error="handleError"
:customer="customer"
:payment-methods="paymentMethods ?? []"
:payment-methods="paymentMethods"
:currency="selectedCurrency"
:return-url="`${props.siteUrl}/hosting/manage`"
:pings="regionPings"
@@ -129,7 +134,7 @@
<PlusIcon />
New server
</AutoLink>
<button v-else :disabled="!canOpenPurchaseModal" @click="openPurchaseModal">
<button v-else @click="openPurchaseModal">
<PlusIcon />
New server
</button>
@@ -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<InstanceType<typeof ServersGuestPlanModal> | null>(null)
const purchaseModal = ref<InstanceType<typeof ModrinthServersPurchaseModal> | null>(null)
const resubscribeModal = ref<InstanceType<typeof ResubscribeModal> | null>(null)
const affiliateCode = ref<string | null>(null)
const selectedCurrency = ref<string>('USD')
const lastSelectedInterval = ref<ServerBillingInterval>('quarterly')
const lastSelectedPlanId = ref<string | null | undefined>(undefined)
const pendingResumeIntent = ref<PendingPurchaseIntent | null>(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<PendingPurchaseIntent>
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(),