From b8bc60d50a9a62f3e9333b561c719c8342074f03 Mon Sep 17 00:00:00 2001 From: tdgao Date: Fri, 17 Apr 2026 20:26:27 -0600 Subject: [PATCH 1/5] fix: upgrade server flow to skip region --- .../billing/ModrinthServersPurchaseModal.vue | 164 ++++++++++++++++-- .../billing/ServersPurchase1Region.vue | 103 ++++++----- .../billing/ServersUpgradeModalWrapper.vue | 3 +- packages/ui/src/locales/en-US/index.json | 3 + 4 files changed, 209 insertions(+), 64 deletions(-) diff --git a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue index 9690f036ce..74c23c7068 100644 --- a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue +++ b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue @@ -112,9 +112,62 @@ const skipPaymentMethods = ref(true) type Step = 'plan' | 'region' | 'payment' | 'review' -const steps: Step[] = props.planStage - ? (['plan', 'region', 'payment', 'review'] as Step[]) - : (['region', 'payment', 'review'] as Step[]) +const isUpgradeFlow = computed(() => !!props.existingSubscription) + +const existingRegion = computed(() => { + const metadata = props.existingSubscription?.metadata + if (metadata?.type === 'pyro') { + return metadata.region + } + + const existingPlanMetadata = props.existingPlan?.metadata + if (existingPlanMetadata?.type === 'medal') { + return existingPlanMetadata.region + } + + return undefined +}) + +const preferredUpgradeRegion = computed(() => { + if (existingRegion.value) { + const matchingRegion = props.regions.find((region) => region.shortcode === existingRegion.value) + if (matchingRegion) { + return matchingRegion.shortcode + } + } + + return props.regions[0]?.shortcode +}) + +watch( + () => [isUpgradeFlow.value, preferredUpgradeRegion.value] as const, + ([upgradeFlow, preferredRegion]) => { + if (upgradeFlow && !selectedRegion.value && preferredRegion) { + selectedRegion.value = preferredRegion + } + }, + { immediate: true }, +) + +const shouldShowRamStep = computed(() => { + if (!props.planStage) return true + if (!isUpgradeFlow.value) return true + + return customServer.value +}) + +const shouldHideRegionSelection = computed(() => isUpgradeFlow.value && customServer.value) + +const steps = computed(() => + props.planStage + ? ([ + 'plan', + ...(shouldShowRamStep.value ? (['region'] as Step[]) : []), + 'payment', + 'review', + ] as Step[]) + : (['region', 'payment', 'review'] as Step[]), +) const titles: Record = { plan: defineMessage({ id: 'servers.purchase.step.plan.title', defaultMessage: 'Plan' }), @@ -125,6 +178,7 @@ const titles: Record = { }), review: defineMessage({ id: 'servers.purchase.step.review.title', defaultMessage: 'Review' }), } +const ramTitle = defineMessage({ id: 'servers.purchase.step.ram.title', defaultMessage: 'Ram' }) const purchaseSuccessTitle = defineMessage({ id: 'servers.purchase.notification.success.title', @@ -146,17 +200,58 @@ const currentPing = computed(() => { const currentStep = ref() -const currentStepIndex = computed(() => (currentStep.value ? steps.indexOf(currentStep.value) : -1)) +const stepOrder: Step[] = ['plan', 'region', 'payment', 'review'] + +function findAdjacentStep( + from: Step, + direction: 'prev' | 'next', + activeSteps: Step[] = steps.value, +): Step | undefined { + const fromOrderIndex = stepOrder.indexOf(from) + if (fromOrderIndex < 0) return undefined + + if (direction === 'prev') { + for (let i = fromOrderIndex - 1; i >= 0; i--) { + const candidate = stepOrder[i] + if (activeSteps.includes(candidate)) return candidate + } + } else { + for (let i = fromOrderIndex + 1; i < stepOrder.length; i++) { + const candidate = stepOrder[i] + if (activeSteps.includes(candidate)) return candidate + } + } + + return undefined +} + +const currentStepIndex = computed(() => { + if (!currentStep.value) return -1 + const index = steps.value.indexOf(currentStep.value) + if (index >= 0) return index + + const fallback = findAdjacentStep(currentStep.value, 'prev') + return fallback ? steps.value.indexOf(fallback) : -1 +}) + const previousStep = computed(() => { - const step = currentStep.value ? steps[steps.indexOf(currentStep.value) - 1] : undefined - if (step === 'payment' && skipPaymentMethods.value && primaryPaymentMethodId.value) { - return 'region' + if (!currentStep.value) return undefined + + if (currentStep.value === 'review' && skipPaymentMethods.value && primaryPaymentMethodId.value) { + return findAdjacentStep('payment', 'prev') } - return step + + if (currentStep.value === 'payment' && skipPaymentMethods.value && primaryPaymentMethodId.value) { + return findAdjacentStep('payment', 'prev') + } + + return findAdjacentStep(currentStep.value, 'prev') +}) + +const nextStep = computed(() => { + if (!currentStep.value) return undefined + return findAdjacentStep(currentStep.value, 'next') }) -const nextStep = computed(() => - currentStep.value ? steps[steps.indexOf(currentStep.value) + 1] : undefined, -) const canProceed = computed(() => { switch (currentStep.value) { @@ -191,10 +286,13 @@ async function beforeProceed(step: string) { return true case 'region': return true - case 'payment': + case 'payment': { await initializeStripe() - if (primaryPaymentMethodId.value && skipPaymentMethods.value) { + const shouldAutoSkipPaymentStep = + primaryPaymentMethodId.value && skipPaymentMethods.value && currentStep.value !== 'review' + + if (shouldAutoSkipPaymentStep) { const paymentMethod = await props.paymentMethods.find( (x) => x.id === primaryPaymentMethodId.value, ) @@ -203,6 +301,7 @@ async function beforeProceed(step: string) { return false } return true + } case 'review': if (noPaymentRequired.value) { return true @@ -253,6 +352,10 @@ async function setStep(step: Step | undefined, skipValidation = false) { return } + if (step && !steps.value.includes(step)) { + return + } + if (await beforeProceed(step)) { currentStep.value = step await nextTick() @@ -267,6 +370,19 @@ watch(selectedPlan, () => { } }) +watch( + steps, + (activeSteps) => { + if (!currentStep.value || activeSteps.includes(currentStep.value)) return + + currentStep.value = + findAdjacentStep(currentStep.value, 'next', activeSteps) ?? + findAdjacentStep(currentStep.value, 'prev', activeSteps) ?? + activeSteps[0] + }, + { immediate: true }, +) + const defaultPlan = computed(() => { return ( props.availableProducts.find((p) => p?.metadata?.type === 'pyro' && p.metadata.ram === 6144) ?? @@ -275,6 +391,14 @@ const defaultPlan = computed(() = ) }) +function getStepTitle(step: Step): MessageDescriptor { + if (step === 'region' && shouldHideRegionSelection.value) { + return ramTitle + } + + return titles[step] +} + function begin( interval: ServerBillingInterval, plan?: Labrinth.Billing.Internal.Product | null, @@ -293,9 +417,10 @@ function begin( selectedInterval.value = interval customServer.value = !selectedPlan.value + selectedRegion.value = isUpgradeFlow.value ? preferredUpgradeRegion.value : undefined selectedPaymentMethod.value = undefined const skipPlanStep = props.planStage && plan !== undefined - currentStep.value = skipPlanStep ? (steps[1] ?? steps[0]) : steps[0] + currentStep.value = skipPlanStep ? (steps.value[1] ?? steps.value[0]) : steps.value[0] skipPaymentMethods.value = true projectId.value = project modal.value?.show() @@ -315,6 +440,12 @@ function handleChooseCustom() { } function handleProceed() { + if (currentStep.value === 'plan') { + customServer.value = !selectedPlan.value + setStep(customServer.value ? 'region' : 'payment') + return + } + setStep(nextStep.value) } @@ -345,7 +476,7 @@ function goToBreadcrumbStep(id: string) { class="bg-transparent active:scale-95 font-bold text-secondary p-0" @click="goToBreadcrumbStep(step)" > - {{ formatMessage(titles[step]) }} + {{ formatMessage(getStepTitle(step)) }} - {{ formatMessage(titles[step]) }} + {{ formatMessage(getStepTitle(step)) }} Promise custom: boolean + hideRegionSelection?: boolean currency: string interval: ServerBillingInterval availableProducts: Labrinth.Billing.Internal.Product[] @@ -179,23 +180,23 @@ const messages = defineMessages({ }, }) -async function updateStock() { - currentStock.value = {} - - const getStockRequest = ( - product: Labrinth.Billing.Internal.Product, - ): Archon.Servers.v0.StockRequest => { - const metadata = product.metadata - if (metadata.type === 'pyro' || metadata.type === 'medal') { - return { - cpu: metadata.cpu, - memory_mb: metadata.ram, - swap_mb: metadata.swap, - storage_mb: metadata.storage, - } +const getStockRequest = ( + product: Labrinth.Billing.Internal.Product, +): Archon.Servers.v0.StockRequest => { + const metadata = product.metadata + if (metadata.type === 'pyro' || metadata.type === 'medal') { + return { + cpu: metadata.cpu, + memory_mb: metadata.ram, + swap_mb: metadata.swap, + storage_mb: metadata.storage, } - return { cpu: 0, memory_mb: 0, swap_mb: 0, storage_mb: 0 } } + return { cpu: 0, memory_mb: 0, swap_mb: 0, storage_mb: 0 } +} + +async function updateStock() { + currentStock.value = {} const capacityChecks = sortedRegions.value.map((region) => props.fetchStock( @@ -219,8 +220,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 +231,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,36 +251,41 @@ onMounted(() => { Checking availability...