Servers new purchase flow (#3719)

* New purchase flow for servers, region selector, etc.

* Lint

* Lint

* Fix expanding total
This commit is contained in:
Prospector
2025-06-03 09:20:53 -07:00
committed by GitHub
parent 7223c2b197
commit c0accb42fa
43 changed files with 3021 additions and 800 deletions

View File

@@ -3,9 +3,9 @@
<button
v-for="(item, index) in items"
:key="`radio-button-${index}`"
class="p-0 py-2 px-2 border-0 flex gap-2 transition-all items-center cursor-pointer active:scale-95 hover:bg-button-bg rounded-xl"
class="p-0 py-2 px-2 border-0 font-medium flex gap-2 transition-all items-center cursor-pointer active:scale-95 hover:bg-button-bg rounded-xl"
:class="{
'text-contrast font-medium bg-button-bg': selected === item,
'text-contrast bg-button-bg': selected === item,
'text-primary bg-transparent': selected !== item,
}"
@click="selected = item"

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref } from 'vue'
import { createStripeElements } from '@modrinth/utils'
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
import { loadStripe, type Stripe as StripsJs, type StripeElements } from '@stripe/stripe-js'
const emit = defineEmits<{
(e: 'startLoading' | 'stopLoading'): void
}>()
export type SetupIntentResponse = {
client_secret: string
}
export type AddPaymentMethodProps = {
publishableKey: string
createSetupIntent: () => Promise<SetupIntentResponse>
returnUrl: string
onError: (error: Error) => void
}
const props = defineProps<AddPaymentMethodProps>()
const elementsLoaded = ref<0 | 1 | 2>(0)
const stripe = ref<StripsJs>()
const elements = ref<StripeElements>()
const error = ref(false)
function handleError(error: Error) {
props.onError(error)
error.value = true
}
async function reload(paymentMethods: Stripe.PaymentMethod[]) {
try {
elementsLoaded.value = 0
error.value = false
const result = await props.createSetupIntent()
stripe.value = await loadStripe(props.publishableKey)
const {
elements: newElements,
addressElement,
paymentElement,
} = createStripeElements(stripe.value, paymentMethods, {
clientSecret: result.client_secret,
})
elements.value = newElements
paymentElement.on('ready', () => {
elementsLoaded.value += 1
})
addressElement.on('ready', () => {
elementsLoaded.value += 1
})
} catch (err) {
handleError(err)
}
}
async function submit(): Promise<boolean> {
emit('startLoading')
const result = await stripe.value.confirmSetup({
elements: elements.value,
confirmParams: {
return_url: props.returnUrl,
},
})
console.log(result)
const { error } = result
emit('stopLoading')
if (error && error.type !== 'validation_error') {
handleError(error.message)
return false
} else if (!error) {
return true
}
}
defineExpose({
reload,
submit,
})
</script>
<template>
<div class="min-h-[16rem] flex flex-col gap-2 justify-center items-center">
<div v-show="elementsLoaded < 2">
<ModalLoadingIndicator :error="error">
Loading...
<template #error> Error loading Stripe payment UI. </template>
</ModalLoadingIndicator>
</div>
<div class="w-full">
<div id="address-element"></div>
<div id="payment-element" class="mt-4"></div>
</div>
</div>
</template>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ButtonStyled, NewModal } from '../index'
import { defineMessages, useVIntl } from '@vintl/vintl'
import AddPaymentMethod from './AddPaymentMethod.vue'
import type { AddPaymentMethodProps } from './AddPaymentMethod.vue'
import { commonMessages } from '../../utils'
import { PlusIcon, XIcon } from '@modrinth/assets'
const { formatMessage } = useVIntl()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const addPaymentMethod = useTemplateRef<InstanceType<typeof AddPaymentMethod>>('addPaymentMethod')
const props = defineProps<AddPaymentMethodProps>()
const loading = ref(false)
async function open(paymentMethods: Stripe.PaymentMethod[]) {
modal.value?.show()
await nextTick()
await addPaymentMethod.value?.reload(paymentMethods)
}
const messages = defineMessages({
addingPaymentMethod: {
id: 'modal.add-payment-method.title',
defaultMessage: 'Adding a payment method',
},
paymentMethodAdd: {
id: 'modal.add-payment-method.action',
defaultMessage: 'Add payment method',
},
})
defineExpose({
show: open,
})
</script>
<template>
<NewModal ref="modal">
<template #title>
<span class="text-lg font-extrabold text-contrast">
{{ formatMessage(messages.addingPaymentMethod) }}
</span>
</template>
<div class="w-[40rem] max-w-full">
<AddPaymentMethod
ref="addPaymentMethod"
:publishable-key="props.publishableKey"
:return-url="props.returnUrl"
:create-setup-intent="props.createSetupIntent"
:on-error="props.onError"
@start-loading="loading = true"
@stop-loading="loading = false"
/>
<div class="input-group mt-auto pt-4">
<ButtonStyled color="brand">
<button :disabled="loading" @click="addPaymentMethod.submit()">
<PlusIcon />
{{ formatMessage(messages.paymentMethodAdd) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import Accordion from '../base/Accordion.vue'
import { formatPrice } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import { SpinnerIcon } from '@modrinth/assets'
import { computed } from 'vue'
const { locale } = useVIntl()
export type BillingItem = {
title: string
amount: number
}
const props = defineProps<{
period?: string
currency: string
total: number
billingItems: BillingItem[]
loading?: boolean
}>()
const periodSuffix = computed(() => {
return props.period ? ` / ${props.period}` : ''
})
</script>
<template>
<Accordion
class="rounded-2xl overflow-hidden bg-bg"
button-class="bg-transparent p-0 w-full p-4 active:scale-[0.98] transition-transform duration-100"
>
<template #title>
<div class="w-full flex items-center justify-between">
<div class="flex items-center gap-2 text-contrast font-bold">Total</div>
<div class="text-right mr-1">
<span class="text-primary font-bold">
<template v-if="loading">
<SpinnerIcon class="animate-spin size-4" />
</template>
<template v-else> {{ formatPrice(locale, total, currency) }} </template
><span class="text-xs text-secondary">{{ periodSuffix }}</span>
</span>
</div>
</div>
</template>
<div class="p-4 flex flex-col gap-4 bg-table-alternateRow">
<div
v-for="{ title, amount } in billingItems"
:key="title"
class="flex items-center justify-between"
>
<div class="font-semibold">
{{ title }}
</div>
<div class="text-right">
<template v-if="loading">
<SpinnerIcon class="animate-spin size-4" />
</template>
<template v-else> {{ formatPrice(locale, amount, currency) }} </template
><span class="text-xs text-secondary">{{ periodSuffix }}</span>
</div>
</div>
</div>
</Accordion>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { CardIcon, CurrencyIcon, PayPalIcon, UnknownIcon } from '@modrinth/assets'
import { commonMessages, paymentMethodMessages } from '../../utils'
import type Stripe from 'stripe'
import { useVIntl } from '@vintl/vintl'
const { formatMessage } = useVIntl()
defineProps<{
method: Stripe.PaymentMethod
}>()
</script>
<template>
<template v-if="'type' in method">
<CardIcon v-if="method.type === 'card'" class="size-[1.5em]" />
<CurrencyIcon v-else-if="method.type === 'cashapp'" class="size-[1.5em]" />
<PayPalIcon v-else-if="method.type === 'paypal'" class="size-[1.5em]" />
<UnknownIcon v-else class="size-[1.5em]" />
<span v-if="method.type === 'card' && 'card' in method && method.card">
{{
formatMessage(commonMessages.paymentMethodCardDisplay, {
card_brand:
formatMessage(paymentMethodMessages[method.card.brand]) ??
formatMessage(paymentMethodMessages.unknown),
last_four: method.card.last4,
})
}}
</span>
<template v-else>
{{
formatMessage(paymentMethodMessages[method.type]) ??
formatMessage(paymentMethodMessages.unknown)
}}
</template>
<span v-if="method.type === 'cashapp' && 'cashapp' in method && method.cashapp">
({{ method.cashapp.cashtag }})
</span>
<span v-else-if="method.type === 'paypal' && 'paypal' in method && method.paypal">
({{ method.paypal.payer_email }})
</span>
</template>
</template>

View File

@@ -0,0 +1,297 @@
<script setup lang="ts">
import { ref, computed, useTemplateRef, nextTick } from 'vue'
import NewModal from '../modal/NewModal.vue'
import { type MessageDescriptor, useVIntl, defineMessage } from '@vintl/vintl'
import {
ChevronRightIcon,
LeftArrowIcon,
RightArrowIcon,
XIcon,
CheckCircleIcon,
} from '@modrinth/assets'
import type {
CreatePaymentIntentRequest,
CreatePaymentIntentResponse,
ServerBillingInterval,
ServerPlan,
ServerRegion,
ServerStockRequest,
UpdatePaymentIntentRequest,
UpdatePaymentIntentResponse,
} from '../../utils/billing'
import { ButtonStyled } from '../index'
import type Stripe from 'stripe'
import { commonMessages } from '../../utils'
import RegionSelector from './ServersPurchase1Region.vue'
import PaymentMethodSelector from './ServersPurchase2PaymentMethod.vue'
import ConfirmPurchase from './ServersPurchase3Review.vue'
import { useStripe } from '../../composables/stripe'
const { formatMessage } = useVIntl()
export type RegionPing = {
region: string
ping: number
}
const props = defineProps<{
publishableKey: string
returnUrl: string
paymentMethods: Stripe.PaymentMethod[]
customer: Stripe.Customer
currency: string
pings: RegionPing[]
regions: ServerRegion[]
availableProducts: ServerPlan[]
refreshPaymentMethods: () => Promise<void>
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
initiatePayment: (
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
) => Promise<UpdatePaymentIntentResponse | CreatePaymentIntentResponse>
}>()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const selectedPlan = ref<ServerPlan>()
const selectedInterval = ref<ServerBillingInterval>()
const loading = ref(false)
const {
initializeStripe,
selectPaymentMethod,
primaryPaymentMethodId,
loadStripeElements,
selectedPaymentMethod,
inputtedPaymentMethod,
createNewPaymentMethod,
loadingElements,
loadingElementsFailed,
tax,
total,
paymentMethodLoading,
reloadPaymentIntent,
hasPaymentMethod,
submitPayment,
} = useStripe(
props.publishableKey,
props.customer,
props.paymentMethods,
props.clientSecret,
props.currency,
selectedPlan,
selectedInterval,
props.initiatePayment,
console.error,
)
const selectedRegion = ref<string>()
const customServer = ref<boolean>(false)
const acceptedEula = ref<boolean>(false)
type Step = 'region' | 'payment' | 'review'
const steps: Step[] = ['region', 'payment', 'review']
const titles: Record<Step, MessageDescriptor> = {
region: defineMessage({ id: 'servers.purchase.step.region.title', defaultMessage: 'Region' }),
payment: defineMessage({
id: 'servers.purchase.step.payment.title',
defaultMessage: 'Payment method',
}),
review: defineMessage({ id: 'servers.purchase.step.review.title', defaultMessage: 'Review' }),
}
const currentRegion = computed(() => {
return props.regions.find((region) => region.shortcode === selectedRegion.value)
})
const currentPing = computed(() => {
return props.pings.find((ping) => ping.region === currentRegion.value?.shortcode)?.ping
})
const currentStep = ref<Step>()
const currentStepIndex = computed(() => steps.indexOf(currentStep.value))
const previousStep = computed(() => steps[steps.indexOf(currentStep.value) - 1])
const nextStep = computed(() => steps[steps.indexOf(currentStep.value) + 1])
const canProceed = computed(() => {
switch (currentStep.value) {
case 'region':
return selectedRegion.value && selectedPlan.value && selectedInterval.value
case 'payment':
return selectedPaymentMethod.value || !loadingElements.value
case 'review':
return acceptedEula.value && hasPaymentMethod.value
default:
return false
}
})
async function beforeProceed(step: string) {
switch (step) {
case 'region':
return true
case 'payment':
await initializeStripe()
if (primaryPaymentMethodId.value) {
const paymentMethod = await props.paymentMethods.find(
(x) => x.id === primaryPaymentMethodId.value,
)
await selectPaymentMethod(paymentMethod)
await setStep('review', true)
return true
}
return true
case 'review':
if (selectedPaymentMethod.value) {
return true
} else {
const token = await createNewPaymentMethod()
return !!token
}
}
}
async function afterProceed(step: string) {
switch (step) {
case 'region':
break
case 'payment':
await loadStripeElements()
break
case 'review':
break
}
}
async function setStep(step: Step, skipValidation = false) {
if (!step) {
await submitPayment(props.returnUrl)
return
}
if (!canProceed.value || skipValidation) {
return
}
if (await beforeProceed(step)) {
currentStep.value = step
await nextTick()
await afterProceed(step)
}
}
function begin(interval: ServerBillingInterval, plan?: ServerPlan) {
loading.value = false
selectedPlan.value = plan
selectedInterval.value = interval
customServer.value = !selectedPlan.value
selectedPaymentMethod.value = undefined
currentStep.value = steps[0]
modal.value?.show()
}
defineExpose({
show: begin,
})
</script>
<template>
<NewModal ref="modal">
<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)"
>
{{ formatMessage(title) }}
</button>
<span
v-else
:class="{
'text-contrast': index === currentStepIndex,
}"
>
{{ formatMessage(title) }}
</span>
<ChevronRightIcon
v-if="index < steps.length - 1"
class="h-5 w-5 text-secondary"
stroke-width="3"
/>
</template>
</div>
</template>
<div class="w-[40rem] max-w-full">
<RegionSelector
v-if="currentStep === 'region'"
v-model:region="selectedRegion"
v-model:plan="selectedPlan"
:regions="regions"
:pings="pings"
:custom="customServer"
:available-products="availableProducts"
:fetch-stock="fetchStock"
/>
<PaymentMethodSelector
v-else-if="currentStep === 'payment' && selectedPlan && selectedInterval"
:payment-methods="paymentMethods"
:selected="selectedPaymentMethod"
:loading-elements="loadingElements"
:loading-elements-failed="loadingElementsFailed"
@select="selectPaymentMethod"
/>
<ConfirmPurchase
v-else-if="
currentStep === 'review' &&
hasPaymentMethod &&
selectedRegion &&
selectedInterval &&
selectedPlan
"
ref="currentStepRef"
v-model:interval="selectedInterval"
v-model:accepted-eula="acceptedEula"
:currency="currency"
:plan="selectedPlan"
:region="regions.find((x) => x.shortcode === selectedRegion)"
:ping="currentPing"
:loading="paymentMethodLoading"
:selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod"
:tax="tax"
:total="total"
:on-error="console.error"
@change-payment-method="setStep('payment')"
@reload-payment-intent="reloadPaymentIntent"
@error="console.error"
/>
<div v-else>Something went wrong</div>
</div>
<div class="flex gap-2 justify-between mt-4">
<ButtonStyled>
<button v-if="previousStep" @click="previousStep && setStep(previousStep)">
<LeftArrowIcon /> {{ formatMessage(commonMessages.backButton) }}
</button>
<button v-else @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canProceed" @click="setStep(nextStep)">
<template v-if="currentStep === 'review'">
<CheckCircleIcon />
Subscribe
</template>
<template v-else>
{{ formatMessage(commonMessages.nextButton) }} <RightArrowIcon />
</template>
</button>
</ButtonStyled>
</div>
</NewModal>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { RadioButtonIcon, RadioButtonCheckedIcon, SpinnerIcon } from '@modrinth/assets'
import type Stripe from 'stripe'
import FormattedPaymentMethod from './FormattedPaymentMethod.vue'
const emit = defineEmits<{
(e: 'select'): void
}>()
withDefaults(
defineProps<{
item: Stripe.PaymentMethod | undefined
selected: boolean
loading?: boolean
}>(),
{
loading: false,
},
)
</script>
<template>
<button
class="flex items-center w-full gap-2 border-none p-3 text-primary rounded-xl transition-all duration-200 hover:bg-button-bg hover:brightness-[--hover-brightness] active:scale-[0.98] hover:cursor-pointer"
:class="selected ? 'bg-button-bg' : 'bg-transparent'"
@click="emit('select')"
>
<RadioButtonCheckedIcon v-if="selected" class="size-6 text-brand" />
<RadioButtonIcon v-else class="size-6 text-secondary" />
<template v-if="item === undefined">
<span>New payment method</span>
</template>
<FormattedPaymentMethod v-else-if="item" :method="item" />
<SpinnerIcon v-if="loading" class="ml-auto size-4 text-secondary animate-spin" />
</button>
</template>

View File

@@ -0,0 +1,229 @@
<script setup lang="ts">
import ServersRegionButton from './ServersRegionButton.vue'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { onMounted, ref, computed, watch } from 'vue'
import type { RegionPing } from './ModrinthServersPurchaseModal.vue'
import type { ServerPlan, ServerRegion, ServerStockRequest } from '../../utils/billing'
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
import Slider from '../base/Slider.vue'
import { SpinnerIcon, XIcon, InfoIcon } from '@modrinth/assets'
import ServersSpecs from './ServersSpecs.vue'
const { formatMessage } = useVIntl()
const props = defineProps<{
regions: ServerRegion[]
pings: RegionPing[]
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
custom: boolean
availableProducts: ServerPlan[]
}>()
const loading = ref(true)
const checkingCustomStock = ref(false)
const selectedPlan = defineModel<ServerPlan>('plan')
const selectedRegion = defineModel<string>('region')
const selectedRam = ref<number>(-1)
const ramOptions = computed(() => {
return props.availableProducts
.map((product) => (product.metadata.ram ?? 0) / 1024)
.filter((x) => x > 0)
})
const minRam = computed(() => {
return Math.min(...ramOptions.value)
})
const maxRam = computed(() => {
return Math.max(...ramOptions.value)
})
const lowestProduct = computed(() => {
return (
props.availableProducts.find(
(product) => (product.metadata.ram ?? 0) / 1024 === minRam.value,
) ?? props.availableProducts[0]
)
})
function updateRamStock(regionToCheck: string, newRam: number) {
if (newRam > 0) {
checkingCustomStock.value = true
const plan = props.availableProducts.find(
(product) => (product.metadata.ram ?? 0) / 1024 === newRam,
)
if (plan) {
const region = props.regions.find((region) => region.shortcode === regionToCheck)
if (region) {
props
.fetchStock(region, {
cpu: plan.metadata.cpu ?? 0,
memory_mb: plan.metadata.ram ?? 0,
swap_mb: plan.metadata.swap ?? 0,
storage_mb: plan.metadata.storage ?? 0,
})
.then((stock: number) => {
if (stock > 0) {
selectedPlan.value = plan
} else {
selectedPlan.value = undefined
}
})
.finally(() => {
checkingCustomStock.value = false
})
} else {
checkingCustomStock.value = false
}
}
}
}
watch(selectedRam, (newRam: number) => {
updateRamStock(selectedRegion.value, newRam)
})
watch(selectedRegion, (newRegion: number) => {
updateRamStock(newRegion, selectedRam.value)
})
const currentStock = ref<{ [region: string]: number }>({})
const bestPing = ref<string>()
const messages = defineMessages({
prompt: {
id: 'servers.region.prompt',
defaultMessage: 'Where would you like your server to be located?',
},
regionUnsupported: {
id: 'servers.region.region-unsupported',
defaultMessage: `Region not listed? <link>Let us know where you'd like to see Modrinth Servers next!</link>`,
},
customPrompt: {
id: 'servers.region.custom.prompt',
defaultMessage: `How much RAM do you want your server to have?`,
},
})
async function updateStock() {
currentStock.value = {}
const capacityChecks = props.regions.map((region) =>
props.fetchStock(
region,
selectedPlan.value
? {
cpu: selectedPlan.value?.metadata.cpu ?? 0,
memory_mb: selectedPlan.value?.metadata.ram ?? 0,
swap_mb: selectedPlan.value?.metadata.swap ?? 0,
storage_mb: selectedPlan.value?.metadata.storage ?? 0,
}
: {
cpu: lowestProduct.value.metadata.cpu ?? 0,
memory_mb: lowestProduct.value.metadata.ram ?? 0,
swap_mb: lowestProduct.value.metadata.swap ?? 0,
storage_mb: lowestProduct.value.metadata.storage ?? 0,
},
),
)
const results = await Promise.all(capacityChecks)
results.forEach((result, index) => {
currentStock.value[props.regions[index].shortcode] = result
})
}
onMounted(() => {
// auto select region with lowest ping
loading.value = true
bestPing.value =
props.pings.length > 0
? props.pings.reduce((acc, cur) => {
return acc.ping < cur.ping ? acc : cur
})?.region
: undefined
selectedRam.value = minRam.value
checkingCustomStock.value = true
updateStock().then(() => {
const firstWithStock = props.regions.find((region) => currentStock.value[region.shortcode] > 0)
let stockedRegion = selectedRegion.value
if (!stockedRegion) {
stockedRegion =
bestPing.value && currentStock.value[bestPing.value] > 0
? bestPing.value
: firstWithStock?.shortcode
}
selectedRegion.value = stockedRegion
updateRamStock(stockedRegion, minRam.value)
loading.value = false
})
})
</script>
<template>
<ModalLoadingIndicator v-if="loading" class="flex py-40 justify-center">
Checking availability...
</ModalLoadingIndicator>
<template v-else>
<h2 class="mt-0 mb-4 text-xl font-bold text-contrast">
{{ formatMessage(messages.prompt) }}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ServersRegionButton
v-for="region in regions"
:key="region.shortcode"
v-model="selectedRegion"
:region="region"
:out-of-stock="currentStock[region.shortcode] === 0"
:ping="pings.find((p) => p.region === region.shortcode)?.ping"
:best-ping="bestPing === region.shortcode"
/>
</div>
<div class="mt-3 text-sm">
<IntlFormatted :message-id="messages.regionUnsupported">
<template #link="{ children }">
<a
class="text-link"
target="_blank"
rel="noopener noreferrer"
href="https://surveys.modrinth.com/servers-region-waitlist"
>
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</div>
<template v-if="custom">
<h2 class="mt-4 mb-2 text-xl font-bold text-contrast">
{{ formatMessage(messages.customPrompt) }}
</h2>
<div>
<Slider v-model="selectedRam" :min="minRam" :max="maxRam" :step="2" unit="GB" />
<div class="bg-bg rounded-xl p-4 mt-4 text-secondary">
<div v-if="checkingCustomStock" class="flex gap-2 items-center">
<SpinnerIcon class="size-5 shrink-0 animate-spin" /> Checking availability...
</div>
<div v-else-if="selectedPlan">
<ServersSpecs
class="!flex-row justify-between"
:ram="selectedPlan.metadata.ram ?? 0"
:storage="selectedPlan.metadata.storage ?? 0"
:cpus="selectedPlan.metadata.cpu ?? 0"
/>
</div>
<div v-else class="flex gap-2 items-center">
<XIcon class="size-5 shrink-0 text-red" /> Sorry, we don't have any plans available with
{{ selectedRam }} GB RAM in this region.
</div>
</div>
<div class="flex gap-2 mt-2">
<InfoIcon class="hidden sm:block shrink-0 mt-1" />
<span class="text-sm text-secondary">
Storage and shared CPU count are currently not configurable independently, and are based
on the amount of RAM you select.
</span>
</div>
</div>
</template>
</template>
</template>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import type Stripe from 'stripe'
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
import { useVIntl, defineMessages } from '@vintl/vintl'
import PaymentMethodOption from './PaymentMethodOption.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: 'select', paymentMethod: Stripe.PaymentMethod | undefined): void
}>()
defineProps<{
paymentMethods: Stripe.PaymentMethod[]
selected?: Stripe.PaymentMethod
loadingElements: boolean
loadingElementsFailed: boolean
}>()
const messages = defineMessages({
prompt: {
id: 'servers.purchase.step.payment.prompt',
defaultMessage: 'Select a payment method',
},
description: {
id: 'servers.purchase.step.payment.description',
defaultMessage: `You won't be charged yet.`,
},
})
</script>
<template>
<h2 class="mt-0 mb-1 text-xl font-bold text-contrast">
{{ formatMessage(messages.prompt) }}
</h2>
<p class="mt-0 mb-4 text-secondary">
{{ formatMessage(messages.description) }}
</p>
<div class="flex flex-col gap-1">
<PaymentMethodOption
v-for="method in paymentMethods"
:key="method.id"
:item="method"
:selected="selected?.id === method.id"
@select="emit('select', method)"
/>
<PaymentMethodOption
:loading="false"
:item="undefined"
:selected="selected === undefined"
@select="emit('select', undefined)"
/>
</div>
<div
v-show="selected === undefined"
class="min-h-[16rem] flex flex-col gap-2 mt-2 p-4 bg-table-alternateRow rounded-xl justify-center items-center"
>
<div v-show="loadingElements">
<ModalLoadingIndicator :error="loadingElementsFailed">
Loading...
<template #error> Error loading Stripe payment UI. </template>
</ModalLoadingIndicator>
</div>
<div class="w-full">
<div id="address-element"></div>
<div id="payment-element" class="mt-4"></div>
</div>
</div>
</template>

View File

@@ -0,0 +1,264 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ServerBillingInterval, ServerPlan, ServerRegion } from '../../utils/billing'
import TagItem from '../base/TagItem.vue'
import ServersSpecs from './ServersSpecs.vue'
import { formatPrice, getPingLevel } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import { regionOverrides } from '../../utils/regions'
import {
EditIcon,
RightArrowIcon,
SignalIcon,
SpinnerIcon,
XIcon,
RadioButtonIcon,
RadioButtonCheckedIcon,
ExternalIcon,
} from '@modrinth/assets'
import type Stripe from 'stripe'
import FormattedPaymentMethod from './FormattedPaymentMethod.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import Checkbox from '../base/Checkbox.vue'
import ExpandableInvoiceTotal from './ExpandableInvoiceTotal.vue'
const vintl = useVIntl()
const { locale, formatMessage } = vintl
const emit = defineEmits<{
(e: 'changePaymentMethod' | 'reloadPaymentIntent'): void
}>()
const props = defineProps<{
plan: ServerPlan
region: ServerRegion
tax?: number
total?: number
currency: string
ping?: number
loading?: boolean
selectedPaymentMethod: Stripe.PaymentMethod | undefined
onError: (error: Error) => void
}>()
const interval = defineModel<ServerBillingInterval>('interval')
const acceptedEula = defineModel<boolean>('accepted-eula', { required: true })
const prices = computed(() => {
return props.plan.prices.find((x) => x.currency_code === props.currency)
})
const planName = computed(() => {
if (!props.plan || !props.plan.metadata || props.plan.metadata.type !== 'pyro') return 'Unknown'
const ram = props.plan.metadata.ram
if (ram === 4096) return 'Small'
if (ram === 6144) return 'Medium'
if (ram === 8192) return 'Large'
return 'Custom'
})
const flag = computed(
() =>
regionOverrides[props.region.shortcode]?.flag ??
`https://flagcdn.com/${props.region.country_code}.svg`,
)
const overrideTitle = computed(() => regionOverrides[props.region.shortcode]?.name)
const title = computed(() =>
overrideTitle.value ? formatMessage(overrideTitle.value) : props.region.display_name,
)
const locationSubtitle = computed(() =>
overrideTitle.value ? props.region.display_name : undefined,
)
const pingLevel = computed(() => getPingLevel(props.ping ?? 0))
const period = computed(() => {
if (interval.value === 'monthly') return 'month'
if (interval.value === 'quarterly') return '3 months'
if (interval.value === 'yearly') return 'year'
return '???'
})
const monthsInInterval: Record<ServerBillingInterval, number> = {
monthly: 1,
quarterly: 3,
yearly: 12,
}
function setInterval(newInterval: ServerBillingInterval) {
interval.value = newInterval
emit('reloadPaymentIntent')
}
</script>
<template>
<div class="grid sm:grid-cols-[3fr_2fr] gap-4">
<div class="bg-table-alternateRow p-4 rounded-2xl">
<div class="flex items-center gap-2 mb-3">
<LazyUiServersModrinthServersIcon class="flex h-5 w-fit" />
<TagItem>{{ planName }}</TagItem>
</div>
<div>
<ServersSpecs
v-if="plan.metadata && plan.metadata.ram && plan.metadata.storage && plan.metadata.cpu"
class="!grid sm:grid-cols-2"
:ram="plan.metadata.ram"
:storage="plan.metadata.storage"
:cpus="plan.metadata.cpu"
/>
</div>
</div>
<div
class="bg-table-alternateRow p-4 rounded-2xl flex flex-col gap-2 items-center justify-center"
>
<img
v-if="flag"
class="aspect-[16/10] max-w-12 w-full object-cover rounded-md border-1 border-button-border border-solid"
:src="flag"
alt=""
aria-hidden="true"
/>
<span class="font-semibold">
{{ title }}
</span>
<span class="text-xs flex items-center gap-1 text-secondary font-medium">
<template v-if="locationSubtitle">
<span>
{{ locationSubtitle }}
</span>
<span v-if="ping !== -1"></span>
</template>
<template v-if="ping !== -1">
<SignalIcon
v-if="ping"
aria-hidden="true"
:style="`--_signal-${pingLevel}: ${pingLevel <= 2 ? 'var(--color-red)' : pingLevel <= 4 ? 'var(--color-orange)' : 'var(--color-green)'}`"
stroke-width="3px"
class="shrink-0"
/>
<SpinnerIcon v-else class="animate-spin" />
<template v-if="ping"> {{ ping }}ms </template>
<span v-else> Testing connection... </span>
</template>
</span>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<button
:class="
interval === 'monthly'
? 'bg-button-bg border-transparent'
: 'bg-transparent border-button-border'
"
class="mt-4 rounded-2xl active:scale-[0.98] transition-transform duration-100 border-2 border-solid p-4 flex items-center gap-2"
@click="setInterval('monthly')"
>
<RadioButtonCheckedIcon v-if="interval === 'monthly'" class="size-6 text-brand" />
<RadioButtonIcon v-else class="size-6 text-secondary" />
<div class="flex flex-col items-start gap-1 font-medium text-primary">
<span class="flex items-center gap-1" :class="{ 'text-contrast': interval === 'monthly' }"
>Pay monthly</span
>
<span class="text-sm text-secondary flex items-center gap-1"
>{{ formatPrice(locale, prices?.prices.intervals['monthly'], currency, true) }} /
month</span
>
</div>
</button>
<button
:class="
interval === 'yearly'
? 'bg-button-bg border-transparent'
: 'bg-transparent border-button-border'
"
class="mt-4 rounded-2xl active:scale-[0.98] transition-transform duration-100 border-2 border-solid p-4 flex items-center gap-2"
@click="setInterval('yearly')"
>
<RadioButtonCheckedIcon v-if="interval === 'yearly'" class="size-6 text-brand" />
<RadioButtonIcon v-else class="size-6 text-secondary" />
<div class="flex flex-col items-start gap-1 font-medium text-primary">
<span class="flex items-center gap-1" :class="{ 'text-contrast': interval === 'yearly' }"
>Pay yearly
<span class="text-xs font-bold text-brand px-1.5 py-0.5 rounded-full bg-brand-highlight"
>{{ interval === 'quarterly' ? 'Saving' : 'Save' }} 16%</span
></span
>
<span class="text-sm text-secondary flex items-center gap-1"
>{{
formatPrice(
locale,
prices?.prices?.intervals?.['yearly'] ?? 0 / monthsInInterval['yearly'],
currency,
true,
)
}}
/ month</span
>
</div>
</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,
},
]
: []
"
/>
</div>
<div 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">
<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-else> Select payment method <RightArrowIcon /> </template>
</button>
</ButtonStyled>
</div>
<p class="m-0 mt-4 text-sm text-secondary">
<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">
<Checkbox
v-model="acceptedEula"
label="I acknowledge that I have read and agree to the"
description="I acknowledge that I have read and agree to the Minecraft EULA"
/>
<a
href="https://www.minecraft.net/en-us/eula"
target="_blank"
class="text-brand underline hover:brightness-[--hover-brightness]"
>Minecraft EULA<ExternalIcon class="size-3 shrink-0 ml-0.5 mb-0.5"
/></a>
</div>
</template>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { useVIntl } from '@vintl/vintl'
import { getPingLevel } from '@modrinth/utils'
import { SignalIcon, SpinnerIcon } from '@modrinth/assets'
import { computed } from 'vue'
import type { ServerRegion } from '../../utils/billing'
import { regionOverrides } from '../../utils/regions'
const { formatMessage } = useVIntl()
const currentRegion = defineModel<string | undefined>({ required: true })
const props = defineProps<{
region: ServerRegion
ping?: number
bestPing?: boolean
outOfStock?: boolean
}>()
const isCurrentRegion = computed(() => currentRegion.value === props.region.shortcode)
const flag = computed(
() =>
regionOverrides[props.region.shortcode]?.flag ??
`https://flagcdn.com/${props.region.country_code}.svg`,
)
const overrideTitle = computed(() => regionOverrides[props.region.shortcode]?.name)
const title = computed(() =>
overrideTitle.value ? formatMessage(overrideTitle.value) : props.region.display_name,
)
const locationSubtitle = computed(() =>
overrideTitle.value ? props.region.display_name : undefined,
)
const pingLevel = computed(() => getPingLevel(props.ping ?? 0))
function setRegion() {
currentRegion.value = props.region.shortcode
}
</script>
<template>
<button
:disabled="outOfStock"
class="rounded-2xl p-4 font-semibold transition-all border-2 border-solid flex flex-col items-center gap-3"
:class="{
'bg-button-bg border-transparent text-primary': !isCurrentRegion,
'bg-brand-highlight border-brand text-contrast': isCurrentRegion,
'opacity-50 cursor-not-allowed': outOfStock,
'hover:text-contrast active:scale-95 hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] ':
!outOfStock,
}"
@click="setRegion"
>
<img
v-if="flag"
class="aspect-[16/10] max-w-16 w-full object-cover rounded-md border-1 border-solid"
:class="[
isCurrentRegion ? 'border-brand' : 'border-button-border',
{ 'saturate-[0.25]': outOfStock },
]"
:src="flag"
alt=""
aria-hidden="true"
/>
<span class="flex flex-col gap-1 items-center">
<span class="flex items-center gap-1 flex-wrap justify-center">
{{ title }} <span v-if="outOfStock" class="text-sm text-secondary">(Out of stock)</span>
</span>
<span class="text-xs flex items-center gap-1 text-secondary font-medium">
<template v-if="locationSubtitle">
<span>
{{ locationSubtitle }}
</span>
<span v-if="ping !== -1"></span>
</template>
<template v-if="ping !== -1">
<SignalIcon
v-if="ping"
aria-hidden="true"
:style="`--_signal-${pingLevel}: ${pingLevel <= 2 ? 'var(--color-red)' : pingLevel <= 4 ? 'var(--color-orange)' : 'var(--color-green)'}`"
stroke-width="3px"
class="shrink-0"
/>
<SpinnerIcon v-else class="animate-spin" />
<span v-if="bestPing" :class="bestPing ? 'text-brand' : 'text-primary'">
Lowest latency ({{ ping }}ms)
</span>
<template v-else-if="ping"> {{ ping }}ms </template>
<span v-else> Testing connection... </span>
</template>
</span>
</span>
</button>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from 'vue'
import AutoLink from '../base/AutoLink.vue'
import { MemoryStickIcon, DatabaseIcon, CPUIcon, SparklesIcon, UnknownIcon } from '@modrinth/assets'
const emit = defineEmits<{
(e: 'click-bursting-link'): void
}>()
const props = withDefaults(
defineProps<{
ram: number
storage: number
cpus: number
burstingLink?: string
}>(),
{
burstingLink: undefined,
},
)
const formattedRam = computed(() => {
return props.ram / 1024
})
const formattedStorage = computed(() => {
return props.storage / 1024
})
const sharedCpus = computed(() => {
return props.cpus / 2
})
</script>
<template>
<ul class="m-0 flex list-none flex-col gap-2 px-0 text-sm leading-normal text-secondary">
<li class="flex items-center gap-2">
<MemoryStickIcon class="h-5 w-5 shrink-0" /> {{ formattedRam }} GB RAM
</li>
<li class="flex items-center gap-2">
<DatabaseIcon class="h-5 w-5 shrink-0" /> {{ formattedStorage }} GB Storage
</li>
<li class="flex items-center gap-2">
<CPUIcon class="h-5 w-5 shrink-0" /> {{ sharedCpus }} Shared CPUs
</li>
<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"
@click="() => emit('click-bursting-link')"
>
<UnknownIcon class="h-4 w-4 text-secondary opacity-80" />
</AutoLink>
</li>
</ul>
</template>

View File

@@ -96,6 +96,8 @@ export { default as SearchSidebarFilter } from './search/SearchSidebarFilter.vue
// Billing
export { default as PurchaseModal } from './billing/PurchaseModal.vue'
export { default as AddPaymentMethodModal } from './billing/AddPaymentMethodModal.vue'
export { default as ModrinthServersPurchaseModal } from './billing/ModrinthServersPurchaseModal.vue'
// Version
export { default as VersionChannelIndicator } from './version/VersionChannelIndicator.vue'
@@ -107,3 +109,4 @@ export { default as ThemeSelector } from './settings/ThemeSelector.vue'
// Servers
export { default as BackupWarning } from './servers/backups/BackupWarning.vue'
export { default as ServersSpecs } from './billing/ServersSpecs.vue'

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { SpinnerIcon, XCircleIcon } from '@modrinth/assets'
withDefaults(
defineProps<{
error?: boolean
}>(),
{
error: false,
},
)
</script>
<template>
<div class="flex items-center gap-2 font-semibold" :class="error ? 'text-red' : 'animate-pulse'">
<XCircleIcon v-if="error" class="w-6 h-6" />
<SpinnerIcon v-else class="w-6 h-6 animate-spin" />
<slot v-if="error" name="error" />
<slot v-else />
</div>
</template>
<style scoped>
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%,
100% {
scale: 1;
}
50% {
scale: 0.95;
}
}
</style>