Files
modrinth/packages/ui/src/components/billing/ServersPurchase0Plan.vue
T
ArthurandProspector 61b52ecd2c fix: inconsistent color vars (#7028)
* Fix inconsistent color vars

* pointless commit

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>

* undo

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>

* prepr

---------

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-07 06:30:42 +00:00

377 lines
11 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 { RightArrowIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { Button } from '#ui/components/base/buttons'
import { useFormatPrice } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import OptionGroup from '../base/OptionGroup.vue'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import ServersSpecs from './ServersSpecs.vue'
const { formatMessage } = useVIntl()
const formatPrice = useFormatPrice()
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' | 'proceed'): void
}>()
const messages = defineMessages({
selectPlan: {
id: 'servers.purchase.step.plan.select',
defaultMessage: 'Select Plan',
},
getStarted: {
id: 'servers.purchase.step.plan.get-started',
defaultMessage: 'Get started',
},
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',
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',
},
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',
},
billedInterval: {
id: 'servers.purchase.step.plan.billed',
defaultMessage:
'billed {interval, select, monthly {monthly} quarterly {quarterly} yearly {yearly} other {{interval}}}',
},
})
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 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) {
if (!plan || !selectedInterval.value) return undefined
const total = getPriceForInterval(plan, props.currency, selectedInterval.value)
if (!total) return undefined
return total / monthsInInterval[selectedInterval.value]
}
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
})
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="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'">Monthly</template>
<span v-else-if="option === 'quarterly'">
Quarterly <span class="text-brand">(Save 16%)</span>
</span>
</OptionGroup>
<div class="text-sm text-secondary text-center">
{{ formatMessage(messages.billingSubtitle) }}
</div>
</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-3 gap-4 items-start">
<!-- Small -->
<div
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>
<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'"
>,
{{ formatMessage(messages.billedInterval, { interval: selectedInterval }) }}</template
>
</span>
</div>
<div class="mt-2 text-sm text-primary">
{{ formatMessage(messages.smallDesc) }}
</div>
</div>
<div class="w-full">
<Button
type="colored"
color="blue"
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>
</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-brand 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>
<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'"
>,
{{
formatMessage(messages.billedInterval, { interval: selectedInterval })
}}</template
>
</span>
</div>
<div class="mt-2 text-sm text-primary">
{{ formatMessage(messages.mediumDesc) }}
</div>
</div>
<div class="w-full">
<Button
type="colored"
color="brand"
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>
</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'"
>,
{{ formatMessage(messages.billedInterval, { interval: selectedInterval }) }}</template
>
</span>
</div>
<div class="mt-2 text-sm text-primary">
{{ formatMessage(messages.largeDesc) }}
</div>
</div>
<div class="w-full">
<Button
type="colored"
color="purple"
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>
</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">
<Button class="flex items-center gap-2" @click="selectCustom">
{{ formatMessage(messages.getStarted) }} <RightArrowIcon class="h-4 w-4" />
</Button>
<div class="text-sm text-secondary whitespace-nowrap">
Starting at {{ formatPrice(customStartingPrice, currency, true) }}/mo
</div>
</div>
</div>
</template>