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
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 990 B |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
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 = {
|
||||
|
||||
@@ -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 15–25 players, modpacks, or heavy modding."
|
||||
},
|
||||
"servers.purchase.step.plan.medium": {
|
||||
"defaultMessage": "Medium"
|
||||
},
|
||||
"servers.purchase.step.plan.medium.desc": {
|
||||
"defaultMessage": "Great for 6–15 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 1–5 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"
|
||||
},
|
||||
|
||||
@@ -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,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 = {}
|
||||