You've already forked AstralRinth
forked from didirus/AstralRinth
feat: medal promotion on servers page (#4117)
* feat: medal promotion on servers page * feat: medal server card * fix: styling changes * fix: colors for dark mode only * fix: light mode medal promotion * feat: finish server card layout * feat: countdown on server panel * fix: lint * feat: use same gradient as promo * fix: scale for medal bg * fix: border around server icon * feat: medal subscr expiry date stuff * feat: progress on plans within the modal * feat: finalize plan modal stage * fix: unused scss * feat: remove buttons from cards * feat: upgrade button opens modal on server panel * feat: billing endpoint * fix: lint issues * fix: lint issues * fix: lint issues * feat: better handling of downgrades + existing plan checks * feat: update medal url * feat: proration visual in modal * feat: standardize upgrade modal into ServersUpgradeModalWrapper * feat: replace upgrade PurchaseModal with ServersUpgradeModalWrapper * feat: allow server region * fix: lint * fix: lint * fix: medal frontend completion * fix: lint issues * feat: ad * fix: hover tooltip + orange new server sparkle * feat: ad * fix: lint issues new eslint * feat: match ad * feat: support for ?dry=true * fix: lint isuses * fix: lint issues * fix: TeleportDropdownMenu imports * fix: hash nav issues * feat: clarify confirm changes btn * fix: lint issues * fix: "Using new payment method" * fix: lint * fix: re-add -mt-2 --------- Signed-off-by: Cal H. <hendersoncal117@gmail.com>
This commit is contained in:
128
packages/ui/src/components/base/OptionGroup.vue
Normal file
128
packages/ui/src/components/base/OptionGroup.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<nav
|
||||
ref="scrollContainer"
|
||||
class="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
>
|
||||
<button
|
||||
v-for="(option, index) in options"
|
||||
:key="`option-group-${index}`"
|
||||
ref="optionButtons"
|
||||
class="button-animation z-[1] flex flex-row items-center gap-2 rounded-full bg-transparent px-4 py-2 font-semibold"
|
||||
:class="{
|
||||
'text-button-textSelected': modelValue === option,
|
||||
'text-primary': modelValue !== option,
|
||||
}"
|
||||
@click="setOption(option)"
|
||||
>
|
||||
<slot :option="option" :selected="modelValue === option" />
|
||||
</button>
|
||||
<div
|
||||
class="navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full bg-button-bgSelected p-1"
|
||||
:style="{
|
||||
left: sliderLeftPx,
|
||||
top: sliderTopPx,
|
||||
right: sliderRightPx,
|
||||
bottom: sliderBottomPx,
|
||||
opacity: initialized ? 1 : 0,
|
||||
}"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
const modelValue = defineModel<T>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
options: T[]
|
||||
}>()
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
const sliderLeft = ref(4)
|
||||
const sliderTop = ref(4)
|
||||
const sliderRight = ref(4)
|
||||
const sliderBottom = ref(4)
|
||||
|
||||
const sliderLeftPx = computed(() => `${sliderLeft.value}px`)
|
||||
const sliderTopPx = computed(() => `${sliderTop.value}px`)
|
||||
const sliderRightPx = computed(() => `${sliderRight.value}px`)
|
||||
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
|
||||
|
||||
const optionButtons = ref()
|
||||
|
||||
const initialized = ref(false)
|
||||
|
||||
function setOption(option: T) {
|
||||
modelValue.value = option
|
||||
}
|
||||
|
||||
watch(modelValue, () => {
|
||||
startAnimation(props.options.indexOf(modelValue.value))
|
||||
})
|
||||
|
||||
function startAnimation(index: number) {
|
||||
const el = optionButtons.value[index]
|
||||
|
||||
if (!el || !el.offsetParent) return
|
||||
|
||||
const newValues = {
|
||||
left: el.offsetLeft,
|
||||
top: el.offsetTop,
|
||||
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
|
||||
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
|
||||
}
|
||||
|
||||
if (sliderLeft.value === 4 && sliderRight.value === 4) {
|
||||
sliderLeft.value = newValues.left
|
||||
sliderRight.value = newValues.right
|
||||
sliderTop.value = newValues.top
|
||||
sliderBottom.value = newValues.bottom
|
||||
} else {
|
||||
const delay = 200
|
||||
|
||||
if (newValues.left < sliderLeft.value) {
|
||||
sliderLeft.value = newValues.left
|
||||
setTimeout(() => {
|
||||
sliderRight.value = newValues.right
|
||||
}, delay)
|
||||
} else {
|
||||
sliderRight.value = newValues.right
|
||||
setTimeout(() => {
|
||||
sliderLeft.value = newValues.left
|
||||
}, delay)
|
||||
}
|
||||
|
||||
if (newValues.top < sliderTop.value) {
|
||||
sliderTop.value = newValues.top
|
||||
setTimeout(() => {
|
||||
sliderBottom.value = newValues.bottom
|
||||
}, delay)
|
||||
} else {
|
||||
sliderBottom.value = newValues.bottom
|
||||
setTimeout(() => {
|
||||
sliderTop.value = newValues.top
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
initialized.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startAnimation(props.options.indexOf(modelValue.value))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navtabs-transition {
|
||||
transition:
|
||||
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
|
||||
}
|
||||
|
||||
.card-shadow {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
</style>
|
||||
138
packages/ui/src/components/billing/ModalBasedServerPlan.vue
Normal file
138
packages/ui/src/components/billing/ModalBasedServerPlan.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon } 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 ServersSpecs from './ServersSpecs.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
plan: ServerPlan
|
||||
title: MessageDescriptor
|
||||
description: MessageDescriptor
|
||||
buttonColor?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
mostPopular?: boolean
|
||||
selected?: boolean
|
||||
}>(),
|
||||
{
|
||||
buttonColor: 'standard',
|
||||
mostPopular: false,
|
||||
selected: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', plan: ServerPlan): void
|
||||
}>()
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
|
||||
// TODO: Use DI framework when merged.
|
||||
const selectedInterval = inject<Ref<ServerBillingInterval>>('selectedInterval')
|
||||
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
|
||||
]
|
||||
if (!total) return undefined
|
||||
return total / monthsInInterval[selectedInterval.value]
|
||||
})
|
||||
|
||||
const mostPopularStyle = computed(() => {
|
||||
if (!props.mostPopular) return undefined
|
||||
const style: Record<string, string> = {
|
||||
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%)',
|
||||
boxShadow: '0px 12px 38.1px rgba(27, 217, 106, 0.13)',
|
||||
}
|
||||
|
||||
if (!props.selected) {
|
||||
style.borderColor = 'rgba(12, 107, 52, 0.55)'
|
||||
}
|
||||
|
||||
return style
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
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="{
|
||||
'bg-brand-highlight border-brand': selected,
|
||||
'bg-button-bg border-transparent': !selected,
|
||||
'!bg-bg': mostPopular,
|
||||
}"
|
||||
:style="mostPopularStyle"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-pressed="selected"
|
||||
@click="emit('select', plan)"
|
||||
@keydown.enter.prevent="emit('select', plan)"
|
||||
@keydown.space.prevent="emit('select', plan)"
|
||||
>
|
||||
<div class="flex h-full flex-col justify-between gap-2">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(title) }}
|
||||
</span>
|
||||
<div
|
||||
v-if="mostPopular"
|
||||
class="relative w-fit rounded-full bg-highlight-green px-3 py-1 text-sm font-bold text-brand backdrop-blur-lg"
|
||||
>
|
||||
Most Popular
|
||||
</div>
|
||||
</div>
|
||||
<span class="m-0 text-lg font-bold text-contrast">
|
||||
{{ formatPrice(locale, perMonth, currency, true) }}
|
||||
<span class="text-sm font-semibold text-secondary">
|
||||
/ month{{ selectedInterval !== 'monthly' ? `, billed ${selectedInterval}` : '' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="text-sm">{{ formatMessage(description) }}</span>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Menu
|
||||
placement="bottom-start"
|
||||
:triggers="['hover', 'focus']"
|
||||
:auto-hide="true"
|
||||
:delay="{ show: 100, hide: 120 }"
|
||||
:distance="8"
|
||||
>
|
||||
<template #default="{ shown }">
|
||||
<div
|
||||
class="flex justify-between text-sm cursor-default select-none outline-none"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="shown"
|
||||
>
|
||||
<span>View plan details</span>
|
||||
<DropdownIcon
|
||||
class="ml-auto my-auto size-4 transition-transform duration-300 shrink-0"
|
||||
:class="{ 'rotate-180': shown }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #popper>
|
||||
<div class="w-72 rounded-md border border-contrast/10 bg-bg p-3 shadow-lg">
|
||||
<ServersSpecs
|
||||
:ram="plan.metadata.ram!"
|
||||
:storage="plan.metadata.storage!"
|
||||
:cpus="plan.metadata.cpu!"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,6 +7,7 @@ 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'
|
||||
@@ -26,6 +27,7 @@ import type {
|
||||
import { ButtonStyled } from '../index'
|
||||
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
|
||||
import NewModal from '../modal/NewModal.vue'
|
||||
import PlanSelector from './ServersPurchase0Plan.vue'
|
||||
import RegionSelector from './ServersPurchase1Region.vue'
|
||||
import PaymentMethodSelector from './ServersPurchase2PaymentMethod.vue'
|
||||
import ConfirmPurchase from './ServersPurchase3Review.vue'
|
||||
@@ -46,12 +48,16 @@ const props = defineProps<{
|
||||
pings: RegionPing[]
|
||||
regions: ServerRegion[]
|
||||
availableProducts: ServerPlan[]
|
||||
planStage?: boolean
|
||||
existingPlan?: ServerPlan
|
||||
existingSubscription?: UserSubscription
|
||||
refreshPaymentMethods: () => Promise<void>
|
||||
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
|
||||
initiatePayment: (
|
||||
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
|
||||
) => Promise<UpdatePaymentIntentResponse | CreatePaymentIntentResponse>
|
||||
) => Promise<UpdatePaymentIntentResponse | CreatePaymentIntentResponse | null>
|
||||
onError: (err: Error) => void
|
||||
onFinalizeNoPaymentChange?: () => Promise<void>
|
||||
}>()
|
||||
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
@@ -78,6 +84,7 @@ const {
|
||||
hasPaymentMethod,
|
||||
submitPayment,
|
||||
completingPurchase,
|
||||
noPaymentRequired,
|
||||
} = useStripe(
|
||||
props.publishableKey,
|
||||
props.customer,
|
||||
@@ -95,11 +102,14 @@ const customServer = ref<boolean>(false)
|
||||
const acceptedEula = ref<boolean>(false)
|
||||
const skipPaymentMethods = ref<boolean>(true)
|
||||
|
||||
type Step = 'region' | 'payment' | 'review'
|
||||
type Step = 'plan' | 'region' | 'payment' | 'review'
|
||||
|
||||
const steps: Step[] = ['region', 'payment', 'review']
|
||||
const steps: Step[] = props.planStage
|
||||
? (['plan', 'region', 'payment', 'review'] as Step[])
|
||||
: (['region', 'payment', 'review'] as Step[])
|
||||
|
||||
const titles: Record<Step, MessageDescriptor> = {
|
||||
plan: defineMessage({ id: 'servers.purchase.step.plan.title', defaultMessage: 'Plan' }),
|
||||
region: defineMessage({ id: 'servers.purchase.step.region.title', defaultMessage: 'Region' }),
|
||||
payment: defineMessage({
|
||||
id: 'servers.purchase.step.payment.title',
|
||||
@@ -132,12 +142,26 @@ const nextStep = computed(() =>
|
||||
|
||||
const canProceed = computed(() => {
|
||||
switch (currentStep.value) {
|
||||
case 'plan':
|
||||
console.log('Plan step:', {
|
||||
customServer: customServer.value,
|
||||
selectedPlan: selectedPlan.value,
|
||||
existingPlan: props.existingPlan,
|
||||
})
|
||||
return (
|
||||
customServer.value ||
|
||||
(!!selectedPlan.value &&
|
||||
(!props.existingPlan || selectedPlan.value.id !== props.existingPlan.id))
|
||||
)
|
||||
case 'region':
|
||||
return selectedRegion.value && selectedPlan.value && selectedInterval.value
|
||||
case 'payment':
|
||||
return selectedPaymentMethod.value || !loadingElements.value
|
||||
case 'review':
|
||||
return acceptedEula.value && hasPaymentMethod.value && !completingPurchase.value
|
||||
return (
|
||||
(noPaymentRequired.value || (acceptedEula.value && hasPaymentMethod.value)) &&
|
||||
!completingPurchase.value
|
||||
)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -145,6 +169,8 @@ const canProceed = computed(() => {
|
||||
|
||||
async function beforeProceed(step: string) {
|
||||
switch (step) {
|
||||
case 'plan':
|
||||
return true
|
||||
case 'region':
|
||||
return true
|
||||
case 'payment':
|
||||
@@ -160,6 +186,9 @@ async function beforeProceed(step: string) {
|
||||
}
|
||||
return true
|
||||
case 'review':
|
||||
if (noPaymentRequired.value) {
|
||||
return true
|
||||
}
|
||||
if (selectedPaymentMethod.value) {
|
||||
return true
|
||||
} else {
|
||||
@@ -200,12 +229,31 @@ async function setStep(step: Step | undefined, skipValidation = false) {
|
||||
}
|
||||
|
||||
watch(selectedPlan, () => {
|
||||
console.log(selectedPlan.value)
|
||||
if (currentStep.value === 'plan') {
|
||||
customServer.value = !selectedPlan.value
|
||||
}
|
||||
})
|
||||
|
||||
const defaultPlan = computed<ServerPlan | undefined>(() => {
|
||||
return (
|
||||
props.availableProducts.find((p) => p?.metadata?.type === 'pyro' && p.metadata.ram === 6144) ??
|
||||
props.availableProducts.find((p) => p?.metadata?.type === 'pyro') ??
|
||||
props.availableProducts[0]
|
||||
)
|
||||
})
|
||||
|
||||
function begin(interval: ServerBillingInterval, plan?: ServerPlan, project?: string) {
|
||||
loading.value = false
|
||||
selectedPlan.value = plan
|
||||
|
||||
if (plan === null) {
|
||||
// Explicitly open in custom mode
|
||||
selectedPlan.value = undefined
|
||||
customServer.value = true
|
||||
} else {
|
||||
selectedPlan.value = plan ?? defaultPlan.value
|
||||
customServer.value = !selectedPlan.value
|
||||
}
|
||||
|
||||
selectedInterval.value = interval
|
||||
customServer.value = !selectedPlan.value
|
||||
selectedPaymentMethod.value = undefined
|
||||
@@ -218,16 +266,42 @@ function begin(interval: ServerBillingInterval, plan?: ServerPlan, project?: str
|
||||
defineExpose({
|
||||
show: begin,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
(e: 'hide'): void
|
||||
}>()
|
||||
|
||||
function handleChooseCustom() {
|
||||
customServer.value = true
|
||||
selectedPlan.value = undefined
|
||||
}
|
||||
|
||||
// When the user explicitly wants to change or add a payment method from Review
|
||||
// we must disable the auto-skip behavior, clear any selected method, and
|
||||
// navigate to the Payment step so Stripe Elements can mount.
|
||||
async function changePaymentMethod() {
|
||||
skipPaymentMethods.value = false
|
||||
selectedPaymentMethod.value = undefined
|
||||
await setStep('payment', true)
|
||||
}
|
||||
|
||||
function goToBreadcrumbStep(id: string) {
|
||||
if (id === 'payment') {
|
||||
return changePaymentMethod()
|
||||
}
|
||||
|
||||
return setStep(id as Step, true)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<NewModal ref="modal">
|
||||
<NewModal ref="modal" @hide="$emit('hide')">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-1 font-bold text-secondary">
|
||||
<template v-for="(title, id, index) in titles" :key="id">
|
||||
<button
|
||||
v-if="index < currentStepIndex"
|
||||
class="bg-transparent active:scale-95 font-bold text-secondary p-0"
|
||||
@click="setStep(id, true)"
|
||||
@click="goToBreadcrumbStep(id as string)"
|
||||
>
|
||||
{{ formatMessage(title) }}
|
||||
</button>
|
||||
@@ -248,8 +322,17 @@ defineExpose({
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-[40rem] max-w-full">
|
||||
<PlanSelector
|
||||
v-if="currentStep === 'plan'"
|
||||
v-model:plan="selectedPlan"
|
||||
v-model:interval="selectedInterval"
|
||||
:existing-plan="existingPlan"
|
||||
:available-products="availableProducts"
|
||||
:currency="currency"
|
||||
@choose-custom="handleChooseCustom"
|
||||
/>
|
||||
<RegionSelector
|
||||
v-if="currentStep === 'region'"
|
||||
v-else-if="currentStep === 'region'"
|
||||
v-model:region="selectedRegion"
|
||||
v-model:plan="selectedPlan"
|
||||
:regions="regions"
|
||||
@@ -271,7 +354,7 @@ defineExpose({
|
||||
<ConfirmPurchase
|
||||
v-else-if="
|
||||
currentStep === 'review' &&
|
||||
hasPaymentMethod &&
|
||||
(hasPaymentMethod || noPaymentRequired) &&
|
||||
currentRegion &&
|
||||
selectedInterval &&
|
||||
selectedPlan
|
||||
@@ -284,14 +367,13 @@ defineExpose({
|
||||
:ping="currentPing"
|
||||
:loading="paymentMethodLoading"
|
||||
:selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod"
|
||||
:has-payment-method="hasPaymentMethod"
|
||||
:tax="tax"
|
||||
:total="total"
|
||||
@change-payment-method="
|
||||
() => {
|
||||
skipPaymentMethods = false
|
||||
setStep('payment', true)
|
||||
}
|
||||
"
|
||||
:no-payment-required="noPaymentRequired"
|
||||
:existing-plan="existingPlan"
|
||||
:existing-subscription="existingSubscription"
|
||||
@change-payment-method="changePaymentMethod"
|
||||
@reload-payment-intent="reloadPaymentIntent"
|
||||
/>
|
||||
<div v-else>Something went wrong</div>
|
||||
@@ -329,17 +411,33 @@ defineExpose({
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="
|
||||
currentStep === 'review' && !acceptedEula
|
||||
currentStep === 'review' && !acceptedEula && !noPaymentRequired
|
||||
? 'You must accept the Minecraft EULA to proceed.'
|
||||
: undefined
|
||||
"
|
||||
:disabled="!canProceed"
|
||||
@click="setStep(nextStep)"
|
||||
@click="
|
||||
noPaymentRequired && currentStep === 'review'
|
||||
? (async () => {
|
||||
if (props.onFinalizeNoPaymentChange) {
|
||||
try {
|
||||
await props.onFinalizeNoPaymentChange()
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
}
|
||||
modal?.hide()
|
||||
})()
|
||||
: setStep(nextStep)
|
||||
"
|
||||
>
|
||||
<template v-if="currentStep === 'review'">
|
||||
<SpinnerIcon v-if="completingPurchase" class="animate-spin" />
|
||||
<CheckCircleIcon v-else />
|
||||
Subscribe
|
||||
<template v-if="noPaymentRequired"><CheckCircleIcon /> Confirm Change</template>
|
||||
<template v-else>
|
||||
<SpinnerIcon v-if="completingPurchase" class="animate-spin" />
|
||||
<CheckCircleIcon v-else />
|
||||
Subscribe
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatMessage(commonMessages.nextButton) }} <RightArrowIcon />
|
||||
|
||||
226
packages/ui/src/components/billing/ServersPurchase0Plan.vue
Normal file
226
packages/ui/src/components/billing/ServersPurchase0Plan.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
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 OptionGroup from '../base/OptionGroup.vue'
|
||||
import ModalBasedServerPlan from './ModalBasedServerPlan.vue'
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
availableProducts: ServerPlan[]
|
||||
currency: string
|
||||
existingPlan?: ServerPlan
|
||||
}>()
|
||||
|
||||
const availableBillingIntervals = ['monthly', 'quarterly']
|
||||
|
||||
const selectedPlan = defineModel<ServerPlan>('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 1–5 friends with a few light mods.',
|
||||
},
|
||||
mediumDesc: {
|
||||
id: 'servers.purchase.step.plan.medium.desc',
|
||||
defaultMessage: 'Great for 6–15 players and multiple mods.',
|
||||
},
|
||||
largeDesc: {
|
||||
id: 'servers.purchase.step.plan.large.desc',
|
||||
defaultMessage: 'Ideal for 15–25 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', ServerPlan | 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?: ServerPlan) {
|
||||
if (!plan) return undefined
|
||||
const total = plan.prices?.find((x) => x.currency_code === props.currency)?.prices?.intervals?.[
|
||||
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 ramGb = (p?.metadata?.ram ?? 0) / 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>
|
||||
@@ -237,7 +237,7 @@ onMounted(() => {
|
||||
>{{ formatPrice(locale, selectedPrice, currency, true) }} / month</span
|
||||
><span v-if="interval !== 'monthly'">, billed {{ interval }}</span>
|
||||
</p>
|
||||
<div class="bg-bg rounded-xl p-4 mt-2 text-secondary">
|
||||
<div class="bg-bg rounded-xl p-4 mt-2 text-secondary h-14">
|
||||
<div v-if="checkingCustomStock" class="flex gap-2 items-center">
|
||||
<SpinnerIcon class="size-5 shrink-0 animate-spin" /> Checking availability...
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { formatPrice, getPingLevel } from '@modrinth/utils'
|
||||
import { formatPrice, getPingLevel, type UserSubscription } from '@modrinth/utils'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import dayjs from 'dayjs'
|
||||
import type Stripe from 'stripe'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -45,6 +46,10 @@ const props = defineProps<{
|
||||
ping?: number
|
||||
loading?: boolean
|
||||
selectedPaymentMethod: Stripe.PaymentMethod | undefined
|
||||
hasPaymentMethod?: boolean
|
||||
noPaymentRequired?: boolean
|
||||
existingPlan?: ServerPlan
|
||||
existingSubscription?: UserSubscription
|
||||
}>()
|
||||
|
||||
const interval = defineModel<ServerBillingInterval>('interval', { required: true })
|
||||
@@ -54,6 +59,75 @@ 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]
|
||||
})
|
||||
|
||||
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]
|
||||
})
|
||||
|
||||
const upgradeDeltaPrice = computed<number | undefined>(() => {
|
||||
if (selectedPlanPriceForInterval.value == null || existingPlanPriceForInterval.value == null)
|
||||
return undefined
|
||||
return selectedPlanPriceForInterval.value - existingPlanPriceForInterval.value
|
||||
})
|
||||
|
||||
const isUpgrade = computed<boolean>(() => {
|
||||
return (upgradeDeltaPrice.value ?? 0) > 0
|
||||
})
|
||||
|
||||
const estimatedDaysInInterval = computed<number>(() => {
|
||||
return monthsInInterval[interval.value] * 30
|
||||
})
|
||||
|
||||
const estimatedProrationDays = computed<number | undefined>(() => {
|
||||
if (!isUpgrade.value) return undefined
|
||||
if (props.total == null || props.tax == null) return undefined
|
||||
const subtotal = props.total - props.tax
|
||||
const delta = upgradeDeltaPrice.value ?? 0
|
||||
if (delta <= 0) return undefined
|
||||
const fraction = Math.max(0, Math.min(1, subtotal / delta))
|
||||
return Math.round(fraction * estimatedDaysInInterval.value)
|
||||
})
|
||||
|
||||
const isProratedCharge = computed<boolean>(() => {
|
||||
return isUpgrade.value && (props.total ?? 0) > 0
|
||||
})
|
||||
|
||||
const exactProrationDays = computed<number | undefined>(() => {
|
||||
if (!props.existingSubscription) return undefined
|
||||
const created = dayjs(props.existingSubscription.created)
|
||||
if (!created.isValid()) return undefined
|
||||
let next = created
|
||||
const now = dayjs()
|
||||
if (props.existingSubscription.interval === 'monthly') {
|
||||
const cycles = now.diff(created, 'month')
|
||||
next = created.add(cycles + 1, 'month')
|
||||
} else if (props.existingSubscription.interval === 'quarterly') {
|
||||
const months = now.diff(created, 'month')
|
||||
const cycles = Math.floor(months / 3)
|
||||
next = created.add((cycles + 1) * 3, 'month')
|
||||
} else if (props.existingSubscription.interval === 'yearly') {
|
||||
const cycles = now.diff(created, 'year')
|
||||
next = created.add(cycles + 1, 'year')
|
||||
} else if (props.existingSubscription.interval === 'five-days') {
|
||||
const days = now.diff(created, 'day')
|
||||
const cycles = Math.floor(days / 5)
|
||||
next = created.add((cycles + 1) * 5, 'day')
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
const days = next.diff(now, 'day')
|
||||
return Math.max(0, days)
|
||||
})
|
||||
|
||||
const prorationDays = computed<number | undefined>(
|
||||
() => exactProrationDays.value ?? estimatedProrationDays.value,
|
||||
)
|
||||
|
||||
const planName = computed(() => {
|
||||
if (!props.plan || !props.plan.metadata || props.plan.metadata.type !== 'pyro') return 'Unknown'
|
||||
const ram = props.plan.metadata.ram
|
||||
@@ -198,57 +272,88 @@ function setInterval(newInterval: ServerBillingInterval) {
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<ExpandableInvoiceTotal
|
||||
:period="period"
|
||||
:currency="currency"
|
||||
:loading="loading"
|
||||
:total="total ?? -1"
|
||||
:billing-items="
|
||||
total !== undefined && tax !== undefined
|
||||
? [
|
||||
{
|
||||
title: `Modrinth Servers (${planName})`,
|
||||
amount: total - tax,
|
||||
},
|
||||
{
|
||||
title: 'Tax',
|
||||
amount: tax,
|
||||
},
|
||||
]
|
||||
: []
|
||||
"
|
||||
/>
|
||||
<template v-if="!noPaymentRequired">
|
||||
<ExpandableInvoiceTotal
|
||||
:period="isProratedCharge ? undefined : period"
|
||||
:currency="currency"
|
||||
:loading="loading"
|
||||
:total="total ?? -1"
|
||||
:billing-items="
|
||||
total !== undefined && tax !== undefined
|
||||
? [
|
||||
{
|
||||
title:
|
||||
isProratedCharge && prorationDays
|
||||
? `Modrinth Servers (${planName}) — prorated for ${prorationDays} day${
|
||||
prorationDays === 1 ? '' : 's'
|
||||
}`
|
||||
: `Modrinth Servers (${planName})`,
|
||||
amount: total - tax,
|
||||
},
|
||||
{
|
||||
title: 'Tax',
|
||||
amount: tax,
|
||||
},
|
||||
]
|
||||
: []
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="p-4 rounded-2xl bg-table-alternateRow text-sm text-secondary leading-relaxed"
|
||||
>
|
||||
No payment required. Your downgrade will apply at the end of the current billing period.
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center pl-4 pr-2 py-3 bg-bg rounded-2xl gap-2 text-secondary">
|
||||
<div
|
||||
v-if="!noPaymentRequired"
|
||||
class="mt-2 flex items-center pl-4 pr-2 py-3 bg-bg rounded-2xl gap-2 text-secondary"
|
||||
>
|
||||
<template v-if="selectedPaymentMethod">
|
||||
<FormattedPaymentMethod :method="selectedPaymentMethod" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-2 text-red">
|
||||
<div v-if="hasPaymentMethod" class="flex items-center gap-2 text-secondary">
|
||||
<RadioButtonCheckedIcon class="text-brand" />
|
||||
Using new payment method
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2 text-red">
|
||||
<XIcon />
|
||||
No payment method selected
|
||||
</div>
|
||||
</template>
|
||||
<ButtonStyled size="small" type="transparent">
|
||||
<button class="ml-auto" @click="emit('changePaymentMethod')">
|
||||
<template v-if="selectedPaymentMethod"> <EditIcon /> Change </template>
|
||||
<template v-if="selectedPaymentMethod || hasPaymentMethod"> <EditIcon /> Change </template>
|
||||
<template v-else> Select payment method <RightArrowIcon /> </template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p class="m-0 mt-4 text-sm text-secondary">
|
||||
<p v-if="!noPaymentRequired" class="m-0 mt-4 text-sm text-secondary">
|
||||
<template v-if="isUpgrade && (total ?? 0) > 0">
|
||||
Today, you will be charged a prorated amount for the remainder of your current billing cycle.
|
||||
<br />
|
||||
Your subscription will renew at
|
||||
{{ formatPrice(locale, selectedPlanPriceForInterval, currency) }} / {{ period }} plus
|
||||
applicable taxes at the end of your current billing interval, until you cancel. You can cancel
|
||||
anytime from your settings page.
|
||||
</template>
|
||||
<template v-else>
|
||||
You'll be charged
|
||||
<SpinnerIcon v-if="loading" class="animate-spin relative top-0.5 mx-2" /><template v-else>{{
|
||||
formatPrice(locale, total, currency)
|
||||
}}</template>
|
||||
every {{ period }} plus applicable taxes starting today, until you cancel. You can cancel
|
||||
anytime from your settings page.
|
||||
</template>
|
||||
<br />
|
||||
<span class="font-semibold"
|
||||
>By clicking "Subscribe", you are purchasing a recurring subscription.</span
|
||||
>
|
||||
<br />
|
||||
You'll be charged
|
||||
<SpinnerIcon v-if="loading" class="animate-spin relative top-0.5 mx-2" /><template v-else>{{
|
||||
formatPrice(locale, total, currency)
|
||||
}}</template>
|
||||
every {{ period }} plus applicable taxes starting today, until you cancel. You can cancel
|
||||
anytime from your settings page.
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-1 text-sm">
|
||||
<div v-if="!noPaymentRequired" class="mt-2 flex items-center gap-1 text-sm">
|
||||
<Checkbox
|
||||
v-model="acceptedEula"
|
||||
label="I acknowledge that I have read and agree to the"
|
||||
|
||||
@@ -8,17 +8,11 @@ const emit = defineEmits<{
|
||||
(e: 'click-bursting-link'): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
ram: number
|
||||
storage: number
|
||||
cpus: number
|
||||
burstingLink?: string
|
||||
}>(),
|
||||
{
|
||||
burstingLink: undefined,
|
||||
},
|
||||
)
|
||||
const props = defineProps<{
|
||||
ram: number
|
||||
storage: number
|
||||
cpus: number
|
||||
}>()
|
||||
|
||||
const formattedRam = computed(() => {
|
||||
return props.ram / 1024
|
||||
@@ -46,12 +40,12 @@ const sharedCpus = computed(() => {
|
||||
<li class="flex items-center gap-2">
|
||||
<SparklesIcon class="h-5 w-5 shrink-0" /> Bursts up to {{ cpus }} CPUs
|
||||
<AutoLink
|
||||
v-if="burstingLink"
|
||||
v-tooltip="
|
||||
`CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. Click for more info.`
|
||||
"
|
||||
class="flex"
|
||||
:to="burstingLink"
|
||||
to="https://modrinth.com/servers#cpu-burst"
|
||||
target="_blank"
|
||||
@click="() => emit('click-bursting-link')"
|
||||
>
|
||||
<UnknownIcon class="h-4 w-4 text-secondary opacity-80" />
|
||||
|
||||
@@ -25,6 +25,7 @@ export { default as HeadingLink } from './base/HeadingLink.vue'
|
||||
export { default as LoadingIndicator } from './base/LoadingIndicator.vue'
|
||||
export { default as ManySelect } from './base/ManySelect.vue'
|
||||
export { default as MarkdownEditor } from './base/MarkdownEditor.vue'
|
||||
export { default as OptionGroup } from './base/OptionGroup.vue'
|
||||
export type { Option as OverflowMenuOption } from './base/OverflowMenu.vue'
|
||||
export { default as OverflowMenu } from './base/OverflowMenu.vue'
|
||||
export { default as Page } from './base/Page.vue'
|
||||
|
||||
Reference in New Issue
Block a user