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

@@ -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"