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:
Cal H.
2025-08-18 18:59:19 +01:00
committed by GitHub
parent 9af1391e0e
commit 14eac461be
34 changed files with 2476 additions and 285 deletions

View File

@@ -7,6 +7,7 @@ import {
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import type { UserSubscription } from '@modrinth/utils'
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import type Stripe from 'stripe'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
@@ -26,6 +27,7 @@ import type {
import { ButtonStyled } from '../index'
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
import NewModal from '../modal/NewModal.vue'
import PlanSelector from './ServersPurchase0Plan.vue'
import RegionSelector from './ServersPurchase1Region.vue'
import PaymentMethodSelector from './ServersPurchase2PaymentMethod.vue'
import ConfirmPurchase from './ServersPurchase3Review.vue'
@@ -46,12 +48,16 @@ const props = defineProps<{
pings: RegionPing[]
regions: ServerRegion[]
availableProducts: ServerPlan[]
planStage?: boolean
existingPlan?: ServerPlan
existingSubscription?: UserSubscription
refreshPaymentMethods: () => Promise<void>
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
initiatePayment: (
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
) => Promise<UpdatePaymentIntentResponse | CreatePaymentIntentResponse>
) => Promise<UpdatePaymentIntentResponse | CreatePaymentIntentResponse | null>
onError: (err: Error) => void
onFinalizeNoPaymentChange?: () => Promise<void>
}>()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
@@ -78,6 +84,7 @@ const {
hasPaymentMethod,
submitPayment,
completingPurchase,
noPaymentRequired,
} = useStripe(
props.publishableKey,
props.customer,
@@ -95,11 +102,14 @@ const customServer = ref<boolean>(false)
const acceptedEula = ref<boolean>(false)
const skipPaymentMethods = ref<boolean>(true)
type Step = 'region' | 'payment' | 'review'
type Step = 'plan' | 'region' | 'payment' | 'review'
const steps: Step[] = ['region', 'payment', 'review']
const steps: Step[] = props.planStage
? (['plan', 'region', 'payment', 'review'] as Step[])
: (['region', 'payment', 'review'] as Step[])
const titles: Record<Step, MessageDescriptor> = {
plan: defineMessage({ id: 'servers.purchase.step.plan.title', defaultMessage: 'Plan' }),
region: defineMessage({ id: 'servers.purchase.step.region.title', defaultMessage: 'Region' }),
payment: defineMessage({
id: 'servers.purchase.step.payment.title',
@@ -132,12 +142,26 @@ const nextStep = computed(() =>
const canProceed = computed(() => {
switch (currentStep.value) {
case 'plan':
console.log('Plan step:', {
customServer: customServer.value,
selectedPlan: selectedPlan.value,
existingPlan: props.existingPlan,
})
return (
customServer.value ||
(!!selectedPlan.value &&
(!props.existingPlan || selectedPlan.value.id !== props.existingPlan.id))
)
case 'region':
return selectedRegion.value && selectedPlan.value && selectedInterval.value
case 'payment':
return selectedPaymentMethod.value || !loadingElements.value
case 'review':
return acceptedEula.value && hasPaymentMethod.value && !completingPurchase.value
return (
(noPaymentRequired.value || (acceptedEula.value && hasPaymentMethod.value)) &&
!completingPurchase.value
)
default:
return false
}
@@ -145,6 +169,8 @@ const canProceed = computed(() => {
async function beforeProceed(step: string) {
switch (step) {
case 'plan':
return true
case 'region':
return true
case 'payment':
@@ -160,6 +186,9 @@ async function beforeProceed(step: string) {
}
return true
case 'review':
if (noPaymentRequired.value) {
return true
}
if (selectedPaymentMethod.value) {
return true
} else {
@@ -200,12 +229,31 @@ async function setStep(step: Step | undefined, skipValidation = false) {
}
watch(selectedPlan, () => {
console.log(selectedPlan.value)
if (currentStep.value === 'plan') {
customServer.value = !selectedPlan.value
}
})
const defaultPlan = computed<ServerPlan | undefined>(() => {
return (
props.availableProducts.find((p) => p?.metadata?.type === 'pyro' && p.metadata.ram === 6144) ??
props.availableProducts.find((p) => p?.metadata?.type === 'pyro') ??
props.availableProducts[0]
)
})
function begin(interval: ServerBillingInterval, plan?: ServerPlan, project?: string) {
loading.value = false
selectedPlan.value = plan
if (plan === null) {
// Explicitly open in custom mode
selectedPlan.value = undefined
customServer.value = true
} else {
selectedPlan.value = plan ?? defaultPlan.value
customServer.value = !selectedPlan.value
}
selectedInterval.value = interval
customServer.value = !selectedPlan.value
selectedPaymentMethod.value = undefined
@@ -218,16 +266,42 @@ function begin(interval: ServerBillingInterval, plan?: ServerPlan, project?: str
defineExpose({
show: begin,
})
defineEmits<{
(e: 'hide'): void
}>()
function handleChooseCustom() {
customServer.value = true
selectedPlan.value = undefined
}
// When the user explicitly wants to change or add a payment method from Review
// we must disable the auto-skip behavior, clear any selected method, and
// navigate to the Payment step so Stripe Elements can mount.
async function changePaymentMethod() {
skipPaymentMethods.value = false
selectedPaymentMethod.value = undefined
await setStep('payment', true)
}
function goToBreadcrumbStep(id: string) {
if (id === 'payment') {
return changePaymentMethod()
}
return setStep(id as Step, true)
}
</script>
<template>
<NewModal ref="modal">
<NewModal ref="modal" @hide="$emit('hide')">
<template #title>
<div class="flex items-center gap-1 font-bold text-secondary">
<template v-for="(title, id, index) in titles" :key="id">
<button
v-if="index < currentStepIndex"
class="bg-transparent active:scale-95 font-bold text-secondary p-0"
@click="setStep(id, true)"
@click="goToBreadcrumbStep(id as string)"
>
{{ formatMessage(title) }}
</button>
@@ -248,8 +322,17 @@ defineExpose({
</div>
</template>
<div class="w-[40rem] max-w-full">
<PlanSelector
v-if="currentStep === 'plan'"
v-model:plan="selectedPlan"
v-model:interval="selectedInterval"
:existing-plan="existingPlan"
:available-products="availableProducts"
:currency="currency"
@choose-custom="handleChooseCustom"
/>
<RegionSelector
v-if="currentStep === 'region'"
v-else-if="currentStep === 'region'"
v-model:region="selectedRegion"
v-model:plan="selectedPlan"
:regions="regions"
@@ -271,7 +354,7 @@ defineExpose({
<ConfirmPurchase
v-else-if="
currentStep === 'review' &&
hasPaymentMethod &&
(hasPaymentMethod || noPaymentRequired) &&
currentRegion &&
selectedInterval &&
selectedPlan
@@ -284,14 +367,13 @@ defineExpose({
:ping="currentPing"
:loading="paymentMethodLoading"
:selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod"
:has-payment-method="hasPaymentMethod"
:tax="tax"
:total="total"
@change-payment-method="
() => {
skipPaymentMethods = false
setStep('payment', true)
}
"
:no-payment-required="noPaymentRequired"
:existing-plan="existingPlan"
:existing-subscription="existingSubscription"
@change-payment-method="changePaymentMethod"
@reload-payment-intent="reloadPaymentIntent"
/>
<div v-else>Something went wrong</div>
@@ -329,17 +411,33 @@ defineExpose({
<ButtonStyled color="brand">
<button
v-tooltip="
currentStep === 'review' && !acceptedEula
currentStep === 'review' && !acceptedEula && !noPaymentRequired
? 'You must accept the Minecraft EULA to proceed.'
: undefined
"
:disabled="!canProceed"
@click="setStep(nextStep)"
@click="
noPaymentRequired && currentStep === 'review'
? (async () => {
if (props.onFinalizeNoPaymentChange) {
try {
await props.onFinalizeNoPaymentChange()
} catch (e) {
return
}
}
modal?.hide()
})()
: setStep(nextStep)
"
>
<template v-if="currentStep === 'review'">
<SpinnerIcon v-if="completingPurchase" class="animate-spin" />
<CheckCircleIcon v-else />
Subscribe
<template v-if="noPaymentRequired"><CheckCircleIcon /> Confirm Change</template>
<template v-else>
<SpinnerIcon v-if="completingPurchase" class="animate-spin" />
<CheckCircleIcon v-else />
Subscribe
</template>
</template>
<template v-else>
{{ formatMessage(commonMessages.nextButton) }} <RightArrowIcon />