refactor guest plan select logic into provider

This commit is contained in:
tdgao
2026-03-31 15:57:00 -06:00
parent 713bb5e2af
commit e300a71530
4 changed files with 200 additions and 131 deletions
@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ChevronRightIcon } from '@modrinth/assets'
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'
@@ -217,6 +217,7 @@ import { useRoute, useRouter } from 'vue-router'
import MedalServerListing from '#ui/components/servers/marketing/MedalServerListing.vue'
import ServerListing from '#ui/components/servers/ServerListing.vue'
import { createHostingPurchaseIntentContext, provideHostingPurchaseIntent } from '#ui/providers'
const props = defineProps<{
stripePublishableKey: string
@@ -231,17 +232,6 @@ const client = injectModrinthClient()
const loggedIn = computed(() => !!auth.user.value)
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 pollingState = ref({
@@ -255,9 +245,6 @@ const purchaseModal = ref<InstanceType<typeof ModrinthServersPurchaseModal> | nu
const resubscribeModal = ref<InstanceType<typeof ResubscribeModal> | null>(null)
const affiliateCode = ref<string | null>(null)
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<
{
region: string
@@ -277,40 +264,6 @@ 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 {
data: customer,
refetch: refetchCustomer,
@@ -488,6 +441,8 @@ const {
const hasError = computed(() => loggedIn.value && !!fetchError.value)
const serverList = computed<Archon.Servers.v0.Server[]>(() => {
return []
if (!serverResponse.value) return []
return serverResponse.value.servers
})
@@ -560,25 +515,6 @@ 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) {
const error = err as Error & { data?: { description?: string } }
addNotification({
@@ -588,78 +524,30 @@ function handleError(err: unknown) {
})
}
function openPurchaseModal() {
guestPlanModal.value?.show(lastSelectedInterval.value, lastSelectedPlanId.value)
function handleSignIn() {
void auth.requestSignIn('/hosting/manage')
}
function handleGuestPlanContinue(payload: {
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
}
pendingResumeIntent.value = intent
if (openCheckoutFromIntent(intent)) {
pendingResumeIntent.value = null
} else {
const hostingPurchaseIntent = createHostingPurchaseIntentContext({
authRequestSignIn: auth.requestSignIn,
signInRedirectPath: '/hosting/manage',
intentSource: 'hosting-manage',
loggedIn,
availableProducts: pyroProducts,
canOpenCheckout: canOpenPurchaseModal,
guestPlanModal,
checkoutModal: purchaseModal,
onCheckoutPending: () => {
addNotification({
title: 'Purchase unavailable',
text: 'Payment information is still loading. Opening checkout as soon as it is ready.',
type: 'info',
})
}
}
function handleSignIn() {
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 },
)
})
provideHostingPurchaseIntent(hostingPurchaseIntent)
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 { openPurchaseModal, handleGuestPlanContinue } = hostingPurchaseIntent
const { data: subscriptions } = useQuery({
queryKey: ['billing', 'subscriptions'],
@@ -0,0 +1,180 @@
import type { Labrinth } from '@modrinth/api-client'
import { type Ref, ref, watch } from 'vue'
import { createContext } from '.'
const PURCHASE_INTENT_STORAGE_KEY = 'modrinth:servers-purchase-intent'
export type ServerBillingInterval = 'monthly' | 'quarterly' | 'yearly'
export type PendingPurchaseIntent = {
interval: ServerBillingInterval
planId: string | null
source: string
ts: number
}
export type GuestPlanContinuePayload = {
interval: ServerBillingInterval
planId: string | null
}
export type CheckoutModalHandle = {
show: (interval: ServerBillingInterval, plan?: Labrinth.Billing.Internal.Product | null) => void
}
export type GuestPlanModalHandle = {
show: (initialInterval?: ServerBillingInterval, initialPlanId?: string | null) => void
}
export interface HostingPurchaseIntentContext {
openPurchaseModal: () => void
handleGuestPlanContinue: (payload: GuestPlanContinuePayload) => void
}
export interface CreateHostingPurchaseIntentContextOptions {
authRequestSignIn: (redirectPath: string) => void | Promise<void>
signInRedirectPath: string
intentSource: string
loggedIn: Readonly<Ref<boolean>>
availableProducts: Readonly<Ref<Labrinth.Billing.Internal.Product[]>>
canOpenCheckout: Readonly<Ref<boolean>>
guestPlanModal: Ref<GuestPlanModalHandle | null>
checkoutModal: Ref<CheckoutModalHandle | null>
onCheckoutPending: () => void
}
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)
}
export function createHostingPurchaseIntentContext(
options: CreateHostingPurchaseIntentContextOptions,
): HostingPurchaseIntentContext {
const lastSelectedInterval = ref<ServerBillingInterval>('quarterly')
const lastSelectedPlanId = ref<string | null | undefined>(undefined)
const pendingResumeIntent = ref<PendingPurchaseIntent | null>(null)
function openCheckoutFromIntent(intent: PendingPurchaseIntent): boolean {
if (!options.checkoutModal.value || !options.canOpenCheckout.value) return false
lastSelectedInterval.value = intent.interval
lastSelectedPlanId.value = intent.planId
const defaultPlan =
options.availableProducts.value.find(
(product) => product?.metadata?.type === 'pyro' && product.metadata.ram === 6144,
) ??
options.availableProducts.value.find((product) => product?.metadata?.type === 'pyro') ??
options.availableProducts.value[0]
const selectedPlan =
intent.planId === null
? null
: (options.availableProducts.value.find((product) => product.id === intent.planId) ??
defaultPlan)
options.checkoutModal.value.show(intent.interval, selectedPlan)
return true
}
function openPurchaseModal() {
options.guestPlanModal.value?.show(lastSelectedInterval.value, lastSelectedPlanId.value)
}
function handleGuestPlanContinue(payload: GuestPlanContinuePayload) {
const intent: PendingPurchaseIntent = {
interval: payload.interval,
planId: payload.planId,
source: options.intentSource,
ts: Date.now(),
}
lastSelectedInterval.value = payload.interval
lastSelectedPlanId.value = payload.planId
if (!options.loggedIn.value) {
writePendingPurchaseIntent(intent)
void options.authRequestSignIn(options.signInRedirectPath)
return
}
pendingResumeIntent.value = intent
if (openCheckoutFromIntent(intent)) {
pendingResumeIntent.value = null
} else {
options.onCheckoutPending()
}
}
watch(
options.loggedIn,
(isLoggedIn) => {
if (!isLoggedIn) return
const pendingIntent = readPendingPurchaseIntent()
if (!pendingIntent || pendingIntent.source !== options.intentSource) return
clearPendingPurchaseIntent()
pendingResumeIntent.value = pendingIntent
lastSelectedInterval.value = pendingIntent.interval
lastSelectedPlanId.value = pendingIntent.planId
},
{ immediate: true },
)
watch(
() =>
[
options.loggedIn.value,
options.canOpenCheckout.value,
pendingResumeIntent.value,
options.checkoutModal.value,
] as const,
([isLoggedIn, canOpen, pendingIntent]) => {
if (!isLoggedIn || !canOpen || !pendingIntent) return
if (openCheckoutFromIntent(pendingIntent)) {
pendingResumeIntent.value = null
}
},
{ immediate: true },
)
return {
openPurchaseModal,
handleGuestPlanContinue,
}
}
export const [injectHostingPurchaseIntent, provideHostingPurchaseIntent] =
createContext<HostingPurchaseIntentContext>('HostingManagePage', 'hostingPurchaseIntent')
+1
View File
@@ -4,6 +4,7 @@ export * from './auth'
export * from './content-manager'
export { createContext } from './create-context'
export * from './file-picker'
export * from './hosting-purchase-intent'
export * from './i18n'
export * from './instance-import'
export * from './modal-behavior'