You've already forked AstralRinth
forked from didirus/AstralRinth
feat: medal promotion on servers page (#4117)
* feat: medal promotion on servers page * feat: medal server card * fix: styling changes * fix: colors for dark mode only * fix: light mode medal promotion * feat: finish server card layout * feat: countdown on server panel * fix: lint * feat: use same gradient as promo * fix: scale for medal bg * fix: border around server icon * feat: medal subscr expiry date stuff * feat: progress on plans within the modal * feat: finalize plan modal stage * fix: unused scss * feat: remove buttons from cards * feat: upgrade button opens modal on server panel * feat: billing endpoint * fix: lint issues * fix: lint issues * fix: lint issues * feat: better handling of downgrades + existing plan checks * feat: update medal url * feat: proration visual in modal * feat: standardize upgrade modal into ServersUpgradeModalWrapper * feat: replace upgrade PurchaseModal with ServersUpgradeModalWrapper * feat: allow server region * fix: lint * fix: lint * fix: medal frontend completion * fix: lint issues * feat: ad * fix: hover tooltip + orange new server sparkle * feat: ad * fix: lint issues new eslint * feat: match ad * feat: support for ?dry=true * fix: lint isuses * fix: lint issues * fix: TeleportDropdownMenu imports * fix: hash nav issues * feat: clarify confirm changes btn * fix: lint issues * fix: "Using new payment method" * fix: lint * fix: re-add -mt-2 --------- Signed-off-by: Cal H. <hendersoncal117@gmail.com>
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user