implement resubscribe modal

This commit is contained in:
tdgao
2026-03-26 19:14:40 -06:00
parent d2ec943e25
commit 557eed9759
4 changed files with 364 additions and 38 deletions
@@ -0,0 +1,201 @@
<template>
<NewModal ref="modal" max-width="550px">
<template #title>
<div class="text-2xl font-semibold text-contrast">Resubscribe to Server</div>
</template>
<div class="flex w-[44rem] max-w-full flex-col gap-6">
<p class="m-0 text-secondary leading-relaxed">
You are about to resubscribe to
<span class="font-semibold text-contrast">{{ modalData.serverName }}</span
>. Your subscription will be reactivated and your server will continue running without
interruption.
</p>
<div class="flex flex-col gap-2.5">
<span class="text-contrast font-semibold">Plan</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 class="text-secondary flex gap-1.5 font-medium text-sm items-center">
{{ modalData.ramGb }} GB RAM
<div class="h-1.5 w-1.5 bg-button-border rounded-full"></div>
{{ modalData.storageGb }} GB Storage
<div class="h-1.5 w-1.5 bg-button-border rounded-full"></div>
{{ modalData.sharedCpus }} Shared CPUs
</div>
</div>
<div class="flex flex-col gap-1 items-end">
<div class="font-semibold text-contrast">
{{ formattedPrice }}
</div>
<div class="text-secondary">/{{ intervalLabel }}</div>
</div>
</div>
</div>
<p v-if="formattedNextChargeDate" class="m-0 text-primary">
Your next charge will be on
<span class="font-semibold text-contrast">{{ formattedNextChargeDate }}</span
>.
</p>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button class="!border !border-surface-5" @click="handleCancel">
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canResubscribe" @click="handleResubscribe">
<RotateCounterClockwiseIcon />
Resubscribe
</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 { useFormatDateTime, useFormatPrice } from '../../composables'
import { ButtonStyled, NewModal } from '../index'
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 | null
nextChargeDate?: string | number | Date | null
}
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 FALLBACK_NEXT_CHARGE_DATE = '2025-02-17'
const modalData = ref<ResubscribeModalState>({
subscriptionId: '',
wasSuspended: false,
serverName: 'this server',
planName: 'Medium plan',
ramGb: 2,
storageGb: 48,
sharedCpus: 3,
priceCents: 1500,
currencyCode: 'USD',
interval: 'monthly',
nextChargeDate: FALLBACK_NEXT_CHARGE_DATE,
})
const canResubscribe = computed(() => !!modalData.value.subscriptionId)
const intervalLabel = computed(() => {
switch (modalData.value.interval) {
case 'monthly':
return 'month'
case 'quarterly':
return 'quarter'
case 'yearly':
return 'year'
case 'five-days':
return '5 days'
default:
return 'month'
}
})
const formattedPrice = computed(() =>
formatPrice(modalData.value.priceCents, modalData.value.currencyCode),
)
const normalizedNextChargeDate = computed(() => {
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) {
modalData.value = {
subscriptionId: payload.subscriptionId,
wasSuspended: payload.wasSuspended,
serverName: payload.serverName?.trim() || 'this server',
planName: payload.planName ?? 'Medium plan',
ramGb: payload.ramGb ?? 2,
storageGb: payload.storageGb ?? 48,
sharedCpus: payload.sharedCpus ?? 3,
priceCents: payload.priceCents ?? 1500,
currencyCode: payload.currencyCode ?? 'USD',
interval: payload.interval ?? 'monthly',
nextChargeDate: payload.nextChargeDate ?? FALLBACK_NEXT_CHARGE_DATE,
}
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function handleCancel() {
hide()
emit('cancel')
}
function handleResubscribe() {
if (!canResubscribe.value) return
hide()
emit('resubscribe', {
subscriptionId: modalData.value.subscriptionId,
wasSuspended: modalData.value.wasSuspended,
})
}
defineExpose({
show,
hide,
})
</script>
@@ -1,5 +1,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 ServersSpecs } from './ServersSpecs.vue'
export { default as ServersUpgradeModalWrapper } from './ServersUpgradeModalWrapper.vue'
@@ -24,6 +24,7 @@
:affiliate-code="affiliateCode"
plan-stage
/>
<ResubscribeModal ref="resubscribeModal" @resubscribe="handleResubscribeConfirm" />
<div
v-if="hasError || fetchError"
@@ -202,6 +203,7 @@ import {
injectModrinthClient,
injectNotificationManager,
ModrinthServersPurchaseModal,
ResubscribeModal,
StyledInput,
} from '@modrinth/ui'
import type { ModrinthServersFetchError } from '@modrinth/utils'
@@ -236,6 +238,7 @@ const pollingState = ref({
})
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 regionPings = ref<
@@ -547,6 +550,11 @@ type ServerBillingInfo = {
onDownloadBackup?: (() => void) | null
}
type ResubscribeRequest = {
subscriptionId: string
wasSuspended: boolean
}
function getLatestBackupDownload(serverId: string): (() => void) | null {
const serverFull = serverFullList.value?.find((s) => s.id === serverId)
if (!serverFull) return null
@@ -587,6 +595,127 @@ function getLatestBackupDownload(serverId: string): (() => void) | null {
}
}
function getProductFromPriceId(priceId: string | null | undefined) {
if (!priceId) return null
return pyroProducts.value.find((product) => product.prices.some((price) => price.id === priceId)) ?? null
}
function getPlanName(product: Labrinth.Billing.Internal.Product | null): string {
if (!product) return 'Medium plan'
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return 'Medium plan'
switch (product.metadata.ram) {
case 4096:
return 'Small plan'
case 6144:
return 'Medium plan'
case 8192:
return 'Large plan'
default:
return 'Custom plan'
}
}
function getRamGb(product: Labrinth.Billing.Internal.Product | null): number | undefined {
if (!product) return undefined
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return undefined
return product.metadata.ram / 1024
}
function getStorageGb(product: Labrinth.Billing.Internal.Product | null): number | undefined {
if (!product) return undefined
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return undefined
return product.metadata.storage / 1024
}
function getSharedCpus(product: Labrinth.Billing.Internal.Product | null): number | undefined {
if (!product) return undefined
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return undefined
return product.metadata.cpu / 2
}
function getRecurringPrice(
product: Labrinth.Billing.Internal.Product | null,
interval: Labrinth.Billing.Internal.PriceDuration,
preferredCurrency?: string,
): { amount: number; currencyCode: string } | null {
if (!product) return null
const recurringPrices = product.prices.filter((price) => price.prices.type === 'recurring')
const preferredPrice = preferredCurrency
? recurringPrices.find((price) => price.currency_code === preferredCurrency)
: undefined
const usdPrice = recurringPrices.find((price) => price.currency_code === 'USD')
const selectedPrice = preferredPrice ?? usdPrice ?? recurringPrices[0]
if (!selectedPrice || selectedPrice.prices.type !== 'recurring') return null
return {
amount: selectedPrice.prices.intervals[interval],
currencyCode: selectedPrice.currency_code,
}
}
function openResubscribeModal(
serverId: string,
subscription: Labrinth.Billing.Internal.UserSubscription,
charge?: Labrinth.Billing.Internal.Charge | null,
) {
const displayInterval = charge?.subscription_interval ?? subscription.interval
const displayPriceId = charge?.price_id ?? subscription.price_id
const product = getProductFromPriceId(displayPriceId)
const fallbackPrice = getRecurringPrice(product, displayInterval, charge?.currency_code)
resubscribeModal.value?.show({
subscriptionId: subscription.id,
wasSuspended: !!charge?.due && dayjs(charge.due).isBefore(dayjs()),
serverName: serverList.value.find((server) => server.server_id === serverId)?.name ?? 'this server',
planName: getPlanName(product),
ramGb: getRamGb(product),
storageGb: getStorageGb(product),
sharedCpus: getSharedCpus(product),
priceCents: charge?.amount ?? fallbackPrice?.amount,
currencyCode: charge?.currency_code ?? fallbackPrice?.currencyCode,
interval: displayInterval,
nextChargeDate: charge?.due,
})
}
async function handleResubscribeConfirm({ subscriptionId, wasSuspended }: ResubscribeRequest) {
try {
await client.labrinth.billing_internal.editSubscription(subscriptionId, {
cancelled: false,
})
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['billing'] }),
queryClient.invalidateQueries({ queryKey: ['servers'] }),
])
if (wasSuspended) {
addNotification({
title: 'Resubscription request submitted',
text: 'If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.',
type: 'success',
})
} else {
addNotification({
title: 'Success',
text: 'Server subscription resubscribed successfully',
type: 'success',
})
}
} catch {
addNotification({
title: 'Error resubscribing',
text: 'An error occurred while resubscribing to your Modrinth server.',
type: 'error',
})
}
}
const serverBillingMap = computed(() => {
const map = new Map<string, ServerBillingInfo>()
if (!subscriptions.value || !charges.value) return map
@@ -607,38 +736,7 @@ const serverBillingMap = computed(() => {
if (charge?.status === 'cancelled') {
info.cancellationDate = charge.due
const subId = sub.id
const wasSuspended = dayjs(charge.due).isBefore(dayjs())
info.onResubscribe = async () => {
try {
await client.labrinth.billing_internal.editSubscription(subId, {
cancelled: false,
})
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['billing'] }),
queryClient.invalidateQueries({ queryKey: ['servers'] }),
])
if (wasSuspended) {
addNotification({
title: 'Resubscription request submitted',
text: 'If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.',
type: 'success',
})
} else {
addNotification({
title: 'Success',
text: 'Server subscription resubscribed successfully',
type: 'success',
})
}
} catch {
addNotification({
title: 'Error resubscribing',
text: 'An error occurred while resubscribing to your Modrinth server.',
type: 'error',
})
}
}
info.onResubscribe = () => openResubscribeModal(serverId, sub, charge)
}
map.set(serverId, info)