Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73abf0f614 | ||
|
|
31f60ef762 | ||
|
|
6ca535375c | ||
|
|
8c2bc4b3a4 | ||
|
|
4a6d0e43b0 | ||
|
|
fe409d55f8 | ||
|
|
d082778dcf | ||
|
|
b7b462cddc | ||
|
|
6f9452a82e | ||
|
|
afac705a39 | ||
|
|
d401f1183e | ||
|
|
696fe8442c | ||
|
|
a4cc76e07d | ||
|
|
1f025694b7 | ||
|
|
8509e03df4 | ||
|
|
49acd75f98 | ||
|
|
340c62eb35 | ||
|
|
557eed9759 | ||
|
|
d2ec943e25 | ||
|
|
2ab69ea08b | ||
|
|
bc2fa1e9e0 | ||
|
|
17710094e5 | ||
|
|
324da50e3e | ||
|
|
0abdefd589 | ||
|
|
8d61bc7ea9 | ||
|
|
ef6837d49e | ||
|
|
eb82562c86 | ||
|
|
1999e79188 | ||
|
|
d01e9d7d52 | ||
|
|
504ba7f8e2 | ||
|
|
fb1445c915 | ||
|
|
345929eeff | ||
|
|
83202a3761 | ||
|
|
672757c295 | ||
|
|
0a05061bf7 | ||
|
|
d37c1bdeb1 | ||
|
|
313ff30d7a | ||
|
|
2386b4df09 | ||
|
|
83df48a454 | ||
|
|
166a47c029 | ||
|
|
b7452dd0a0 | ||
|
|
6f81061925 | ||
|
|
957e43a0d0 | ||
|
|
423818523a | ||
|
|
86dad839b4 | ||
|
|
7d4ce863b0 |
@@ -29,5 +29,7 @@
|
||||
},
|
||||
"[rust]": {
|
||||
"editor.defaultFormatter": "rust-lang.rust-analyzer"
|
||||
}
|
||||
},
|
||||
"css.lint.unknownAtRules": "ignore",
|
||||
"scss.lint.unknownAtRules": "ignore"
|
||||
}
|
||||
|
||||
@@ -106,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'
|
||||
|
||||
@@ -454,6 +455,10 @@ const credentials = ref()
|
||||
|
||||
const modrinthLoginFlowWaitModal = ref()
|
||||
|
||||
setupAuthProvider(credentials, async (_redirectPath) => {
|
||||
await signIn()
|
||||
})
|
||||
|
||||
async function fetchCredentials() {
|
||||
const creds = await getCreds().catch(handleError)
|
||||
if (creds && creds.user_id) {
|
||||
@@ -977,13 +982,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<NavButton v-if="themeStore.featureFlags.worlds_tab" v-tooltip.right="'Worlds'" to="/worlds">
|
||||
<WorldIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-if="themeStore.featureFlags.servers_in_app"
|
||||
v-tooltip.right="'Servers'"
|
||||
to="/hosting/manage"
|
||||
>
|
||||
<ServerIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="'Discover content'"
|
||||
to="/browse/modpack"
|
||||
@@ -1007,6 +1005,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
<LibraryIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-if="themeStore.featureFlags.servers_in_app"
|
||||
v-tooltip.right="'Servers'"
|
||||
to="/hosting/manage"
|
||||
>
|
||||
<ServerIcon />
|
||||
</NavButton>
|
||||
<div class="h-px w-6 mx-auto my-2 bg-surface-5"></div>
|
||||
<suspense>
|
||||
<QuickInstanceSwitcher />
|
||||
|
||||
@@ -77,12 +77,8 @@ body {
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-link);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
@@ -174,4 +170,11 @@ img {
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
input[type='button'] {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
@import '@modrinth/assets/omorphia.scss';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// src/config.ts
|
||||
export const config = {
|
||||
siteUrl: import.meta.env.VITE_SITE_URL,
|
||||
stripePublishableKey:
|
||||
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ||
|
||||
'pk_test_51JbFxJJygY5LJFfKV50mnXzz3YLvBVe2Gd1jn7ljWAkaBlRz3VQdxN9mXcPSrFbSqxwAb0svte9yhnsmm7qHfcWn00R611Ce7b',
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { injectModrinthClient, ServersManagePageIndex } from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { config } from '../config'
|
||||
|
||||
const stripePublishableKey = (config.stripePublishableKey as string) || ''
|
||||
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const { data: products } = useQuery({
|
||||
queryKey: ['billing', 'products'],
|
||||
queryFn: () => client.labrinth.billing_internal.getProducts(),
|
||||
})
|
||||
|
||||
const resolvedProducts = computed<Labrinth.Billing.Internal.Product[]>(() => products.value ?? [])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ServersManagePageIndex
|
||||
:stripe-publishable-key="stripePublishableKey"
|
||||
:site-url="'https://modrinth.com'"
|
||||
:products="resolvedProducts"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
import Browse from './Browse.vue'
|
||||
import Index from './Index.vue'
|
||||
import Servers from './Servers.vue'
|
||||
import Skins from './Skins.vue'
|
||||
import Worlds from './Worlds.vue'
|
||||
|
||||
export { Browse, Index, Skins, Worlds }
|
||||
export { Browse, Index, Servers, Skins, Worlds }
|
||||
|
||||
@@ -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,4 +1,3 @@
|
||||
import { ServersManagePageIndex } from '@modrinth/ui'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import * as Pages from '@/pages'
|
||||
@@ -31,7 +30,7 @@ export default new createRouter({
|
||||
{
|
||||
path: '/hosting/manage/',
|
||||
name: 'Servers',
|
||||
component: ServersManagePageIndex,
|
||||
component: Pages.Servers,
|
||||
meta: {
|
||||
breadcrumb: [{ name: 'Servers' }],
|
||||
},
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
"strict": true,
|
||||
|
||||
"types": ["vite/client"],
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||
@@ -87,12 +87,12 @@
|
||||
"capabilities": ["ads", "core", "plugins"],
|
||||
"csp": {
|
||||
"default-src": "'self' customprotocol: asset:",
|
||||
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net 'self' data: blob:",
|
||||
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com wss://*.nodes.modrinth.com 'self' data: blob:",
|
||||
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
|
||||
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
||||
"style-src": "'unsafe-inline' 'self'",
|
||||
"script-src": "https://*.posthog.com https://tally.so/widgets/embed.js 'self'",
|
||||
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ 'self'",
|
||||
"script-src": "https://*.posthog.com https://posthog.modrinth.com https://js.stripe.com https://tally.so/widgets/embed.js 'self'",
|
||||
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ https://js.stripe.com https://hooks.stripe.com 'self'",
|
||||
"media-src": "https://*.githubusercontent.com"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,9 +456,9 @@ kbd {
|
||||
font-size: 0.85em !important;
|
||||
}
|
||||
|
||||
@import '~/assets/styles/layout.scss';
|
||||
@import '~/assets/styles/utils.scss';
|
||||
@import '~/assets/styles/components.scss';
|
||||
@import './layout.scss';
|
||||
@import './utils.scss';
|
||||
@import './components.scss';
|
||||
|
||||
// OMORPHIA FIXES
|
||||
.card {
|
||||
|
||||
@@ -3333,7 +3333,7 @@
|
||||
"message": "Error resubscribing"
|
||||
},
|
||||
"settings.billing.pyro.resubscribe.request-submitted.text": {
|
||||
"message": "If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made."
|
||||
"message": "If the server is currently cancelled, it may take 10-15 minutes to set up the server."
|
||||
},
|
||||
"settings.billing.pyro.resubscribe.request-submitted.title": {
|
||||
"message": "Resubscription request submitted"
|
||||
|
||||
@@ -519,7 +519,7 @@ async function modifyCharge() {
|
||||
})
|
||||
addNotification({
|
||||
title: 'Modifications made',
|
||||
text: 'If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.',
|
||||
text: 'If the server is currently cancelled, it may take up to 10 minutes for another charge attempt to be made.',
|
||||
type: 'success',
|
||||
})
|
||||
await refreshCharges()
|
||||
|
||||
@@ -1181,7 +1181,8 @@ const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallation
|
||||
}
|
||||
|
||||
const updateStats = (currentStats: Stats['current']) => {
|
||||
isConnected.value = true
|
||||
if (!isMounted.value) return
|
||||
if (!isConnected.value) isConnected.value = true
|
||||
stats.value = {
|
||||
current: currentStats,
|
||||
past: { ...stats.value.current },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<ServersUpgradeModalWrapper ref="upgradeModal" />
|
||||
<ResubscribeModal ref="pyroResubscribeModal" @resubscribe="handlePyroResubscribeConfirm" />
|
||||
<section class="universal-card experimental-styles-within">
|
||||
<h2>{{ formatMessage(messages.subscriptionTitle) }}</h2>
|
||||
<p>{{ formatMessage(messages.subscriptionDescription) }}</p>
|
||||
@@ -284,6 +285,8 @@
|
||||
v-if="subscription.serverInfo"
|
||||
v-bind="subscription.serverInfo"
|
||||
:pending-change="getPendingChange(subscription)"
|
||||
:cancellation-date="getCancellationDate(subscription)"
|
||||
:on-download-backup="getLatestBackupDownload(subscription.serverInfo)"
|
||||
/>
|
||||
<div v-else class="w-fit">
|
||||
<p>
|
||||
@@ -514,15 +517,9 @@
|
||||
"
|
||||
color="green"
|
||||
>
|
||||
<button
|
||||
@click="
|
||||
resubscribePyro(
|
||||
subscription.id,
|
||||
$dayjs(getPyroCharge(subscription).due).isBefore($dayjs()),
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ formatMessage(messages.resubscribe) }} <RightArrowIcon />
|
||||
<button @click="openPyroResubscribeModal(subscription)">
|
||||
{{ formatMessage(messages.resubscribe) }}
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -709,6 +706,7 @@ import {
|
||||
OverflowMenu,
|
||||
paymentMethodMessages,
|
||||
PurchaseModal,
|
||||
ResubscribeModal,
|
||||
ServerListing,
|
||||
useFormatDateTime,
|
||||
useFormatPrice,
|
||||
@@ -716,7 +714,8 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { calculateSavings, getCurrency } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue'
|
||||
import ServersUpgradeModalWrapper from '~/components/ui/servers/ServersUpgradeModalWrapper.vue'
|
||||
@@ -998,7 +997,7 @@ const messages = defineMessages({
|
||||
pyroResubscribeRequestSubmittedText: {
|
||||
id: 'settings.billing.pyro.resubscribe.request-submitted.text',
|
||||
defaultMessage:
|
||||
'If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.',
|
||||
'If the server is currently cancelled, it may take 10-15 minutes to set up the server.',
|
||||
},
|
||||
pyroResubscribeSuccessText: {
|
||||
id: 'settings.billing.pyro.resubscribe.success.text',
|
||||
@@ -1053,6 +1052,11 @@ const { data: serversData } = useQuery({
|
||||
queryFn: () => client.archon.servers_v0.list(),
|
||||
})
|
||||
|
||||
const { data: serverFullList } = useQuery({
|
||||
queryKey: ['servers', 'v1'],
|
||||
queryFn: () => client.archon.servers_v1.list(),
|
||||
})
|
||||
|
||||
const midasProduct = ref(products.find((x) => x.metadata?.type === 'midas'))
|
||||
const midasSubscription = computed(() =>
|
||||
subscriptions.value?.find(
|
||||
@@ -1082,16 +1086,38 @@ const pyroSubscriptions = computed(() => {
|
||||
const pyroSubs = subscriptions.value?.filter((s) => s?.metadata?.type === 'pyro') || []
|
||||
const servers = serversData.value?.servers || []
|
||||
|
||||
return pyroSubs.map((subscription) => {
|
||||
const server = servers.find((s) => s.server_id === subscription.metadata.id)
|
||||
return {
|
||||
...subscription,
|
||||
serverInfo: server,
|
||||
}
|
||||
})
|
||||
return pyroSubs
|
||||
.map((subscription) => {
|
||||
const server = servers.find((s) => s.server_id === subscription.metadata.id)
|
||||
const charge = getPyroCharge(subscription)
|
||||
|
||||
return {
|
||||
...subscription,
|
||||
serverInfo: {
|
||||
...server,
|
||||
isProvisioning:
|
||||
subscription.status === 'unprovisioned' &&
|
||||
(charge?.status === 'processing' || charge?.status === 'open'),
|
||||
},
|
||||
}
|
||||
})
|
||||
.filter((subscription) => {
|
||||
// files expire 30 days after cancellation
|
||||
const cancellationDate = getCancellationDate(subscription)
|
||||
if (
|
||||
!cancellationDate ||
|
||||
subscription.serverInfo?.status !== 'suspended' ||
|
||||
subscription.serverInfo?.suspension_reason !== 'cancelled'
|
||||
)
|
||||
return true
|
||||
const cancellation = new Date(cancellationDate)
|
||||
const thirtyDaysLater = new Date(cancellation.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
return new Date() <= thirtyDaysLater
|
||||
})
|
||||
})
|
||||
|
||||
const midasPurchaseModal = ref()
|
||||
const pyroResubscribeModal = ref()
|
||||
const country = useUserCountry()
|
||||
const price = computed(() =>
|
||||
midasProduct.value?.prices?.find((x) => x.currency_code === getCurrency(country.value)),
|
||||
@@ -1201,13 +1227,20 @@ const getProductFromPriceId = (priceId) => {
|
||||
return productsData.value.find((p) => p.prices?.some((x) => x.id === priceId))
|
||||
}
|
||||
|
||||
const getPyroCharge = (subscription) => {
|
||||
function getPyroCharge(subscription) {
|
||||
if (!subscription || !charges.value) return null
|
||||
return charges.value.find(
|
||||
(charge) => charge.subscription_id === subscription.id && charge.status !== 'succeeded',
|
||||
)
|
||||
}
|
||||
|
||||
function getCancellationDate(subscription) {
|
||||
const charge = getPyroCharge(subscription)
|
||||
if (!charge) return null
|
||||
if (charge.status === 'cancelled') return charge.due
|
||||
return null
|
||||
}
|
||||
|
||||
const getProductSize = (product) => {
|
||||
if (!product || !product.metadata) return formatMessage(commonMessages.planUnknownLabel)
|
||||
const ramSize = product.metadata.ram
|
||||
@@ -1243,12 +1276,38 @@ const showPyroUpgradeModal = (subscription) => {
|
||||
upgradeModal.value?.open(subscription?.metadata?.id)
|
||||
}
|
||||
|
||||
const CHARGE_POLL_INTERVAL_MS = 20_000
|
||||
|
||||
const hasProvisioningSubscription = computed(() =>
|
||||
pyroSubscriptions.value?.some((s) => s.serverInfo?.isProvisioning),
|
||||
)
|
||||
|
||||
const { pause: pauseChargePoll, resume: resumeChargePoll } = useIntervalFn(
|
||||
() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billing', 'payments'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['billing', 'subscriptions'] })
|
||||
},
|
||||
CHARGE_POLL_INTERVAL_MS,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
watch(
|
||||
hasProvisioningSubscription,
|
||||
(isProvisioning) => {
|
||||
if (isProvisioning) {
|
||||
resumeChargePoll()
|
||||
} else {
|
||||
pauseChargePoll()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const resubscribePyro = async (subscriptionId, wasSuspended) => {
|
||||
try {
|
||||
await client.labrinth.billing_internal.editSubscription(subscriptionId, {
|
||||
cancelled: false,
|
||||
})
|
||||
await refresh()
|
||||
if (wasSuspended) {
|
||||
addNotification({
|
||||
title: formatMessage(messages.pyroResubscribeRequestSubmittedTitle),
|
||||
@@ -1271,6 +1330,71 @@ const resubscribePyro = async (subscriptionId, wasSuspended) => {
|
||||
}
|
||||
}
|
||||
|
||||
function openPyroResubscribeModal(subscription) {
|
||||
const charge = getPyroCharge(subscription)
|
||||
const product = getPyroProduct(subscription)
|
||||
const interval = charge?.subscription_interval || subscription?.interval
|
||||
const productPrice = getProductPrice(product, interval)
|
||||
|
||||
pyroResubscribeModal.value?.show({
|
||||
subscriptionId: subscription?.id ?? '',
|
||||
wasSuspended: charge?.due ? new Date(charge.due).getTime() < Date.now() : false,
|
||||
serverName: subscription?.serverInfo?.name ?? 'this server',
|
||||
planName: `${getProductSize(product)} plan`,
|
||||
ramGb: product?.metadata?.ram ? product.metadata.ram / 1024 : undefined,
|
||||
storageGb: product?.metadata?.storage ? product.metadata.storage / 1024 : undefined,
|
||||
sharedCpus: product?.metadata?.cpu ? product.metadata.cpu / 2 : undefined,
|
||||
priceCents: charge?.amount ?? productPrice?.prices?.intervals?.[interval],
|
||||
currencyCode: charge?.currency_code ?? productPrice?.currency_code,
|
||||
interval,
|
||||
nextChargeDate: charge?.due,
|
||||
})
|
||||
}
|
||||
|
||||
function handlePyroResubscribeConfirm({ subscriptionId, wasSuspended }) {
|
||||
return resubscribePyro(subscriptionId, wasSuspended)
|
||||
}
|
||||
|
||||
function getLatestBackupDownload(serverInfo) {
|
||||
const serverFull = serverFullList.value?.find((s) => s.id === serverInfo.server_id)
|
||||
if (!serverFull) return null
|
||||
|
||||
const activeWorld = serverFull.worlds.find((w) => w.is_active) ?? serverFull.worlds[0]
|
||||
if (!activeWorld?.backups?.length) return null
|
||||
|
||||
const latestBackup = activeWorld.backups
|
||||
.filter((b) => b.status === 'done')
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
|
||||
if (!latestBackup) return null
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
const server = await client.archon.servers_v0.get(serverInfo.server_id)
|
||||
const kyrosUrl = server.node?.instance
|
||||
const jwt = server.node?.token
|
||||
if (!kyrosUrl || !jwt) {
|
||||
addNotification({
|
||||
title: 'Download unavailable',
|
||||
text: 'Server connection info is not available. Please contact support.',
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
window.open(
|
||||
`https://${kyrosUrl}/modrinth/v0/backups/${latestBackup.id}/download?auth=${jwt}`,
|
||||
'_blank',
|
||||
)
|
||||
} catch {
|
||||
addNotification({
|
||||
title: 'Download failed',
|
||||
text: 'An error occurred while trying to download the backup.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['billing'] }),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { provideNotificationManager } from '@modrinth/ui'
|
||||
|
||||
import { FrontendNotificationManager } from './frontend-notifications'
|
||||
import { setupAuthProvider } from './setup/auth'
|
||||
import { setupFilePickerProvider } from './setup/file-picker'
|
||||
import { setupModrinthClientProvider } from './setup/modrinth-client'
|
||||
import { setupPageContextProvider } from './setup/page-context'
|
||||
@@ -9,6 +10,7 @@ import { setupTagsProvider } from './setup/tags'
|
||||
export function setupProviders(auth: Awaited<ReturnType<typeof useAuth>>) {
|
||||
provideNotificationManager(new FrontendNotificationManager())
|
||||
|
||||
setupAuthProvider(auth)
|
||||
setupModrinthClientProvider(auth)
|
||||
setupTagsProvider()
|
||||
setupFilePickerProvider()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { type AuthProvider, provideAuth } from '@modrinth/ui'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
|
||||
export function setupAuthProvider(auth: Awaited<ReturnType<typeof useAuth>>) {
|
||||
const router = useRouter()
|
||||
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',
|
||||
query: {
|
||||
redirect: redirectPath,
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
sessionToken.value = auth.value.token || null
|
||||
user.value = (auth.value.user as Labrinth.Users.v2.User | null) ?? null
|
||||
})
|
||||
|
||||
provideAuth(authProvider)
|
||||
}
|
||||
@@ -103,8 +103,11 @@ export class TauriModrinthClient extends XHRUploadClient {
|
||||
throw error
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data as T
|
||||
const text = await response.text()
|
||||
if (!text) {
|
||||
return undefined as T
|
||||
}
|
||||
return JSON.parse(text) as T
|
||||
} catch (error) {
|
||||
throw this.normalizeError(error)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import '@modrinth/assets/omorphia.scss'
|
||||
import 'floating-vue/dist/style.css'
|
||||
import '../src/styles/tailwind.css'
|
||||
import '../../assets/styles/defaults.scss'
|
||||
// frontend css imports
|
||||
// import '../../../apps/frontend/src/assets/styles/global.scss'
|
||||
// import '../../../apps/frontend/src/assets/styles/tailwind.css'
|
||||
// ---
|
||||
// app-frontend css imports
|
||||
import '../../../apps/app-frontend/src/assets/stylesheets/global.scss'
|
||||
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { GenericModrinthClient } from '@modrinth/api-client'
|
||||
import { withThemeByClassName } from '@storybook/addon-themes'
|
||||
import type { Preview } from '@storybook/vue3-vite'
|
||||
import { setup } from '@storybook/vue3-vite'
|
||||
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
|
||||
import FloatingVue from 'floating-vue'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { computed, defineComponent, h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import NotificationPanel from '../src/components/nav/NotificationPanel.vue'
|
||||
import PopupNotificationPanel from '../src/components/nav/PopupNotificationPanel.vue'
|
||||
@@ -109,9 +116,68 @@ class StorybookPopupNotificationManager extends AbstractPopupNotificationManager
|
||||
}
|
||||
}
|
||||
|
||||
const StorybookLink = defineComponent({
|
||||
name: 'StorybookLink',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
to: {
|
||||
type: [String, Object],
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
setup(props, { attrs, slots }) {
|
||||
const href = computed(() => {
|
||||
if (typeof props.to === 'string') return props.to || '#'
|
||||
if (props.to && typeof props.to === 'object' && 'path' in props.to) {
|
||||
const path = props.to.path
|
||||
return typeof path === 'string' ? path : '#'
|
||||
}
|
||||
return '#'
|
||||
})
|
||||
|
||||
return () =>
|
||||
h(
|
||||
'a',
|
||||
{
|
||||
...attrs,
|
||||
href: href.value,
|
||||
},
|
||||
slots.default?.(),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const StorybookClientOnly = defineComponent({
|
||||
name: 'StorybookClientOnly',
|
||||
setup(_, { slots }) {
|
||||
return () => slots.default?.()
|
||||
},
|
||||
})
|
||||
|
||||
setup((app) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
app.use(VueQueryPlugin, { queryClient })
|
||||
app.use(i18n)
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/:pathMatch(.*)*', component: { render: () => null } }],
|
||||
})
|
||||
app.use(router)
|
||||
|
||||
app.component('NuxtLink', StorybookLink)
|
||||
app.component('RouterLink', StorybookLink)
|
||||
app.component('ClientOnly', StorybookClientOnly)
|
||||
|
||||
// Provide the custom I18nContext for components using injectI18n()
|
||||
const i18nContext: I18nContext = {
|
||||
locale: i18n.global.locale,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<button class="code" :class="{ copied }" :title="formatMessage(copiedMessage)" @click="copyText">
|
||||
<button
|
||||
class="!m-0 inline-flex w-fit select-text items-center gap-2 rounded-[10px] bg-[var(--color-button-bg)] px-2 py-1 font-mono text-sm text-primary transition-[opacity,filter,transform,outline] duration-200 ease-in-out hover:brightness-[0.85] active:scale-95 active:brightness-[0.8] motion-reduce:transition-none [&>svg]:h-[1em] [&>svg]:w-[1em]"
|
||||
:title="formatMessage(copiedMessage)"
|
||||
@click="copyText"
|
||||
>
|
||||
<span>{{ text }}</span>
|
||||
<CheckIcon v-if="copied" />
|
||||
<ClipboardCopyIcon v-else />
|
||||
@@ -27,42 +31,3 @@ async function copyText() {
|
||||
copied.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.code {
|
||||
color: var(--color-text);
|
||||
display: inline-flex;
|
||||
grid-gap: 0.5rem;
|
||||
font-family: var(--mono-font);
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-button-bg);
|
||||
width: fit-content;
|
||||
border-radius: 10px;
|
||||
user-select: text;
|
||||
transition:
|
||||
opacity 0.5s ease-in-out,
|
||||
filter 0.2s ease-in-out,
|
||||
transform 0.05s ease-in-out,
|
||||
outline 0.2s ease-in-out;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.85);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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'"
|
||||
@@ -374,7 +379,7 @@ function goToBreadcrumbStep(id: string) {
|
||||
:ping="currentPing"
|
||||
:loading="paymentMethodLoading"
|
||||
:selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod"
|
||||
:has-payment-method="hasPaymentMethod"
|
||||
:has-payment-method="!!hasPaymentMethod"
|
||||
:tax="tax"
|
||||
:total="total"
|
||||
:no-payment-required="noPaymentRequired"
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<NewModal ref="modal" max-width="550px">
|
||||
<template #title>
|
||||
<div class="text-2xl font-semibold text-contrast">Resubscribe to Server</div>
|
||||
</template>
|
||||
|
||||
<div class="flex w-[44rem] max-w-full flex-col gap-6">
|
||||
<p class="m-0 text-secondary leading-relaxed">
|
||||
You are about to resubscribe to
|
||||
<span class="font-semibold text-contrast">{{ modalData.serverName }}</span
|
||||
>. Your subscription will be reactivated and your server will continue running without
|
||||
interruption.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="text-contrast font-semibold">Plan</span>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-5"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="truncate font-semibold text-contrast">{{ modalData.planName }}</div>
|
||||
<div class="text-secondary flex gap-1.5 font-medium text-sm items-center">
|
||||
{{ modalData.ramGb }} GB RAM
|
||||
<div class="h-1.5 w-1.5 bg-button-border rounded-full"></div>
|
||||
{{ modalData.storageGb }} GB Storage
|
||||
<div class="h-1.5 w-1.5 bg-button-border rounded-full"></div>
|
||||
{{ modalData.sharedCpus }} Shared CPUs
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 items-end">
|
||||
<div class="font-semibold text-contrast">
|
||||
{{ formattedPrice }}
|
||||
</div>
|
||||
<div class="text-secondary">/{{ intervalLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="formattedNextChargeDate" class="m-0 text-primary">
|
||||
Your next charge will be on
|
||||
<span class="font-semibold text-contrast">{{ formattedNextChargeDate }}</span
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-5" @click="handleCancel">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!canResubscribe" @click="handleResubscribe">
|
||||
<RotateCounterClockwiseIcon />
|
||||
Resubscribe
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { RotateCounterClockwiseIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { useFormatDateTime, useFormatPrice } from '../../composables'
|
||||
import { ButtonStyled, NewModal } from '../index'
|
||||
|
||||
type BillingInterval = Labrinth.Billing.Internal.PriceDuration
|
||||
|
||||
export type ResubscribeModalPayload = {
|
||||
subscriptionId: string
|
||||
wasSuspended: boolean
|
||||
serverName?: string
|
||||
planName?: string
|
||||
ramGb?: number
|
||||
storageGb?: number
|
||||
sharedCpus?: number
|
||||
priceCents?: number
|
||||
currencyCode?: string
|
||||
interval?: BillingInterval | null
|
||||
nextChargeDate?: string | number | Date | null
|
||||
}
|
||||
|
||||
type ResubscribeModalState = {
|
||||
subscriptionId: string
|
||||
wasSuspended: boolean
|
||||
serverName: string
|
||||
planName: string
|
||||
ramGb: number
|
||||
storageGb: number
|
||||
sharedCpus: number
|
||||
priceCents: number
|
||||
currencyCode: string
|
||||
interval: BillingInterval
|
||||
nextChargeDate: string | number | Date
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancel'): void
|
||||
(e: 'resubscribe', payload: { subscriptionId: string; wasSuspended: boolean }): void
|
||||
}>()
|
||||
|
||||
const formatDate = useFormatDateTime({ dateStyle: 'long' })
|
||||
const formatPrice = useFormatPrice()
|
||||
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
|
||||
const FALLBACK_NEXT_CHARGE_DATE = '2025-02-17'
|
||||
|
||||
const modalData = ref<ResubscribeModalState>({
|
||||
subscriptionId: '',
|
||||
wasSuspended: false,
|
||||
serverName: 'this server',
|
||||
planName: 'Medium plan',
|
||||
ramGb: 2,
|
||||
storageGb: 48,
|
||||
sharedCpus: 3,
|
||||
priceCents: 1500,
|
||||
currencyCode: 'USD',
|
||||
interval: 'monthly',
|
||||
nextChargeDate: FALLBACK_NEXT_CHARGE_DATE,
|
||||
})
|
||||
|
||||
const canResubscribe = computed(() => !!modalData.value.subscriptionId)
|
||||
|
||||
const intervalLabel = computed(() => {
|
||||
switch (modalData.value.interval) {
|
||||
case 'monthly':
|
||||
return 'month'
|
||||
case 'quarterly':
|
||||
return 'quarter'
|
||||
case 'yearly':
|
||||
return 'year'
|
||||
case 'five-days':
|
||||
return '5 days'
|
||||
default:
|
||||
return 'month'
|
||||
}
|
||||
})
|
||||
|
||||
const formattedPrice = computed(() =>
|
||||
formatPrice(modalData.value.priceCents, modalData.value.currencyCode),
|
||||
)
|
||||
|
||||
const normalizedNextChargeDate = computed(() => {
|
||||
const date = new Date(modalData.value.nextChargeDate)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null
|
||||
}
|
||||
return date
|
||||
})
|
||||
|
||||
const formattedNextChargeDate = computed(() =>
|
||||
normalizedNextChargeDate.value ? formatDate(normalizedNextChargeDate.value) : '',
|
||||
)
|
||||
|
||||
function show(payload: ResubscribeModalPayload) {
|
||||
modalData.value = {
|
||||
subscriptionId: payload.subscriptionId,
|
||||
wasSuspended: payload.wasSuspended,
|
||||
serverName: payload.serverName?.trim() || 'this server',
|
||||
planName: payload.planName ?? 'Medium plan',
|
||||
ramGb: payload.ramGb ?? 2,
|
||||
storageGb: payload.storageGb ?? 48,
|
||||
sharedCpus: payload.sharedCpus ?? 3,
|
||||
priceCents: payload.priceCents ?? 1500,
|
||||
currencyCode: payload.currencyCode ?? 'USD',
|
||||
interval: payload.interval ?? 'monthly',
|
||||
nextChargeDate: payload.nextChargeDate ?? FALLBACK_NEXT_CHARGE_DATE,
|
||||
}
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
hide()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function handleResubscribe() {
|
||||
if (!canResubscribe.value) return
|
||||
hide()
|
||||
emit('resubscribe', {
|
||||
subscriptionId: modalData.value.subscriptionId,
|
||||
wasSuspended: modalData.value.wasSuspended,
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
@@ -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>
|
||||
|
||||
@@ -179,7 +179,7 @@ function setInterval(newInterval: ServerBillingInterval) {
|
||||
|
||||
<template>
|
||||
<div class="grid sm:grid-cols-[3fr_2fr] gap-4">
|
||||
<div class="bg-table-alternateRow p-4 rounded-2xl">
|
||||
<div class="bg-surface-2 p-4 rounded-2xl">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<ModrinthServersIcon class="flex h-5 w-fit" />
|
||||
<TagItem>{{ planName }}</TagItem>
|
||||
@@ -194,9 +194,7 @@ function setInterval(newInterval: ServerBillingInterval) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-table-alternateRow p-4 rounded-2xl flex flex-col gap-2 items-center justify-center"
|
||||
>
|
||||
<div class="bg-surface-2 p-4 rounded-2xl flex flex-col gap-2 items-center justify-center">
|
||||
<img
|
||||
v-if="flag"
|
||||
class="aspect-[16/10] max-w-12 w-full object-cover rounded-md border-1 border-button-border border-solid"
|
||||
@@ -304,10 +302,7 @@ function setInterval(newInterval: ServerBillingInterval) {
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="p-4 rounded-2xl bg-table-alternateRow text-sm text-secondary leading-relaxed"
|
||||
>
|
||||
<div v-else class="p-4 rounded-2xl bg-surface-2 text-sm text-secondary leading-relaxed">
|
||||
No payment required. Your downgrade will apply at the end of the current billing period.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { default as AddPaymentMethodModal } from './AddPaymentMethodModal.vue'
|
||||
export { default as ModrinthServersPurchaseModal } from './ModrinthServersPurchaseModal.vue'
|
||||
export { default as PurchaseModal } from './PurchaseModal.vue'
|
||||
export { default as ResubscribeModal } from './ResubscribeModal.vue'
|
||||
export { default as ServersSpecs } from './ServersSpecs.vue'
|
||||
export { default as ServersUpgradeModalWrapper } from './ServersUpgradeModalWrapper.vue'
|
||||
|
||||
@@ -1,32 +1,46 @@
|
||||
<template>
|
||||
<div>
|
||||
<NuxtLink :to="status === 'suspended' ? '' : `/hosting/manage/${props.server_id}`">
|
||||
<NuxtLink :to="isDisabled ? '' : `/hosting/manage/${server_id}`">
|
||||
<div
|
||||
class="flex flex-row items-center overflow-x-hidden rounded-2xl border-[1px] border-solid border-button-bg bg-bg-raised p-4 transition-transform duration-100"
|
||||
class="flex flex-row items-center overflow-x-hidden rounded-2xl border-[1px] border-solid border-surface-5 bg-bg-raised p-4 transition-transform duration-100"
|
||||
:class="{
|
||||
'!rounded-b-none border-b-0': status === 'suspended' || !!pendingChange,
|
||||
'opacity-75': status === 'suspended',
|
||||
'active:scale-95': status !== 'suspended' && !pendingChange,
|
||||
'!rounded-b-none border-b-0': hasNotice,
|
||||
'bg-surface-2': isDisabled,
|
||||
'active:scale-95': !isDisabled && !hasNotice,
|
||||
}"
|
||||
data-pyro-server-listing
|
||||
:data-pyro-server-listing-id="server_id"
|
||||
>
|
||||
<ServerIcon v-if="status !== 'suspended'" :image="image" />
|
||||
<div
|
||||
v-else
|
||||
class="bg-bg-secondary flex size-16 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
v-if="hasIconOverlay"
|
||||
class="flex size-16 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
>
|
||||
<LockIcon class="size-12 text-secondary" />
|
||||
<ServerIcon :image="image ?? undefined" :disabled="isDisabled" />
|
||||
<SpinnerIcon
|
||||
v-if="isProvisioning || isUpgrading"
|
||||
class="size-8 animate-spin absolute text-contrast"
|
||||
:class="{ 'opacity-50': isDisabled }"
|
||||
/>
|
||||
<LockIcon v-else class="size-8 absolute" :class="{ 'opacity-50': isDisabled }" />
|
||||
</div>
|
||||
<ServerIcon v-else :image="image ?? undefined" :disabled="isDisabled" />
|
||||
<div class="ml-4 flex flex-col gap-2.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 text-xl font-bold text-contrast">{{ name }}</h2>
|
||||
<ChevronRightIcon />
|
||||
<h2 class="m-0 text-xl font-bold text-contrast" :class="{ 'opacity-50': isDisabled }">
|
||||
{{ name }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="isConfiguring"
|
||||
class="flex min-w-0 items-center gap-2 truncate text-sm font-medium text-brand rounded-full bg-brand-highlight border border-solid border-brand px-2.5 h-[28px]"
|
||||
>
|
||||
<SparklesIcon class="size-5 shrink-0 font-semibold" /> New
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="projectData?.title"
|
||||
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
|
||||
class="m-0 flex flex-row items-center gap-2 text-sm font-medium"
|
||||
:class="{ 'opacity-50': isDisabled }"
|
||||
>
|
||||
<Avatar
|
||||
:src="iconUrl"
|
||||
@@ -37,72 +51,122 @@
|
||||
Using {{ projectData?.title || 'Unknown' }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isConfiguring"
|
||||
class="flex min-w-0 items-center gap-2 truncate text-sm font-semibold text-brand"
|
||||
>
|
||||
<SparklesIcon class="size-5 shrink-0" /> New server
|
||||
</div>
|
||||
<ServerInfoLabels
|
||||
v-else
|
||||
:server-data="{ game, mc_version, loader, loader_version, net }"
|
||||
:server-data="
|
||||
isConfiguring
|
||||
? { net }
|
||||
: {
|
||||
game,
|
||||
mc_version,
|
||||
loader,
|
||||
loader_version,
|
||||
net,
|
||||
online,
|
||||
players: playerCount
|
||||
? { current: playerCount.current, max: playerCount.max }
|
||||
: undefined,
|
||||
}
|
||||
"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:show-player-count="showPlayerCount"
|
||||
:class="{ 'opacity-50': isDisabled }"
|
||||
:linked="false"
|
||||
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-2 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-2 text-primary *:hidden sm:flex-row sm:*:flex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div
|
||||
v-if="status === 'suspended' && suspension_reason === 'upgrading'"
|
||||
class="relative flex w-full flex-row items-center gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-blue bg-bg-blue p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<LoaderCircleIcon class="size-5 animate-spin" />
|
||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended' && suspension_reason === 'cancelled'"
|
||||
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<TriangleAlertIcon class="!size-5" /> Your server has been cancelled. Please update your
|
||||
billing information or contact Modrinth Support for more information.
|
||||
|
||||
<div v-if="noticeType" class="server-listing-notice">
|
||||
<div v-if="noticeType === 'provisioning'" class="flex gap-2">
|
||||
Please wait while we set up your server. This can take up to 10 minutes.
|
||||
</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended' && suspension_reason"
|
||||
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<TriangleAlertIcon class="!size-5" /> Your server has been suspended:
|
||||
{{ suspension_reason }}. Please update your billing information or contact Modrinth Support
|
||||
for more information.
|
||||
<div v-else-if="noticeType === 'upgrading'" class="flex gap-2">
|
||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||
</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended'"
|
||||
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<TriangleAlertIcon class="!size-5" /> Your server has been suspended. Please update your
|
||||
billing information or contact Modrinth Support for more information.
|
||||
<div v-else-if="noticeType === 'cancelled' || noticeType === 'paymentfailed'">
|
||||
Your subscription was cancelled<template v-if="cancellationDate">
|
||||
on
|
||||
<span class="font-medium text-contrast">
|
||||
{{ formatDate(cancellationDate) }}
|
||||
</span></template
|
||||
><template v-if="noticeType === 'paymentfailed'"> due to payment failure</template
|
||||
>.<template v-if="!isFilesExpired">
|
||||
Your files will be kept for
|
||||
<span class="font-medium text-red"
|
||||
>{{ filesRemainingDays }} more {{ filesRemainingDays === 1 ? 'day' : 'days' }}</span
|
||||
>
|
||||
and can be downloaded below before they're deleted.</template
|
||||
>
|
||||
</div>
|
||||
<div v-else-if="noticeType === 'setToCancel'">
|
||||
Your subscription is set to cancel<template v-if="cancellationDate">
|
||||
on
|
||||
<span class="font-medium text-contrast">
|
||||
{{ formatDate(cancellationDate) }}
|
||||
</span></template
|
||||
>.<template v-if="!isFilesExpired">
|
||||
Your files will be preserved for 30 days after cancellation.
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="noticeType === 'moderated'">
|
||||
Your server has been suspended by moderation action.
|
||||
</div>
|
||||
<div v-else>
|
||||
Your server has been suspended. Please contact Modrinth Support for more information.
|
||||
</div>
|
||||
|
||||
<div v-if="noticeButtons" class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-if="noticeButtons.downloadBackup && onDownloadBackup"
|
||||
type="outlined"
|
||||
circular
|
||||
>
|
||||
<button
|
||||
v-tooltip="'Download latest backup'"
|
||||
class="!border-surface-5"
|
||||
@click="onDownloadBackup"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="noticeButtons.copyId" type="outlined">
|
||||
<button
|
||||
v-tooltip="'Copy code to clipboard'"
|
||||
class="!border-surface-5"
|
||||
@click="copyToClipboard(server_id)"
|
||||
>
|
||||
<template v-if="copied"> Copied <CheckIcon class="text-green" /> </template>
|
||||
<template v-else> Copy ID <CopyIcon /> </template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="noticeButtons.support">
|
||||
<a href="https://support.modrinth.com/en/" target="_blank"
|
||||
><MessagesSquareIcon /> Support
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="noticeButtons.manageBilling" color="brand">
|
||||
<AutoLink :to="`/settings/billing#server-${server_id}`">
|
||||
<CardIcon /> Manage billing
|
||||
</AutoLink>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="noticeButtons.resubscribe && onResubscribe" color="brand">
|
||||
<button @click="onResubscribe"><RotateCounterClockwiseIcon /> Resubscribe</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
<div
|
||||
v-if="pendingChange && status !== 'suspended'"
|
||||
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-orange bg-bg-orange p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
|
||||
<div v-if="pendingChange && status !== 'suspended'" class="server-listing-notice">
|
||||
<div>
|
||||
Your server will {{ pendingChange.verb.toLowerCase() }} to the "{{
|
||||
pendingChange.planSize
|
||||
}}" plan on {{ formatDate(pendingChange.date) }}.
|
||||
}}" plan on
|
||||
<span class="font-medium text-contrast">{{ formatDate(pendingChange.date) }}</span
|
||||
>.
|
||||
</div>
|
||||
<ServersSpecs
|
||||
class="!font-normal !text-contrast"
|
||||
class="!font-normal !text-primary"
|
||||
:ram="Math.round((pendingChange.ramGb ?? 0) * 1024)"
|
||||
:storage="Math.round((pendingChange.storageGb ?? 0) * 1024)"
|
||||
:cpus="pendingChange.cpuBurst"
|
||||
@@ -115,19 +179,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
LoaderCircleIcon,
|
||||
DownloadIcon,
|
||||
LockIcon,
|
||||
MessagesSquareIcon,
|
||||
SparklesIcon,
|
||||
TriangleAlertIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { AutoLink, ButtonStyled } from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
CardIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
RotateCounterClockwiseIcon,
|
||||
} from '../../../../assets/generated-icons'
|
||||
import { useFormatDateTime } from '../../composables'
|
||||
import { injectModrinthClient } from '../../providers/api-client'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import CopyCode from '../base/CopyCode.vue'
|
||||
import ServersSpecs from '../billing/ServersSpecs.vue'
|
||||
import ServerIcon from './icons/ServerIcon.vue'
|
||||
import ServerInfoLabels from './labels/ServerInfoLabels.vue'
|
||||
@@ -159,14 +229,97 @@ type ServerListingProps = {
|
||||
upstream?: Archon.Servers.v0.Upstream | null
|
||||
flows?: Archon.Servers.v0.Flows
|
||||
pendingChange?: PendingChange
|
||||
online?: boolean
|
||||
playerCount?: {
|
||||
current?: number
|
||||
max?: number
|
||||
}
|
||||
isProvisioning?: boolean
|
||||
cancellationDate?: string | Date | null
|
||||
onResubscribe?: (() => void) | null
|
||||
onDownloadBackup?: (() => void) | null
|
||||
}
|
||||
|
||||
const props = defineProps<ServerListingProps>()
|
||||
|
||||
const { archon, kyros, labrinth } = injectModrinthClient()
|
||||
const { kyros, labrinth } = injectModrinthClient()
|
||||
|
||||
const showGameLabel = computed(() => !!props.game)
|
||||
const showLoaderLabel = computed(() => !!props.loader)
|
||||
const isConfiguring = computed(() => props.flows?.intro)
|
||||
const isUpgrading = computed(
|
||||
() => props.status === 'suspended' && props.suspension_reason === 'upgrading',
|
||||
)
|
||||
const isDisabled = computed(() => props.status === 'suspended' || props.isProvisioning)
|
||||
const isSetToCancel = computed(() => !!props.cancellationDate && props.status !== 'suspended')
|
||||
const filesRemainingDays = computed(() => {
|
||||
if (!props.cancellationDate) return 0
|
||||
const cancellation = new Date(props.cancellationDate)
|
||||
const expiresAt = new Date(cancellation.getTime() + 30 * 24 * 60 * 60 * 1000) // expires 30 days after cancellation
|
||||
const remaining = Math.ceil((expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000))
|
||||
return Math.max(0, remaining)
|
||||
})
|
||||
const isFilesExpired = computed(() => filesRemainingDays.value <= 0)
|
||||
|
||||
const hasIconOverlay = computed(
|
||||
() => props.isProvisioning || isUpgrading.value || props.status === 'suspended',
|
||||
)
|
||||
|
||||
type NoticeType =
|
||||
| 'provisioning'
|
||||
| 'upgrading'
|
||||
| 'cancelled'
|
||||
| 'paymentfailed'
|
||||
| 'moderated'
|
||||
| 'suspended'
|
||||
| 'setToCancel'
|
||||
|
||||
const noticeType = computed<NoticeType | null>(() => {
|
||||
if (props.isProvisioning) return 'provisioning'
|
||||
if (props.status === 'suspended') {
|
||||
switch (props.suspension_reason) {
|
||||
case 'upgrading':
|
||||
return 'upgrading'
|
||||
case 'cancelled':
|
||||
return 'cancelled'
|
||||
case 'paymentfailed':
|
||||
return 'paymentfailed'
|
||||
case 'moderated':
|
||||
return 'moderated'
|
||||
default:
|
||||
return 'suspended'
|
||||
}
|
||||
}
|
||||
if (isSetToCancel.value) return 'setToCancel'
|
||||
return null
|
||||
})
|
||||
|
||||
type NoticeButtons = {
|
||||
downloadBackup?: boolean
|
||||
copyId?: boolean
|
||||
support?: boolean
|
||||
manageBilling?: boolean
|
||||
resubscribe?: boolean
|
||||
}
|
||||
|
||||
const noticeButtons = computed<NoticeButtons | null>(() => {
|
||||
switch (noticeType.value) {
|
||||
case 'cancelled':
|
||||
case 'setToCancel':
|
||||
return { downloadBackup: true, copyId: true, support: true, resubscribe: true }
|
||||
case 'paymentfailed':
|
||||
return { downloadBackup: true, copyId: true, support: true, manageBilling: true }
|
||||
case 'moderated':
|
||||
case 'suspended':
|
||||
return { copyId: true, support: true }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const hasNotice = computed(() => !!noticeType.value || !!props.pendingChange)
|
||||
|
||||
const showGameLabel = computed(() => !!props.game && !isConfiguring.value)
|
||||
const showLoaderLabel = computed(() => !!props.loader && !isConfiguring.value)
|
||||
const showPlayerCount = computed(() => !!props.playerCount && !isConfiguring.value)
|
||||
|
||||
const { data: projectData } = useQuery({
|
||||
queryKey: ['project', props.upstream?.project_id] as const,
|
||||
@@ -207,14 +360,8 @@ const { data: image } = useQuery({
|
||||
if (!props.server_id || props.status !== 'available') return null
|
||||
|
||||
try {
|
||||
const auth = await archon.servers_v0.getFilesystemAuth(props.server_id)
|
||||
|
||||
try {
|
||||
const blob = await kyros.files_v0.downloadFile(
|
||||
auth.url,
|
||||
auth.token,
|
||||
'/server-icon-original.png',
|
||||
)
|
||||
const blob = await kyros.files_v0.downloadFile('/server-icon-original.png')
|
||||
|
||||
return await processImageBlob(blob, 512)
|
||||
} catch {
|
||||
@@ -227,17 +374,12 @@ const { data: image } = useQuery({
|
||||
const scaledBlob = await dataURLToBlob(scaledDataUrl)
|
||||
const scaledFile = new File([scaledBlob], 'server-icon.png', { type: 'image/png' })
|
||||
|
||||
await kyros.files_v0.uploadFile(auth.url, auth.token, '/server-icon.png', scaledFile)
|
||||
kyros.files_v0.uploadFile('/server-icon.png', scaledFile)
|
||||
|
||||
const originalFile = new File([blob], 'server-icon-original.png', {
|
||||
type: 'image/png',
|
||||
})
|
||||
await kyros.files_v0.uploadFile(
|
||||
auth.url,
|
||||
auth.token,
|
||||
'/server-icon-original.png',
|
||||
originalFile,
|
||||
)
|
||||
kyros.files_v0.uploadFile('/server-icon-original.png', originalFile)
|
||||
|
||||
return scaledDataUrl
|
||||
}
|
||||
@@ -252,5 +394,19 @@ const { data: image } = useQuery({
|
||||
enabled: computed(() => !!props.server_id && props.status === 'available'),
|
||||
})
|
||||
|
||||
const isConfiguring = computed(() => props.flows?.intro)
|
||||
const copied = ref(false)
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 3000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.server-listing-notice {
|
||||
@apply relative flex w-full rounded-b-2xl border-[1px] border-solid p-4 flex-col gap-4 border-surface-5 bg-bg-raised text-primary;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="experimental-styles-within flex size-16 shrink-0 overflow-hidden rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
class="experimental-styles-within relative flex size-16 shrink-0 overflow-hidden rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
>
|
||||
<client-only>
|
||||
<img
|
||||
@@ -16,6 +16,7 @@
|
||||
:src="MinecraftServerIcon"
|
||||
/>
|
||||
</client-only>
|
||||
<div v-if="disabled" class="absolute inset-0 bg-surface-1 opacity-50" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -24,5 +25,6 @@ import { MinecraftServerIcon } from '@modrinth/assets'
|
||||
|
||||
defineProps<{
|
||||
image: string | undefined
|
||||
disabled?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -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,3 @@
|
||||
<template>
|
||||
<div class="experimental-styles-within h-1.5 w-1.5 bg-button-border rounded-full"></div>
|
||||
</template>
|
||||
@@ -2,9 +2,11 @@
|
||||
<div
|
||||
v-if="game"
|
||||
v-tooltip="'Change server version'"
|
||||
class="min-w-0 flex-none flex-row items-center gap-2 first:!flex"
|
||||
class="min-w-0 flex-none flex-row items-center gap-1.5 first:!flex"
|
||||
>
|
||||
<GameIcon aria-hidden="true" class="size-5 shrink-0" />
|
||||
<Separator v-if="!noSeparator" />
|
||||
|
||||
<GameIcon aria-hidden="true" />
|
||||
<AutoLink
|
||||
v-if="isLink"
|
||||
:to="serverId ? `/hosting/manage/${serverId}/options/loader` : ''"
|
||||
@@ -30,11 +32,13 @@ import { GameIcon } from '@modrinth/assets'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AutoLink from '../../base/AutoLink.vue'
|
||||
import Separator from './Separator.vue'
|
||||
|
||||
defineProps<{
|
||||
game: string
|
||||
mcVersion: string
|
||||
isLink?: boolean
|
||||
noSeparator?: boolean
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
<template>
|
||||
<div>
|
||||
<ServerPlayerCount
|
||||
v-if="showPlayerCount"
|
||||
:current-players="serverData.players.current"
|
||||
:max-players="serverData.players.max"
|
||||
:online="serverData.online"
|
||||
/>
|
||||
<ServerGameLabel
|
||||
v-if="showGameLabel"
|
||||
:game="serverData.game"
|
||||
:mc-version="serverData.mc_version ?? ''"
|
||||
:no-separator="column || !showPlayerCount"
|
||||
:is-link="linked"
|
||||
/>
|
||||
<ServerLoaderLabel
|
||||
v-if="showLoaderLabel"
|
||||
:loader="serverData.loader"
|
||||
:loader-version="serverData.loader_version ?? ''"
|
||||
:no-separator="column"
|
||||
:no-separator="column || !showGameLabel"
|
||||
:is-link="linked"
|
||||
/>
|
||||
<ServerSubdomainLabel
|
||||
v-if="serverData.net?.domain"
|
||||
:subdomain="serverData.net.domain"
|
||||
:no-separator="column"
|
||||
:no-separator="column || (!showLoaderLabel && !showGameLabel)"
|
||||
:is-link="linked"
|
||||
/>
|
||||
<ServerUptimeLabel
|
||||
@@ -29,6 +37,7 @@
|
||||
<script setup lang="ts">
|
||||
import ServerGameLabel from './ServerGameLabel.vue'
|
||||
import ServerLoaderLabel from './ServerLoaderLabel.vue'
|
||||
import ServerPlayerCount from './ServerPlayerCount.vue'
|
||||
import ServerSubdomainLabel from './ServerSubdomainLabel.vue'
|
||||
import ServerUptimeLabel from './ServerUptimeLabel.vue'
|
||||
|
||||
@@ -37,6 +46,7 @@ interface ServerInfoLabelsProps {
|
||||
serverData: Record<string, any>
|
||||
showGameLabel: boolean
|
||||
showLoaderLabel: boolean
|
||||
showPlayerCount?: boolean
|
||||
uptimeSeconds?: number
|
||||
column?: boolean
|
||||
linked?: boolean
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div v-tooltip="'Change server loader'" class="flex min-w-0 flex-row items-center gap-2 truncate">
|
||||
<div v-if="!noSeparator" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<LoaderIcon v-if="loader" :loader="loader" class="flex shrink-0 [&&]:size-5" />
|
||||
<Separator v-if="!noSeparator" />
|
||||
<div class="flex flex-row items-center gap-1.5">
|
||||
<LoaderIcon v-if="loader" :loader="loader" />
|
||||
<div v-else class="size-5 shrink-0 animate-pulse rounded-full bg-button-border"></div>
|
||||
<AutoLink
|
||||
v-if="isLink"
|
||||
@@ -38,6 +38,7 @@ import { useRoute } from 'vue-router'
|
||||
|
||||
import AutoLink from '../../base/AutoLink.vue'
|
||||
import LoaderIcon from '../icons/LoaderIcon.vue'
|
||||
import Separator from './Separator.vue'
|
||||
|
||||
defineProps<{
|
||||
noSeparator?: boolean
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="flex gap-1">
|
||||
<!-- indicator icon -->
|
||||
|
||||
{{ currentPlayers }} / {{ maxPlayers }} Players
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface ServerPlayerCountProps {
|
||||
currentPlayers: number
|
||||
maxPlayers: number
|
||||
online: boolean
|
||||
}
|
||||
|
||||
defineProps<ServerPlayerCountProps>()
|
||||
</script>
|
||||
@@ -4,9 +4,10 @@
|
||||
v-tooltip="'Copy custom URL'"
|
||||
class="flex min-w-0 flex-row items-center gap-2 truncate hover:cursor-pointer"
|
||||
>
|
||||
<div v-if="!noSeparator" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<LinkIcon class="flex size-5 shrink-0" />
|
||||
<Separator v-if="!noSeparator" />
|
||||
|
||||
<div class="flex flex-row items-center gap-1.5">
|
||||
<LinkIcon />
|
||||
<div
|
||||
class="flex min-w-0 text-sm font-semibold"
|
||||
:class="serverId ? 'hover:underline' : ''"
|
||||
@@ -25,6 +26,8 @@ import { useStorage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import Separator from './Separator.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
class="server-action-buttons-anim flex min-w-0 flex-row items-center gap-2"
|
||||
data-pyro-uptime
|
||||
>
|
||||
<div v-if="!noSeparator" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
||||
<Separator v-if="!noSeparator" />
|
||||
|
||||
<div class="flex gap-2">
|
||||
<TimerIcon class="flex size-5 shrink-0" />
|
||||
<div class="flex gap-1.5">
|
||||
<TimerIcon />
|
||||
<time class="truncate text-sm font-semibold" :aria-label="verboseUptime">
|
||||
{{ formattedUptime }}
|
||||
</time>
|
||||
@@ -20,6 +20,8 @@
|
||||
import { TimerIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Separator from './Separator.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
uptimeSeconds: number
|
||||
noSeparator?: boolean
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:linked="false"
|
||||
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-2 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-primary *:hidden sm:flex-row sm:*:flex"
|
||||
/>
|
||||
</div>
|
||||
</AutoLink>
|
||||
|
||||
@@ -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 |
@@ -3,16 +3,31 @@
|
||||
data-pyro-server-list-root
|
||||
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
|
||||
>
|
||||
<ServersUpgradeModalWrapper
|
||||
v-if="isNuxt"
|
||||
ref="upgradeModal"
|
||||
:stripe-publishable-key
|
||||
:site-url
|
||||
:products
|
||||
<ModrinthServersPurchaseModal
|
||||
v-if="customer && regions"
|
||||
:key="`purchase-modal-${customer.id}`"
|
||||
ref="purchaseModal"
|
||||
:publishable-key="props.stripePublishableKey"
|
||||
:initiate-payment="
|
||||
async (body) => await client.labrinth.billing_internal.initiatePayment(body)
|
||||
"
|
||||
:available-products="pyroProducts"
|
||||
:on-error="handleError"
|
||||
:customer="customer"
|
||||
:payment-methods="paymentMethods ?? []"
|
||||
:currency="selectedCurrency"
|
||||
:return-url="`${props.siteUrl}/hosting/manage`"
|
||||
:pings="regionPings"
|
||||
:regions="regions"
|
||||
:refresh-payment-methods="fetchPaymentData"
|
||||
:fetch-stock="fetchStock"
|
||||
:affiliate-code="affiliateCode"
|
||||
plan-stage
|
||||
/>
|
||||
<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">
|
||||
@@ -85,21 +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 to="/servers#plan">Create a server</AutoLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-else key="list">
|
||||
@@ -116,11 +123,15 @@
|
||||
placeholder="Search servers..."
|
||||
wrapper-class="w-full md:w-72"
|
||||
/>
|
||||
<ButtonStyled v-if="isNuxt" type="standard">
|
||||
<AutoLink :to="{ path: '/servers', hash: '#plan' }">
|
||||
<ButtonStyled type="standard">
|
||||
<AutoLink v-if="isNuxt" :to="{ path: '/servers', hash: '#plan' }">
|
||||
<PlusIcon />
|
||||
New server
|
||||
</AutoLink>
|
||||
<button v-else :disabled="!canOpenPurchaseModal" @click="openPurchaseModal">
|
||||
<PlusIcon />
|
||||
New server
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,17 +163,22 @@
|
||||
v-for="server in filteredData.filter((s) => s.is_medal)"
|
||||
:key="server.server_id"
|
||||
v-bind="server"
|
||||
@upgrade="openUpgradeModal(server.server_id)"
|
||||
@upgrade="openPurchaseModal"
|
||||
/>
|
||||
<ServerListing
|
||||
v-for="server in filteredData.filter((s) => !s.is_medal)"
|
||||
:key="server.server_id"
|
||||
v-bind="server"
|
||||
:cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate"
|
||||
:is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning"
|
||||
:on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe"
|
||||
: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>
|
||||
@@ -171,32 +187,44 @@
|
||||
<script setup lang="ts">
|
||||
import { type Archon, type Labrinth, NuxtModrinthClient } from '@modrinth/api-client'
|
||||
import { HammerIcon, LoaderCircleIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { AutoLink, ButtonStyled, CopyCode, injectModrinthClient, StyledInput } from '@modrinth/ui'
|
||||
import {
|
||||
AutoLink,
|
||||
ButtonStyled,
|
||||
CopyCode,
|
||||
injectAuth,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
ModrinthServersPurchaseModal,
|
||||
ResubscribeModal,
|
||||
ServerListEmpty,
|
||||
StyledInput,
|
||||
} from '@modrinth/ui'
|
||||
import type { ModrinthServersFetchError } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import dayjs from 'dayjs'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import type Stripe from 'stripe'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ServersUpgradeModalWrapper from '#ui/components/billing/ServersUpgradeModalWrapper.vue'
|
||||
import MedalServerListing from '#ui/components/servers/marketing/MedalServerListing.vue'
|
||||
import ServerListing from '#ui/components/servers/ServerListing.vue'
|
||||
|
||||
defineProps<{
|
||||
stripePublishableKey?: string
|
||||
siteUrl?: string
|
||||
products?: Labrinth.Billing.Internal.Product[]
|
||||
const props = defineProps<{
|
||||
stripePublishableKey: string
|
||||
siteUrl: string
|
||||
products: Labrinth.Billing.Internal.Product[]
|
||||
}>()
|
||||
|
||||
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,
|
||||
@@ -204,6 +232,159 @@ const pollingState = ref({
|
||||
initialServers: [] as Archon.Servers.v0.Server[],
|
||||
})
|
||||
|
||||
const purchaseModal = ref<InstanceType<typeof ModrinthServersPurchaseModal> | null>(null)
|
||||
const resubscribeModal = ref<InstanceType<typeof ResubscribeModal> | null>(null)
|
||||
const affiliateCode = ref<string | null>(null)
|
||||
const selectedCurrency = ref<string>('USD')
|
||||
const regionPings = ref<
|
||||
{
|
||||
region: string
|
||||
ping: number
|
||||
}[]
|
||||
>([])
|
||||
|
||||
const pyroProducts = computed(() => {
|
||||
return [...props.products]
|
||||
.filter((p) => p?.metadata?.type === 'pyro' || p?.metadata?.type === 'medal')
|
||||
.sort((a, b) => {
|
||||
const aRam =
|
||||
a?.metadata?.type === 'pyro' || a?.metadata?.type === 'medal' ? a.metadata.ram : 0
|
||||
const bRam =
|
||||
b?.metadata?.type === 'pyro' || b?.metadata?.type === 'medal' ? b.metadata.ram : 0
|
||||
return aRam - bRam
|
||||
})
|
||||
})
|
||||
|
||||
const {
|
||||
data: customer,
|
||||
refetch: refetchCustomer,
|
||||
isLoading: customerLoading,
|
||||
} = useQuery({
|
||||
queryKey: ['billing', 'customer'],
|
||||
queryFn: () => client.labrinth.billing_internal.getCustomer() as Promise<Stripe.Customer>,
|
||||
enabled: loggedIn,
|
||||
})
|
||||
|
||||
const {
|
||||
data: paymentMethods,
|
||||
refetch: refetchPaymentMethods,
|
||||
isLoading: paymentMethodsLoading,
|
||||
} = useQuery({
|
||||
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(
|
||||
regions,
|
||||
(newRegions) => {
|
||||
regionPings.value = []
|
||||
if (newRegions) {
|
||||
newRegions.forEach((region) => {
|
||||
runPingTest(region)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function fetchPaymentData() {
|
||||
await Promise.all([refetchCustomer(), refetchPaymentMethods()])
|
||||
}
|
||||
|
||||
async function fetchStock(
|
||||
region: Archon.Servers.v1.Region,
|
||||
request: Archon.Servers.v0.StockRequest,
|
||||
): Promise<number> {
|
||||
const result = await client.archon.servers_v0.checkStock(region.shortcode, request)
|
||||
return result.available
|
||||
}
|
||||
|
||||
const PING_COUNT = 20
|
||||
const PING_INTERVAL = 200
|
||||
const MAX_PING_TIME = 1000
|
||||
|
||||
function runPingTest(region: Archon.Servers.v1.Region, index = 1) {
|
||||
if (index > 10) {
|
||||
regionPings.value = regionPings.value.filter((entry) => entry.region !== region.shortcode)
|
||||
regionPings.value.push({
|
||||
region: region.shortcode,
|
||||
ping: -1,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const wsUrl = `wss://${region.shortcode}${index}.${region.zone}/pingtest`
|
||||
try {
|
||||
const socket = new WebSocket(wsUrl)
|
||||
const pings: number[] = []
|
||||
let finalized = false
|
||||
|
||||
const finalize = (ping: number) => {
|
||||
if (finalized) return
|
||||
finalized = true
|
||||
clearTimeout(connectTimeout)
|
||||
regionPings.value = regionPings.value.filter((entry) => entry.region !== region.shortcode)
|
||||
regionPings.value.push({
|
||||
region: region.shortcode,
|
||||
ping,
|
||||
})
|
||||
socket.close()
|
||||
}
|
||||
|
||||
const retryNext = () => {
|
||||
if (finalized) return
|
||||
finalized = true
|
||||
clearTimeout(connectTimeout)
|
||||
socket.close()
|
||||
runPingTest(region, index + 1)
|
||||
}
|
||||
|
||||
// Prevent hangs where the socket never opens or errors.
|
||||
const connectTimeout = setTimeout(() => {
|
||||
retryNext()
|
||||
}, 3000)
|
||||
|
||||
socket.onopen = () => {
|
||||
clearTimeout(connectTimeout)
|
||||
|
||||
for (let i = 0; i < PING_COUNT; i++) {
|
||||
setTimeout(() => {
|
||||
socket.send(String(performance.now()))
|
||||
}, i * PING_INTERVAL)
|
||||
}
|
||||
setTimeout(
|
||||
() => {
|
||||
const median =
|
||||
pings.length > 0
|
||||
? Math.round([...pings].sort((a, b) => a - b)[Math.floor(pings.length / 2)])
|
||||
: -1
|
||||
finalize(median)
|
||||
},
|
||||
PING_COUNT * PING_INTERVAL + MAX_PING_TIME,
|
||||
)
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const start = Number(event.data)
|
||||
pings.push(performance.now() - start)
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
retryNext()
|
||||
}
|
||||
} catch {
|
||||
runPingTest(region, index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
data: serverResponse,
|
||||
error: fetchError,
|
||||
@@ -245,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 []
|
||||
@@ -273,13 +453,23 @@ function introToTop(array: Archon.Servers.v0.Server[]): Archon.Servers.v0.Server
|
||||
})
|
||||
}
|
||||
|
||||
// files expire 30 days after cancellation
|
||||
function filesExpired(server: Archon.Servers.v0.Server): boolean {
|
||||
if (server.status !== 'suspended' || server.suspension_reason !== 'cancelled') return false
|
||||
const cancellationDate = serverBillingMap.value.get(server.server_id)?.cancellationDate
|
||||
if (!cancellationDate) return false
|
||||
const cancellation = new Date(cancellationDate)
|
||||
const thirtyDaysLater = new Date(cancellation.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
return new Date() > thirtyDaysLater
|
||||
}
|
||||
|
||||
const filteredData = computed<Archon.Servers.v0.Server[]>(() => {
|
||||
if (!searchInput.value.trim()) {
|
||||
return introToTop(serverList.value)
|
||||
}
|
||||
return fuse.value
|
||||
? introToTop(fuse.value.search(searchInput.value).map((result) => result.item))
|
||||
: []
|
||||
const base = !searchInput.value.trim()
|
||||
? introToTop(serverList.value)
|
||||
: fuse.value
|
||||
? introToTop(fuse.value.search(searchInput.value).map((result) => result.item))
|
||||
: []
|
||||
return base.filter((server) => !filesExpired(server))
|
||||
})
|
||||
|
||||
// Start polling only after initial data is available so the baseline is correct
|
||||
@@ -299,14 +489,309 @@ watch(serverResponse, (response) => {
|
||||
}
|
||||
})
|
||||
|
||||
type ServersUpgradeModalWrapperRef = ComponentPublicInstance<{
|
||||
open: (id: string) => void | Promise<void>
|
||||
}>
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const upgradeModal = ref<ServersUpgradeModalWrapperRef | null>(null)
|
||||
function openUpgradeModal(serverId: string) {
|
||||
upgradeModal.value?.open(serverId)
|
||||
const canOpenPurchaseModal = computed(() => {
|
||||
return (
|
||||
Boolean(props.stripePublishableKey) &&
|
||||
Boolean(customer.value) &&
|
||||
paymentMethods.value !== undefined &&
|
||||
Boolean(regions.value) &&
|
||||
!customerLoading.value &&
|
||||
!paymentMethodsLoading.value &&
|
||||
!regionsLoading.value
|
||||
)
|
||||
})
|
||||
|
||||
function handleError(err: unknown) {
|
||||
const error = err as Error & { data?: { description?: string } }
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
type: 'error',
|
||||
text: error?.message ?? error?.data?.description ?? String(err),
|
||||
})
|
||||
}
|
||||
|
||||
function openPurchaseModal() {
|
||||
if (!canOpenPurchaseModal.value || !purchaseModal.value) {
|
||||
addNotification({
|
||||
title: 'Purchase unavailable',
|
||||
text: 'Payment information is still loading. Please try again in a moment.',
|
||||
type: 'warning',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
const hasProvisioningSubscription = computed(() => {
|
||||
if (!subscriptions.value || !charges.value) return false
|
||||
return subscriptions.value
|
||||
.filter((s) => s?.metadata?.type === 'pyro')
|
||||
.some((sub) => {
|
||||
if (sub.status !== 'unprovisioned') return false
|
||||
const charge = charges.value?.find((c) => c.subscription_id === sub.id)
|
||||
return charge?.status === 'processing' || charge?.status === 'open'
|
||||
})
|
||||
})
|
||||
|
||||
const { pause: pauseChargePoll, resume: resumeChargePoll } = useIntervalFn(
|
||||
() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billing', 'payments'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['billing', 'subscriptions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['servers'] })
|
||||
},
|
||||
CHARGE_POLL_INTERVAL_MS,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
watch(
|
||||
hasProvisioningSubscription,
|
||||
(isProvisioning) => {
|
||||
if (isProvisioning) {
|
||||
resumeChargePoll()
|
||||
} else {
|
||||
pauseChargePoll()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const { data: serverFullList } = useQuery({
|
||||
queryKey: ['servers', 'v1'],
|
||||
queryFn: () => client.archon.servers_v1.list(),
|
||||
enabled: loggedIn,
|
||||
})
|
||||
|
||||
type ServerBillingInfo = {
|
||||
cancellationDate?: string | null
|
||||
isProvisioning?: boolean
|
||||
onResubscribe?: () => void
|
||||
onDownloadBackup?: (() => void) | null
|
||||
}
|
||||
|
||||
type ResubscribeRequest = {
|
||||
subscriptionId: string
|
||||
wasSuspended: boolean
|
||||
}
|
||||
|
||||
function getLatestBackupDownload(serverId: string): (() => void) | null {
|
||||
const serverFull = serverFullList.value?.find((s) => s.id === serverId)
|
||||
if (!serverFull) return null
|
||||
|
||||
const activeWorld = serverFull.worlds.find((w) => w.is_active) ?? serverFull.worlds[0]
|
||||
if (!activeWorld?.backups?.length) return null
|
||||
|
||||
const latestBackup = activeWorld.backups
|
||||
.filter((b) => b.status === 'done')
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
|
||||
if (!latestBackup) return null
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
const server = await client.archon.servers_v0.get(serverId)
|
||||
const kyrosUrl = server.node?.instance
|
||||
const jwt = server.node?.token
|
||||
if (!kyrosUrl || !jwt) {
|
||||
addNotification({
|
||||
title: 'Download unavailable',
|
||||
text: 'Server connection info is not available. Please contact support.',
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
window.open(
|
||||
`https://${kyrosUrl}/modrinth/v0/backups/${latestBackup.id}/download?auth=${jwt}`,
|
||||
'_blank',
|
||||
)
|
||||
} catch {
|
||||
addNotification({
|
||||
title: 'Download failed',
|
||||
text: 'An error occurred while trying to download the backup.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getProductFromPriceId(priceId: string | null | undefined) {
|
||||
if (!priceId) return null
|
||||
|
||||
return (
|
||||
pyroProducts.value.find((product) => product.prices.some((price) => price.id === priceId)) ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function getPlanName(product: Labrinth.Billing.Internal.Product | null): string {
|
||||
if (!product) return 'Medium plan'
|
||||
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return 'Medium plan'
|
||||
|
||||
switch (product.metadata.ram) {
|
||||
case 4096:
|
||||
return 'Small plan'
|
||||
case 6144:
|
||||
return 'Medium plan'
|
||||
case 8192:
|
||||
return 'Large plan'
|
||||
default:
|
||||
return 'Custom plan'
|
||||
}
|
||||
}
|
||||
|
||||
function getRamGb(product: Labrinth.Billing.Internal.Product | null): number | undefined {
|
||||
if (!product) return undefined
|
||||
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return undefined
|
||||
|
||||
return product.metadata.ram / 1024
|
||||
}
|
||||
|
||||
function getStorageGb(product: Labrinth.Billing.Internal.Product | null): number | undefined {
|
||||
if (!product) return undefined
|
||||
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return undefined
|
||||
|
||||
return product.metadata.storage / 1024
|
||||
}
|
||||
|
||||
function getSharedCpus(product: Labrinth.Billing.Internal.Product | null): number | undefined {
|
||||
if (!product) return undefined
|
||||
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return undefined
|
||||
|
||||
return product.metadata.cpu / 2
|
||||
}
|
||||
|
||||
function getRecurringPrice(
|
||||
product: Labrinth.Billing.Internal.Product | null,
|
||||
interval: Labrinth.Billing.Internal.PriceDuration,
|
||||
preferredCurrency?: string,
|
||||
): { amount: number; currencyCode: string } | null {
|
||||
if (!product) return null
|
||||
|
||||
const recurringPrices = product.prices.filter((price) => price.prices.type === 'recurring')
|
||||
const preferredPrice = preferredCurrency
|
||||
? recurringPrices.find((price) => price.currency_code === preferredCurrency)
|
||||
: undefined
|
||||
const usdPrice = recurringPrices.find((price) => price.currency_code === 'USD')
|
||||
const selectedPrice = preferredPrice ?? usdPrice ?? recurringPrices[0]
|
||||
|
||||
if (!selectedPrice || selectedPrice.prices.type !== 'recurring') return null
|
||||
|
||||
return {
|
||||
amount: selectedPrice.prices.intervals[interval],
|
||||
currencyCode: selectedPrice.currency_code,
|
||||
}
|
||||
}
|
||||
|
||||
function openResubscribeModal(
|
||||
serverId: string,
|
||||
subscription: Labrinth.Billing.Internal.UserSubscription,
|
||||
charge?: Labrinth.Billing.Internal.Charge | null,
|
||||
) {
|
||||
const displayInterval = charge?.subscription_interval ?? subscription.interval
|
||||
const displayPriceId = charge?.price_id ?? subscription.price_id
|
||||
const product = getProductFromPriceId(displayPriceId)
|
||||
const fallbackPrice = getRecurringPrice(product, displayInterval, charge?.currency_code)
|
||||
|
||||
resubscribeModal.value?.show({
|
||||
subscriptionId: subscription.id,
|
||||
wasSuspended: !!charge?.due && dayjs(charge.due).isBefore(dayjs()),
|
||||
serverName:
|
||||
serverList.value.find((server) => server.server_id === serverId)?.name ?? 'this server',
|
||||
planName: getPlanName(product),
|
||||
ramGb: getRamGb(product),
|
||||
storageGb: getStorageGb(product),
|
||||
sharedCpus: getSharedCpus(product),
|
||||
priceCents: charge?.amount ?? fallbackPrice?.amount,
|
||||
currencyCode: charge?.currency_code ?? fallbackPrice?.currencyCode,
|
||||
interval: displayInterval,
|
||||
nextChargeDate: charge?.due,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleResubscribeConfirm({ subscriptionId, wasSuspended }: ResubscribeRequest) {
|
||||
try {
|
||||
await client.labrinth.billing_internal.editSubscription(subscriptionId, {
|
||||
cancelled: false,
|
||||
})
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['billing'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['servers'] }),
|
||||
])
|
||||
if (wasSuspended) {
|
||||
addNotification({
|
||||
title: 'Resubscription request submitted',
|
||||
text: 'If the server is currently cancelled, it may take up to 10 minutes for another charge attempt to be made.',
|
||||
type: 'success',
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
title: 'Success',
|
||||
text: 'Server subscription resubscribed successfully',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
addNotification({
|
||||
title: 'Error resubscribing',
|
||||
text: 'An error occurred while resubscribing to your Modrinth server.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const serverBillingMap = computed(() => {
|
||||
const map = new Map<string, ServerBillingInfo>()
|
||||
if (!subscriptions.value || !charges.value) return map
|
||||
|
||||
const pyroSubs = subscriptions.value.filter((s) => s?.metadata?.type === 'pyro')
|
||||
for (const sub of pyroSubs) {
|
||||
const serverId = (sub.metadata as { id?: string })?.id
|
||||
if (!serverId) continue
|
||||
|
||||
const charge = charges.value.find(
|
||||
(c) => c.subscription_id === sub.id && c.status !== 'succeeded',
|
||||
)
|
||||
|
||||
const info: ServerBillingInfo = {
|
||||
isProvisioning:
|
||||
sub.status === 'unprovisioned' &&
|
||||
(charge?.status === 'processing' || charge?.status === 'open'),
|
||||
}
|
||||
|
||||
info.onDownloadBackup = getLatestBackupDownload(serverId)
|
||||
|
||||
if (charge?.status === 'cancelled') {
|
||||
info.cancellationDate = charge.due
|
||||
|
||||
info.onResubscribe = () => openResubscribeModal(serverId, sub, charge)
|
||||
}
|
||||
|
||||
map.set(serverId, info)
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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: Ref<string | null>
|
||||
user: Ref<Labrinth.Users.v2.User | null>
|
||||
requestSignIn: (redirectPath: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
export const [injectAuth, provideAuth] = createContext<AuthProvider>('root', 'auth')
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './api-client'
|
||||
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 = {}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ServerListing from '../../components/servers/ServerListing.vue'
|
||||
|
||||
const baseServer = {
|
||||
server_id: '8759b459-6f9e-49e0-aa70-dfab5b2abb2f',
|
||||
name: 'Survival SMP',
|
||||
status: 'available',
|
||||
game: 'Minecraft',
|
||||
mc_version: '1.21.5',
|
||||
loader: 'Fabric',
|
||||
loader_version: '0.16.14',
|
||||
net: {
|
||||
ip: '203.0.113.42',
|
||||
port: 25565,
|
||||
domain: 'play',
|
||||
},
|
||||
} as const
|
||||
|
||||
const pendingChange = {
|
||||
planSize: 'Performance 8 GB',
|
||||
cpu: 4,
|
||||
cpuBurst: 6,
|
||||
ramGb: 8,
|
||||
storageGb: 80,
|
||||
date: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
verb: 'Upgrade',
|
||||
} as const
|
||||
|
||||
const meta = {
|
||||
title: 'Servers/ServerListing',
|
||||
component: ServerListing,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div style="max-width: 920px;"><story /></div>',
|
||||
}),
|
||||
],
|
||||
} satisfies Meta<typeof ServerListing>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
...baseServer,
|
||||
},
|
||||
}
|
||||
|
||||
export const ConfiguringNewServer: Story = {
|
||||
args: {
|
||||
...baseServer,
|
||||
name: 'Fresh Vanilla World',
|
||||
status: 'installing',
|
||||
flows: { intro: true },
|
||||
loader: 'Vanilla',
|
||||
loader_version: null,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithPendingDowngrade: Story = {
|
||||
args: {
|
||||
...baseServer,
|
||||
name: 'Competitive UHC',
|
||||
pendingChange: {
|
||||
...pendingChange,
|
||||
planSize: 'Small',
|
||||
ramGb: 4,
|
||||
storageGb: 40,
|
||||
cpuBurst: 4,
|
||||
verb: 'Downgrade',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const SuspendedUpgrading: Story = {
|
||||
args: {
|
||||
...baseServer,
|
||||
name: 'Creative Build Team',
|
||||
status: 'suspended',
|
||||
suspension_reason: 'upgrading',
|
||||
},
|
||||
}
|
||||
|
||||
export const Provisioning: Story = {
|
||||
args: {
|
||||
...baseServer,
|
||||
name: 'My New Server',
|
||||
status: 'installing',
|
||||
},
|
||||
}
|
||||
|
||||
export const SetToCancel: Story = {
|
||||
args: {
|
||||
...baseServer,
|
||||
name: 'Survival SMP',
|
||||
cancellationDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
onResubscribe: () => alert('Resubscribe clicked'),
|
||||
onDownloadBackup: () => alert('Download backup clicked'),
|
||||
},
|
||||
}
|
||||
|
||||
export const SuspendedCancelled: Story = {
|
||||
args: {
|
||||
...baseServer,
|
||||
name: 'Old Event Server',
|
||||
status: 'suspended',
|
||||
suspension_reason: 'cancelled',
|
||||
cancellationDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
onResubscribe: () => alert('Resubscribe clicked'),
|
||||
onDownloadBackup: () => alert('Download backup clicked'),
|
||||
},
|
||||
}
|
||||
|
||||
export const SuspendedCancelledFilesExpired: Story = {
|
||||
args: {
|
||||
...baseServer,
|
||||
onDownloadBackup: null,
|
||||
name: 'Old Event Server',
|
||||
status: 'suspended',
|
||||
suspension_reason: 'cancelled',
|
||||
cancellationDate: new Date(2025, 1, 17).toISOString(),
|
||||
onResubscribe: () => alert('Resubscribe clicked'),
|
||||
},
|
||||
}
|
||||
|
||||
export const SuspendedPaymentFailed: Story = {
|
||||
args: {
|
||||
...baseServer,
|
||||
name: 'Minigames Network',
|
||||
status: 'suspended',
|
||||
suspension_reason: 'paymentfailed',
|
||||
cancellationDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
onDownloadBackup: () => alert('Download backup clicked'),
|
||||
},
|
||||
}
|
||||
|
||||
export const SuspendedModerated: Story = {
|
||||
args: {
|
||||
...baseServer,
|
||||
name: 'Banned Server',
|
||||
status: 'suspended',
|
||||
suspension_reason: 'moderated',
|
||||
},
|
||||
}
|
||||
|
||||
export const SuspendedGeneric: Story = {
|
||||
args: {
|
||||
...baseServer,
|
||||
name: 'Server with other suspension reason',
|
||||
status: 'suspended',
|
||||
},
|
||||
}
|
||||