feat: server management in app (#5628)

* start new server settings tabs

* update properties tab to match design

* better stying in general tab

* feat: add suffix input for hostname field

* implement tables for allocations and DNS records

* add tags for dns record type

* small gap adjustment

* polish advanced page

* adjust properties page hierarchy

* fix searching properties, empty state and projection radius appearing

* pnpm prepr

* update copy to match designs

* fix suffix input component

* style fixes and match heading size

* small fix

* fix search allocations placeholder

* adjust table styles

* move all installation settings helper text to below input

* update icon to use overflow menu buttons

* fix modal to be consistent

* open advanced properties when search

* remove other and custom properties, and update styles

* remove hide/show all java versions

* handle mc 26

* refactor: move server settings pages into /ui and add app ServerSettingsModal

* hook up server pages for app

* add server page header to app

* hook up server settings modal

* use large size

* fix card box shadow style

* fix hostname input for app

* fix app/website card containers

* implement external tabs for billing and admin billing

* fix save banner fixed to parent instead of page body

* remove unused prop to FriendsList causing warning in app

* fix client-only not available for app

* fix bottom cut off

* wire node auth

* implement full copy buttons

* dedup copy button tailwind styles

* fix hover class not working in @apply

* fix spacing

* fix error validation styles

* apply consistent styles and spacing

* feat: update hosting server card (#5609)

* fix type errors

* fix some stylesheets not imported for storybook

* add server listing stories

* add fix for frontend stylesheet imports

* remove props.

* convert copy code to use tailwind

* update server listing component styles

* update server info label styles

* start status/player count info label, more style updates and fixes

* add new server card buttons

* hook up server cards and implement updated styles

* hook up on download button

* fix tauri throwing error when api returns 204 No Content

* hook up purchase server modal in app

* fix upgrading state loading icon

* pnpm prepr

* filter out servers past 30 days after cancellation

* do not apply opacity on lock or spiner icons

* fix disabled server icon background

* update pending change stage

* handle known suspension states

* refactor: reduce code duplication for server listing

* update disabled state text color

* fix loading icon color

* clean up copy

* fix disabled opacity for server card

* update server listing files kept to be countdown

* implement resubscribe modal

* implement proper provisioning state for resubscribe

* fix duplicate attribute and pnpm prepr

* feat: add shared UI package auth DI

* feat: update purchase server flow (#5714)

* implement server list empty state component

* fix stories and adjust spacing

* implement select plan design refresh

* implement auth for empty server list

* use refs instead of reactive

* pnpm prepr

* fix auth usage for empty servers list

* move app auth provider setup to src/providers/setup

* pnpm prepr

* fix max height

* style fix

* fix getCreds no auth is blocking api client

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

* refactor guest plan select logic into provider

* implement sign in or create account popup

* remove force empty serverList

* add download button for suspended mod and generic

* add handling for when user logs out

* QA pass style fixes

* more consistent page styles

* fix duplicate export

* refactor: remove all fallback stuff from resubscribe modal

* implement shared download latest backup util

* i18n pass

* pnpm prepr

* fix region being selected if ping failed

* pnpm prepr

* feat: servers in app finalization (#5744)

* feat: start on shared console implementation into logs and overview pages

* fix: terminal gap issues

* feat: swap word wrap for full screen

* fix: stats cards alignment

* fix: stats

* feat: fix console clear + remove copy

* fix: lint

* fix: use reset not clear

* feat: shared server header & overview page for app and website (#5736)

* feat: implement shared server header for app and website

* feat: implement wrapped overview page with shared composable and hook it up

* pnpm prepr

* fix: bugs

* qa: cleanup

* feat: root.vue shared layout

* feat: delete old options pages + fix discovery frontend

* fix: discovery

* fix: misc style/layout issues

* fix page padding

* fix: modal height jankiness

* feat: implement server install content in app and server setup modal with DI

* fix: spacing

* remove servers in app feature flag

* Revert "remove servers in app feature flag"

This reverts commit 86e284c4bd.

* fix: qa

* feat: remove legacy components from apps/frontend/src/components/ui/servers

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>

* qa pass (#5738)

* fix: qa

* feat: qa

* fix: server icon fetch fails due to global node auth race condition overriding each other

* fix: lint

* fix: server icon upload/sync and centralize logic

* fix: server settings modal not closing for server reset

* fix: better server sorting

* feat: copy address in server listing card

* fix: notification panel in modal and when overlapping with action bar

* fix: empty server list empty state flashing when refresh, fixed by adding isReady auth flag

* feat: use floating action bar for save banner

* fix: saving state in save bar

* fix: edit server icon styling

* fix: confirm modal to have consistent buttons

* feat: loading animation for server panel + caching improvements for app

* pnpm prepr

* feat: search page deduplication (#5754)

* fix: action bar behind modal

* fix: remove warning modal for stopping

* fix: server cards states

* we hate webkit we hate webkit

* fix: update allocation creation to not use modal

* fix: properties tab spacing and styles

* feat: add files tab copy

* fix: advanced properties icon

* fix: remove back to all servers link

* feat: add files tab link in copy

* fix: server header styles to be consistent with instance

* fix: add header icons back

* feat: update instance settings icon to be consistent

* fix: icon container

* feat: upload state persistence across tabs

* fix: server labels text wrapping

* fix: use surface-5 border

* fix: loading spinner showing with onboarding below

* feat: new server button shows purchase modal in website

* fix: billing page not showing quarterly interval

* fix: server downgrade not showing updated subscription notification

* fix: server settings invalidate saved state and remove server context provider since its already provided in the page

* pnpm prepr

* add stripe publishable key to app build

* feat: console highlighting

* fix: rename servers title to modrinth hosting

* feat: search fix

* fix: qa/styles

* fix: ip click active and remove power dont ask again

* fix: qa

* feat: highlighting fix console

* fix: disable conflicts action

* fix: error dismiss bug

* feat: modal clarification

* fix: files perms issue

* fix: lint

* feat: modal fix

* enable show uptime

* fix: add loading state to edit server icon

* fix: notification panel take in has sidebar from settings

* fix: consistency pass on app settings

* fix: consistency pass on instance settings

* pnpm prepr

* fix: nagivate to billing button in app to go to website

* fix: stripe return url in app causing app to open modrinth.com in tauri

* refactor: better show polling UI code

* fix: new server polling comparison to use server ids instead of length

* fix: buttonstyled story

* fix: button styling

* fix: content.vue regression

* feat: project url redirects

* fix: breadcrumbs

* fix: purchase with newly added card

* fix: console ordering problems

* fix: app-frontend missing env config and staging environment

* fix: log syncing for instances and server panel accidentally

* fix: QA issues

* fix: server page loading state

* fix: stats card logic

* fix: lint

* fix: qa

* fix: console height padding

* fix: terminal padding + loading indicator

* feat: update medal server listing styling

* fix: no upgrade button for medal server listing in app

* fix: go to overview instead of content tab after onboarding

* fix: qa

* fix: teleport modals to body

* fix: logs tab + qa

* fix: local storage for user preferences

* fix: qa loading indic

* feat: considitonal debug and trace

* fix: jump to top on install bug

* feat: swap out server hard drive icon to server stack icon

* feat: servers in app feature flag default true

* fix: highlight row ufll

* fix: webkit thing onto a tag

* fix: input field

* fix: clear fix

* fix: lint

* fix: fmt

* feat: improve share modal and bring it back for sharing log

* pnpm prepr

* fix: menu overflowing

* feat: remove servers in app feature flag

* fix: server stat charts no longer showing color

* fix: library nav no primary state

* fix: better modal height and width

* fix: highlighting bugs

* fix: empty states

* fix: delay import to fix overview page slow load on MacOS

* fix: medal server listing too bright on light mode

* fix: admon analysis + fix logs

* fix: bug

* fix: clear purchase intent from sign-in after closing modal

* performance: improve server manage stats loading by splitting reactivity

* fix: deploy + admon + disable highlighting

* fix: clippy

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>

* feat: temp wrangler

* fix: lint

* fix: logs upload

* fix: console empty state and admon regressions

* fix: fields

* feat: log deleting + prefetch for Logs.vue

* feat: move delete before share

* feat: clear endpoint

* feat: we ball!

---------

Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Truman Gao
2026-04-12 21:38:08 +00:00
committed by GitHub
co-authored by Calum H. tdgao Calum H.
parent a2a97d1313
commit 693a371d61
278 changed files with 15973 additions and 12607 deletions
@@ -1,149 +0,0 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { InfoIcon } from '@modrinth/assets'
import { Menu } from 'floating-vue'
import { computed, inject, type Ref } from 'vue'
import { useFormatPrice } from '../../composables'
import { type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import ServersSpecs from './ServersSpecs.vue'
const props = withDefaults(
defineProps<{
plan: Labrinth.Billing.Internal.Product
title: MessageDescriptor
description: MessageDescriptor
buttonColor?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
mostPopular?: boolean
selected?: boolean
}>(),
{
buttonColor: 'standard',
mostPopular: false,
selected: false,
},
)
const emit = defineEmits<{
(e: 'select', plan: Labrinth.Billing.Internal.Product): void
}>()
const { formatMessage } = useVIntl()
const formatPrice = useFormatPrice()
// TODO: Use DI framework when merged.
const selectedInterval = inject<Ref<ServerBillingInterval>>('selectedInterval')
const currency = inject<string>('currency')
const perMonth = computed(() => {
if (!props.plan || !currency || !selectedInterval?.value) return undefined
const total = getPriceForInterval(props.plan, currency, selectedInterval.value)
if (!total) return undefined
return total / monthsInInterval[selectedInterval.value]
})
const planSpecs = computed(() => {
const metadata = props.plan.metadata
if (metadata.type === 'pyro' || metadata.type === 'medal') {
return {
ram: metadata.ram,
storage: metadata.storage,
cpu: metadata.cpu,
}
}
return null
})
const mostPopularStyle = computed(() => {
if (!props.mostPopular) return undefined
const style: Record<string, string> = {
backgroundImage:
'radial-gradient(86.12% 101.64% at 95.97% 94.07%, rgba(27, 217, 106, 0.23) 0%, rgba(14, 115, 56, 0.2) 100%)',
boxShadow: '0px 12px 38.1px rgba(27, 217, 106, 0.13)',
}
if (!props.selected) {
style.borderColor = 'rgba(12, 107, 52, 0.55)'
}
return style
})
</script>
<template>
<div
class="rounded-2xl p-4 font-semibold transition-all duration-300 experimental-styles-within h-full border-2 border-solid cursor-pointer select-none"
:class="{
'bg-brand-highlight border-brand': selected,
'bg-button-bg border-transparent': !selected,
'!bg-bg': mostPopular,
}"
:style="mostPopularStyle"
role="button"
tabindex="0"
:aria-pressed="selected"
@click="emit('select', plan)"
@keydown.enter.prevent="emit('select', plan)"
@keydown.space.prevent="emit('select', plan)"
>
<div class="flex h-full flex-col justify-between gap-2">
<div class="flex flex-col">
<div class="flex items-center justify-between">
<span class="text-2xl font-semibold text-contrast">
{{ formatMessage(title) }}
</span>
<div
v-if="mostPopular"
class="relative w-fit rounded-full bg-highlight-green px-3 py-1 text-sm font-bold text-brand backdrop-blur-lg"
>
Most Popular
</div>
</div>
<span class="m-0 text-lg font-bold text-contrast">
{{ formatPrice(perMonth, currency, true) }}
<span class="text-sm font-semibold text-secondary">
/ month{{ selectedInterval !== 'monthly' ? `, billed ${selectedInterval}` : '' }}
</span>
</span>
</div>
<span class="text-sm">{{ formatMessage(description) }}</span>
<div class="w-fit">
<Menu
placement="bottom-start"
:triggers="['hover', 'focus']"
:auto-hide="true"
:delay="{ show: 100, hide: 120 }"
:distance="6"
>
<template #default="{ shown }">
<div
class="flex w-fit items-center gap-2 cursor-help text-sm font-medium cursor-default select-none outline-none"
:class="shown ? 'text-primary' : 'text-secondary'"
role="button"
tabindex="0"
aria-haspopup="true"
:aria-expanded="shown"
>
<InfoIcon />
View plan details
</div>
</template>
<template #popper>
<div v-if="planSpecs" class="w-fit rounded-md border border-contrast/10 p-3 shadow-lg">
<ServersSpecs
:ram="planSpecs.ram"
:storage="planSpecs.storage"
:cpus="planSpecs.cpu"
/>
</div>
</template>
</Menu>
</div>
</div>
</div>
</template>
@@ -8,9 +8,12 @@ import {
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import { useQueryClient } from '@tanstack/vue-query'
import type Stripe from 'stripe'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { useStripe } from '../../composables/stripe'
import { commonMessages } from '../../utils'
@@ -23,6 +26,8 @@ import PaymentMethodSelector from './ServersPurchase2PaymentMethod.vue'
import ConfirmPurchase from './ServersPurchase3Review.vue'
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const queryClient = useQueryClient()
export type RegionPing = {
region: string
@@ -34,7 +39,7 @@ export type ServerBillingInterval = 'monthly' | 'quarterly' | 'yearly'
const props = defineProps<{
publishableKey: string
returnUrl: string
returnUrl?: string
paymentMethods: Stripe.PaymentMethod[]
customer: Stripe.Customer
currency: string
@@ -121,6 +126,16 @@ const titles: Record<Step, MessageDescriptor> = {
review: defineMessage({ id: 'servers.purchase.step.review.title', defaultMessage: 'Review' }),
}
const purchaseSuccessTitle = defineMessage({
id: 'servers.purchase.notification.success.title',
defaultMessage: 'Purchase success',
})
const purchaseSuccessText = defineMessage({
id: 'servers.purchase.notification.success.text',
defaultMessage: 'Your Modrinth Hosting purchase was completed successfully.',
})
const currentRegion = computed(() => {
return props.regions.find((region) => region.shortcode === selectedRegion.value)
})
@@ -215,7 +230,22 @@ async function afterProceed(step: string) {
async function setStep(step: Step | undefined, skipValidation = false) {
if (!step) {
await submitPayment(props.returnUrl)
const success = await submitPayment(props.returnUrl)
if (success) {
modal.value?.hide()
addNotification({
title: formatMessage(purchaseSuccessTitle),
text: formatMessage(purchaseSuccessText),
type: 'success',
})
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['servers'] }),
queryClient.invalidateQueries({ queryKey: ['servers', 'v1'] }),
])
emit('purchase-success')
}
return
}
@@ -264,7 +294,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()
@@ -274,8 +305,8 @@ defineExpose({
show: begin,
})
defineEmits<{
(e: 'hide'): void
const emit = defineEmits<{
(e: 'hide' | 'purchase-success'): void
}>()
function handleChooseCustom() {
@@ -283,6 +314,10 @@ function handleChooseCustom() {
selectedPlan.value = undefined
}
function handleProceed() {
setStep(nextStep.value)
}
// When the user explicitly wants to change or add a payment method from Review
// we must disable the auto-skip behavior, clear any selected method, and
// navigate to the Payment step so Stripe Elements can mount.
@@ -328,7 +363,7 @@ function goToBreadcrumbStep(id: string) {
</template>
</div>
</template>
<div class="w-[40rem] max-w-full">
<div :class="currentStep === 'plan' ? 'w-[56rem] max-w-full' : 'w-[40rem] max-w-full'">
<PlanSelector
v-if="currentStep === 'plan'"
v-model:plan="selectedPlan"
@@ -337,6 +372,7 @@ function goToBreadcrumbStep(id: string) {
:available-products="availableProducts"
:currency="currency"
@choose-custom="handleChooseCustom"
@proceed="handleProceed"
/>
<RegionSelector
v-else-if="currentStep === 'region'"
@@ -374,7 +410,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"
@@ -410,12 +446,12 @@ function goToBreadcrumbStep(id: string) {
<button v-if="previousStep" @click="previousStep && setStep(previousStep, true)">
<LeftArrowIcon /> {{ formatMessage(commonMessages.backButton) }}
</button>
<button v-else @click="modal?.hide()">
<button v-else-if="currentStep !== 'plan'" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<ButtonStyled v-if="currentStep !== 'plan'" color="brand">
<button
v-tooltip="
currentStep === 'review' && !acceptedEula && !noPaymentRequired
@@ -0,0 +1,287 @@
<template>
<NewModal ref="modal" max-width="550px">
<template #title>
<div class="text-2xl font-semibold text-contrast">{{ formatMessage(messages.title) }}</div>
</template>
<div class="flex w-[44rem] max-w-full flex-col gap-6">
<template v-if="modalData">
<p class="m-0 text-secondary leading-relaxed">
<IntlFormatted
:message-id="messages.description"
:values="{ serverName: modalData.serverName }"
>
<template #server-name="{ children }">
<span class="font-semibold text-contrast"><component :is="() => children" /></span>
</template>
</IntlFormatted>
</p>
<div v-if="formattedPrice" class="flex flex-col gap-2.5">
<span class="text-contrast font-semibold">{{ formatMessage(messages.planLabel) }}</span>
<div
class="flex items-center justify-between gap-4 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-5"
>
<div class="flex flex-col gap-1">
<div class="truncate font-semibold text-contrast">{{ modalData.planName }}</div>
<div
v-if="
modalData.ramGb != null ||
modalData.storageGb != null ||
modalData.sharedCpus != null
"
class="text-secondary flex gap-1.5 font-medium text-sm items-center"
>
<template v-if="modalData.ramGb != null">
{{ formatMessage(messages.ramLabel, { ramGb: modalData.ramGb }) }}
</template>
<template v-if="modalData.storageGb != null">
<div
v-if="modalData.ramGb != null"
class="h-1.5 w-1.5 bg-button-border rounded-full"
></div>
{{ formatMessage(messages.storageLabel, { storageGb: modalData.storageGb }) }}
</template>
<template v-if="modalData.sharedCpus != null">
<div
v-if="modalData.ramGb != null || modalData.storageGb != null"
class="h-1.5 w-1.5 bg-button-border rounded-full"
></div>
{{ formatMessage(messages.cpusLabel, { sharedCpus: modalData.sharedCpus }) }}
</template>
</div>
</div>
<div class="flex flex-col gap-1 items-end">
<div class="font-semibold text-contrast">
{{ formattedPrice }}
</div>
<div v-if="intervalLabel" class="text-secondary">{{ intervalLabel }}</div>
</div>
</div>
</div>
<p v-if="formattedNextChargeDate" class="m-0 text-primary">
<IntlFormatted
:message-id="messages.nextCharge"
:values="{ date: formattedNextChargeDate }"
>
<template #charge-date="{ children }">
<span class="font-semibold text-contrast"><component :is="() => children" /></span>
</template>
</IntlFormatted>
</p>
</template>
<template v-else>
<p class="m-0 text-secondary">{{ formatMessage(messages.failedLoad) }}</p>
</template>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button class="!border !border-surface-5" @click="handleCancel">
<XIcon />
{{ formatMessage(messages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canResubscribe" @click="handleResubscribe">
<RotateCounterClockwiseIcon />
{{ formatMessage(messages.resubscribeButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { RotateCounterClockwiseIcon, XIcon } from '@modrinth/assets'
import { computed, ref, useTemplateRef } from 'vue'
import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
import { useFormatDateTime, useFormatPrice } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import IntlFormatted from '../base/IntlFormatted.vue'
import { ButtonStyled, NewModal } from '../index'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
type BillingInterval = Labrinth.Billing.Internal.PriceDuration
export type ResubscribeModalPayload = {
subscriptionId: string
wasSuspended: boolean
serverName: string
planName: string
ramGb?: number
storageGb?: number
sharedCpus?: number
priceCents?: number
currencyCode?: string
interval: BillingInterval
nextChargeDate?: string | number | Date
}
type ResubscribeModalState = {
subscriptionId: string
wasSuspended: boolean
serverName: string
planName: string
ramGb?: number
storageGb?: number
sharedCpus?: number
priceCents?: number
currencyCode?: string
interval: BillingInterval
nextChargeDate?: string | number | Date
}
const emit = defineEmits<{
(e: 'cancel'): void
(e: 'resubscribe', payload: { subscriptionId: string; wasSuspended: boolean }): void
}>()
const formatDate = useFormatDateTime({ dateStyle: 'long' })
const formatPrice = useFormatPrice()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const modalData = ref<ResubscribeModalState | null>(null)
const messages = defineMessages({
title: { id: 'billing.resubscribe-modal.title', defaultMessage: 'Resubscribe to Server' },
description: {
id: 'billing.resubscribe-modal.description',
defaultMessage:
'You are about to resubscribe to <server-name>{serverName}</server-name>. Your subscription will be reactivated and your server will continue running without interruption.',
},
planLabel: { id: 'billing.resubscribe-modal.plan-label', defaultMessage: 'Plan' },
ramLabel: { id: 'billing.resubscribe-modal.ram', defaultMessage: '{ramGb} GB RAM' },
storageLabel: {
id: 'billing.resubscribe-modal.storage',
defaultMessage: '{storageGb} GB Storage',
},
cpusLabel: {
id: 'billing.resubscribe-modal.cpus',
defaultMessage: '{sharedCpus} Shared CPUs',
},
nextCharge: {
id: 'billing.resubscribe-modal.next-charge',
defaultMessage: 'Your next charge will be on <charge-date>{date}</charge-date>.',
},
failedLoad: {
id: 'billing.resubscribe-modal.failed-load',
defaultMessage: 'Failed to load subscription details.',
},
cancelButton: { id: 'billing.resubscribe-modal.cancel', defaultMessage: 'Cancel' },
resubscribeButton: {
id: 'billing.resubscribe-modal.resubscribe',
defaultMessage: 'Resubscribe',
},
intervalMonthly: { id: 'billing.resubscribe-modal.interval.monthly', defaultMessage: '/month' },
intervalQuarterly: {
id: 'billing.resubscribe-modal.interval.quarterly',
defaultMessage: '/quarter',
},
intervalYearly: { id: 'billing.resubscribe-modal.interval.yearly', defaultMessage: '/year' },
intervalFiveDays: {
id: 'billing.resubscribe-modal.interval.five-days',
defaultMessage: '/5 days',
},
errorTitle: { id: 'billing.resubscribe-modal.error.title', defaultMessage: 'Error' },
errorText: {
id: 'billing.resubscribe-modal.error.text',
defaultMessage: 'Cannot resubscribe, failed to load subscription details.',
},
})
const canResubscribe = computed(() => !!modalData.value?.subscriptionId)
const intervalLabel = computed(() => {
switch (modalData.value?.interval) {
case 'monthly':
return formatMessage(messages.intervalMonthly)
case 'quarterly':
return formatMessage(messages.intervalQuarterly)
case 'yearly':
return formatMessage(messages.intervalYearly)
case 'five-days':
return formatMessage(messages.intervalFiveDays)
default:
return null
}
})
const formattedPrice = computed(() => {
const { priceCents, currencyCode } = modalData.value ?? {}
if (priceCents == null || currencyCode == null) return ''
return formatPrice(priceCents, currencyCode)
})
const normalizedNextChargeDate = computed(() => {
if (!modalData.value?.nextChargeDate) return null
const date = new Date(modalData.value.nextChargeDate)
if (Number.isNaN(date.getTime())) {
return null
}
return date
})
const formattedNextChargeDate = computed(() =>
normalizedNextChargeDate.value ? formatDate(normalizedNextChargeDate.value) : '',
)
function show(payload: ResubscribeModalPayload) {
if (!payload) {
addNotification({
type: 'error',
title: formatMessage(messages.errorTitle),
text: formatMessage(messages.errorText),
})
return
}
modalData.value = {
subscriptionId: payload.subscriptionId,
wasSuspended: payload.wasSuspended,
serverName: payload.serverName.trim(),
planName: payload.planName,
ramGb: payload.ramGb,
storageGb: payload.storageGb,
sharedCpus: payload.sharedCpus,
priceCents: payload.priceCents,
currencyCode: payload.currencyCode,
interval: payload.interval,
nextChargeDate: payload.nextChargeDate,
}
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function handleCancel() {
hide()
emit('cancel')
}
function handleResubscribe() {
if (!canResubscribe.value || !modalData.value?.subscriptionId) return
hide()
emit('resubscribe', {
subscriptionId: modalData.value.subscriptionId,
wasSuspended: modalData.value.wasSuspended,
})
}
defineExpose({
show,
hide,
})
</script>
@@ -0,0 +1,169 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ChevronRightIcon, ExternalIcon, XIcon } from '@modrinth/assets'
import { computed, ref, useTemplateRef } from 'vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import NewModal from '../modal/NewModal.vue'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import PlanSelector from './ServersPurchase0Plan.vue'
const props = withDefaults(
defineProps<{
availableProducts: Labrinth.Billing.Internal.Product[]
currency: string
loggedIn?: boolean
}>(),
{
loggedIn: false,
},
)
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 showSignInPrompt = ref(false)
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 emitSelection() {
emit('continue', {
interval: selectedInterval.value,
planId: selectedPlan.value?.id ?? null,
})
}
function continueWithSelection() {
if (!props.loggedIn) {
showSignInPrompt.value = true
return
}
emitSelection()
modal.value?.hide()
}
function chooseCustom() {
selectedPlan.value = undefined
}
function closeSignInPrompt() {
showSignInPrompt.value = false
}
function continueToAuth() {
emitSelection()
closeSignInPrompt()
modal.value?.hide()
}
function handleModalHide() {
closeSignInPrompt()
}
function show(initialInterval?: ServerBillingInterval, initialPlanId?: string | null) {
closeSignInPrompt()
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" :on-hide="handleModalHide" no-padding>
<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="relative w-[56rem] max-w-full">
<div class="w-full h-full p-6">
<PlanSelector
v-model:plan="selectedPlan"
v-model:interval="selectedInterval"
:available-products="availableProducts"
:currency="currency"
@choose-custom="chooseCustom"
@proceed="continueWithSelection"
/>
</div>
<Transition
enter-active-class="transition-opacity duration-150 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showSignInPrompt"
class="pointer-events-auto absolute inset-0 z-20 bg-black/60"
@click="closeSignInPrompt"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="translate-y-4 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-4 opacity-0"
>
<div
v-if="showSignInPrompt"
class="absolute inset-x-0 bottom-0 -m-px z-30 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-6 shadow-2xl"
>
<div class="absolute right-4 top-4">
<ButtonStyled circular type="transparent">
<button aria-label="Close sign in prompt" @click="closeSignInPrompt">
<XIcon />
</button>
</ButtonStyled>
</div>
<div class="mx-auto flex max-w-xl flex-col items-center gap-4 text-center">
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Sign in to continue your purchase</div>
<div class="">You need a Modrinth account to add your billing details.</div>
</div>
<ButtonStyled color="brand" class="mt-2">
<button @click="continueToAuth">
Sign in or create an account
<ExternalIcon class="size-4" />
</button>
</ButtonStyled>
</div>
</div>
</Transition>
</div>
</NewModal>
</template>
@@ -1,13 +1,15 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { computed, provide } from 'vue'
import { RightArrowIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { useFormatPrice } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import ButtonStyled from '../base/ButtonStyled.vue'
import OptionGroup from '../base/OptionGroup.vue'
import ModalBasedServerPlan from './ModalBasedServerPlan.vue'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import ServersSpecs from './ServersSpecs.vue'
const { formatMessage } = useVIntl()
const formatPrice = useFormatPrice()
@@ -23,18 +25,10 @@ const availableBillingIntervals = ['monthly', 'quarterly']
const selectedPlan = defineModel<Labrinth.Billing.Internal.Product>('plan')
const selectedInterval = defineModel<ServerBillingInterval>('interval')
const emit = defineEmits<{
(e: 'choose-custom'): void
(e: 'choose-custom' | 'proceed'): void
}>()
const messages = defineMessages({
title: {
id: 'servers.purchase.step.plan.prompt',
defaultMessage: 'Choose a plan',
},
subtitle: {
id: 'servers.purchase.step.plan.subtitle',
defaultMessage: 'Pick the amount of RAM and specs that fit your needs.',
},
selectPlan: {
id: 'servers.purchase.step.plan.select',
defaultMessage: 'Select Plan',
@@ -43,9 +37,17 @@ const messages = defineMessages({
id: 'servers.purchase.step.plan.get-started',
defaultMessage: 'Get started',
},
billed: {
id: 'servers.purchase.step.plan.billed',
defaultMessage: 'billed {interval}',
smallTitle: {
id: 'servers.purchase.step.plan.small',
defaultMessage: 'Small',
},
mediumTitle: {
id: 'servers.purchase.step.plan.medium',
defaultMessage: 'Medium',
},
largeTitle: {
id: 'servers.purchase.step.plan.large',
defaultMessage: 'Large',
},
smallDesc: {
id: 'servers.purchase.step.plan.small.desc',
@@ -67,6 +69,18 @@ const messages = defineMessages({
id: 'servers.purchase.step.plan.most-popular',
defaultMessage: 'Most Popular',
},
billingSubtitle: {
id: 'servers.purchase.step.plan.billing-subtitle',
defaultMessage: 'Available in North America, Europe, and Southeast Asia.',
},
customHeading: {
id: 'servers.purchase.step.plan.custom.heading',
defaultMessage: 'Know exactly what you need?',
},
yourCurrentPlan: {
id: 'servers.purchase.step.plan.your-current-plan',
defaultMessage: 'Your current plan',
},
})
const isSameAsExistingPlan = computed(() => {
@@ -95,8 +109,12 @@ const plansByRam = computed(() => {
return byName
})
function handleCustomPlan() {
emit('choose-custom')
function planSpecs(plan: Labrinth.Billing.Internal.Product) {
const m = plan.metadata
if (m.type === 'pyro' || m.type === 'medal') {
return { ram: m.ram, storage: m.storage, cpus: m.cpu }
}
return null
}
function pricePerMonth(plan?: Labrinth.Billing.Internal.Product) {
@@ -106,22 +124,6 @@ function pricePerMonth(plan?: Labrinth.Billing.Internal.Product) {
return total / monthsInInterval[selectedInterval.value]
}
const customPricePerGb = computed(() => {
// Calculate lowest price per GB among products for current interval
let min: number | undefined
for (const p of props.availableProducts) {
const perMonth = pricePerMonth(p)
const metadata = p?.metadata
if (!metadata || (metadata.type !== 'pyro' && metadata.type !== 'medal')) continue
const ramGb = metadata.ram / 1024
if (perMonth && ramGb > 0) {
const perGb = perMonth / ramGb
if (min === undefined || perGb < min) min = perGb
}
}
return min
})
const customStartingPrice = computed(() => {
let min: number | undefined
for (const p of props.availableProducts) {
@@ -131,26 +133,47 @@ const customStartingPrice = computed(() => {
return min
})
provide('currency', props.currency)
provide('selectedInterval', selectedInterval)
const smallPrice = computed(() => pricePerMonth(plansByRam.value.small))
const mediumPrice = computed(() => pricePerMonth(plansByRam.value.medium))
const largePrice = computed(() => pricePerMonth(plansByRam.value.large))
const smallSpecs = computed(() =>
plansByRam.value.small ? planSpecs(plansByRam.value.small) : null,
)
const mediumSpecs = computed(() =>
plansByRam.value.medium ? planSpecs(plansByRam.value.medium) : null,
)
const largeSpecs = computed(() =>
plansByRam.value.large ? planSpecs(plansByRam.value.large) : null,
)
function selectPlan(plan: Labrinth.Billing.Internal.Product) {
selectedPlan.value = plan
emit('proceed')
}
function selectCustom() {
emit('choose-custom')
emit('proceed')
}
</script>
<template>
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 mb-5 !mt-0">
<span></span>
<div class="flex flex-col items-center gap-2 mb-5 !mt-0">
<OptionGroup
v-slot="{ option }"
v-model="selectedInterval"
class="!bg-button-bg !shadow-none"
:options="availableBillingIntervals"
>
<template v-if="option === 'monthly'"> Pay monthly </template>
<span v-else-if="option === 'quarterly'"> Pay quarterly </span>
<span v-else-if="option === 'yearly'"> Pay yearly </span>
<template v-if="option === 'monthly'">Monthly</template>
<span v-else-if="option === 'quarterly'">
Quarterly <span class="text-brand">(Save 16%)</span>
</span>
</OptionGroup>
<span class="bg-transparent p-0 text-sm text-xs font-bold text-brand">
{{ selectedInterval !== 'quarterly' ? 'Save' : 'Saving' }} 16% with quarterly billing!
</span>
<div class="text-sm text-secondary text-center">
{{ formatMessage(messages.billingSubtitle) }}
</div>
</div>
<Transition
enter-active-class="transition-all duration-300 ease-out"
@@ -164,68 +187,182 @@ provide('selectedInterval', selectedInterval)
Your server is already on this plan, choose a different plan.
</div>
</Transition>
<div class="grid grid-cols-1 sm:grid-cols-2 !gap-4">
<ModalBasedServerPlan
v-if="plansByRam.small"
:plan="plansByRam.small"
:title="{ id: 'servers.purchase.step.plan.small', defaultMessage: 'Small' }"
:description="messages.smallDesc"
:button-color="'blue'"
:selected="selectedPlan?.id === plansByRam.small.id"
@select="selectedPlan = $event"
/>
<ModalBasedServerPlan
v-if="plansByRam.medium"
:plan="plansByRam.medium"
:title="{ id: 'servers.purchase.step.plan.medium', defaultMessage: 'Medium' }"
:description="messages.mediumDesc"
most-popular
:button-color="'brand'"
:selected="selectedPlan?.id === plansByRam.medium.id"
@select="selectedPlan = $event"
/>
<ModalBasedServerPlan
v-if="plansByRam.large"
:plan="plansByRam.large"
:title="{ id: 'servers.purchase.step.plan.large', defaultMessage: 'Large' }"
:description="messages.largeDesc"
:button-color="'purple'"
:selected="selectedPlan?.id === plansByRam.large.id"
@select="selectedPlan = $event"
/>
<div class="grid grid-cols-3 gap-4 items-start">
<!-- Small -->
<div
v-if="customStartingPrice"
class="rounded-2xl p-4 font-semibold transition-all duration-300 experimental-styles-within h-full border-2 border-solid cursor-pointer select-none"
:class="!selectedPlan ? 'bg-brand-highlight border-brand' : 'bg-button-bg border-transparent'"
role="button"
tabindex="0"
:aria-pressed="!selectedPlan"
@click="handleCustomPlan"
@keydown.enter.prevent="handleCustomPlan"
@keydown.space.prevent="handleCustomPlan"
v-if="plansByRam.small && smallPrice"
class="flex flex-col gap-4 rounded-2xl bg-surface-2 border-2 border-solid border-transparent p-5 h-full"
>
<div class="flex h-full flex-col justify-between">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-2xl font-semibold text-contrast">Custom</span>
<div>
<div class="text-3xl font-semibold text-contrast leading-none">
{{ formatMessage(messages.smallTitle) }}
</div>
<div class="mt-1">
<span class="text-2xl font-bold text-contrast">
{{ formatPrice(smallPrice, currency, true) }}
</span>
<span class="text-sm">
/ month<template v-if="selectedInterval !== 'monthly'"
>, billed {{ selectedInterval }}</template
>
</span>
</div>
<div class="mt-2 text-sm text-primary">
{{ formatMessage(messages.smallDesc) }}
</div>
</div>
<div class="w-full">
<ButtonStyled color="blue" class="w-full">
<button
class="w-full"
:disabled="existingPlan?.id === plansByRam.small.id"
@click="selectPlan(plansByRam.small!)"
>
{{
existingPlan?.id === plansByRam.small.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</button>
</ButtonStyled>
</div>
<ServersSpecs
v-if="smallSpecs"
:ram="smallSpecs.ram"
:storage="smallSpecs.storage"
:cpus="smallSpecs.cpus"
/>
</div>
<!-- Medium (Most Popular) -->
<div v-if="plansByRam.medium && mediumPrice" class="flex flex-col items-center relative h-full">
<div
class="z-10 -mb-3.5 rounded-full text-sm font-medium text-brand whitespace-nowrap absolute -top-3 right-4 bg-surface-3"
>
<div
class="bg-brand-highlight border border-solid border-highlight-green px-2.5 py-0.5 rounded-full"
>
{{ formatMessage(messages.mostPopular) }}
</div>
</div>
<div
class="w-full flex flex-col gap-4 rounded-2xl bg-brand-inverted border-brand-highlight border border-solid p-5 h-full"
:style="{
backgroundImage:
'radial-gradient(86.12% 101.64% at 95.97% 94.07%, rgba(27, 217, 106, 0.23) 0%, rgba(14, 115, 56, 0.2) 100%)',
}"
>
<div>
<div class="text-3xl font-semibold text-contrast leading-none">
{{ formatMessage(messages.mediumTitle) }}
</div>
<span class="m-0 text-lg font-bold text-contrast">
{{ formatPrice(customStartingPrice, currency, true) }}
<span class="text-sm font-semibold text-secondary">
<div class="mt-1">
<span class="text-2xl font-bold text-contrast">
{{ formatPrice(mediumPrice, currency, true) }}
</span>
<span class="text-sm">
/ month<template v-if="selectedInterval !== 'monthly'"
>, billed {{ selectedInterval }}</template
>
</span>
</span>
<span class="text-sm">{{ formatMessage(messages.customDesc) }}</span>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3">
<span v-if="customPricePerGb" class="text-sm text-secondary">
From {{ formatPrice(customPricePerGb, currency, true) }} / GB
</span>
</div>
<div class="mt-2 text-sm text-primary">
{{ formatMessage(messages.mediumDesc) }}
</div>
</div>
<div class="w-full">
<ButtonStyled color="brand" class="w-full">
<button
class="w-full"
:disabled="existingPlan?.id === plansByRam.medium.id"
@click="selectPlan(plansByRam.medium!)"
>
{{
existingPlan?.id === plansByRam.medium.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</button>
</ButtonStyled>
</div>
<ServersSpecs
v-if="mediumSpecs"
:ram="mediumSpecs.ram"
:storage="mediumSpecs.storage"
:cpus="mediumSpecs.cpus"
/>
</div>
</div>
<!-- Large -->
<div
v-if="plansByRam.large && largePrice"
class="flex flex-col gap-4 rounded-2xl bg-surface-2 border-2 border-solid border-transparent p-5 h-full"
>
<div>
<div class="text-3xl font-semibold text-contrast leading-none">
{{ formatMessage(messages.largeTitle) }}
</div>
<div class="mt-1">
<span class="text-2xl font-bold text-contrast">
{{ formatPrice(largePrice, currency, true) }}
</span>
<span class="text-sm">
/ month<template v-if="selectedInterval !== 'monthly'"
>, billed {{ selectedInterval }}</template
>
</span>
</div>
<div class="mt-2 text-sm text-primary">
{{ formatMessage(messages.largeDesc) }}
</div>
</div>
<div class="w-full">
<ButtonStyled color="purple" class="w-full">
<button
class="w-full"
:disabled="existingPlan?.id === plansByRam.large.id"
@click="selectPlan(plansByRam.large!)"
>
{{
existingPlan?.id === plansByRam.large.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</button>
</ButtonStyled>
</div>
<ServersSpecs
v-if="largeSpecs"
:ram="largeSpecs.ram"
:storage="largeSpecs.storage"
:cpus="largeSpecs.cpus"
/>
</div>
</div>
<!-- Custom plan banner -->
<div
v-if="customStartingPrice"
class="mt-4 flex items-center justify-between gap-4 rounded-2xl bg-surface-2 border-2 border-solid border-transparent p-5"
>
<div class="flex flex-col gap-1">
<div class="text-2xl font-semibold text-contrast">
{{ formatMessage(messages.customHeading) }}
</div>
<div class="text-sm text-secondary">
{{ formatMessage(messages.customDesc) }}
</div>
</div>
<div class="flex flex-col items-end gap-2 shrink-0">
<ButtonStyled>
<button class="flex items-center gap-2" @click="selectCustom">
{{ formatMessage(messages.getStarted) }} <RightArrowIcon class="h-4 w-4" />
</button>
</ButtonStyled>
<div class="text-sm text-secondary whitespace-nowrap">
Starting at {{ formatPrice(customStartingPrice, currency, true) }}/mo
</div>
</div>
</div>
@@ -30,7 +30,7 @@ const props = defineProps<{
availableProducts: Labrinth.Billing.Internal.Product[]
}>()
const loading = ref(true)
const loading = ref(false)
const checkingCustomStock = ref(false)
const selectedPlan = defineModel<Labrinth.Billing.Internal.Product>('plan')
const selectedRegion = defineModel<string>('region')
@@ -214,12 +214,11 @@ async function updateStock() {
onMounted(() => {
// auto select region with lowest ping
loading.value = true
bestPing.value =
props.pings.length > 0
? props.pings.reduce((acc, cur) => {
return acc.ping < cur.ping ? acc : cur
})?.region
: undefined
bestPing.value = [...props.pings].sort((a, b) => {
if (a.ping <= 0) return 1
if (b.ping <= 0) return -1
return a.ping - b.ping
})[0]?.region
selectedRegion.value = undefined
selectedRam.value = minRam.value
checkingCustomStock.value = true
@@ -179,7 +179,7 @@ function setInterval(newInterval: ServerBillingInterval) {
<template>
<div class="grid sm:grid-cols-[3fr_2fr] gap-4">
<div class="bg-table-alternateRow p-4 rounded-2xl">
<div class="bg-surface-2 p-4 rounded-2xl">
<div class="flex items-center gap-2 mb-3">
<ModrinthServersIcon class="flex h-5 w-fit" />
<TagItem>{{ planName }}</TagItem>
@@ -194,9 +194,7 @@ function setInterval(newInterval: ServerBillingInterval) {
/>
</div>
</div>
<div
class="bg-table-alternateRow p-4 rounded-2xl flex flex-col gap-2 items-center justify-center"
>
<div class="bg-surface-2 p-4 rounded-2xl flex flex-col gap-2 items-center justify-center">
<img
v-if="flag"
class="aspect-[16/10] max-w-12 w-full object-cover rounded-md border-1 border-button-border border-solid"
@@ -304,10 +302,7 @@ function setInterval(newInterval: ServerBillingInterval) {
"
/>
</template>
<div
v-else
class="p-4 rounded-2xl bg-table-alternateRow text-sm text-secondary leading-relaxed"
>
<div v-else class="p-4 rounded-2xl bg-surface-2 text-sm text-secondary leading-relaxed">
No payment required. Your downgrade will apply at the end of the current billing period.
</div>
</div>
@@ -9,7 +9,7 @@
:customer="customer"
:payment-methods="paymentMethods"
:currency="selectedCurrency"
:return-url="`${props.siteUrl}/hosting/manage`"
:return-url="checkoutReturnUrl"
:pings="regionPings"
:regions="regionsData"
:refresh-payment-methods="fetchPaymentData"
@@ -40,10 +40,14 @@ import { computed, ref, watch } from 'vue'
const props = defineProps<{
stripePublishableKey: string
siteUrl: string
siteUrl?: string
products: Labrinth.Billing.Internal.Product[]
}>()
const checkoutReturnUrl = computed(() => {
return props.siteUrl ? `${props.siteUrl}/hosting/manage` : undefined
})
const { addNotification } = injectNotificationManager()
const { labrinth, archon } = injectModrinthClient()
const debug = useDebugLogger('ServersUpgradeModalWrapper')
@@ -185,11 +189,6 @@ function runPingTest(region: Archon.Servers.v1.Region, index = 1) {
}
const subscription = ref<Labrinth.Billing.Internal.UserSubscription | null>(null)
// Dry run state
const dryRunResponse = ref<{
requires_payment: boolean
required_payment_is_proration: boolean
} | null>(null)
const pendingDowngradeBody = ref<Labrinth.Billing.Internal.EditSubscriptionRequest | null>(null)
const currentPlanFromSubscription = computed<Labrinth.Billing.Internal.Product | undefined>(() => {
return subscription.value
@@ -246,20 +245,16 @@ async function initiatePayment(
dry: true,
})
if (dry && typeof dry === 'object' && 'payment_intent_id' in dry) {
dryRunResponse.value = {
requires_payment: !!dry.payment_intent_id,
required_payment_is_proration: true,
}
pendingDowngradeBody.value = transformedBody
if (dry.payment_intent_id) {
return await finalizeImmediate(transformedBody)
} else {
return null
}
} else {
// Fallback if dry run not supported
const requiresPayment =
dry && typeof dry === 'object' && 'requires_payment' in dry && dry.requires_payment
if (requiresPayment) {
// Upgrade: requires payment — finalize to create the payment intent
return await finalizeImmediate(transformedBody)
} else {
// Downgrade or no payment change — defer until user confirms
pendingDowngradeBody.value = transformedBody
return null
}
} catch (e) {
debug('Dry run failed, attempting immediate patch', e)
@@ -291,8 +286,10 @@ async function finalizeImmediate(body: Labrinth.Billing.Internal.EditSubscriptio
}
async function finalizeDowngrade() {
if (!subscription.value || !pendingDowngradeBody.value) return
try {
if (!subscription.value || !pendingDowngradeBody.value)
throw new Error('Missing subscription or pending downgrade body')
await finalizeImmediate(pendingDowngradeBody.value)
addNotification({
title: 'Subscription updated',
@@ -307,7 +304,6 @@ async function finalizeDowngrade() {
})
throw e
} finally {
dryRunResponse.value = null
pendingDowngradeBody.value = null
}
}
@@ -1,5 +1,7 @@
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'