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:
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user