You've already forked AstralRinth
feat: start of cross platform page system (#4731)
* feat: abstract api-client DI into ui package * feat: cross platform page system * feat: tanstack as cross platform useAsyncData * feat: archon servers routes + labrinth billing routes * fix: dont use partial * feat: migrate server list page to tanstack + api-client + re-enabled broken features! * feat: migrate servers manage page to api-client before page system * feat: migrate manage page to page system * fix: type issues * fix: upgrade wrapper bugs * refactor: move state types into api-client * feat: disable financial stuff on app frontend * feat: finalize cross platform page system for now * fix: lint * fix: build issues * feat: remove papaparse * fix: lint * fix: interface error --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { InfoIcon } from '@modrinth/assets'
|
||||
import { formatPrice } from '@modrinth/utils'
|
||||
import { type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
import { Menu } from 'floating-vue'
|
||||
import { computed, inject, type Ref } from 'vue'
|
||||
|
||||
import { monthsInInterval, type ServerBillingInterval, type ServerPlan } from '../../utils/billing'
|
||||
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
|
||||
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
|
||||
import ServersSpecs from './ServersSpecs.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
plan: ServerPlan
|
||||
plan: Labrinth.Billing.Internal.Product
|
||||
title: MessageDescriptor
|
||||
description: MessageDescriptor
|
||||
buttonColor?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
@@ -25,7 +27,7 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', plan: ServerPlan): void
|
||||
(e: 'select', plan: Labrinth.Billing.Internal.Product): void
|
||||
}>()
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
@@ -36,13 +38,23 @@ const currency = inject<string>('currency')
|
||||
|
||||
const perMonth = computed(() => {
|
||||
if (!props.plan || !currency || !selectedInterval?.value) return undefined
|
||||
const total = props.plan.prices?.find((x) => x.currency_code === currency)?.prices?.intervals?.[
|
||||
selectedInterval.value
|
||||
]
|
||||
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> = {
|
||||
@@ -121,11 +133,11 @@ const mostPopularStyle = computed(() => {
|
||||
</template>
|
||||
|
||||
<template #popper>
|
||||
<div class="w-fit rounded-md border border-contrast/10 p-3 shadow-lg">
|
||||
<div v-if="planSpecs" class="w-fit rounded-md border border-contrast/10 p-3 shadow-lg">
|
||||
<ServersSpecs
|
||||
:ram="plan.metadata.ram!"
|
||||
:storage="plan.metadata.storage!"
|
||||
:cpus="plan.metadata.cpu!"
|
||||
:ram="planSpecs.ram"
|
||||
:storage="planSpecs.storage"
|
||||
:cpus="planSpecs.cpu"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronRightIcon,
|
||||
@@ -7,23 +8,12 @@ import {
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { UserSubscription } from '@modrinth/utils'
|
||||
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
import type Stripe from 'stripe'
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { useStripe } from '../../composables/stripe'
|
||||
import { commonMessages } from '../../utils'
|
||||
import type {
|
||||
CreatePaymentIntentRequest,
|
||||
CreatePaymentIntentResponse,
|
||||
ServerBillingInterval,
|
||||
ServerPlan,
|
||||
ServerRegion,
|
||||
ServerStockRequest,
|
||||
UpdatePaymentIntentRequest,
|
||||
UpdatePaymentIntentResponse,
|
||||
} from '../../utils/billing'
|
||||
import { ButtonStyled } from '../index'
|
||||
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
|
||||
import NewModal from '../modal/NewModal.vue'
|
||||
@@ -39,6 +29,9 @@ export type RegionPing = {
|
||||
ping: number
|
||||
}
|
||||
|
||||
// Type alias for billing interval that matches both the local and api-client types
|
||||
export type ServerBillingInterval = 'monthly' | 'quarterly' | 'yearly'
|
||||
|
||||
const props = defineProps<{
|
||||
publishableKey: string
|
||||
returnUrl: string
|
||||
@@ -46,23 +39,30 @@ const props = defineProps<{
|
||||
customer: Stripe.Customer
|
||||
currency: string
|
||||
pings: RegionPing[]
|
||||
regions: ServerRegion[]
|
||||
availableProducts: ServerPlan[]
|
||||
regions: Archon.Servers.v1.Region[]
|
||||
availableProducts: Labrinth.Billing.Internal.Product[]
|
||||
planStage?: boolean
|
||||
existingPlan?: ServerPlan
|
||||
existingSubscription?: UserSubscription
|
||||
existingPlan?: Labrinth.Billing.Internal.Product
|
||||
existingSubscription?: Labrinth.Billing.Internal.UserSubscription
|
||||
refreshPaymentMethods: () => Promise<void>
|
||||
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
|
||||
fetchStock: (
|
||||
region: Archon.Servers.v1.Region,
|
||||
request: Archon.Servers.v0.StockRequest,
|
||||
) => Promise<number>
|
||||
initiatePayment: (
|
||||
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
|
||||
) => Promise<UpdatePaymentIntentResponse | CreatePaymentIntentResponse | null>
|
||||
body: Labrinth.Billing.Internal.InitiatePaymentRequest,
|
||||
) => Promise<
|
||||
| Labrinth.Billing.Internal.InitiatePaymentResponse
|
||||
| Labrinth.Billing.Internal.EditSubscriptionResponse
|
||||
| null
|
||||
>
|
||||
onError: (err: Error) => void
|
||||
onFinalizeNoPaymentChange?: () => Promise<void>
|
||||
affiliateCode?: string | null
|
||||
}>()
|
||||
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
const selectedPlan = ref<ServerPlan>()
|
||||
const selectedPlan = ref<Labrinth.Billing.Internal.Product>()
|
||||
const selectedInterval = ref<ServerBillingInterval>('quarterly')
|
||||
const loading = ref(false)
|
||||
const selectedRegion = ref<string>()
|
||||
@@ -237,7 +237,7 @@ watch(selectedPlan, () => {
|
||||
}
|
||||
})
|
||||
|
||||
const defaultPlan = computed<ServerPlan | undefined>(() => {
|
||||
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') ??
|
||||
@@ -245,7 +245,11 @@ const defaultPlan = computed<ServerPlan | undefined>(() => {
|
||||
)
|
||||
})
|
||||
|
||||
function begin(interval: ServerBillingInterval, plan?: ServerPlan, project?: string) {
|
||||
function begin(
|
||||
interval: ServerBillingInterval,
|
||||
plan?: Labrinth.Billing.Internal.Product | null,
|
||||
project?: string,
|
||||
) {
|
||||
loading.value = false
|
||||
|
||||
if (plan === null) {
|
||||
|
||||
@@ -552,7 +552,7 @@ import Checkbox from '../base/Checkbox.vue'
|
||||
import Slider from '../base/Slider.vue'
|
||||
import AnimatedLogo from '../brand/AnimatedLogo.vue'
|
||||
import NewModal from '../modal/NewModal.vue'
|
||||
import LoaderIcon from '../servers/LoaderIcon.vue'
|
||||
import LoaderIcon from '../servers/icons/LoaderIcon.vue'
|
||||
|
||||
const { locale, formatMessage } = useVIntl()
|
||||
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { formatPrice } from '@modrinth/utils'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import { monthsInInterval, type ServerBillingInterval, type ServerPlan } from '../../utils/billing'
|
||||
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
|
||||
import OptionGroup from '../base/OptionGroup.vue'
|
||||
import ModalBasedServerPlan from './ModalBasedServerPlan.vue'
|
||||
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
availableProducts: ServerPlan[]
|
||||
availableProducts: Labrinth.Billing.Internal.Product[]
|
||||
currency: string
|
||||
existingPlan?: ServerPlan
|
||||
existingPlan?: Labrinth.Billing.Internal.Product
|
||||
}>()
|
||||
|
||||
const availableBillingIntervals = ['monthly', 'quarterly']
|
||||
|
||||
const selectedPlan = defineModel<ServerPlan>('plan')
|
||||
const selectedPlan = defineModel<Labrinth.Billing.Internal.Product>('plan')
|
||||
const selectedInterval = defineModel<ServerBillingInterval>('interval')
|
||||
const emit = defineEmits<{
|
||||
(e: 'choose-custom'): void
|
||||
@@ -75,7 +77,10 @@ const isSameAsExistingPlan = computed(() => {
|
||||
})
|
||||
|
||||
const plansByRam = computed(() => {
|
||||
const byName: Record<'small' | 'medium' | 'large', ServerPlan | undefined> = {
|
||||
const byName: Record<
|
||||
'small' | 'medium' | 'large',
|
||||
Labrinth.Billing.Internal.Product | undefined
|
||||
> = {
|
||||
small: undefined,
|
||||
medium: undefined,
|
||||
large: undefined,
|
||||
@@ -93,13 +98,11 @@ function handleCustomPlan() {
|
||||
emit('choose-custom')
|
||||
}
|
||||
|
||||
function pricePerMonth(plan?: ServerPlan) {
|
||||
if (!plan) return undefined
|
||||
const total = plan.prices?.find((x) => x.currency_code === props.currency)?.prices?.intervals?.[
|
||||
selectedInterval.value!
|
||||
]
|
||||
function pricePerMonth(plan?: Labrinth.Billing.Internal.Product) {
|
||||
if (!plan || !selectedInterval.value) return undefined
|
||||
const total = getPriceForInterval(plan, props.currency, selectedInterval.value)
|
||||
if (!total) return undefined
|
||||
return total / monthsInInterval[selectedInterval.value!]
|
||||
return total / monthsInInterval[selectedInterval.value]
|
||||
}
|
||||
|
||||
const customPricePerGb = computed(() => {
|
||||
@@ -107,7 +110,9 @@ const customPricePerGb = computed(() => {
|
||||
let min: number | undefined
|
||||
for (const p of props.availableProducts) {
|
||||
const perMonth = pricePerMonth(p)
|
||||
const ramGb = (p?.metadata?.ram ?? 0) / 1024
|
||||
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
|
||||
|
||||
@@ -1,44 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import { InfoIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { formatPrice } from '../../../../utils'
|
||||
import {
|
||||
monthsInInterval,
|
||||
type ServerBillingInterval,
|
||||
type ServerPlan,
|
||||
type ServerRegion,
|
||||
type ServerStockRequest,
|
||||
} from '../../utils/billing'
|
||||
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils.ts'
|
||||
import { regionOverrides } from '../../utils/regions.ts'
|
||||
import Slider from '../base/Slider.vue'
|
||||
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
|
||||
import type { RegionPing } from './ModrinthServersPurchaseModal.vue'
|
||||
import type { RegionPing, ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
|
||||
import ServersRegionButton from './ServersRegionButton.vue'
|
||||
import ServersSpecs from './ServersSpecs.vue'
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
regions: ServerRegion[]
|
||||
regions: Archon.Servers.v1.Region[]
|
||||
pings: RegionPing[]
|
||||
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
|
||||
fetchStock: (
|
||||
region: Archon.Servers.v1.Region,
|
||||
request: Archon.Servers.v0.StockRequest,
|
||||
) => Promise<number>
|
||||
custom: boolean
|
||||
currency: string
|
||||
interval: ServerBillingInterval
|
||||
availableProducts: ServerPlan[]
|
||||
availableProducts: Labrinth.Billing.Internal.Product[]
|
||||
}>()
|
||||
|
||||
const loading = ref(true)
|
||||
const checkingCustomStock = ref(false)
|
||||
const selectedPlan = defineModel<ServerPlan>('plan')
|
||||
const selectedPlan = defineModel<Labrinth.Billing.Internal.Product>('plan')
|
||||
const selectedRegion = defineModel<string>('region')
|
||||
|
||||
const selectedPrice = computed(() => {
|
||||
const amount = selectedPlan.value?.prices?.find((price) => price.currency_code === props.currency)
|
||||
?.prices?.intervals?.[props.interval]
|
||||
if (!selectedPlan.value) return undefined
|
||||
const amount = getPriceForInterval(selectedPlan.value, props.currency, props.interval)
|
||||
return amount ? amount / monthsInInterval[props.interval] : undefined
|
||||
})
|
||||
|
||||
@@ -67,7 +65,13 @@ const selectedRam = ref<number>(-1)
|
||||
|
||||
const ramOptions = computed(() => {
|
||||
return props.availableProducts
|
||||
.map((product) => (product.metadata.ram ?? 0) / 1024)
|
||||
.map((product) => {
|
||||
const metadata = product.metadata
|
||||
if (metadata.type === 'pyro' || metadata.type === 'medal') {
|
||||
return metadata.ram / 1024
|
||||
}
|
||||
return 0
|
||||
})
|
||||
.filter((x) => x > 0)
|
||||
})
|
||||
|
||||
@@ -80,38 +84,63 @@ const maxRam = computed(() => {
|
||||
|
||||
const lowestProduct = computed(() => {
|
||||
return (
|
||||
props.availableProducts.find(
|
||||
(product) => (product.metadata.ram ?? 0) / 1024 === minRam.value,
|
||||
) ?? props.availableProducts[0]
|
||||
props.availableProducts.find((product) => {
|
||||
const metadata = product.metadata
|
||||
return (
|
||||
(metadata.type === 'pyro' || metadata.type === 'medal') &&
|
||||
metadata.ram / 1024 === minRam.value
|
||||
)
|
||||
}) ?? props.availableProducts[0]
|
||||
)
|
||||
})
|
||||
|
||||
const selectedPlanSpecs = computed(() => {
|
||||
if (!selectedPlan.value) return null
|
||||
const metadata = selectedPlan.value.metadata
|
||||
if (metadata.type === 'pyro' || metadata.type === 'medal') {
|
||||
return {
|
||||
ram: metadata.ram,
|
||||
storage: metadata.storage,
|
||||
cpu: metadata.cpu,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function updateRamStock(regionToCheck: string, newRam: number) {
|
||||
if (newRam > 0) {
|
||||
checkingCustomStock.value = true
|
||||
const plan = props.availableProducts.find(
|
||||
(product) => (product.metadata.ram ?? 0) / 1024 === newRam,
|
||||
)
|
||||
const plan = props.availableProducts.find((product) => {
|
||||
const metadata = product.metadata
|
||||
return (
|
||||
(metadata.type === 'pyro' || metadata.type === 'medal') && metadata.ram / 1024 === newRam
|
||||
)
|
||||
})
|
||||
if (plan) {
|
||||
const region = sortedRegions.value.find((region) => region.shortcode === regionToCheck)
|
||||
if (region) {
|
||||
props
|
||||
.fetchStock(region, {
|
||||
cpu: plan.metadata.cpu ?? 0,
|
||||
memory_mb: plan.metadata.ram ?? 0,
|
||||
swap_mb: plan.metadata.swap ?? 0,
|
||||
storage_mb: plan.metadata.storage ?? 0,
|
||||
})
|
||||
.then((stock: number) => {
|
||||
if (stock > 0) {
|
||||
selectedPlan.value = plan
|
||||
} else {
|
||||
selectedPlan.value = undefined
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
checkingCustomStock.value = false
|
||||
})
|
||||
const metadata = plan.metadata
|
||||
if (metadata.type === 'pyro' || metadata.type === 'medal') {
|
||||
props
|
||||
.fetchStock(region, {
|
||||
cpu: metadata.cpu,
|
||||
memory_mb: metadata.ram,
|
||||
swap_mb: metadata.swap,
|
||||
storage_mb: metadata.storage,
|
||||
})
|
||||
.then((stock: number) => {
|
||||
if (stock > 0) {
|
||||
selectedPlan.value = plan
|
||||
} else {
|
||||
selectedPlan.value = undefined
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
checkingCustomStock.value = false
|
||||
})
|
||||
} else {
|
||||
checkingCustomStock.value = false
|
||||
}
|
||||
} else {
|
||||
checkingCustomStock.value = false
|
||||
}
|
||||
@@ -151,22 +180,28 @@ 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,
|
||||
}
|
||||
}
|
||||
return { cpu: 0, memory_mb: 0, swap_mb: 0, storage_mb: 0 }
|
||||
}
|
||||
|
||||
const capacityChecks = sortedRegions.value.map((region) =>
|
||||
props.fetchStock(
|
||||
region,
|
||||
selectedPlan.value
|
||||
? {
|
||||
cpu: selectedPlan.value?.metadata.cpu ?? 0,
|
||||
memory_mb: selectedPlan.value?.metadata.ram ?? 0,
|
||||
swap_mb: selectedPlan.value?.metadata.swap ?? 0,
|
||||
storage_mb: selectedPlan.value?.metadata.storage ?? 0,
|
||||
}
|
||||
: {
|
||||
cpu: lowestProduct.value.metadata.cpu ?? 0,
|
||||
memory_mb: lowestProduct.value.metadata.ram ?? 0,
|
||||
swap_mb: lowestProduct.value.metadata.swap ?? 0,
|
||||
storage_mb: lowestProduct.value.metadata.storage ?? 0,
|
||||
},
|
||||
? getStockRequest(selectedPlan.value)
|
||||
: getStockRequest(lowestProduct.value),
|
||||
),
|
||||
)
|
||||
const results = await Promise.all(capacityChecks)
|
||||
@@ -255,12 +290,12 @@ onMounted(() => {
|
||||
<div v-if="checkingCustomStock" class="flex gap-2 items-center">
|
||||
<SpinnerIcon class="size-5 shrink-0 animate-spin" /> Checking availability...
|
||||
</div>
|
||||
<div v-else-if="selectedPlan">
|
||||
<div v-else-if="selectedPlanSpecs">
|
||||
<ServersSpecs
|
||||
class="!flex-row justify-between"
|
||||
:ram="selectedPlan.metadata.ram ?? 0"
|
||||
:storage="selectedPlan.metadata.storage ?? 0"
|
||||
:cpus="selectedPlan.metadata.cpu ?? 0"
|
||||
:ram="selectedPlanSpecs.ram"
|
||||
:storage="selectedPlanSpecs.storage"
|
||||
:cpus="selectedPlanSpecs.cpu"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex gap-2 items-center">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
EditIcon,
|
||||
ExternalIcon,
|
||||
@@ -9,18 +10,13 @@ import {
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { formatPrice, getPingLevel, type UserSubscription } from '@modrinth/utils'
|
||||
import { formatPrice, getPingLevel } from '@modrinth/utils'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import dayjs from 'dayjs'
|
||||
import type Stripe from 'stripe'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
monthsInInterval,
|
||||
type ServerBillingInterval,
|
||||
type ServerPlan,
|
||||
type ServerRegion,
|
||||
} from '../../utils/billing'
|
||||
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
|
||||
import { regionOverrides } from '../../utils/regions'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import Checkbox from '../base/Checkbox.vue'
|
||||
@@ -28,6 +24,7 @@ import TagItem from '../base/TagItem.vue'
|
||||
import ModrinthServersIcon from '../servers/ModrinthServersIcon.vue'
|
||||
import ExpandableInvoiceTotal from './ExpandableInvoiceTotal.vue'
|
||||
import FormattedPaymentMethod from './FormattedPaymentMethod.vue'
|
||||
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
|
||||
import ServersSpecs from './ServersSpecs.vue'
|
||||
|
||||
const vintl = useVIntl()
|
||||
@@ -38,8 +35,8 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
plan: ServerPlan
|
||||
region: ServerRegion
|
||||
plan: Labrinth.Billing.Internal.Product
|
||||
region: Archon.Servers.v1.Region
|
||||
tax?: number
|
||||
total?: number
|
||||
currency: string
|
||||
@@ -48,25 +45,28 @@ const props = defineProps<{
|
||||
selectedPaymentMethod: Stripe.PaymentMethod | undefined
|
||||
hasPaymentMethod?: boolean
|
||||
noPaymentRequired?: boolean
|
||||
existingPlan?: ServerPlan
|
||||
existingSubscription?: UserSubscription
|
||||
existingPlan?: Labrinth.Billing.Internal.Product
|
||||
existingSubscription?: Labrinth.Billing.Internal.UserSubscription
|
||||
}>()
|
||||
|
||||
const interval = defineModel<ServerBillingInterval>('interval', { required: true })
|
||||
const acceptedEula = defineModel<boolean>('acceptedEula', { required: true })
|
||||
|
||||
const prices = computed(() => {
|
||||
return props.plan.prices.find((x) => x.currency_code === props.currency)
|
||||
})
|
||||
|
||||
const selectedPlanPriceForInterval = computed<number | undefined>(() => {
|
||||
return prices.value?.prices?.intervals?.[interval.value as keyof typeof monthsInInterval]
|
||||
return getPriceForInterval(props.plan, props.currency, interval.value)
|
||||
})
|
||||
|
||||
const existingPlanPriceForInterval = computed<number | undefined>(() => {
|
||||
if (!props.existingPlan) return undefined
|
||||
const p = props.existingPlan.prices.find((x) => x.currency_code === props.currency)
|
||||
return p?.prices?.intervals?.[interval.value as keyof typeof monthsInInterval]
|
||||
return getPriceForInterval(props.existingPlan, props.currency, interval.value)
|
||||
})
|
||||
|
||||
const monthlyPrice = computed<number | undefined>(() => {
|
||||
return getPriceForInterval(props.plan, props.currency, 'monthly')
|
||||
})
|
||||
|
||||
const quarterlyPrice = computed<number | undefined>(() => {
|
||||
return getPriceForInterval(props.plan, props.currency, 'quarterly')
|
||||
})
|
||||
|
||||
const upgradeDeltaPrice = computed<number | undefined>(() => {
|
||||
@@ -137,6 +137,18 @@ const planName = computed(() => {
|
||||
return 'Custom'
|
||||
})
|
||||
|
||||
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 flag = computed(
|
||||
() =>
|
||||
regionOverrides[props.region.shortcode]?.flag ??
|
||||
@@ -173,11 +185,11 @@ function setInterval(newInterval: ServerBillingInterval) {
|
||||
</div>
|
||||
<div>
|
||||
<ServersSpecs
|
||||
v-if="plan.metadata && plan.metadata.ram && plan.metadata.storage && plan.metadata.cpu"
|
||||
v-if="planSpecs"
|
||||
class="!grid sm:grid-cols-2"
|
||||
:ram="plan.metadata.ram"
|
||||
:storage="plan.metadata.storage"
|
||||
:cpus="plan.metadata.cpu"
|
||||
:ram="planSpecs.ram"
|
||||
:storage="planSpecs.storage"
|
||||
:cpus="planSpecs.cpu"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,8 +246,7 @@ function setInterval(newInterval: ServerBillingInterval) {
|
||||
>Pay monthly</span
|
||||
>
|
||||
<span class="text-sm text-secondary flex items-center gap-1"
|
||||
>{{ formatPrice(locale, prices?.prices.intervals['monthly'], currency, true) }} /
|
||||
month</span
|
||||
>{{ formatPrice(locale, monthlyPrice, currency, true) }} / month</span
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
@@ -261,7 +272,7 @@ function setInterval(newInterval: ServerBillingInterval) {
|
||||
>{{
|
||||
formatPrice(
|
||||
locale,
|
||||
(prices?.prices?.intervals?.['quarterly'] ?? 0) / monthsInInterval['quarterly'],
|
||||
(quarterlyPrice ?? 0) / monthsInInterval['quarterly'],
|
||||
currency,
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { SignalIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { getPingLevel } from '@modrinth/utils'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ServerRegion } from '../../utils/billing'
|
||||
import { regionOverrides } from '../../utils/regions'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -12,7 +12,7 @@ const { formatMessage } = useVIntl()
|
||||
const currentRegion = defineModel<string | undefined>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
region: ServerRegion
|
||||
region: Archon.Servers.v1.Region
|
||||
ping?: number
|
||||
bestPing?: boolean
|
||||
outOfStock?: boolean
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
<template>
|
||||
<ModrinthServersPurchaseModal
|
||||
v-if="customer && regionsData"
|
||||
ref="purchaseModal"
|
||||
:publishable-key="props.stripePublishableKey"
|
||||
:initiate-payment="async (body) => await initiatePayment(body)"
|
||||
:available-products="pyroProducts"
|
||||
:on-error="handleError"
|
||||
:customer="customer"
|
||||
:payment-methods="paymentMethods"
|
||||
:currency="selectedCurrency"
|
||||
:return-url="`${props.siteUrl}/servers/manage`"
|
||||
:pings="regionPings"
|
||||
:regions="regionsData"
|
||||
:refresh-payment-methods="fetchPaymentData"
|
||||
:fetch-stock="fetchStock"
|
||||
:plan-stage="true"
|
||||
:existing-plan="currentPlanFromSubscription"
|
||||
:existing-subscription="subscription || undefined"
|
||||
:on-finalize-no-payment-change="finalizeDowngrade"
|
||||
@hide="
|
||||
() => {
|
||||
debug('modal hidden, resetting subscription')
|
||||
subscription = null
|
||||
}
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
ModrinthServersPurchaseModal,
|
||||
useDebugLogger,
|
||||
} from '@modrinth/ui'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
stripePublishableKey: string
|
||||
siteUrl: string
|
||||
products: Labrinth.Billing.Internal.Product[]
|
||||
}>()
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { labrinth, archon } = injectModrinthClient()
|
||||
const debug = useDebugLogger('ServersUpgradeModalWrapper')
|
||||
const purchaseModal = ref<InstanceType<typeof ModrinthServersPurchaseModal> | null>(null)
|
||||
|
||||
// stripe type
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const customer = ref<any>(null)
|
||||
|
||||
// stripe type
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const paymentMethods = ref<any[]>([])
|
||||
const selectedCurrency = ref<string>('USD')
|
||||
|
||||
const regionPings = ref<
|
||||
{
|
||||
region: string
|
||||
ping: number
|
||||
}[]
|
||||
>([])
|
||||
|
||||
const pyroProducts = (props.products as Labrinth.Billing.Internal.Product[])
|
||||
.filter((p) => p?.metadata?.type === 'pyro' || p?.metadata?.type === 'medal')
|
||||
.sort((a, b) => {
|
||||
const aRam = a?.metadata?.type === 'pyro' || a?.metadata?.type === 'medal' ? a.metadata.ram : 0
|
||||
const bRam = b?.metadata?.type === 'pyro' || b?.metadata?.type === 'medal' ? b.metadata.ram : 0
|
||||
return aRam - bRam
|
||||
})
|
||||
|
||||
function handleError(err: unknown) {
|
||||
debug('Purchase modal error:', err)
|
||||
}
|
||||
|
||||
const { data: customerData } = useQuery({
|
||||
queryKey: ['billing', 'customer'],
|
||||
queryFn: () => labrinth.billing_internal.getCustomer(),
|
||||
})
|
||||
|
||||
const { data: paymentMethodsData, refetch: refetchPaymentMethods } = useQuery({
|
||||
queryKey: ['billing', 'payment-methods'],
|
||||
queryFn: () => labrinth.billing_internal.getPaymentMethods(),
|
||||
})
|
||||
|
||||
const { data: regionsData } = useQuery({
|
||||
queryKey: ['servers', 'regions'],
|
||||
queryFn: () => archon.servers_v1.getRegions(),
|
||||
})
|
||||
|
||||
watch(customerData, (newCustomer) => {
|
||||
if (newCustomer) customer.value = newCustomer
|
||||
})
|
||||
|
||||
watch(paymentMethodsData, (newMethods) => {
|
||||
if (newMethods) paymentMethods.value = newMethods
|
||||
})
|
||||
|
||||
watch(regionsData, (newRegions) => {
|
||||
if (newRegions) {
|
||||
newRegions.forEach((region) => {
|
||||
runPingTest(region)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchPaymentData() {
|
||||
await refetchPaymentMethods()
|
||||
}
|
||||
|
||||
async function fetchStock(
|
||||
region: Archon.Servers.v1.Region,
|
||||
request: Archon.Servers.v0.StockRequest,
|
||||
): Promise<number> {
|
||||
const result = await archon.servers_v0.checkStock(region.shortcode, request)
|
||||
return result.available
|
||||
}
|
||||
|
||||
const PING_COUNT = 20
|
||||
const PING_INTERVAL = 200
|
||||
const MAX_PING_TIME = 1000
|
||||
|
||||
function runPingTest(region: Archon.Servers.v1.Region, index = 1) {
|
||||
if (index > 10) {
|
||||
regionPings.value.push({
|
||||
region: region.shortcode,
|
||||
ping: -1,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const wsUrl = `wss://${region.shortcode}${index}.${region.zone}/pingtest`
|
||||
try {
|
||||
const socket = new WebSocket(wsUrl)
|
||||
const pings: number[] = []
|
||||
|
||||
socket.onopen = () => {
|
||||
for (let i = 0; i < PING_COUNT; i++) {
|
||||
setTimeout(() => {
|
||||
socket.send(String(performance.now()))
|
||||
}, i * PING_INTERVAL)
|
||||
}
|
||||
setTimeout(
|
||||
() => {
|
||||
socket.close()
|
||||
const median = Math.round([...pings].sort((a, b) => a - b)[Math.floor(pings.length / 2)])
|
||||
if (median) {
|
||||
regionPings.value.push({
|
||||
region: region.shortcode,
|
||||
ping: median,
|
||||
})
|
||||
}
|
||||
},
|
||||
PING_COUNT * PING_INTERVAL + MAX_PING_TIME,
|
||||
)
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const start = Number(event.data)
|
||||
pings.push(performance.now() - start)
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
runPingTest(region, index + 1)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
? pyroProducts.find((p) =>
|
||||
p.prices.some((price) => price.id === subscription.value?.price_id),
|
||||
) ?? undefined
|
||||
: undefined
|
||||
})
|
||||
|
||||
const currentInterval = computed<'monthly' | 'quarterly'>(() => {
|
||||
const interval = subscription.value?.interval
|
||||
|
||||
if (interval === 'monthly' || interval === 'quarterly') {
|
||||
return interval
|
||||
}
|
||||
|
||||
return 'monthly'
|
||||
})
|
||||
|
||||
const editSubscriptionMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
body,
|
||||
dry,
|
||||
}: {
|
||||
id: string
|
||||
body: Labrinth.Billing.Internal.EditSubscriptionRequest
|
||||
dry: boolean
|
||||
}) => {
|
||||
return await labrinth.billing_internal.editSubscription(id, body, dry)
|
||||
},
|
||||
})
|
||||
|
||||
async function initiatePayment(
|
||||
body: Labrinth.Billing.Internal.InitiatePaymentRequest,
|
||||
): Promise<Labrinth.Billing.Internal.EditSubscriptionResponse | null> {
|
||||
debug('initiatePayment called', {
|
||||
hasSubscription: !!subscription.value,
|
||||
subscriptionId: subscription.value?.id,
|
||||
body,
|
||||
})
|
||||
if (subscription.value) {
|
||||
const transformedBody: Labrinth.Billing.Internal.EditSubscriptionRequest = {
|
||||
interval: body.charge.type === 'new' ? body.charge.interval : undefined,
|
||||
payment_method: body.type === 'confirmation_token' ? body.token : body.id,
|
||||
product: body.charge.type === 'new' ? body.charge.product_id : undefined,
|
||||
region: body.metadata?.server_region,
|
||||
}
|
||||
|
||||
try {
|
||||
const dry = await editSubscriptionMutation.mutateAsync({
|
||||
id: subscription.value.id,
|
||||
body: transformedBody,
|
||||
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
|
||||
return await finalizeImmediate(transformedBody)
|
||||
}
|
||||
} catch (e) {
|
||||
debug('Dry run failed, attempting immediate patch', e)
|
||||
return await finalizeImmediate(transformedBody)
|
||||
}
|
||||
} else {
|
||||
debug('subscription.value is null/undefined', {
|
||||
subscriptionValue: subscription.value,
|
||||
})
|
||||
addNotification({
|
||||
title: 'Unable to determine subscription ID.',
|
||||
text: 'Please contact support.',
|
||||
type: 'error',
|
||||
})
|
||||
return Promise.reject(new Error('Unable to determine subscription ID.'))
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizeImmediate(body: Labrinth.Billing.Internal.EditSubscriptionRequest) {
|
||||
if (!subscription.value) return null
|
||||
|
||||
const result = await editSubscriptionMutation.mutateAsync({
|
||||
id: subscription.value.id,
|
||||
body,
|
||||
dry: false,
|
||||
})
|
||||
|
||||
return result ?? null
|
||||
}
|
||||
|
||||
async function finalizeDowngrade() {
|
||||
if (!subscription.value || !pendingDowngradeBody.value) return
|
||||
try {
|
||||
await finalizeImmediate(pendingDowngradeBody.value)
|
||||
addNotification({
|
||||
title: 'Subscription updated',
|
||||
text: 'Your plan has been downgraded and will take effect next billing cycle.',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: 'Failed to apply subscription changes',
|
||||
text: 'Please try again or contact support.',
|
||||
type: 'error',
|
||||
})
|
||||
throw e
|
||||
} finally {
|
||||
dryRunResponse.value = null
|
||||
pendingDowngradeBody.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function open(id?: string) {
|
||||
debug('open called', { id })
|
||||
if (id) {
|
||||
const subscriptions = await labrinth.billing_internal.getSubscriptions()
|
||||
debug('fetched subscriptions', {
|
||||
count: subscriptions.length,
|
||||
subscriptions: subscriptions.map((s) => ({
|
||||
id: s.id,
|
||||
metadataType: s.metadata?.type,
|
||||
metadataId: s.metadata?.id,
|
||||
})),
|
||||
})
|
||||
for (const sub of subscriptions) {
|
||||
if (
|
||||
(sub?.metadata?.type === 'pyro' || sub?.metadata?.type === 'medal') &&
|
||||
sub.metadata.id === id
|
||||
) {
|
||||
subscription.value = sub
|
||||
debug('found matching subscription', {
|
||||
subscriptionId: sub.id,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!subscription.value) {
|
||||
debug('no matching subscription found for id', id)
|
||||
}
|
||||
} else {
|
||||
debug('no id provided, resetting subscription')
|
||||
subscription.value = null
|
||||
}
|
||||
|
||||
purchaseModal.value?.show(currentInterval.value)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user