fix: upgrade server flow to skip region (#5842)

* fix: upgrade server flow to skip region

* remove: previous hide region select implementation

* feat: implement skipping region select section for upgrade modal

* fix: modal not getting stripe customer and payment methods on page hard refresh

* refactor: pnpm prepr
This commit is contained in:
Truman Gao
2026-04-22 13:49:07 -06:00
committed by GitHub
parent 77e4c41480
commit 16e1bf4611
4 changed files with 119 additions and 64 deletions

View File

@@ -10,7 +10,7 @@ import {
} from '@modrinth/assets'
import { useQueryClient } from '@tanstack/vue-query'
import type Stripe from 'stripe'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { computed, nextTick, ref, toRef, useTemplateRef, watch } from 'vue'
import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
@@ -94,8 +94,8 @@ const {
noPaymentRequired,
} = useStripe(
props.publishableKey,
props.customer,
props.paymentMethods,
toRef(props, 'customer'),
toRef(props, 'paymentMethods'),
props.currency,
selectedPlan,
selectedInterval,
@@ -116,6 +116,10 @@ const steps: Step[] = props.planStage
? (['plan', 'region', 'payment', 'review'] as Step[])
: (['region', 'payment', 'review'] as Step[])
const isUpgrade = computed(() => !!props.existingSubscription)
const skipRegionStep = computed(() => isUpgrade.value && !customServer.value)
const visibleSteps = computed(() => steps.filter((s) => !(s === 'region' && skipRegionStep.value)))
const titles: Record<Step, MessageDescriptor> = {
plan: defineMessage({ id: 'servers.purchase.step.plan.title', defaultMessage: 'Plan' }),
region: defineMessage({ id: 'servers.purchase.step.region.title', defaultMessage: 'Region' }),
@@ -146,17 +150,24 @@ const currentPing = computed(() => {
const currentStep = ref<Step>()
const currentStepIndex = computed(() => (currentStep.value ? steps.indexOf(currentStep.value) : -1))
const currentStepIndex = computed(() =>
currentStep.value ? visibleSteps.value.indexOf(currentStep.value) : -1,
)
const previousStep = computed(() => {
const step = currentStep.value ? steps[steps.indexOf(currentStep.value) - 1] : undefined
if (!currentStep.value) return undefined
const idx = visibleSteps.value.indexOf(currentStep.value)
let step = idx > 0 ? visibleSteps.value[idx - 1] : undefined
if (step === 'payment' && skipPaymentMethods.value && primaryPaymentMethodId.value) {
return 'region'
const paymentIdx = visibleSteps.value.indexOf('payment')
step = paymentIdx > 0 ? visibleSteps.value[paymentIdx - 1] : undefined
}
return step
})
const nextStep = computed(() =>
currentStep.value ? steps[steps.indexOf(currentStep.value) + 1] : undefined,
)
const nextStep = computed(() => {
if (!currentStep.value) return undefined
const idx = visibleSteps.value.indexOf(currentStep.value)
return idx >= 0 ? visibleSteps.value[idx + 1] : undefined
})
const canProceed = computed(() => {
switch (currentStep.value) {
@@ -195,12 +206,14 @@ async function beforeProceed(step: string) {
await initializeStripe()
if (primaryPaymentMethodId.value && skipPaymentMethods.value) {
const paymentMethod = await props.paymentMethods.find(
const paymentMethod = props.paymentMethods.find(
(x) => x.id === primaryPaymentMethodId.value,
)
await selectPaymentMethod(paymentMethod)
await setStep('review', true)
return false
if (paymentMethod) {
await selectPaymentMethod(paymentMethod)
await setStep('review', true)
return false
}
}
return true
case 'review':
@@ -261,11 +274,25 @@ async function setStep(step: Step | undefined, skipValidation = false) {
}
}
watch(selectedPlan, () => {
if (currentStep.value === 'plan') {
customServer.value = !selectedPlan.value
}
})
watch(
selectedPlan,
() => {
if (currentStep.value === 'plan') {
customServer.value = !selectedPlan.value
}
},
{ flush: 'sync' },
)
watch(
() => props.existingSubscription,
(sub) => {
if (sub?.metadata?.type === 'pyro' && sub.metadata.region) {
selectedRegion.value = sub.metadata.region
}
},
{ immediate: true },
)
const defaultPlan = computed<Labrinth.Billing.Internal.Product | undefined>(() => {
return (
@@ -295,7 +322,9 @@ function begin(
customServer.value = !selectedPlan.value
selectedPaymentMethod.value = undefined
const skipPlanStep = props.planStage && plan !== undefined
currentStep.value = skipPlanStep ? (steps[1] ?? steps[0]) : steps[0]
currentStep.value = skipPlanStep
? (visibleSteps.value[1] ?? visibleSteps.value[0])
: visibleSteps.value[0]
skipPaymentMethods.value = true
projectId.value = project
modal.value?.show()
@@ -339,7 +368,7 @@ function goToBreadcrumbStep(id: string) {
<NewModal ref="modal" @hide="$emit('hide')">
<template #title>
<div class="flex items-center gap-1 font-bold text-secondary">
<template v-for="(step, index) in steps" :key="step">
<template v-for="(step, index) in visibleSteps" :key="step">
<button
v-if="index < currentStepIndex"
class="bg-transparent active:scale-95 font-bold text-secondary p-0"
@@ -356,7 +385,7 @@ function goToBreadcrumbStep(id: string) {
{{ formatMessage(titles[step]) }}
</span>
<ChevronRightIcon
v-if="index < steps.length - 1"
v-if="index < visibleSteps.length - 1"
class="h-5 w-5 text-secondary"
stroke-width="3"
/>
@@ -381,6 +410,7 @@ function goToBreadcrumbStep(id: string) {
:regions="regions"
:pings="pings"
:custom="customServer"
:hide-region-selection="isUpgrade"
:available-products="availableProducts"
:currency="currency"
:interval="selectedInterval"

View File

@@ -25,6 +25,7 @@ const props = defineProps<{
request: Archon.Servers.v0.StockRequest,
) => Promise<number>
custom: boolean
hideRegionSelection?: boolean
currency: string
interval: ServerBillingInterval
availableProducts: Labrinth.Billing.Internal.Product[]
@@ -177,6 +178,10 @@ const messages = defineMessages({
id: 'servers.region.custom.prompt',
defaultMessage: `How much RAM do you want your server to have?`,
},
customPromptRamOnly: {
id: 'servers.region.custom.prompt-ram-only',
defaultMessage: `RAM`,
},
})
async function updateStock() {
@@ -219,8 +224,10 @@ onMounted(() => {
if (b.ping <= 0) return -1
return a.ping - b.ping
})[0]?.region
selectedRegion.value = undefined
selectedRam.value = minRam.value
if (!props.hideRegionSelection) {
selectedRegion.value = undefined
}
checkingCustomStock.value = true
updateStock().then(() => {
const firstWithStock = sortedRegions.value.find(
@@ -228,8 +235,9 @@ onMounted(() => {
)
let stockedRegion = selectedRegion.value
if (!stockedRegion) {
stockedRegion =
bestPing.value && currentStock.value[bestPing.value] > 0
stockedRegion = props.hideRegionSelection
? firstWithStock?.shortcode
: bestPing.value && currentStock.value[bestPing.value] > 0
? bestPing.value
: firstWithStock?.shortcode
}
@@ -247,37 +255,44 @@ onMounted(() => {
Checking availability...
</ModalLoadingIndicator>
<template v-else>
<h2 class="mt-0 mb-4 text-xl font-bold text-contrast">
{{ formatMessage(messages.prompt) }}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ServersRegionButton
v-for="region in visibleRegions"
:key="region.shortcode"
v-model="selectedRegion"
:region="region"
:out-of-stock="currentStock[region.shortcode] === 0"
:ping="pings.find((p) => p.region === region.shortcode)?.ping"
:best-ping="bestPing === region.shortcode"
/>
</div>
<div class="mt-3 text-sm">
<IntlFormatted :message-id="messages.regionUnsupported">
<template #link="{ children }">
<a
class="text-link"
target="_blank"
rel="noopener noreferrer"
href="https://surveys.modrinth.com/servers-region-waitlist"
>
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</div>
<template v-if="!hideRegionSelection">
<h2 class="mt-0 mb-4 text-xl font-bold text-contrast">
{{ formatMessage(messages.prompt) }}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ServersRegionButton
v-for="region in visibleRegions"
:key="region.shortcode"
v-model="selectedRegion"
:region="region"
:out-of-stock="currentStock[region.shortcode] === 0"
:ping="pings.find((p) => p.region === region.shortcode)?.ping"
:best-ping="bestPing === region.shortcode"
/>
</div>
<div class="mt-3 text-sm">
<IntlFormatted :message-id="messages.regionUnsupported">
<template #link="{ children }">
<a
class="text-link"
target="_blank"
rel="noopener noreferrer"
href="https://surveys.modrinth.com/servers-region-waitlist"
>
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</div>
</template>
<template v-if="custom">
<h2 class="mt-4 mb-2 text-xl font-bold text-contrast">
{{ formatMessage(messages.customPrompt) }}
<h2
class="mb-2 text-xl font-bold text-contrast"
:class="hideRegionSelection ? 'mt-0' : 'mt-4'"
>
{{
formatMessage(hideRegionSelection ? messages.customPromptRamOnly : messages.customPrompt)
}}
</h2>
<div>
<Slider v-model="selectedRam" :min="minRam" :max="maxRam" :step="2" unit="GB" />

View File

@@ -18,8 +18,8 @@ import { getPriceForInterval } from '../utils/product-utils'
export const useStripe = (
publishableKey: string,
customer: Stripe.Customer,
paymentMethods: Stripe.PaymentMethod[],
customer: Ref<Stripe.Customer | null | undefined>,
paymentMethods: Ref<Stripe.PaymentMethod[]>,
currency: string,
product: Ref<Labrinth.Billing.Internal.Product | undefined>,
interval: Ref<ServerBillingInterval>,
@@ -96,7 +96,7 @@ export const useStripe = (
const contacts: ContactOption[] = []
paymentMethods.forEach((method) => {
paymentMethods.value.forEach((method) => {
const addr = method.billing_details?.address
if (
addr &&
@@ -131,15 +131,22 @@ export const useStripe = (
}
const primaryPaymentMethodId = computed<string | null>(() => {
if (customer && customer.invoice_settings && customer.invoice_settings.default_payment_method) {
const method = customer.invoice_settings.default_payment_method
const customerValue = customer.value
const paymentMethodsValue = paymentMethods.value
if (
customerValue &&
customerValue.invoice_settings &&
customerValue.invoice_settings.default_payment_method
) {
const method = customerValue.invoice_settings.default_payment_method
if (typeof method === 'string') {
return method
} else {
return method.id
}
} else if (paymentMethods && paymentMethods[0] && paymentMethods[0].id) {
return paymentMethods[0].id
} else if (paymentMethodsValue[0] && paymentMethodsValue[0].id) {
return paymentMethodsValue[0].id
} else {
return null
}
@@ -148,7 +155,7 @@ export const useStripe = (
const loadStripeElements = async () => {
loadingFailed.value = undefined
try {
if (!customer && primaryPaymentMethodId.value) {
if (!customer.value && primaryPaymentMethodId.value) {
paymentMethodLoading.value = true
await refreshPaymentIntent(primaryPaymentMethodId.value, false)
paymentMethodLoading.value = false
@@ -189,7 +196,7 @@ export const useStripe = (
try {
paymentMethodLoading.value = true
if (!confirmation) {
selectedPaymentMethod.value = paymentMethods.find((x) => x.id === id)
selectedPaymentMethod.value = paymentMethods.value.find((x) => x.id === id)
}
if (!product.value) {
@@ -248,7 +255,7 @@ export const useStripe = (
}
).payment_method
if (typeof paymentMethod === 'string') {
const method = paymentMethods.find((x) => x.id === paymentMethod)
const method = paymentMethods.value.find((x) => x.id === paymentMethod)
if (method) {
inputtedPaymentMethod.value = method
}

View File

@@ -3044,6 +3044,9 @@
"servers.region.custom.prompt": {
"defaultMessage": "How much RAM do you want your server to have?"
},
"servers.region.custom.prompt-ram-only": {
"defaultMessage": "RAM"
},
"servers.region.north-america": {
"defaultMessage": "North America"
},