Files
AstralRinth/packages/ui/src/components/billing/ServersPurchase0Plan.vue
Calum H. 7ccc32675b 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>
2025-11-14 17:15:09 +00:00

232 lines
7.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 { 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: Labrinth.Billing.Internal.Product[]
currency: string
existingPlan?: Labrinth.Billing.Internal.Product
}>()
const availableBillingIntervals = ['monthly', 'quarterly']
const selectedPlan = defineModel<Labrinth.Billing.Internal.Product>('plan')
const selectedInterval = defineModel<ServerBillingInterval>('interval')
const emit = defineEmits<{
(e: 'choose-custom'): 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',
},
getStarted: {
id: 'servers.purchase.step.plan.get-started',
defaultMessage: 'Get started',
},
billed: {
id: 'servers.purchase.step.plan.billed',
defaultMessage: 'billed {interval}',
},
smallDesc: {
id: 'servers.purchase.step.plan.small.desc',
defaultMessage: 'Perfect for 15 friends with a few light mods.',
},
mediumDesc: {
id: 'servers.purchase.step.plan.medium.desc',
defaultMessage: 'Great for 615 players and multiple mods.',
},
largeDesc: {
id: 'servers.purchase.step.plan.large.desc',
defaultMessage: 'Ideal for 1525 players, modpacks, or heavy modding.',
},
customDesc: {
id: 'servers.purchase.step.plan.custom.desc',
defaultMessage: 'Pick a customized plan with just the specs you need.',
},
mostPopular: {
id: 'servers.purchase.step.plan.most-popular',
defaultMessage: 'Most Popular',
},
})
const isSameAsExistingPlan = computed(() => {
return !!(
props.existingPlan &&
selectedPlan.value &&
props.existingPlan.id === selectedPlan.value.id
)
})
const plansByRam = computed(() => {
const byName: Record<
'small' | 'medium' | 'large',
Labrinth.Billing.Internal.Product | undefined
> = {
small: undefined,
medium: undefined,
large: undefined,
}
for (const p of props.availableProducts) {
if (p?.metadata?.type !== 'pyro') continue
if (p.metadata.ram === 4096) byName.small = p
else if (p.metadata.ram === 6144) byName.medium = p
else if (p.metadata.ram === 8192) byName.large = p
}
return byName
})
function handleCustomPlan() {
emit('choose-custom')
}
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]
}
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) {
const perMonth = pricePerMonth(p)
if (perMonth && (min === undefined || perMonth < min)) min = perMonth
}
return min
})
provide('currency', props.currency)
provide('selectedInterval', selectedInterval)
</script>
<template>
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 mb-5 !mt-0">
<span></span>
<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>
</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>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="isSameAsExistingPlan" class="text-orange mb-5 text-center" role="alert">
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
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"
>
<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>
<span class="m-0 text-lg font-bold text-contrast">
{{ formatPrice(locale, customStartingPrice, currency, true) }}
<span class="text-sm font-semibold text-secondary">
/ 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(locale, customPricePerGb, currency, true) }} / GB
</span>
</div>
</div>
</div>
</div>
</div>
</template>