Medal promo v2 (#4220)

* Revert "Revert "feat: medal promotion on servers page (#4117)""

This reverts commit 2e6cff7efc.

* Revert "Revert "update changelog""

This reverts commit b2ff2d8737.

* Revert "Revert "turn off medal promo""

This reverts commit eaa4b44a16.

* Revert "Revert "Revert "turn off medal promo"""

This reverts commit 76d0ef03e7.

* Revert "Revert "fix medal thing showing up for everyone""

This reverts commit ee8c47adcb.

* New medal colors

* Update medal server listings

* Upgrade modal enhancements & more medal consistency

* undo app promo changes

* Only apply medal promo with flag on

* remove unneessary files

* lint

* disable medal flag
This commit is contained in:
Prospector
2025-08-19 10:39:09 -07:00
committed by GitHub
parent 07703e49ef
commit d3459e4b12
37 changed files with 2077 additions and 347 deletions

View File

@@ -3,7 +3,7 @@ import { computed } from 'vue'
const props = withDefaults(
defineProps<{
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'medal-promo'
size?: 'standard' | 'large' | 'small'
circular?: boolean
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text'
@@ -34,6 +34,7 @@ const highlightedColorVar = computed(() => {
return 'var(--color-orange-highlight)'
case 'green':
return 'var(--color-green-highlight)'
case 'medal-promo':
case 'blue':
return 'var(--color-blue-highlight)'
case 'purple':
@@ -58,6 +59,8 @@ const colorVar = computed(() => {
return 'var(--color-blue)'
case 'purple':
return 'var(--color-purple)'
case 'medal-promo':
return 'var(--medal-promotion-text-orange)'
case 'standard':
default:
return null

View 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>

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
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 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="w-fit">
<Menu
placement="bottom-start"
:triggers="['hover', 'focus']"
:auto-hide="true"
:delay="{ show: 100, hide: 120 }"
:distance="6"
>
<template #default="{ shown }">
<div
class="flex w-fit items-center gap-2 cursor-help text-sm font-medium cursor-default select-none outline-none"
:class="shown ? 'text-primary' : 'text-secondary'"
role="button"
tabindex="0"
aria-haspopup="true"
:aria-expanded="shown"
>
<InfoIcon />
View plan details
</div>
</template>
<template #popper>
<div 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!"
/>
</div>
</template>
</Menu>
</div>
</div>
</div>
</template>

View File

@@ -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 />

View 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 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', 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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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" />

View File

@@ -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'

View File

@@ -35,7 +35,7 @@ export const useStripe = (
project: Ref<string | undefined>,
initiatePayment: (
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
) => Promise<CreatePaymentIntentResponse | UpdatePaymentIntentResponse>,
) => Promise<CreatePaymentIntentResponse | UpdatePaymentIntentResponse | null>,
onError: (err: Error) => void,
) => {
const stripe = ref<StripeJs | null>(null)
@@ -55,17 +55,22 @@ export const useStripe = (
const inputtedPaymentMethod = ref<Stripe.PaymentMethod>()
const clientSecret = ref<string>()
const completingPurchase = ref<boolean>(false)
const noPaymentRequired = ref<boolean>(false)
async function initialize() {
stripe.value = await loadStripe(publishableKey)
}
function createIntent(body: CreatePaymentIntentRequest): Promise<CreatePaymentIntentResponse> {
return initiatePayment(body) as Promise<CreatePaymentIntentResponse>
function createIntent(
body: CreatePaymentIntentRequest,
): Promise<CreatePaymentIntentResponse | null> {
return initiatePayment(body) as Promise<CreatePaymentIntentResponse | null>
}
function updateIntent(body: UpdatePaymentIntentRequest): Promise<UpdatePaymentIntentResponse> {
return initiatePayment(body) as Promise<UpdatePaymentIntentResponse>
function updateIntent(
body: UpdatePaymentIntentRequest,
): Promise<UpdatePaymentIntentResponse | null> {
return initiatePayment(body) as Promise<UpdatePaymentIntentResponse | null>
}
const planPrices = computed(() => {
@@ -222,7 +227,7 @@ export const useStripe = (
interval: interval.value,
}
let result: BasePaymentIntentResponse
let result: BasePaymentIntentResponse | null = null
const metadata: CreatePaymentIntentRequest['metadata'] = {
type: 'pyro',
@@ -241,26 +246,34 @@ export const useStripe = (
existing_payment_intent: paymentIntentId.value,
metadata,
})
console.log(`Updated payment intent: ${interval.value} for ${result.total}`)
if (result) console.log(`Updated payment intent: ${interval.value} for ${result.total}`)
} else {
;({
payment_intent_id: paymentIntentId.value,
client_secret: clientSecret.value,
...result
} = await createIntent({
const created = await createIntent({
...requestType,
charge,
metadata: metadata,
}))
console.log(`Created payment intent: ${interval.value} for ${result.total}`)
})
if (created) {
paymentIntentId.value = created.payment_intent_id
clientSecret.value = created.client_secret
result = created
console.log(`Created payment intent: ${interval.value} for ${created.total}`)
}
}
tax.value = result.tax
total.value = result.total
if (!result) {
tax.value = 0
total.value = 0
noPaymentRequired.value = true
} else {
tax.value = result.tax
total.value = result.total
noPaymentRequired.value = false
}
if (confirmation) {
confirmationToken.value = id
if (result.payment_method) {
if (result && result.payment_method) {
inputtedPaymentMethod.value = result.payment_method
}
}
@@ -346,6 +359,10 @@ export const useStripe = (
const loadingElements = computed(() => elementsLoaded.value < 2)
async function submitPayment(returnUrl: string) {
if (noPaymentRequired.value) {
completingPurchase.value = false
return true
}
completingPurchase.value = true
const secert = clientSecret.value
@@ -387,7 +404,9 @@ export const useStripe = (
}
}
const hasPaymentMethod = computed(() => selectedPaymentMethod.value || confirmationToken.value)
const hasPaymentMethod = computed(
() => selectedPaymentMethod.value || confirmationToken.value || noPaymentRequired.value,
)
return {
initializeStripe: initialize,
@@ -406,5 +425,6 @@ export const useStripe = (
total,
submitPayment,
completingPurchase,
noPaymentRequired,
}
}

View File

@@ -506,6 +506,39 @@
"servers.purchase.step.payment.title": {
"defaultMessage": "Payment method"
},
"servers.purchase.step.plan.billed": {
"defaultMessage": "billed {interval}"
},
"servers.purchase.step.plan.custom.desc": {
"defaultMessage": "Pick a customized plan with just the specs you need."
},
"servers.purchase.step.plan.get-started": {
"defaultMessage": "Get started"
},
"servers.purchase.step.plan.large.desc": {
"defaultMessage": "Ideal for 1525 players, modpacks, or heavy modding."
},
"servers.purchase.step.plan.medium.desc": {
"defaultMessage": "Great for 615 players and multiple mods."
},
"servers.purchase.step.plan.most-popular": {
"defaultMessage": "Most Popular"
},
"servers.purchase.step.plan.prompt": {
"defaultMessage": "Choose a plan"
},
"servers.purchase.step.plan.select": {
"defaultMessage": "Select Plan"
},
"servers.purchase.step.plan.small.desc": {
"defaultMessage": "Perfect for 15 friends with a few light mods."
},
"servers.purchase.step.plan.subtitle": {
"defaultMessage": "Pick the amount of RAM and specs that fit your needs."
},
"servers.purchase.step.plan.title": {
"defaultMessage": "Plan"
},
"servers.purchase.step.region.title": {
"defaultMessage": "Region"
},