mirror of
https://github.com/modrinth/code.git
synced 2026-09-04 22:10:15 +00:00
implement servers guest plan modal and signin which redirects back to modal's next step
This commit is contained in:
@@ -264,7 +264,8 @@ function begin(
|
|||||||
selectedInterval.value = interval
|
selectedInterval.value = interval
|
||||||
customServer.value = !selectedPlan.value
|
customServer.value = !selectedPlan.value
|
||||||
selectedPaymentMethod.value = undefined
|
selectedPaymentMethod.value = undefined
|
||||||
currentStep.value = steps[0]
|
const skipPlanStep = props.planStage && plan !== undefined
|
||||||
|
currentStep.value = skipPlanStep ? (steps[1] ?? steps[0]) : steps[0]
|
||||||
skipPaymentMethods.value = true
|
skipPaymentMethods.value = true
|
||||||
projectId.value = project
|
projectId.value = project
|
||||||
modal.value?.show()
|
modal.value?.show()
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
|
import { computed, ref, useTemplateRef } from 'vue'
|
||||||
|
|
||||||
|
import { ChevronRightIcon } from '@modrinth/assets'
|
||||||
|
import NewModal from '../modal/NewModal.vue'
|
||||||
|
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
|
||||||
|
import PlanSelector from './ServersPurchase0Plan.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
availableProducts: Labrinth.Billing.Internal.Product[]
|
||||||
|
currency: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'continue', payload: { interval: ServerBillingInterval; planId: string | null }): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||||
|
|
||||||
|
const selectedPlan = ref<Labrinth.Billing.Internal.Product>()
|
||||||
|
const selectedInterval = ref<ServerBillingInterval>('quarterly')
|
||||||
|
|
||||||
|
const defaultPlan = computed<Labrinth.Billing.Internal.Product | 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 continueWithSelection() {
|
||||||
|
emit('continue', {
|
||||||
|
interval: selectedInterval.value,
|
||||||
|
planId: selectedPlan.value?.id ?? null,
|
||||||
|
})
|
||||||
|
modal.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseCustom() {
|
||||||
|
selectedPlan.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(initialInterval?: ServerBillingInterval, initialPlanId?: string | null) {
|
||||||
|
selectedInterval.value = initialInterval ?? 'quarterly'
|
||||||
|
if (initialPlanId === null) {
|
||||||
|
selectedPlan.value = undefined
|
||||||
|
} else if (initialPlanId) {
|
||||||
|
selectedPlan.value = props.availableProducts.find((product) => product.id === initialPlanId)
|
||||||
|
} else {
|
||||||
|
selectedPlan.value = defaultPlan.value
|
||||||
|
}
|
||||||
|
modal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
show,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NewModal ref="modal">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex items-center gap-1 font-bold text-secondary">
|
||||||
|
<span class="text-contrast">Plan</span>
|
||||||
|
<ChevronRightIcon class="h-5 w-5 text-secondary" stroke-width="3" />
|
||||||
|
<span class=""> Region </span>
|
||||||
|
<ChevronRightIcon class="h-5 w-5 text-secondary" stroke-width="3" />
|
||||||
|
<span class=""> Payment method </span>
|
||||||
|
<ChevronRightIcon class="h-5 w-5 text-secondary" stroke-width="3" />
|
||||||
|
<span class=""> Review </span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="w-[56rem] max-w-full">
|
||||||
|
<PlanSelector
|
||||||
|
v-model:plan="selectedPlan"
|
||||||
|
v-model:interval="selectedInterval"
|
||||||
|
:available-products="availableProducts"
|
||||||
|
:currency="currency"
|
||||||
|
@choose-custom="chooseCustom"
|
||||||
|
@proceed="continueWithSelection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
@@ -2,5 +2,6 @@ export { default as AddPaymentMethodModal } from './AddPaymentMethodModal.vue'
|
|||||||
export { default as ModrinthServersPurchaseModal } from './ModrinthServersPurchaseModal.vue'
|
export { default as ModrinthServersPurchaseModal } from './ModrinthServersPurchaseModal.vue'
|
||||||
export { default as PurchaseModal } from './PurchaseModal.vue'
|
export { default as PurchaseModal } from './PurchaseModal.vue'
|
||||||
export { default as ResubscribeModal } from './ResubscribeModal.vue'
|
export { default as ResubscribeModal } from './ResubscribeModal.vue'
|
||||||
|
export { default as ServersGuestPlanModal } from './ServersGuestPlanModal.vue'
|
||||||
export { default as ServersSpecs } from './ServersSpecs.vue'
|
export { default as ServersSpecs } from './ServersSpecs.vue'
|
||||||
export { default as ServersUpgradeModalWrapper } from './ServersUpgradeModalWrapper.vue'
|
export { default as ServersUpgradeModalWrapper } from './ServersUpgradeModalWrapper.vue'
|
||||||
|
|||||||
@@ -4,9 +4,14 @@
|
|||||||
class="experimental-styles-within relative mx-auto mb-6 flex w-full max-w-[1280px] flex-col px-6"
|
class="experimental-styles-within relative mx-auto mb-6 flex w-full max-w-[1280px] flex-col px-6"
|
||||||
:class="serverList.length ? 'min-h-screen' : 'min-h-[calc(100vh-4.5rem)]'"
|
:class="serverList.length ? 'min-h-screen' : 'min-h-[calc(100vh-4.5rem)]'"
|
||||||
>
|
>
|
||||||
|
<ServersGuestPlanModal
|
||||||
|
ref="guestPlanModal"
|
||||||
|
:available-products="pyroProducts"
|
||||||
|
:currency="selectedCurrency"
|
||||||
|
@continue="handleGuestPlanContinue"
|
||||||
|
/>
|
||||||
<ModrinthServersPurchaseModal
|
<ModrinthServersPurchaseModal
|
||||||
v-if="customer && regions"
|
v-if="customer && paymentMethods && regions"
|
||||||
:key="`purchase-modal-${customer.id}`"
|
|
||||||
ref="purchaseModal"
|
ref="purchaseModal"
|
||||||
:publishable-key="props.stripePublishableKey"
|
:publishable-key="props.stripePublishableKey"
|
||||||
:initiate-payment="
|
:initiate-payment="
|
||||||
@@ -15,7 +20,7 @@
|
|||||||
:available-products="pyroProducts"
|
:available-products="pyroProducts"
|
||||||
:on-error="handleError"
|
:on-error="handleError"
|
||||||
:customer="customer"
|
:customer="customer"
|
||||||
:payment-methods="paymentMethods ?? []"
|
:payment-methods="paymentMethods"
|
||||||
:currency="selectedCurrency"
|
:currency="selectedCurrency"
|
||||||
:return-url="`${props.siteUrl}/hosting/manage`"
|
:return-url="`${props.siteUrl}/hosting/manage`"
|
||||||
:pings="regionPings"
|
:pings="regionPings"
|
||||||
@@ -129,7 +134,7 @@
|
|||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
New server
|
New server
|
||||||
</AutoLink>
|
</AutoLink>
|
||||||
<button v-else :disabled="!canOpenPurchaseModal" @click="openPurchaseModal">
|
<button v-else @click="openPurchaseModal">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
New server
|
New server
|
||||||
</button>
|
</button>
|
||||||
@@ -198,6 +203,7 @@ import {
|
|||||||
ModrinthServersPurchaseModal,
|
ModrinthServersPurchaseModal,
|
||||||
ResubscribeModal,
|
ResubscribeModal,
|
||||||
ServerListEmpty,
|
ServerListEmpty,
|
||||||
|
ServersGuestPlanModal,
|
||||||
StyledInput,
|
StyledInput,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import type { ModrinthServersFetchError } from '@modrinth/utils'
|
import type { ModrinthServersFetchError } from '@modrinth/utils'
|
||||||
@@ -225,6 +231,17 @@ const client = injectModrinthClient()
|
|||||||
const loggedIn = computed(() => !!auth.user.value)
|
const loggedIn = computed(() => !!auth.user.value)
|
||||||
|
|
||||||
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
|
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
|
||||||
|
const PURCHASE_INTENT_STORAGE_KEY = 'modrinth:servers-purchase-intent'
|
||||||
|
const PURCHASE_INTENT_SOURCE = 'hosting-manage'
|
||||||
|
|
||||||
|
type ServerBillingInterval = 'monthly' | 'quarterly' | 'yearly'
|
||||||
|
|
||||||
|
type PendingPurchaseIntent = {
|
||||||
|
interval: ServerBillingInterval
|
||||||
|
planId: string | null
|
||||||
|
source: string
|
||||||
|
ts: number
|
||||||
|
}
|
||||||
|
|
||||||
const isPollingForNewServers = ref(false)
|
const isPollingForNewServers = ref(false)
|
||||||
const pollingState = ref({
|
const pollingState = ref({
|
||||||
@@ -233,10 +250,14 @@ const pollingState = ref({
|
|||||||
initialServers: [] as Archon.Servers.v0.Server[],
|
initialServers: [] as Archon.Servers.v0.Server[],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const guestPlanModal = ref<InstanceType<typeof ServersGuestPlanModal> | null>(null)
|
||||||
const purchaseModal = ref<InstanceType<typeof ModrinthServersPurchaseModal> | null>(null)
|
const purchaseModal = ref<InstanceType<typeof ModrinthServersPurchaseModal> | null>(null)
|
||||||
const resubscribeModal = ref<InstanceType<typeof ResubscribeModal> | null>(null)
|
const resubscribeModal = ref<InstanceType<typeof ResubscribeModal> | null>(null)
|
||||||
const affiliateCode = ref<string | null>(null)
|
const affiliateCode = ref<string | null>(null)
|
||||||
const selectedCurrency = ref<string>('USD')
|
const selectedCurrency = ref<string>('USD')
|
||||||
|
const lastSelectedInterval = ref<ServerBillingInterval>('quarterly')
|
||||||
|
const lastSelectedPlanId = ref<string | null | undefined>(undefined)
|
||||||
|
const pendingResumeIntent = ref<PendingPurchaseIntent | null>(null)
|
||||||
const regionPings = ref<
|
const regionPings = ref<
|
||||||
{
|
{
|
||||||
region: string
|
region: string
|
||||||
@@ -256,6 +277,40 @@ const pyroProducts = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function readPendingPurchaseIntent(): PendingPurchaseIntent | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
const rawIntent = window.sessionStorage.getItem(PURCHASE_INTENT_STORAGE_KEY)
|
||||||
|
if (!rawIntent) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedIntent = JSON.parse(rawIntent) as Partial<PendingPurchaseIntent>
|
||||||
|
if (
|
||||||
|
(parsedIntent.interval === 'monthly' ||
|
||||||
|
parsedIntent.interval === 'quarterly' ||
|
||||||
|
parsedIntent.interval === 'yearly') &&
|
||||||
|
(parsedIntent.planId === null || typeof parsedIntent.planId === 'string') &&
|
||||||
|
typeof parsedIntent.source === 'string' &&
|
||||||
|
typeof parsedIntent.ts === 'number'
|
||||||
|
) {
|
||||||
|
return parsedIntent as PendingPurchaseIntent
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePendingPurchaseIntent(intent: PendingPurchaseIntent) {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.sessionStorage.setItem(PURCHASE_INTENT_STORAGE_KEY, JSON.stringify(intent))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingPurchaseIntent() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.sessionStorage.removeItem(PURCHASE_INTENT_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: customer,
|
data: customer,
|
||||||
refetch: refetchCustomer,
|
refetch: refetchCustomer,
|
||||||
@@ -505,6 +560,25 @@ const canOpenPurchaseModal = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function openCheckoutFromIntent(intent: PendingPurchaseIntent): boolean {
|
||||||
|
console.log('Attempting to open checkout from intent', intent)
|
||||||
|
if (!purchaseModal.value || !canOpenPurchaseModal.value) return false
|
||||||
|
|
||||||
|
lastSelectedInterval.value = intent.interval
|
||||||
|
lastSelectedPlanId.value = intent.planId
|
||||||
|
const defaultPlan =
|
||||||
|
pyroProducts.value.find((p) => p?.metadata?.type === 'pyro' && p.metadata.ram === 6144) ??
|
||||||
|
pyroProducts.value.find((p) => p?.metadata?.type === 'pyro') ??
|
||||||
|
pyroProducts.value[0]
|
||||||
|
const selectedPlan =
|
||||||
|
intent.planId === null
|
||||||
|
? null
|
||||||
|
: (pyroProducts.value.find((product) => product.id === intent.planId) ?? defaultPlan)
|
||||||
|
|
||||||
|
purchaseModal.value.show(intent.interval, selectedPlan)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function handleError(err: unknown) {
|
function handleError(err: unknown) {
|
||||||
const error = err as Error & { data?: { description?: string } }
|
const error = err as Error & { data?: { description?: string } }
|
||||||
addNotification({
|
addNotification({
|
||||||
@@ -515,22 +589,78 @@ function handleError(err: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openPurchaseModal() {
|
function openPurchaseModal() {
|
||||||
if (!canOpenPurchaseModal.value || !purchaseModal.value) {
|
guestPlanModal.value?.show(lastSelectedInterval.value, lastSelectedPlanId.value)
|
||||||
addNotification({
|
}
|
||||||
title: 'Purchase unavailable',
|
|
||||||
text: 'Payment information is still loading. Please try again in a moment.',
|
function handleGuestPlanContinue(payload: {
|
||||||
type: 'warning',
|
interval: ServerBillingInterval
|
||||||
})
|
planId: string | null
|
||||||
|
}) {
|
||||||
|
const intent: PendingPurchaseIntent = {
|
||||||
|
interval: payload.interval,
|
||||||
|
planId: payload.planId,
|
||||||
|
source: PURCHASE_INTENT_SOURCE,
|
||||||
|
ts: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSelectedInterval.value = payload.interval
|
||||||
|
lastSelectedPlanId.value = payload.planId
|
||||||
|
|
||||||
|
if (!loggedIn.value) {
|
||||||
|
writePendingPurchaseIntent(intent)
|
||||||
|
void auth.requestSignIn('/hosting/manage')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
purchaseModal.value.show('quarterly')
|
pendingResumeIntent.value = intent
|
||||||
|
if (openCheckoutFromIntent(intent)) {
|
||||||
|
pendingResumeIntent.value = null
|
||||||
|
} else {
|
||||||
|
addNotification({
|
||||||
|
title: 'Purchase unavailable',
|
||||||
|
text: 'Payment information is still loading. Opening checkout as soon as it is ready.',
|
||||||
|
type: 'info',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSignIn() {
|
function handleSignIn() {
|
||||||
void auth.requestSignIn('/hosting/manage')
|
void auth.requestSignIn('/hosting/manage')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
loggedIn,
|
||||||
|
(isLoggedIn) => {
|
||||||
|
if (!isLoggedIn) return
|
||||||
|
|
||||||
|
const pendingIntent = readPendingPurchaseIntent()
|
||||||
|
if (!pendingIntent || pendingIntent.source !== PURCHASE_INTENT_SOURCE) return
|
||||||
|
|
||||||
|
clearPendingPurchaseIntent()
|
||||||
|
pendingResumeIntent.value = pendingIntent
|
||||||
|
lastSelectedInterval.value = pendingIntent.interval
|
||||||
|
lastSelectedPlanId.value = pendingIntent.planId
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
loggedIn.value,
|
||||||
|
canOpenPurchaseModal.value,
|
||||||
|
pendingResumeIntent.value,
|
||||||
|
purchaseModal.value,
|
||||||
|
] as const,
|
||||||
|
([isLoggedIn, canOpen, pendingIntent]) => {
|
||||||
|
if (!isLoggedIn || !canOpen || !pendingIntent) return
|
||||||
|
if (openCheckoutFromIntent(pendingIntent)) {
|
||||||
|
pendingResumeIntent.value = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
const { data: subscriptions } = useQuery({
|
const { data: subscriptions } = useQuery({
|
||||||
queryKey: ['billing', 'subscriptions'],
|
queryKey: ['billing', 'subscriptions'],
|
||||||
queryFn: () => client.labrinth.billing_internal.getSubscriptions(),
|
queryFn: () => client.labrinth.billing_internal.getSubscriptions(),
|
||||||
|
|||||||
Reference in New Issue
Block a user