forked from didirus/AstralRinth
Medal promo v2 (#4220)
* Revert "Revert "feat: medal promotion on servers page (#4117)"" This reverts commit2e6cff7efc. * Revert "Revert "update changelog"" This reverts commitb2ff2d8737. * Revert "Revert "turn off medal promo"" This reverts commiteaa4b44a16. * Revert "Revert "Revert "turn off medal promo""" This reverts commit76d0ef03e7. * Revert "Revert "fix medal thing showing up for everyone"" This reverts commitee8c47adcb. * 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:
@@ -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
|
||||
|
||||
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>
|
||||
136
packages/ui/src/components/billing/ModalBasedServerPlan.vue
Normal file
136
packages/ui/src/components/billing/ModalBasedServerPlan.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 15–25 players, modpacks, or heavy modding."
|
||||
},
|
||||
"servers.purchase.step.plan.medium.desc": {
|
||||
"defaultMessage": "Great for 6–15 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 1–5 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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user