mirror of
https://github.com/modrinth/code.git
synced 2026-09-04 05:48:57 +00:00
implement resubscribe modal
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user