Add quick server button, dynamic price preview for custom server modal (#3815)

* Add quick server creation button, and dynamic pricing to custom server selection

* Remove test in compatibility card

* Lint + remove duplicate file

* Adjust z-index of popup

* $6 -> $5

* Dismiss prompt if the button is clicked

* Make "Create a server" disabled for now

* Use existing loaders type
This commit is contained in:
Prospector
2025-06-26 08:38:42 -07:00
committed by GitHub
parent 47af459f24
commit c793b68aed
15 changed files with 362 additions and 92 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg>

After

Width:  |  Height:  |  Size: 441 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M22 12H2M11.1 4H7.2c-.8 0-1.5.4-1.8 1.1L2 12v6c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2v-6l-1.5-3M6 16h0M10 16h0M14.4 4h6M17.4 1v6"/></svg>

After

Width:  |  Height:  |  Size: 297 B

View File

@@ -44,6 +44,7 @@ import _AlignLeftIcon from './icons/align-left.svg?component'
import _ArchiveIcon from './icons/archive.svg?component'
import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component'
import _AsteriskIcon from './icons/asterisk.svg?component'
import _BadgeCheckIcon from './icons/badge-check.svg?component'
import _BanIcon from './icons/ban.svg?component'
import _BellIcon from './icons/bell.svg?component'
import _BellRingIcon from './icons/bell-ring.svg?component'
@@ -163,6 +164,7 @@ import _ScanEyeIcon from './icons/scan-eye.svg?component'
import _SearchIcon from './icons/search.svg?component'
import _SendIcon from './icons/send.svg?component'
import _ServerIcon from './icons/server.svg?component'
import _ServerPlusIcon from './icons/server-plus.svg?component'
import _SettingsIcon from './icons/settings.svg?component'
import _ShareIcon from './icons/share.svg?component'
import _ShieldIcon from './icons/shield.svg?component'
@@ -264,6 +266,7 @@ export const AlignLeftIcon = _AlignLeftIcon
export const ArchiveIcon = _ArchiveIcon
export const ArrowBigUpDashIcon = _ArrowBigUpDashIcon
export const AsteriskIcon = _AsteriskIcon
export const BadgeCheckIcon = _BadgeCheckIcon
export const BanIcon = _BanIcon
export const BellIcon = _BellIcon
export const BellRingIcon = _BellRingIcon
@@ -383,6 +386,7 @@ export const ScanEyeIcon = _ScanEyeIcon
export const SearchIcon = _SearchIcon
export const SendIcon = _SendIcon
export const ServerIcon = _ServerIcon
export const ServerPlusIcon = _ServerPlusIcon
export const SettingsIcon = _SettingsIcon
export const ShareIcon = _ShareIcon
export const ShieldIcon = _ShieldIcon

View File

@@ -822,10 +822,69 @@ a,
// TOOLTIPS
.v-popper--theme-dropdown,
.v-popper--theme-dropdown.v-popper--theme-ribbit-popout {
.v-popper__inner {
border: 1px solid var(--color-button-bg) !important;
padding: var(--gap-sm) !important;
width: fit-content !important;
border-radius: var(--radius-md) !important;
background-color: var(--color-raised-bg) !important;
box-shadow: var(--shadow-floating) !important;
}
.v-popper__arrow-outer {
border-color: var(--color-button-bg) !important;
}
.v-popper__arrow-inner {
border-color: var(--color-raised-bg) !important;
}
}
.v-popper__popper[data-popper-placement='bottom-end'] .v-popper__wrapper {
transform-origin: top right;
}
.v-popper__popper[data-popper-placement='top-end'] .v-popper__wrapper {
transform-origin: bottom right;
}
.v-popper__popper[data-popper-placement='bottom-start'] .v-popper__wrapper {
transform-origin: top left;
}
.v-popper__popper[data-popper-placement='top-start'] .v-popper__wrapper {
transform-origin: bottom left;
}
.v-popper__popper.v-popper__popper--show-from .v-popper__wrapper {
transform: scale(0.85);
opacity: 0;
}
.v-popper__popper.v-popper__popper--show-to .v-popper__wrapper {
transform: scale(1);
opacity: 1;
transition:
transform 0.125s ease-in-out,
opacity 0.125s ease-in-out;
}
.v-popper__popper.v-popper__popper--hide-from .v-popper__wrapper {
transform: none;
opacity: 1;
transition: transform 0.0625s;
}
.v-popper__popper.v-popper__popper--hide-to .v-popper__wrapper {
//transform: scale(.9);
}
.v-popper--theme-tooltip {
.v-popper__inner {
background: var(--color-tooltip-bg) !important;
color: var(--color-tooltip-text) !important;
color: initial !important;
padding: 0.5rem 0.5rem !important;
border-radius: var(--radius-sm) !important;
filter: drop-shadow(5px 5px 0.8rem rgba(0, 0, 0, 0.35));
@@ -840,6 +899,30 @@ a,
}
}
.v-popper--theme-dismissable-prompt {
z-index: 10;
.v-popper__inner {
background: var(--color-raised-bg) !important;
border: 1px solid var(--color-button-border);
color: var(--color-tooltip-text) !important;
padding: 0.75rem 1rem !important;
border-radius: 0.75rem !important;
filter: drop-shadow(5px 5px 0.8rem rgba(0, 0, 0, 0.35));
font-size: 0.9rem;
font-weight: bold;
line-height: 1;
}
.v-popper__arrow-outer {
border-color: var(--color-button-border);
}
.v-popper__arrow-inner {
border-color: var(--color-raised-bg);
}
}
// MARKDOWN
.markdown-body {
@@ -1205,65 +1288,6 @@ select {
border-top-right-radius: var(--radius-md) !important;
}
.v-popper--theme-dropdown,
.v-popper--theme-dropdown.v-popper--theme-ribbit-popout {
.v-popper__inner {
border: 1px solid var(--color-button-bg) !important;
padding: var(--gap-sm) !important;
width: fit-content !important;
border-radius: var(--radius-md) !important;
background-color: var(--color-raised-bg) !important;
box-shadow: var(--shadow-floating) !important;
}
.v-popper__arrow-outer {
border-color: var(--color-button-bg) !important;
}
.v-popper__arrow-inner {
border-color: var(--color-raised-bg) !important;
}
}
.v-popper__popper[data-popper-placement='bottom-end'] .v-popper__wrapper {
transform-origin: top right;
}
.v-popper__popper[data-popper-placement='top-end'] .v-popper__wrapper {
transform-origin: bottom right;
}
.v-popper__popper[data-popper-placement='bottom-start'] .v-popper__wrapper {
transform-origin: top left;
}
.v-popper__popper[data-popper-placement='top-start'] .v-popper__wrapper {
transform-origin: bottom left;
}
.v-popper__popper.v-popper__popper--show-from .v-popper__wrapper {
transform: scale(0.85);
opacity: 0;
}
.v-popper__popper.v-popper__popper--show-to .v-popper__wrapper {
transform: scale(1);
opacity: 1;
transition:
transform 0.125s ease-in-out,
opacity 0.125s ease-in-out;
}
.v-popper__popper.v-popper__popper--hide-from .v-popper__wrapper {
transform: none;
opacity: 1;
transition: transform 0.0625s;
}
.v-popper__popper.v-popper__popper--hide-to .v-popper__wrapper {
//transform: scale(.9);
}
.preview-radio {
width: 100% !important;
border-radius: var(--radius-md);

View File

@@ -59,6 +59,7 @@ const selectedPlan = ref<ServerPlan>()
const selectedInterval = ref<ServerBillingInterval>('quarterly')
const loading = ref(false)
const selectedRegion = ref<string>()
const projectId = ref<string>()
const {
initializeStripe,
@@ -85,6 +86,7 @@ const {
selectedPlan,
selectedInterval,
selectedRegion,
projectId,
props.initiatePayment,
props.onError,
)
@@ -201,7 +203,7 @@ watch(selectedPlan, () => {
console.log(selectedPlan.value)
})
function begin(interval: ServerBillingInterval, plan?: ServerPlan) {
function begin(interval: ServerBillingInterval, plan?: ServerPlan, project?: string) {
loading.value = false
selectedPlan.value = plan
selectedInterval.value = interval
@@ -209,6 +211,7 @@ function begin(interval: ServerBillingInterval, plan?: ServerPlan) {
selectedPaymentMethod.value = undefined
currentStep.value = steps[0]
skipPaymentMethods.value = true
projectId.value = project
modal.value?.show()
}
@@ -253,6 +256,8 @@ defineExpose({
:pings="pings"
:custom="customServer"
:available-products="availableProducts"
:currency="currency"
:interval="selectedInterval"
:fetch-stock="fetchStock"
/>
<PaymentMethodSelector

View File

@@ -4,19 +4,28 @@ 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 {
monthsInInterval,
type ServerBillingInterval,
type ServerPlan,
type ServerRegion,
type 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'
import { formatPrice } from '../../../../utils'
const { formatMessage } = useVIntl()
const { formatMessage, locale } = useVIntl()
const props = defineProps<{
regions: ServerRegion[]
pings: RegionPing[]
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
custom: boolean
currency: string
interval: ServerBillingInterval
availableProducts: ServerPlan[]
}>()
@@ -25,6 +34,12 @@ const checkingCustomStock = ref(false)
const selectedPlan = defineModel<ServerPlan>('plan')
const selectedRegion = defineModel<string>('region')
const selectedPrice = computed(() => {
const amount = selectedPlan.value?.prices?.find((price) => price.currency_code === props.currency)
?.prices?.intervals?.[props.interval]
return amount ? amount / monthsInInterval[props.interval] : undefined
})
const regionOrder: string[] = ['us-vin', 'eu-lim']
const sortedRegions = computed(() => {
@@ -216,7 +231,12 @@ onMounted(() => {
</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">
<p v-if="selectedPrice" class="mt-2 mb-0">
<span class="text-contrast text-lg font-bold"
>{{ 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 v-if="checkingCustomStock" class="flex gap-2 items-center">
<SpinnerIcon class="size-5 shrink-0 animate-spin" /> Checking availability...
</div>

View File

@@ -1,6 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ServerBillingInterval, ServerPlan, ServerRegion } from '../../utils/billing'
import {
monthsInInterval,
type ServerBillingInterval,
type ServerPlan,
type ServerRegion,
} from '../../utils/billing'
import TagItem from '../base/TagItem.vue'
import ServersSpecs from './ServersSpecs.vue'
import { formatPrice, getPingLevel } from '@modrinth/utils'
@@ -77,12 +82,6 @@ const period = computed(() => {
return '???'
})
const monthsInInterval: Record<ServerBillingInterval, number> = {
monthly: 1,
quarterly: 3,
yearly: 12,
}
function setInterval(newInterval: ServerBillingInterval) {
interval.value = newInterval
emit('reloadPaymentIntent')

View File

@@ -109,5 +109,6 @@ export { default as VersionSummary } from './version/VersionSummary.vue'
export { default as ThemeSelector } from './settings/ThemeSelector.vue'
// Servers
export { default as ServersPromo } from './servers/ServersPromo.vue'
export { default as BackupWarning } from './servers/backups/BackupWarning.vue'
export { default as ServersSpecs } from './billing/ServersSpecs.vue'

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { RightArrowIcon, ModrinthIcon, XIcon } from '@modrinth/assets'
import ButtonStyled from '../base/ButtonStyled.vue'
import AutoLink from '../base/AutoLink.vue'
const emit = defineEmits<{
(e: 'close'): void
}>()
withDefaults(
defineProps<{
link: string
closable?: boolean
}>(),
{
closable: true,
},
)
</script>
<template>
<div
class="brand-gradient-bg card-shadow bg-bg relative p-4 border-[1px] border-solid border-brand rounded-2xl grid grid-cols-[1fr_auto] overflow-hidden"
>
<ModrinthIcon
class="absolute -top-12 -right-12 size-48 text-brand-highlight opacity-25"
fill="none"
stroke="var(--color-brand)"
stroke-width="4"
/>
<div class="flex flex-col gap-2">
<span class="text-lg leading-tight font-extrabold text-contrast"
>Want to play with <br />
<span class="text-brand">your friends?</span></span
>
<span class="text-sm font-medium">Create a server with Modrinth in just a few clicks.</span>
</div>
<div class="flex flex-col items-end justify-end z-10">
<ButtonStyled color="brand">
<AutoLink :to="link"> View plans <RightArrowIcon /> </AutoLink>
</ButtonStyled>
</div>
<div class="absolute top-2 right-2 z-10">
<ButtonStyled v-if="closable" size="small" circular>
<button v-tooltip="`Don't show again`" @click="emit('close')">
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</div>
</template>
<style scoped>
.brand-gradient-bg {
background-image: linear-gradient(
to top right,
var(--color-brand-highlight) -80%,
var(--color-bg)
);
--color-button-bg: var(--brand-gradient-button);
}
</style>

View File

@@ -31,6 +31,7 @@ export const useStripe = (
product: Ref<ServerPlan | undefined>,
interval: Ref<ServerBillingInterval>,
region: Ref<string | undefined>,
project: Ref<string | undefined>,
initiatePayment: (
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
) => Promise<CreatePaymentIntentResponse | UpdatePaymentIntentResponse>,
@@ -222,16 +223,22 @@ export const useStripe = (
let result: BasePaymentIntentResponse
const metadata: CreatePaymentIntentRequest['metadata'] = {
type: 'pyro',
server_region: region.value,
source: project.value
? {
project_id: project.value,
}
: {},
}
if (paymentIntentId.value) {
result = await updateIntent({
...requestType,
charge,
existing_payment_intent: paymentIntentId.value,
metadata: {
type: 'pyro',
server_region: region.value,
source: {},
},
metadata,
})
console.log(`Updated payment intent: ${interval.value} for ${result.total}`)
} else {
@@ -242,11 +249,7 @@ export const useStripe = (
} = await createIntent({
...requestType,
charge,
metadata: {
type: 'pyro',
server_region: region.value,
source: {},
},
metadata: metadata,
}))
console.log(`Created payment intent: ${interval.value} for ${result.total}`)
}

View File

@@ -1,7 +1,14 @@
import type Stripe from 'stripe'
import type { Loaders } from '@modrinth/utils'
export type ServerBillingInterval = 'monthly' | 'yearly' | 'quarterly'
export const monthsInInterval: Record<ServerBillingInterval, number> = {
monthly: 1,
quarterly: 3,
yearly: 12,
}
export interface ServerPlan {
id: string
name: string
@@ -72,11 +79,18 @@ export type CreatePaymentIntentRequest = PaymentRequestType & {
type: 'pyro'
server_name?: string
server_region?: string
source: {
loader?: string
game_version?: string
loader_version?: string
}
source:
| {
loader: Loaders
game_version?: string
loader_version?: string
}
| {
project_id: string
version_id?: string
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
| {}
}
}