feat: update purchase server flow (#5714)

* implement server list empty state component

* fix stories and adjust spacing

* implement select plan design refresh

* implement auth for empty server list

* use refs instead of reactive

* pnpm prepr

* fix auth usage for empty servers list

* move app auth provider setup to src/providers/setup

* pnpm prepr
This commit is contained in:
Truman Gao
2026-03-31 11:08:33 -07:00
committed by GitHub
parent afac705a39
commit 7d6ad93528
35 changed files with 596 additions and 302 deletions
+4 -15
View File
@@ -40,7 +40,6 @@ import {
OverflowMenu,
PopupNotificationPanel,
ProgressSpinner,
provideAuth,
provideModalBehavior,
provideModrinthClient,
provideNotificationManager,
@@ -58,7 +57,7 @@ import { openUrl } from '@tauri-apps/plugin-opener'
import { type } from '@tauri-apps/plugin-os'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { $fetch } from 'ofetch'
import { computed, onMounted, onUnmounted, provide, reactive, ref, watch, watchEffect } from 'vue'
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
@@ -107,6 +106,7 @@ import {
} from '@/providers/download-progress.ts'
import { createServerInstall, provideServerInstall } from '@/providers/server-install'
import { setupProviders } from '@/providers/setup'
import { setupAuthProvider } from '@/providers/setup/auth'
import { useError } from '@/store/error.js'
import { useLoading, useTheming } from '@/store/state'
@@ -455,21 +455,10 @@ const credentials = ref()
const modrinthLoginFlowWaitModal = ref()
const authProvider = reactive({
session_token: null,
user: null,
requestSignIn: async (_redirectPath) => {
await signIn()
},
setupAuthProvider(credentials, async (_redirectPath) => {
await signIn()
})
watchEffect(() => {
authProvider.session_token = credentials.value?.session ?? null
authProvider.user = credentials.value?.user ?? null
})
provideAuth(authProvider)
async function fetchCredentials() {
const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) {
@@ -0,0 +1,29 @@
import type { Labrinth } from '@modrinth/api-client'
import { type AuthProvider, provideAuth } from '@modrinth/ui'
import { type Ref, ref, watchEffect } from 'vue'
type AppCredentials = {
session?: string | null
user?: Labrinth.Users.v2.User | null
}
export function setupAuthProvider(
credentials: Ref<AppCredentials | null | undefined>,
requestSignIn: (redirectPath: string) => void | Promise<void>,
) {
const sessionToken = ref<string | null>(null)
const user = ref<Labrinth.Users.v2.User | null>(null)
const authProvider: AuthProvider = {
session_token: sessionToken,
user,
requestSignIn,
}
watchEffect(() => {
sessionToken.value = credentials.value?.session ?? null
user.value = credentials.value?.user ?? null
})
provideAuth(authProvider)
}
+10 -7
View File
@@ -1,12 +1,15 @@
import type { Labrinth } from '@modrinth/api-client'
import { type AuthProvider, provideAuth } from '@modrinth/ui'
import { reactive, watchEffect } from 'vue'
import { ref, watchEffect } from 'vue'
export function setupAuthProvider(auth: Awaited<ReturnType<typeof useAuth>>) {
const router = useRouter()
const authProvider = reactive<AuthProvider>({
session_token: null,
user: null,
const sessionToken = ref<string | null>(null)
const user = ref<Labrinth.Users.v2.User | null>(null)
const authProvider: AuthProvider = {
session_token: sessionToken,
user,
requestSignIn: async (redirectPath: string) => {
await router.push({
path: '/auth/sign-in',
@@ -15,11 +18,11 @@ export function setupAuthProvider(auth: Awaited<ReturnType<typeof useAuth>>) {
},
})
},
})
}
watchEffect(() => {
authProvider.session_token = auth.value.token || null
authProvider.user = (auth.value.user as Labrinth.Users.v2.User | null) ?? null
sessionToken.value = auth.value.token || null
user.value = (auth.value.user as Labrinth.Users.v2.User | null) ?? null
})
provideAuth(authProvider)
@@ -1,149 +0,0 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { InfoIcon } from '@modrinth/assets'
import { Menu } from 'floating-vue'
import { computed, inject, type Ref } from 'vue'
import { useFormatPrice } from '../../composables'
import { type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import ServersSpecs from './ServersSpecs.vue'
const props = withDefaults(
defineProps<{
plan: Labrinth.Billing.Internal.Product
title: MessageDescriptor
description: MessageDescriptor
buttonColor?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
mostPopular?: boolean
selected?: boolean
}>(),
{
buttonColor: 'standard',
mostPopular: false,
selected: false,
},
)
const emit = defineEmits<{
(e: 'select', plan: Labrinth.Billing.Internal.Product): void
}>()
const { formatMessage } = useVIntl()
const formatPrice = useFormatPrice()
// TODO: Use DI framework when merged.
const selectedInterval = inject<Ref<ServerBillingInterval>>('selectedInterval')
const currency = inject<string>('currency')
const perMonth = computed(() => {
if (!props.plan || !currency || !selectedInterval?.value) return undefined
const total = getPriceForInterval(props.plan, currency, selectedInterval.value)
if (!total) return undefined
return total / monthsInInterval[selectedInterval.value]
})
const planSpecs = computed(() => {
const metadata = props.plan.metadata
if (metadata.type === 'pyro' || metadata.type === 'medal') {
return {
ram: metadata.ram,
storage: metadata.storage,
cpu: metadata.cpu,
}
}
return null
})
const mostPopularStyle = computed(() => {
if (!props.mostPopular) return undefined
const style: Record<string, string> = {
backgroundImage:
'radial-gradient(86.12% 101.64% at 95.97% 94.07%, rgba(27, 217, 106, 0.23) 0%, rgba(14, 115, 56, 0.2) 100%)',
boxShadow: '0px 12px 38.1px rgba(27, 217, 106, 0.13)',
}
if (!props.selected) {
style.borderColor = 'rgba(12, 107, 52, 0.55)'
}
return style
})
</script>
<template>
<div
class="rounded-2xl p-4 font-semibold transition-all duration-300 experimental-styles-within h-full border-2 border-solid cursor-pointer select-none"
:class="{
'bg-brand-highlight border-brand': selected,
'bg-button-bg border-transparent': !selected,
'!bg-bg': mostPopular,
}"
:style="mostPopularStyle"
role="button"
tabindex="0"
:aria-pressed="selected"
@click="emit('select', plan)"
@keydown.enter.prevent="emit('select', plan)"
@keydown.space.prevent="emit('select', plan)"
>
<div class="flex h-full flex-col justify-between gap-2">
<div class="flex flex-col">
<div class="flex items-center justify-between">
<span class="text-2xl font-semibold text-contrast">
{{ formatMessage(title) }}
</span>
<div
v-if="mostPopular"
class="relative w-fit rounded-full bg-highlight-green px-3 py-1 text-sm font-bold text-brand backdrop-blur-lg"
>
Most Popular
</div>
</div>
<span class="m-0 text-lg font-bold text-contrast">
{{ formatPrice(perMonth, currency, true) }}
<span class="text-sm font-semibold text-secondary">
/ month{{ selectedInterval !== 'monthly' ? `, billed ${selectedInterval}` : '' }}
</span>
</span>
</div>
<span class="text-sm">{{ formatMessage(description) }}</span>
<div class="w-fit">
<Menu
placement="bottom-start"
:triggers="['hover', 'focus']"
:auto-hide="true"
:delay="{ show: 100, hide: 120 }"
:distance="6"
>
<template #default="{ shown }">
<div
class="flex w-fit items-center gap-2 cursor-help text-sm font-medium cursor-default select-none outline-none"
:class="shown ? 'text-primary' : 'text-secondary'"
role="button"
tabindex="0"
aria-haspopup="true"
:aria-expanded="shown"
>
<InfoIcon />
View plan details
</div>
</template>
<template #popper>
<div v-if="planSpecs" class="w-fit rounded-md border border-contrast/10 p-3 shadow-lg">
<ServersSpecs
:ram="planSpecs.ram"
:storage="planSpecs.storage"
:cpus="planSpecs.cpu"
/>
</div>
</template>
</Menu>
</div>
</div>
</div>
</template>
@@ -283,6 +283,10 @@ function handleChooseCustom() {
selectedPlan.value = undefined
}
function handleProceed() {
setStep(nextStep.value)
}
// 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.
@@ -328,7 +332,7 @@ function goToBreadcrumbStep(id: string) {
</template>
</div>
</template>
<div class="w-[40rem] max-w-full">
<div :class="currentStep === 'plan' ? 'w-[56rem] max-w-full' : 'w-[40rem] max-w-full'">
<PlanSelector
v-if="currentStep === 'plan'"
v-model:plan="selectedPlan"
@@ -337,6 +341,7 @@ function goToBreadcrumbStep(id: string) {
:available-products="availableProducts"
:currency="currency"
@choose-custom="handleChooseCustom"
@proceed="handleProceed"
/>
<RegionSelector
v-else-if="currentStep === 'region'"
@@ -415,7 +420,7 @@ function goToBreadcrumbStep(id: string) {
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<ButtonStyled v-if="currentStep !== 'plan'" color="brand">
<button
v-tooltip="
currentStep === 'review' && !acceptedEula && !noPaymentRequired
@@ -1,13 +1,15 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { computed, provide } from 'vue'
import { RightArrowIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { useFormatPrice } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import ButtonStyled from '../base/ButtonStyled.vue'
import OptionGroup from '../base/OptionGroup.vue'
import ModalBasedServerPlan from './ModalBasedServerPlan.vue'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import ServersSpecs from './ServersSpecs.vue'
const { formatMessage } = useVIntl()
const formatPrice = useFormatPrice()
@@ -23,18 +25,10 @@ const availableBillingIntervals = ['monthly', 'quarterly']
const selectedPlan = defineModel<Labrinth.Billing.Internal.Product>('plan')
const selectedInterval = defineModel<ServerBillingInterval>('interval')
const emit = defineEmits<{
(e: 'choose-custom'): void
(e: 'choose-custom' | 'proceed'): void
}>()
const messages = defineMessages({
title: {
id: 'servers.purchase.step.plan.prompt',
defaultMessage: 'Choose a plan',
},
subtitle: {
id: 'servers.purchase.step.plan.subtitle',
defaultMessage: 'Pick the amount of RAM and specs that fit your needs.',
},
selectPlan: {
id: 'servers.purchase.step.plan.select',
defaultMessage: 'Select Plan',
@@ -43,9 +37,17 @@ const messages = defineMessages({
id: 'servers.purchase.step.plan.get-started',
defaultMessage: 'Get started',
},
billed: {
id: 'servers.purchase.step.plan.billed',
defaultMessage: 'billed {interval}',
smallTitle: {
id: 'servers.purchase.step.plan.small',
defaultMessage: 'Small',
},
mediumTitle: {
id: 'servers.purchase.step.plan.medium',
defaultMessage: 'Medium',
},
largeTitle: {
id: 'servers.purchase.step.plan.large',
defaultMessage: 'Large',
},
smallDesc: {
id: 'servers.purchase.step.plan.small.desc',
@@ -67,6 +69,18 @@ const messages = defineMessages({
id: 'servers.purchase.step.plan.most-popular',
defaultMessage: 'Most Popular',
},
billingSubtitle: {
id: 'servers.purchase.step.plan.billing-subtitle',
defaultMessage: 'Available in North America, Europe, and Southeast Asia.',
},
customHeading: {
id: 'servers.purchase.step.plan.custom.heading',
defaultMessage: 'Know exactly what you need?',
},
yourCurrentPlan: {
id: 'servers.purchase.step.plan.your-current-plan',
defaultMessage: 'Your current plan',
},
})
const isSameAsExistingPlan = computed(() => {
@@ -95,8 +109,12 @@ const plansByRam = computed(() => {
return byName
})
function handleCustomPlan() {
emit('choose-custom')
function planSpecs(plan: Labrinth.Billing.Internal.Product) {
const m = plan.metadata
if (m.type === 'pyro' || m.type === 'medal') {
return { ram: m.ram, storage: m.storage, cpus: m.cpu }
}
return null
}
function pricePerMonth(plan?: Labrinth.Billing.Internal.Product) {
@@ -106,22 +124,6 @@ function pricePerMonth(plan?: Labrinth.Billing.Internal.Product) {
return total / monthsInInterval[selectedInterval.value]
}
const customPricePerGb = computed(() => {
// Calculate lowest price per GB among products for current interval
let min: number | undefined
for (const p of props.availableProducts) {
const perMonth = pricePerMonth(p)
const metadata = p?.metadata
if (!metadata || (metadata.type !== 'pyro' && metadata.type !== 'medal')) continue
const ramGb = metadata.ram / 1024
if (perMonth && ramGb > 0) {
const perGb = perMonth / ramGb
if (min === undefined || perGb < min) min = perGb
}
}
return min
})
const customStartingPrice = computed(() => {
let min: number | undefined
for (const p of props.availableProducts) {
@@ -131,26 +133,47 @@ const customStartingPrice = computed(() => {
return min
})
provide('currency', props.currency)
provide('selectedInterval', selectedInterval)
const smallPrice = computed(() => pricePerMonth(plansByRam.value.small))
const mediumPrice = computed(() => pricePerMonth(plansByRam.value.medium))
const largePrice = computed(() => pricePerMonth(plansByRam.value.large))
const smallSpecs = computed(() =>
plansByRam.value.small ? planSpecs(plansByRam.value.small) : null,
)
const mediumSpecs = computed(() =>
plansByRam.value.medium ? planSpecs(plansByRam.value.medium) : null,
)
const largeSpecs = computed(() =>
plansByRam.value.large ? planSpecs(plansByRam.value.large) : null,
)
function selectPlan(plan: Labrinth.Billing.Internal.Product) {
selectedPlan.value = plan
emit('proceed')
}
function selectCustom() {
emit('choose-custom')
emit('proceed')
}
</script>
<template>
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 mb-5 !mt-0">
<span></span>
<div class="flex flex-col items-center gap-2 mb-5 !mt-0">
<OptionGroup
v-slot="{ option }"
v-model="selectedInterval"
class="!bg-button-bg !shadow-none"
:options="availableBillingIntervals"
>
<template v-if="option === 'monthly'"> Pay monthly </template>
<span v-else-if="option === 'quarterly'"> Pay quarterly </span>
<span v-else-if="option === 'yearly'"> Pay yearly </span>
<template v-if="option === 'monthly'">Monthly</template>
<span v-else-if="option === 'quarterly'">
Quarterly <span class="text-brand">(Save 16%)</span>
</span>
</OptionGroup>
<span class="bg-transparent p-0 text-sm text-xs font-bold text-brand">
{{ selectedInterval !== 'quarterly' ? 'Save' : 'Saving' }} 16% with quarterly billing!
</span>
<div class="text-sm text-secondary text-center">
{{ formatMessage(messages.billingSubtitle) }}
</div>
</div>
<Transition
enter-active-class="transition-all duration-300 ease-out"
@@ -164,68 +187,182 @@ provide('selectedInterval', selectedInterval)
Your server is already on this plan, choose a different plan.
</div>
</Transition>
<div class="grid grid-cols-1 sm:grid-cols-2 !gap-4">
<ModalBasedServerPlan
v-if="plansByRam.small"
:plan="plansByRam.small"
:title="{ id: 'servers.purchase.step.plan.small', defaultMessage: 'Small' }"
:description="messages.smallDesc"
:button-color="'blue'"
:selected="selectedPlan?.id === plansByRam.small.id"
@select="selectedPlan = $event"
/>
<ModalBasedServerPlan
v-if="plansByRam.medium"
:plan="plansByRam.medium"
:title="{ id: 'servers.purchase.step.plan.medium', defaultMessage: 'Medium' }"
:description="messages.mediumDesc"
most-popular
:button-color="'brand'"
:selected="selectedPlan?.id === plansByRam.medium.id"
@select="selectedPlan = $event"
/>
<ModalBasedServerPlan
v-if="plansByRam.large"
:plan="plansByRam.large"
:title="{ id: 'servers.purchase.step.plan.large', defaultMessage: 'Large' }"
:description="messages.largeDesc"
:button-color="'purple'"
:selected="selectedPlan?.id === plansByRam.large.id"
@select="selectedPlan = $event"
/>
<div class="grid grid-cols-3 gap-4 items-start">
<!-- Small -->
<div
v-if="customStartingPrice"
class="rounded-2xl p-4 font-semibold transition-all duration-300 experimental-styles-within h-full border-2 border-solid cursor-pointer select-none"
:class="!selectedPlan ? 'bg-brand-highlight border-brand' : 'bg-button-bg border-transparent'"
role="button"
tabindex="0"
:aria-pressed="!selectedPlan"
@click="handleCustomPlan"
@keydown.enter.prevent="handleCustomPlan"
@keydown.space.prevent="handleCustomPlan"
v-if="plansByRam.small && smallPrice"
class="flex flex-col gap-4 rounded-2xl bg-surface-2 border-2 border-solid border-transparent p-5 h-full"
>
<div class="flex h-full flex-col justify-between">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-2xl font-semibold text-contrast">Custom</span>
<div>
<div class="text-[1.75rem] font-semibold text-contrast leading-none">
{{ formatMessage(messages.smallTitle) }}
</div>
<div class="mt-1">
<span class="text-2xl font-bold text-contrast">
{{ formatPrice(smallPrice, currency, true) }}
</span>
<span class="text-sm">
/ month<template v-if="selectedInterval !== 'monthly'"
>, billed {{ selectedInterval }}</template
>
</span>
</div>
<div class="mt-2 text-sm text-primary">
{{ formatMessage(messages.smallDesc) }}
</div>
</div>
<div class="w-full">
<ButtonStyled color="blue" class="w-full">
<button
class="w-full"
:disabled="existingPlan?.id === plansByRam.small.id"
@click="selectPlan(plansByRam.small!)"
>
{{
existingPlan?.id === plansByRam.small.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</button>
</ButtonStyled>
</div>
<ServersSpecs
v-if="smallSpecs"
:ram="smallSpecs.ram"
:storage="smallSpecs.storage"
:cpus="smallSpecs.cpus"
/>
</div>
<!-- Medium (Most Popular) -->
<div v-if="plansByRam.medium && mediumPrice" class="flex flex-col items-center relative">
<div
class="z-10 -mb-3.5 rounded-full text-sm font-medium text-brand whitespace-nowrap absolute -top-3 right-4 bg-surface-3"
>
<div
class="bg-brand-highlight border border-solid border-highlight-green px-2.5 py-0.5 rounded-full"
>
{{ formatMessage(messages.mostPopular) }}
</div>
</div>
<div
class="w-full flex flex-col gap-4 rounded-2xl bg-brand-inverted border-brand-highlight border border-solid p-5 h-full"
:style="{
backgroundImage:
'radial-gradient(86.12% 101.64% at 95.97% 94.07%, rgba(27, 217, 106, 0.23) 0%, rgba(14, 115, 56, 0.2) 100%)',
}"
>
<div>
<div class="text-[1.75rem] font-semibold text-contrast leading-none">
{{ formatMessage(messages.mediumTitle) }}
</div>
<span class="m-0 text-lg font-bold text-contrast">
{{ formatPrice(customStartingPrice, currency, true) }}
<span class="text-sm font-semibold text-secondary">
<div class="mt-1">
<span class="text-2xl font-bold text-contrast">
{{ formatPrice(mediumPrice, currency, true) }}
</span>
<span class="text-sm">
/ month<template v-if="selectedInterval !== 'monthly'"
>, billed {{ selectedInterval }}</template
>
</span>
</span>
<span class="text-sm">{{ formatMessage(messages.customDesc) }}</span>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3">
<span v-if="customPricePerGb" class="text-sm text-secondary">
From {{ formatPrice(customPricePerGb, currency, true) }} / GB
</span>
</div>
<div class="mt-2 text-sm text-primary">
{{ formatMessage(messages.mediumDesc) }}
</div>
</div>
<div class="w-full">
<ButtonStyled color="brand" class="w-full">
<button
class="w-full"
:disabled="existingPlan?.id === plansByRam.medium.id"
@click="selectPlan(plansByRam.medium!)"
>
{{
existingPlan?.id === plansByRam.medium.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</button>
</ButtonStyled>
</div>
<ServersSpecs
v-if="mediumSpecs"
:ram="mediumSpecs.ram"
:storage="mediumSpecs.storage"
:cpus="mediumSpecs.cpus"
/>
</div>
</div>
<!-- Large -->
<div
v-if="plansByRam.large && largePrice"
class="flex flex-col gap-4 rounded-2xl bg-surface-2 border-2 border-solid border-transparent p-5 h-full"
>
<div>
<div class="text-[1.75rem] font-semibold text-contrast leading-none">
{{ formatMessage(messages.largeTitle) }}
</div>
<div class="mt-1">
<span class="text-2xl font-bold text-contrast">
{{ formatPrice(largePrice, currency, true) }}
</span>
<span class="text-sm">
/ month<template v-if="selectedInterval !== 'monthly'"
>, billed {{ selectedInterval }}</template
>
</span>
</div>
<div class="mt-2 text-sm text-primary">
{{ formatMessage(messages.largeDesc) }}
</div>
</div>
<div class="w-full">
<ButtonStyled color="purple" class="w-full">
<button
class="w-full"
:disabled="existingPlan?.id === plansByRam.large.id"
@click="selectPlan(plansByRam.large!)"
>
{{
existingPlan?.id === plansByRam.large.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</button>
</ButtonStyled>
</div>
<ServersSpecs
v-if="largeSpecs"
:ram="largeSpecs.ram"
:storage="largeSpecs.storage"
:cpus="largeSpecs.cpus"
/>
</div>
</div>
<!-- Custom plan banner -->
<div
v-if="customStartingPrice"
class="mt-4 flex items-center justify-between gap-4 rounded-2xl bg-surface-2 border-2 border-solid border-transparent p-5"
>
<div class="flex flex-col gap-1">
<div class="text-xl font-semibold text-contrast">
{{ formatMessage(messages.customHeading) }}
</div>
<div class="text-sm text-secondary">
{{ formatMessage(messages.customDesc) }}
</div>
</div>
<div class="flex flex-col items-end gap-2 shrink-0">
<ButtonStyled>
<button class="flex items-center gap-2" @click="selectCustom">
{{ formatMessage(messages.getStarted) }} <RightArrowIcon class="h-4 w-4" />
</button>
</ButtonStyled>
<div class="text-sm text-secondary whitespace-nowrap">
Starting at {{ formatPrice(customStartingPrice, currency, true) }}/mo
</div>
</div>
</div>
+2 -1
View File
@@ -4,7 +4,8 @@ export * from './icons'
export { default as InstallingBanner } from './InstallingBanner.vue'
export * from './labels'
export * from './marketing'
export type { PendingChange } from './ServerListing.vue'
export { default as ServerListEmpty } from './server-list-empty/ServerListEmpty.vue'
export type { PendingChange, PendingChange } from './ServerListing.vue'
export { default as ServerListing } from './ServerListing.vue'
export { default as ServerSetupModal } from './ServerSetupModal.vue'
export { default as ServersPromo } from './ServersPromo.vue'
@@ -0,0 +1,251 @@
<template>
<div class="flex gap-8 items-center justify-center py-10">
<!-- Left column -->
<div class="flex flex-col gap-8 items-start pr-8 shrink-0 w-[380px]">
<!-- Heading -->
<div class="flex flex-col gap-2 items-start w-[300px]">
<p class="text-sm text-secondary">Modrinth Hosting</p>
<p class="text-[30px] leading-9 font-semibold text-contrast">No servers yet</p>
<p class="text-base font-normal text-primary">
Install mods, invite friends, and play together all from the Modrinth App.
</p>
</div>
<!-- Feature list -->
<div class="flex flex-col gap-4 items-start w-full">
<div class="flex gap-3 items-start">
<div
class="bg-surface-4 border border-surface-5 rounded-full shrink-0 size-8 flex items-center justify-center"
>
<PackageOpenIcon class="size-5 text-secondary" aria-hidden="true" />
</div>
<div class="flex flex-col gap-0.5">
<p class="text-base font-semibold text-contrast">One-click mod installs</p>
<p class="text-base font-normal text-primary">
Pick your favourite mods and we handle the rest.
</p>
</div>
</div>
<div class="flex gap-3 items-start">
<div
class="bg-surface-4 border border-surface-5 rounded-full shrink-0 size-8 flex items-center justify-center overflow-hidden"
>
<GlobeIcon class="size-5 text-secondary" aria-hidden="true" />
</div>
<div class="flex flex-col gap-0.5">
<p class="text-base font-semibold text-contrast">Simple setup</p>
<p class="text-base font-normal text-primary">
Set up your server just like a singleplayer world.
</p>
</div>
</div>
<div class="flex gap-3 items-start">
<div
class="bg-surface-4 border border-surface-5 rounded-full shrink-0 size-8 flex items-center justify-center overflow-hidden"
>
<UsersIcon class="size-5 text-secondary" aria-hidden="true" />
</div>
<div class="flex flex-col gap-0.5">
<p class="text-base font-semibold text-contrast">Play with friends</p>
<p class="text-base font-normal text-primary">
Invite friends and get them set up right in the Modrinth App.
</p>
</div>
</div>
</div>
<!-- CTA section -->
<div class="flex flex-col gap-6 items-start w-[300px]">
<div class="flex flex-col gap-3 items-start">
<ButtonStyled color="brand">
<button @click="onClickNewServer?.()">
<PlusIcon aria-hidden="true" />
New server
</button>
</ButtonStyled>
<AutoLink
to="https://modrinth.com/hosting"
target="_blank"
class="flex items-center gap-1"
>
Learn more about Modrinth Hosting
<RightArrowIcon class="size-5 shrink-0" aria-hidden="true" />
</AutoLink>
</div>
<template v-if="!loggedIn">
<div class="h-px w-full bg-surface-5" />
<div class="flex gap-3 items-center flex-wrap">
<p class="text-base font-normal text-primary">Already have a server?</p>
<ButtonStyled>
<button @click="onClickSignIn?.()">
<LogInIcon aria-hidden="true" />
Sign in
</button>
</ButtonStyled>
</div>
</template>
</div>
</div>
<!-- Right column - mod icon grid -->
<div
class="relative flex h-[617px] w-[380px] shrink-0 items-center justify-center overflow-hidden rounded-[40px]"
>
<div class="rotate-[15deg]">
<div class="flex flex-col gap-4">
<div
v-for="row in GRID_ROWS"
:key="row"
class="flex gap-4 items-center shrink-0"
:class="animated ? (row % 2 === 1 ? 'drift-left' : 'drift-right relative left-14') : ''"
>
<div class="hidden drift-right drift-left"></div>
<div
v-for="col in GRID_COLS"
:key="col"
class="border border-surface-5 rounded-[20px] shrink-0 size-[112px] bg-surface-4 overflow-hidden"
>
<img :src="getGridImage(row - 1, col - 1)" alt="" class="size-full object-cover" />
</div>
<div
v-for="col in GRID_COLS"
:key="col"
class="border border-surface-5 rounded-[20px] shrink-0 size-[112px] bg-surface-4 overflow-hidden"
>
<img :src="getGridImage(row - 1, col - 1)" alt="" class="size-full object-cover" />
</div>
<div
v-for="col in GRID_COLS"
:key="col"
class="border border-surface-5 rounded-[20px] shrink-0 size-[112px] bg-surface-4 overflow-hidden"
>
<img :src="getGridImage(row - 1, col - 1)" alt="" class="size-full object-cover" />
</div>
</div>
</div>
</div>
<!-- Gradient overlay fading to page background -->
<div
class="absolute inset-0 rounded-[40px] pointer-events-none bg-gradient-to-b from-transparent to-[var(--color-bg)]"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {
GlobeIcon,
LogInIcon,
PackageOpenIcon,
PlusIcon,
RightArrowIcon,
UsersIcon,
} from '@modrinth/assets'
import { AutoLink } from '@modrinth/ui'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import imgAircraft from './grid-images/aircraft.png'
import imgAlexs from "./grid-images/alex's.png"
import imgArtifacts from './grid-images/artifacts.png'
import imgBiomes from './grid-images/biomes.png'
import imgCatac from './grid-images/catac.png'
import imgCobble from './grid-images/cobble.png'
import imgComforts from './grid-images/comforts.png'
import imgCreate from './grid-images/create.png'
import imgCreate1 from './grid-images/create1.png'
import imgCreate2 from './grid-images/create2.png'
import imgCreate3 from './grid-images/create3.png'
import imgCreeper from './grid-images/creeper.png'
import imgFriends from './grid-images/friends.png'
import imgGeo from './grid-images/geo.png'
import imgNaturalist from './grid-images/naturalist.png'
import imgSeasons from './grid-images/seasons.png'
import imgTravellers from './grid-images/travellers.png'
import imgTree from './grid-images/tree.png'
import imgYum1 from './grid-images/yum1.png'
import imgYum2 from './grid-images/yum2.png'
import imgYum3 from './grid-images/yum3.png'
import imgYung from './grid-images/yung.png'
withDefaults(
defineProps<{
animated?: boolean
onClickNewServer?: () => void
onClickSignIn?: () => void
loggedIn?: boolean
}>(),
{ animated: false },
)
const GRID_ROWS = 6
const GRID_COLS = 5
const GRID_IMAGES = [
imgYum1,
imgYum2,
imgYum3,
imgYung,
imgCreeper,
imgFriends,
imgNaturalist,
imgBiomes,
imgCatac,
imgCobble,
imgGeo,
imgCreate,
imgCreate1,
imgCreate2,
imgCreate3,
imgAircraft,
imgArtifacts,
imgComforts,
imgTravellers,
imgAlexs,
imgSeasons,
imgTree,
]
function getGridImage(row: number, col: number): string {
return GRID_IMAGES[(row * GRID_COLS + col) % GRID_IMAGES.length]
}
</script>
<style scoped>
p {
margin: 0;
}
@keyframes drift-right {
from {
transform: translateX(-33%);
}
to {
transform: translateX(33%);
}
}
@keyframes drift-left {
from {
transform: translateX(33%);
}
to {
transform: translateX(-33%);
}
}
.drift-left {
animation: drift-left linear infinite alternate;
animation-duration: 400s;
}
.drift-right {
animation: drift-right linear infinite alternate;
animation-duration: 400s;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -27,7 +27,7 @@
<ResubscribeModal ref="resubscribeModal" @resubscribe="handleResubscribeConfirm" />
<div
v-if="hasError || fetchError"
v-if="hasError"
class="mx-auto flex h-full min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 text-left"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@@ -100,24 +100,13 @@
<div
v-else-if="serverList.length === 0 && !isPollingForNewServers"
key="empty"
class="flex h-full flex-col items-center justify-center gap-8"
class="flex h-full flex-col items-center justify-center gap-8 grow max-h-[1100px]"
>
<img
src="https://cdn.modrinth.com/servers/excitement.webp"
alt=""
class="max-w-[360px]"
style="
mask-image: radial-gradient(97% 77% at 50% 25%, #d9d9d9 0, hsla(0, 0%, 45%, 0) 100%);
"
<ServerListEmpty
:logged-in="loggedIn"
@click-new-server="openPurchaseModal"
@click-sign-in="handleSignIn"
/>
<h1 class="m-0 text-contrast">You don't have any servers yet!</h1>
<p class="m-0">Modrinth Hosting is a new way to play modded Minecraft with your friends.</p>
<ButtonStyled size="large" type="standard" color="brand">
<AutoLink v-if="isNuxt" to="/servers#plan">Create a server</AutoLink>
<button v-else :disabled="!canOpenPurchaseModal" @click="openPurchaseModal">
Create a server
</button>
</ButtonStyled>
</div>
<div v-else key="list">
@@ -186,9 +175,10 @@
:on-download-backup="serverBillingMap.get(server.server_id)?.onDownloadBackup"
/>
</TransitionGroup>
<div v-else class="flex h-full items-center justify-center">
<div v-else-if="isLoading" class="flex h-full items-center justify-center">
<p class="text-contrast"><LoaderCircleIcon class="size-5 animate-spin" /></p>
</div>
<div v-else>No servers found.</div>
</div>
</Transition>
</div>
@@ -201,10 +191,12 @@ import {
AutoLink,
ButtonStyled,
CopyCode,
injectAuth,
injectModrinthClient,
injectNotificationManager,
ModrinthServersPurchaseModal,
ResubscribeModal,
ServerListEmpty,
StyledInput,
} from '@modrinth/ui'
import type { ModrinthServersFetchError } from '@modrinth/utils'
@@ -227,11 +219,12 @@ const props = defineProps<{
const router = useRouter()
const route = useRoute()
const auth = injectAuth()
const client = injectModrinthClient()
const loggedIn = computed(() => !!auth.user.value)
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
const hasError = ref(false)
const isPollingForNewServers = ref(false)
const pollingState = ref({
enabled: false,
@@ -269,6 +262,7 @@ const {
} = useQuery({
queryKey: ['billing', 'customer'],
queryFn: () => client.labrinth.billing_internal.getCustomer() as Promise<Stripe.Customer>,
enabled: loggedIn,
})
const {
@@ -279,11 +273,13 @@ const {
queryKey: ['billing', 'payment-methods'],
queryFn: () =>
client.labrinth.billing_internal.getPaymentMethods() as Promise<Stripe.PaymentMethod[]>,
enabled: loggedIn,
})
const { data: regions, isLoading: regionsLoading } = useQuery({
queryKey: ['servers', 'regions'],
queryFn: () => client.archon.servers_v1.getRegions(),
enabled: loggedIn,
})
watch(
@@ -430,11 +426,10 @@ const {
return response
},
refetchInterval: computed(() => (pollingState.value.enabled ? 5000 : false)),
enabled: loggedIn,
})
watch([fetchError, serverResponse], ([error, response]) => {
hasError.value = !!error || !response
})
const hasError = computed(() => loggedIn.value && !!fetchError.value)
const serverList = computed<Archon.Servers.v0.Server[]>(() => {
if (!serverResponse.value) return []
@@ -531,14 +526,20 @@ function openPurchaseModal() {
purchaseModal.value.show('quarterly')
}
function handleSignIn() {
void auth.requestSignIn('/hosting/manage')
}
const { data: subscriptions } = useQuery({
queryKey: ['billing', 'subscriptions'],
queryFn: () => client.labrinth.billing_internal.getSubscriptions(),
enabled: loggedIn,
})
const { data: charges } = useQuery({
queryKey: ['billing', 'payments'],
queryFn: () => client.labrinth.billing_internal.getPayments(),
enabled: loggedIn,
})
const CHARGE_POLL_INTERVAL_MS = 20_000
@@ -579,6 +580,7 @@ watch(
const { data: serverFullList } = useQuery({
queryKey: ['servers', 'v1'],
queryFn: () => client.archon.servers_v1.list(),
enabled: loggedIn,
})
type ServerBillingInfo = {
+17 -8
View File
@@ -2600,39 +2600,48 @@
"servers.purchase.step.payment.title": {
"defaultMessage": "Payment method"
},
"servers.purchase.step.plan.billed": {
"defaultMessage": "billed {interval}"
"servers.purchase.step.plan.billing-subtitle": {
"defaultMessage": "Available in North America, Europe, and Southeast Asia."
},
"servers.purchase.step.plan.custom.desc": {
"defaultMessage": "Pick a customized plan with just the specs you need."
},
"servers.purchase.step.plan.custom.heading": {
"defaultMessage": "Know exactly what you need?"
},
"servers.purchase.step.plan.get-started": {
"defaultMessage": "Get started"
},
"servers.purchase.step.plan.large": {
"defaultMessage": "Large"
},
"servers.purchase.step.plan.large.desc": {
"defaultMessage": "Ideal for 1525 players, modpacks, or heavy modding."
},
"servers.purchase.step.plan.medium": {
"defaultMessage": "Medium"
},
"servers.purchase.step.plan.medium.desc": {
"defaultMessage": "Great for 615 players and multiple mods."
},
"servers.purchase.step.plan.most-popular": {
"defaultMessage": "Most Popular"
},
"servers.purchase.step.plan.prompt": {
"defaultMessage": "Choose a plan"
},
"servers.purchase.step.plan.select": {
"defaultMessage": "Select Plan"
},
"servers.purchase.step.plan.small": {
"defaultMessage": "Small"
},
"servers.purchase.step.plan.small.desc": {
"defaultMessage": "Perfect for 15 friends with a few light mods."
},
"servers.purchase.step.plan.subtitle": {
"defaultMessage": "Pick the amount of RAM and specs that fit your needs."
},
"servers.purchase.step.plan.title": {
"defaultMessage": "Plan"
},
"servers.purchase.step.plan.your-current-plan": {
"defaultMessage": "Your current plan"
},
"servers.purchase.step.region.title": {
"defaultMessage": "Region"
},
+3 -2
View File
@@ -1,10 +1,11 @@
import type { Labrinth } from '@modrinth/api-client/src/modules/labrinth/types'
import type { Ref } from 'vue'
import { createContext } from './create-context'
export interface AuthProvider {
session_token: string | null
user: Labrinth.Users.v2.User | null
session_token: Ref<string | null>
user: Ref<Labrinth.Users.v2.User | null>
requestSignIn: (redirectPath: string) => void | Promise<void>
}
+1 -1
View File
@@ -1,6 +1,6 @@
export * from './api-client'
export * from './auth'
export * from './app-backup'
export * from './auth'
export * from './content-manager'
export { createContext } from './create-context'
export * from './file-picker'
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ServerListEmpty from '../../components/servers/server-list-empty/ServerListEmpty.vue'
const meta = {
title: 'Servers/ServerListEmpty',
component: ServerListEmpty,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof ServerListEmpty>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}