You've already forked AstralRinth
forked from didirus/AstralRinth
Medal promo v2 (#4220)
* Revert "Revert "feat: medal promotion on servers page (#4117)"" This reverts commit2e6cff7efc. * Revert "Revert "update changelog"" This reverts commitb2ff2d8737. * Revert "Revert "turn off medal promo"" This reverts commiteaa4b44a16. * Revert "Revert "Revert "turn off medal promo""" This reverts commit76d0ef03e7. * Revert "Revert "fix medal thing showing up for everyone"" This reverts commitee8c47adcb. * New medal colors * Update medal server listings * Upgrade modal enhancements & more medal consistency * undo app promo changes * Only apply medal promo with flag on * remove unneessary files * lint * disable medal flag
This commit is contained in:
@@ -2,9 +2,8 @@
|
||||
::backdrop,
|
||||
:root[data-theme='light'],
|
||||
[data-theme='light'] ::backdrop {
|
||||
--sl-font-system:
|
||||
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell,
|
||||
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
--sl-font-system: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
|
||||
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
|
||||
--sl-color-white: var(--color-contrast); /* “white” */
|
||||
--sl-color-gray-1: var(--color-base);
|
||||
|
||||
11
apps/frontend/src/assets/images/illustrations/medal_icon.svg
Normal file
11
apps/frontend/src/assets/images/illustrations/medal_icon.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg viewBox="7 18 57 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M22.562 46.8959L31.189 42.2804C31.8922 41.9026 31.8873 40.9333 31.1763 40.558L22.426 35.9147C21.8463 35.6082 21.1377 36.0016 21.1373 36.6321L21.1319 46.0958C21.1315 46.7967 21.9244 47.2404 22.562 46.8985L22.562 46.8959Z"
|
||||
fill="currentColor" />
|
||||
<path
|
||||
d="M48.7804 47.0911L40.1588 42.3472C39.4561 41.9589 39.4621 40.9896 40.1735 40.625L48.9288 36.112C49.5092 35.8141 50.2172 36.218 50.2168 36.8485L50.2114 46.3122C50.211 47.0131 49.4178 47.445 48.7804 47.0937L48.7804 47.0911Z"
|
||||
fill="currentColor" />
|
||||
<path
|
||||
d="M62.1735 23.588L54.919 19.3778C54.3937 19.0742 53.7374 19.0615 53.2066 19.3468L36.5152 28.3219C35.9844 28.6072 35.3333 28.6024 34.8028 28.3092L18.1193 19.0882C17.5888 18.7951 16.9323 18.7954 16.4069 19.0937L9.14477 23.1933C8.62214 23.4891 8.30179 24.0235 8.30145 24.6046L8.29042 43.8064C8.2901 44.3589 8.58797 44.877 9.07744 45.1777L15.2929 48.9763C15.4214 49.0554 15.6784 49.2085 15.971 49.3853C16.5917 49.7573 17.3935 49.3359 17.3884 48.6401C17.3886 48.3431 17.3887 48.0721 17.3888 47.9053L17.4315 30.8117C17.432 29.9049 18.4636 29.3471 19.2894 29.8041L34.7506 38.3879C35.3084 38.697 35.9922 38.7021 36.5477 38.4013L52.0186 30.0477C52.845 29.603 53.8731 30.1762 53.8753 31.083L53.8983 48.177C53.8982 48.3282 53.9008 48.6044 53.9033 48.9171C53.9084 49.6077 54.707 50.0358 55.3225 49.6729C55.599 49.5108 55.8509 49.3642 55.9931 49.2792L62.2128 45.5732C62.7025 45.2798 63.0011 44.7687 63.0014 44.2137L63.0125 25.0118C63.0128 24.4307 62.693 23.8889 62.1707 23.588L62.1735 23.588Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -131,6 +131,15 @@ html {
|
||||
|
||||
--landing-raw-bg: #fff;
|
||||
|
||||
--medal-promotion-bg: #fff;
|
||||
--medal-promotion-bg-orange: #48aaff;
|
||||
--medal-promotion-text-orange: #156db8;
|
||||
--medal-promotion-bg-gradient: linear-gradient(
|
||||
90deg,
|
||||
rgba(152, 207, 255, 0.2) 20%,
|
||||
rgba(152, 207, 255, 0.1) 100%
|
||||
);
|
||||
|
||||
--banner-error-bg: #fee2e2;
|
||||
--banner-error-text: #991b1b;
|
||||
--banner-error-border: #ef4444;
|
||||
@@ -237,6 +246,15 @@ html {
|
||||
|
||||
--landing-raw-bg: #000;
|
||||
|
||||
--medal-promotion-bg: #000;
|
||||
--medal-promotion-bg-orange: rgba(208, 246, 255, 0.25);
|
||||
--medal-promotion-text-orange: #42abff;
|
||||
--medal-promotion-bg-gradient: linear-gradient(
|
||||
90deg,
|
||||
rgba(66, 170, 255, 0.15),
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
|
||||
--hover-filter: brightness(120%);
|
||||
--active-filter: brightness(140%);
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
|
||||
<nuxt-link
|
||||
to="/servers"
|
||||
:to="flags.enableMedalPromotion ? '/servers?plan&ref=medal' : '/servers'"
|
||||
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
|
||||
>
|
||||
<img
|
||||
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp"
|
||||
:src="`https://cdn-raw.modrinth.com/${flags.enableMedalPromotion ? 'medal-modrinth-servers' : 'modrinth-servers-placeholder'}-light.webp`"
|
||||
alt="Host your next server with Modrinth Servers"
|
||||
class="light-image hidden rounded-[inherit]"
|
||||
/>
|
||||
<img
|
||||
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp"
|
||||
:src="`https://cdn-raw.modrinth.com/${flags.enableMedalPromotion ? 'medal-modrinth-servers' : 'modrinth-servers-placeholder'}-dark.webp`"
|
||||
alt="Host your next server with Modrinth Servers"
|
||||
class="dark-image rounded-[inherit]"
|
||||
/>
|
||||
@@ -23,6 +23,8 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
// {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="experimental-styles-within flex size-24 shrink-0 overflow-hidden rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
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"
|
||||
>
|
||||
<client-only>
|
||||
<img
|
||||
|
||||
@@ -1,74 +1,69 @@
|
||||
<template>
|
||||
<NuxtLink
|
||||
class="contents"
|
||||
:to="status === 'suspended' ? '' : `/servers/manage/${props.server_id}`"
|
||||
>
|
||||
<div
|
||||
v-tooltip="
|
||||
status === 'suspended'
|
||||
? suspension_reason === 'upgrading'
|
||||
? 'This server is being transferred to a new node. It will be unavailable until this process finishes.'
|
||||
: 'This server has been suspended. Please visit your billing settings or contact Modrinth Support for more information.'
|
||||
: ''
|
||||
"
|
||||
class="flex cursor-pointer flex-row items-center overflow-x-hidden rounded-3xl bg-bg-raised p-4 transition-transform duration-100"
|
||||
:class="status === 'suspended' ? '!rounded-b-none opacity-75' : 'active:scale-95'"
|
||||
data-pyro-server-listing
|
||||
:data-pyro-server-listing-id="server_id"
|
||||
>
|
||||
<ServerIcon v-if="status !== 'suspended'" :image="image" />
|
||||
<div>
|
||||
<NuxtLink :to="status === 'suspended' ? '' : `/servers/manage/${props.server_id}`">
|
||||
<div
|
||||
v-else
|
||||
class="bg-bg-secondary flex size-24 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
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="{
|
||||
'!rounded-b-none border-b-0': status === 'suspended' || !!pendingChange,
|
||||
'opacity-75': status === 'suspended',
|
||||
'active:scale-95': status !== 'suspended' && !pendingChange,
|
||||
}"
|
||||
data-pyro-server-listing
|
||||
:data-pyro-server-listing-id="server_id"
|
||||
>
|
||||
<LockIcon class="size-20 text-secondary" />
|
||||
</div>
|
||||
<div class="ml-8 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 />
|
||||
</div>
|
||||
|
||||
<ServerIcon v-if="status !== 'suspended'" :image="image" />
|
||||
<div
|
||||
v-if="projectData?.title"
|
||||
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
|
||||
>
|
||||
<Avatar
|
||||
:src="iconUrl"
|
||||
no-shadow
|
||||
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
|
||||
alt="Server Icon"
|
||||
/>
|
||||
Using {{ projectData?.title || 'Unknown' }}
|
||||
</div>
|
||||
<div v-else class="min-h-[20px]"></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 }"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:linked="false"
|
||||
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
/>
|
||||
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"
|
||||
>
|
||||
<LockIcon class="size-12 text-secondary" />
|
||||
</div>
|
||||
<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 />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="projectData?.title"
|
||||
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
|
||||
>
|
||||
<Avatar
|
||||
:src="iconUrl"
|
||||
no-shadow
|
||||
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
|
||||
alt="Server Icon"
|
||||
/>
|
||||
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 }"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:linked="false"
|
||||
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div
|
||||
v-if="status === 'suspended' && suspension_reason === 'upgrading'"
|
||||
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-blue p-4 text-sm font-bold text-contrast"
|
||||
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"
|
||||
>
|
||||
<PanelSpinner />
|
||||
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 -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
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">
|
||||
<PanelErrorIcon class="!size-5" /> Your server has been cancelled. Please update your
|
||||
@@ -78,7 +73,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended' && suspension_reason"
|
||||
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
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">
|
||||
<PanelErrorIcon class="!size-5" /> Your server has been suspended: {{ suspension_reason }}.
|
||||
@@ -88,7 +83,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended'"
|
||||
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
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">
|
||||
<PanelErrorIcon class="!size-5" /> Your server has been suspended. Please update your
|
||||
@@ -96,13 +91,31 @@
|
||||
</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<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>
|
||||
Your server will {{ pendingChange.verb.toLowerCase() }} to the "{{
|
||||
pendingChange.planSize
|
||||
}}" plan on {{ formatDate(pendingChange.date) }}.
|
||||
</div>
|
||||
<ServersSpecs
|
||||
class="!font-normal !text-contrast"
|
||||
:ram="Math.round((pendingChange.ramGb ?? 0) * 1024)"
|
||||
:storage="Math.round((pendingChange.storageGb ?? 0) * 1024)"
|
||||
:cpus="pendingChange.cpuBurst"
|
||||
bursting-link="https://docs.modrinth.com/servers/bursting"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon, LockIcon, SparklesIcon } from '@modrinth/assets'
|
||||
import { Avatar, CopyCode } from '@modrinth/ui'
|
||||
import { Avatar, CopyCode, ServersSpecs } from '@modrinth/ui'
|
||||
import type { Project, Server } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
@@ -111,7 +124,19 @@ import PanelSpinner from './PanelSpinner.vue'
|
||||
import ServerIcon from './ServerIcon.vue'
|
||||
import ServerInfoLabels from './ServerInfoLabels.vue'
|
||||
|
||||
const props = defineProps<Partial<Server>>()
|
||||
type PendingChange = {
|
||||
planSize: string
|
||||
cpu: number
|
||||
cpuBurst: number
|
||||
ramGb: number
|
||||
swapGb?: number
|
||||
storageGb?: number
|
||||
date: string | number | Date
|
||||
intervalChange?: string | null
|
||||
verb: string
|
||||
}
|
||||
|
||||
const props = defineProps<Partial<Server> & { pendingChange?: PendingChange }>()
|
||||
|
||||
if (props.server_id && props.status === 'available') {
|
||||
// Necessary only to get server icon
|
||||
@@ -138,4 +163,12 @@ if (props.upstream) {
|
||||
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined)
|
||||
const iconUrl = computed(() => projectData.value?.icon_url || undefined)
|
||||
const isConfiguring = computed(() => props.flows?.intro)
|
||||
|
||||
const formatDate = (d: unknown) => {
|
||||
try {
|
||||
return dayjs(d as any).format('MMMM D, YYYY')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<ModrinthServersPurchaseModal
|
||||
v-if="customer"
|
||||
ref="purchaseModal"
|
||||
:publishable-key="config.public.stripePublishableKey"
|
||||
:initiate-payment="async (body) => await initiatePayment(body)"
|
||||
:available-products="pyroProducts"
|
||||
:on-error="handleError"
|
||||
:customer="customer"
|
||||
:payment-methods="paymentMethods"
|
||||
:currency="selectedCurrency"
|
||||
:return-url="`${config.public.siteUrl}/servers/manage`"
|
||||
:pings="regionPings"
|
||||
:regions="regions"
|
||||
:refresh-payment-methods="fetchPaymentData"
|
||||
:fetch-stock="fetchStock"
|
||||
:plan-stage="true"
|
||||
:existing-plan="currentPlanFromSubscription"
|
||||
:existing-subscription="subscription || undefined"
|
||||
:on-finalize-no-payment-change="finalizeDowngrade"
|
||||
@hide="
|
||||
() => {
|
||||
subscription = null
|
||||
}
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { injectNotificationManager, ModrinthServersPurchaseModal } from '@modrinth/ui'
|
||||
import type { ServerPlan } from '@modrinth/ui/src/utils/billing'
|
||||
import type { UserSubscription } from '@modrinth/utils'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
import { products } from '~/generated/state.json'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const purchaseModal = ref<InstanceType<typeof ModrinthServersPurchaseModal> | null>(null)
|
||||
const customer = ref<any>(null)
|
||||
const paymentMethods = ref<any[]>([])
|
||||
const selectedCurrency = ref<string>('USD')
|
||||
const regions = ref<any[]>([])
|
||||
const regionPings = ref<any[]>([])
|
||||
|
||||
const pyroProducts = (products as any[])
|
||||
.filter((p) => p?.metadata?.type === 'pyro')
|
||||
.sort((a, b) => (a?.metadata?.ram ?? 0) - (b?.metadata?.ram ?? 0))
|
||||
|
||||
function handleError(err: any) {
|
||||
console.error('Purchase modal error:', err)
|
||||
}
|
||||
|
||||
async function fetchPaymentData() {
|
||||
try {
|
||||
const [customerData, paymentMethodsData] = await Promise.all([
|
||||
useBaseFetch('billing/customer', { internal: true }),
|
||||
useBaseFetch('billing/payment_methods', { internal: true }),
|
||||
])
|
||||
customer.value = customerData as any
|
||||
paymentMethods.value = paymentMethodsData as any[]
|
||||
} catch (error) {
|
||||
console.error('Error fetching payment data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function fetchStock(region: any, request: any) {
|
||||
return useServersFetch(`stock?region=${region.shortcode}`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
...request,
|
||||
},
|
||||
bypassAuth: true,
|
||||
}).then((res: any) => res.available as number)
|
||||
}
|
||||
|
||||
function pingRegions() {
|
||||
useServersFetch('regions', {
|
||||
method: 'GET',
|
||||
version: 1,
|
||||
bypassAuth: true,
|
||||
}).then((res: any) => {
|
||||
regions.value = res as any[]
|
||||
;(regions.value as any[]).forEach((region: any) => {
|
||||
runPingTest(region)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const PING_COUNT = 20
|
||||
const PING_INTERVAL = 200
|
||||
const MAX_PING_TIME = 1000
|
||||
|
||||
function runPingTest(region: any, index = 1) {
|
||||
if (index > 10) {
|
||||
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[] = []
|
||||
|
||||
socket.onopen = () => {
|
||||
for (let i = 0; i < PING_COUNT; i++) {
|
||||
setTimeout(() => {
|
||||
socket.send(String(performance.now()))
|
||||
}, i * PING_INTERVAL)
|
||||
}
|
||||
setTimeout(
|
||||
() => {
|
||||
socket.close()
|
||||
const median = Math.round([...pings].sort((a, b) => a - b)[Math.floor(pings.length / 2)])
|
||||
if (median) {
|
||||
regionPings.value.push({
|
||||
region: region.shortcode,
|
||||
ping: median,
|
||||
})
|
||||
}
|
||||
},
|
||||
PING_COUNT * PING_INTERVAL + MAX_PING_TIME,
|
||||
)
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const start = Number(event.data)
|
||||
pings.push(performance.now() - start)
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
runPingTest(region, index + 1)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const subscription = ref<UserSubscription | null>(null)
|
||||
// Dry run state
|
||||
const dryRunResponse = ref<{
|
||||
requires_payment: boolean
|
||||
required_payment_is_proration: boolean
|
||||
} | null>(null)
|
||||
const pendingDowngradeBody = ref<any | null>(null)
|
||||
const currentPlanFromSubscription = computed<ServerPlan | undefined>(() => {
|
||||
return subscription.value
|
||||
? (pyroProducts.find(
|
||||
(p) =>
|
||||
p.prices.filter((price: { id: string }) => price.id === subscription.value?.price_id)
|
||||
.length > 0,
|
||||
) ?? undefined)
|
||||
: undefined
|
||||
})
|
||||
|
||||
async function initiatePayment(body: any): Promise<any> {
|
||||
if (subscription.value) {
|
||||
const transformedBody = {
|
||||
interval: body.charge?.interval,
|
||||
payment_method: body.id,
|
||||
product: body.charge?.product_id,
|
||||
region: body.metadata?.server_region,
|
||||
}
|
||||
|
||||
try {
|
||||
const dry = await useBaseFetch(`billing/subscription/${subscription.value.id}?dry=true`, {
|
||||
internal: true,
|
||||
method: 'PATCH',
|
||||
body: transformedBody,
|
||||
})
|
||||
|
||||
if (dry && typeof dry === 'object' && 'requires_payment' in dry) {
|
||||
dryRunResponse.value = dry as any
|
||||
pendingDowngradeBody.value = transformedBody
|
||||
if (dry.requires_payment) {
|
||||
return await finalizeImmediate(transformedBody)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
// Fallback if dry run not supported
|
||||
return await finalizeImmediate(transformedBody)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Dry run failed, attempting immediate patch', e)
|
||||
return await finalizeImmediate(transformedBody)
|
||||
}
|
||||
} else {
|
||||
addNotification({
|
||||
title: 'Unable to determine subscription ID.',
|
||||
text: 'Please contact support.',
|
||||
type: 'error',
|
||||
})
|
||||
return Promise.reject(new Error('Unable to determine subscription ID.'))
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizeImmediate(body: any) {
|
||||
const result = await useBaseFetch(`billing/subscription/${subscription.value?.id}`, {
|
||||
internal: true,
|
||||
method: 'PATCH',
|
||||
body,
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function finalizeDowngrade() {
|
||||
if (!subscription.value || !pendingDowngradeBody.value) return
|
||||
try {
|
||||
await finalizeImmediate(pendingDowngradeBody.value)
|
||||
addNotification({
|
||||
title: 'Subscription updated',
|
||||
text: 'Your plan has been downgraded and will take effect next billing cycle.',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: 'Failed to apply subscription changes',
|
||||
text: 'Please try again or contact support.',
|
||||
type: 'error',
|
||||
})
|
||||
throw e
|
||||
} finally {
|
||||
dryRunResponse.value = null
|
||||
pendingDowngradeBody.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function open(id?: string) {
|
||||
if (id) {
|
||||
const subscriptions = (await useBaseFetch(`billing/subscriptions`, {
|
||||
internal: true,
|
||||
})) as any[]
|
||||
for (const sub of subscriptions) {
|
||||
if (sub?.metadata?.id === id) {
|
||||
subscription.value = sub
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
subscription.value = null
|
||||
}
|
||||
|
||||
purchaseModal.value?.show('quarterly')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchPaymentData()
|
||||
pingRegions()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="overlay"></div>
|
||||
<img
|
||||
src="https://cdn-raw.modrinth.com/medal-banner-background.webp"
|
||||
class="background-pattern dark-pattern shadow-xl"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
src="https://cdn-raw.modrinth.com/medal-banner-background-light.webp"
|
||||
class="background-pattern light-pattern shadow-xl"
|
||||
alt=""
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
<style scoped lang="scss">
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--medal-promotion-bg-gradient);
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.light-mode,
|
||||
.light {
|
||||
.background-pattern.dark-pattern {
|
||||
display: none;
|
||||
}
|
||||
.background-pattern.light-pattern {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.background-pattern.dark-pattern {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.background-pattern.light-pattern {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.background-pattern {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
object-fit: cover;
|
||||
object-position: bottom;
|
||||
background-color: var(--medal-promotion-bg);
|
||||
border-radius: inherit;
|
||||
color: var(--medal-promotion-bg-orange);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div
|
||||
id="medal"
|
||||
class="medal-promotion flex w-full flex-row justify-between rounded-xl px-8 py-6 shadow-xl"
|
||||
>
|
||||
<MedalBackgroundImage />
|
||||
<div class="z-10 flex items-center gap-6 text-2xl font-semibold text-contrast">
|
||||
<MedalIcon class="h-10 w-auto text-contrast" />
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span>
|
||||
Try a free
|
||||
<span class="text-medal-orange">3GB server</span> for 5 days powered by
|
||||
<span class="text-medal-orange">Medal</span>
|
||||
</span>
|
||||
<span class="text-sm font-medium text-secondary">
|
||||
Limited-time offer. No credit card required. Available for US servers.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled color="medal-promo" type="outlined" size="large">
|
||||
<nuxt-link to="https://medal.tv/modrinth" class="z-10 my-auto"
|
||||
>Learn more <ExternalIcon
|
||||
/></nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ExternalIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
|
||||
import MedalIcon from '~/assets/images/illustrations/medal_icon.svg?component'
|
||||
import MedalBackgroundImage from '~/components/ui/servers/marketing/MedalBackgroundImage.vue'
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.medal-promotion {
|
||||
position: relative;
|
||||
border: 1px solid var(--medal-promotion-bg-orange);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clock-glow {
|
||||
filter: drop-shadow(0 0 72px var(--medal-promotion-bg-orange))
|
||||
drop-shadow(0 0 36px var(--medal-promotion-bg-orange))
|
||||
drop-shadow(0 0 18px var(--medal-promotion-bg-orange));
|
||||
}
|
||||
|
||||
.text-medal-orange {
|
||||
color: var(--medal-promotion-text-orange);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div
|
||||
class="medal-promotion relative flex w-full flex-row items-center justify-between rounded-2xl p-4 shadow-xl"
|
||||
>
|
||||
<MedalBackgroundImage />
|
||||
|
||||
<div class="z-10 mr-2 flex flex-col gap-1">
|
||||
<Transition
|
||||
enter-from-class="opacity-0 translate-y-1"
|
||||
enter-active-class="transition-all duration-300"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-150"
|
||||
leave-to-class="opacity-0 -translate-y-1"
|
||||
>
|
||||
<div
|
||||
v-if="expiryDate"
|
||||
class="flex items-center gap-2 whitespace-nowrap font-semibold text-contrast"
|
||||
>
|
||||
<ClockIcon class="clock-glow text-medal-orange size-5 shrink-0" />
|
||||
<span class="w-full text-wrap text-lg">
|
||||
Your <span class="text-medal-orange">Medal</span>-powered Modrinth Server will expire in
|
||||
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.days }}</span> days
|
||||
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.hours }}</span> hours
|
||||
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.minutes }}</span> minutes
|
||||
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.seconds }}</span>
|
||||
seconds.
|
||||
</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<ButtonStyled color="medal-promo" type="outlined" size="large">
|
||||
<button class="z-10 my-auto" @click="openUpgradeModal"><RocketIcon /> Upgrade</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<ServersUpgradeModalWrapper ref="upgradeModal" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ClockIcon, RocketIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
import type { UserSubscription } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import dayjsDuration from 'dayjs/plugin/duration'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
|
||||
import MedalBackgroundImage from '~/components/ui/servers/marketing/MedalBackgroundImage.vue'
|
||||
|
||||
import ServersUpgradeModalWrapper from '../ServersUpgradeModalWrapper.vue'
|
||||
|
||||
dayjs.extend(dayjsDuration)
|
||||
|
||||
type UpgradeWrapperRef = ComponentPublicInstance<{ open: (id?: string) => void | Promise<void> }>
|
||||
const upgradeModal = ref<UpgradeWrapperRef | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
serverId?: string
|
||||
}>()
|
||||
|
||||
const { data: subscriptions } = await useLazyAsyncData(
|
||||
'countdown-subscriptions',
|
||||
() =>
|
||||
useBaseFetch(`billing/subscriptions`, {
|
||||
internal: true,
|
||||
}) as Promise<UserSubscription[]>,
|
||||
)
|
||||
|
||||
const expiryDate = computed(() => {
|
||||
for (const subscription of subscriptions.value || []) {
|
||||
if (subscription.metadata?.id === props.serverId) {
|
||||
return dayjs(subscription.created).add(5, 'days')
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
function openUpgradeModal() {
|
||||
upgradeModal.value?.open(props.serverId)
|
||||
}
|
||||
|
||||
const timeLeftCountdown = ref({ days: 0, hours: 0, minutes: 0, seconds: 0 })
|
||||
|
||||
function updateCountdown() {
|
||||
if (!expiryDate.value) {
|
||||
timeLeftCountdown.value = { days: 0, hours: 0, minutes: 0, seconds: 0 }
|
||||
return
|
||||
}
|
||||
|
||||
const now = dayjs()
|
||||
const diff = expiryDate.value.diff(now)
|
||||
|
||||
if (diff <= 0) {
|
||||
timeLeftCountdown.value = { days: 0, hours: 0, minutes: 0, seconds: 0 }
|
||||
return
|
||||
}
|
||||
|
||||
const duration = dayjs.duration(diff)
|
||||
timeLeftCountdown.value = {
|
||||
days: duration.days(),
|
||||
hours: duration.hours(),
|
||||
minutes: duration.minutes(),
|
||||
seconds: duration.seconds(),
|
||||
}
|
||||
}
|
||||
|
||||
updateCountdown()
|
||||
|
||||
const intervalId = ref<NodeJS.Timeout | null>(null)
|
||||
onMounted(() => {
|
||||
intervalId.value = setInterval(updateCountdown, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId.value) clearInterval(intervalId.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.medal-promotion {
|
||||
position: relative;
|
||||
border: 1px solid var(--medal-promotion-bg-orange);
|
||||
background: inherit; // allows overlay + pattern to take over
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--medal-promotion-bg-gradient);
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.background-pattern {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
background-color: var(--medal-promotion-bg);
|
||||
border-radius: inherit;
|
||||
color: var(--medal-promotion-text-orange);
|
||||
}
|
||||
|
||||
.clock-glow {
|
||||
filter: drop-shadow(0 0 72px var(--color-orange)) drop-shadow(0 0 36px var(--color-orange))
|
||||
drop-shadow(0 0 18px var(--color-orange));
|
||||
}
|
||||
|
||||
.text-medal-orange {
|
||||
color: var(--medal-promotion-text-orange);
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="rounded-2xl shadow-xl">
|
||||
<div
|
||||
class="medal-promotion flex flex-row items-center overflow-x-hidden rounded-t-2xl p-4 transition-transform duration-100"
|
||||
:class="status === 'suspended' ? 'rounded-b-none border-b-0 opacity-75' : 'rounded-b-2xl'"
|
||||
data-pyro-server-listing
|
||||
:data-pyro-server-listing-id="server_id"
|
||||
>
|
||||
<MedalBackgroundImage />
|
||||
<AutoLink
|
||||
:to="status === 'suspended' ? '' : `/servers/manage/${props.server_id}`"
|
||||
class="z-10 flex flex-grow flex-row items-center overflow-x-hidden"
|
||||
:class="status !== 'suspended' && 'active:scale-95'"
|
||||
>
|
||||
<Avatar
|
||||
v-if="status !== 'suspended'"
|
||||
src="https://cdn-raw.modrinth.com/medal_icon.webp"
|
||||
size="64px"
|
||||
class="z-10"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="bg-bg-secondary z-10 flex size-16 shrink-0 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
>
|
||||
<LockIcon class="size-12 text-secondary" />
|
||||
</div>
|
||||
|
||||
<div class="z-10 ml-4 flex min-w-0 flex-col gap-2.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 truncate text-xl font-bold text-contrast">{{ name }}</h2>
|
||||
<ChevronRightIcon />
|
||||
|
||||
<span class="truncate">
|
||||
<span class="text-medal-orange">
|
||||
{{ timeLeftCountdown.days }}
|
||||
</span>
|
||||
days
|
||||
<span class="text-medal-orange">
|
||||
{{ timeLeftCountdown.hours }}
|
||||
</span>
|
||||
hours
|
||||
<span class="text-medal-orange">
|
||||
{{ timeLeftCountdown.minutes }}
|
||||
</span>
|
||||
minutes
|
||||
<span class="text-medal-orange">
|
||||
{{ timeLeftCountdown.seconds }}
|
||||
</span>
|
||||
seconds remaining...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="projectData?.title"
|
||||
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
|
||||
>
|
||||
<Avatar
|
||||
:src="iconUrl"
|
||||
no-shadow
|
||||
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
|
||||
alt="Server Icon"
|
||||
/>
|
||||
Using {{ projectData?.title || 'Unknown' }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isConfiguring"
|
||||
class="text-medal-orange flex min-w-0 items-center gap-2 truncate text-sm font-semibold"
|
||||
>
|
||||
<SparklesIcon class="size-5 shrink-0" /> New server
|
||||
</div>
|
||||
<ServerInfoLabels
|
||||
v-else
|
||||
:server-data="{ game, mc_version, loader, loader_version, net }"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:linked="false"
|
||||
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
/>
|
||||
</div>
|
||||
</AutoLink>
|
||||
|
||||
<div class="z-10 ml-auto">
|
||||
<ButtonStyled color="medal-promo" type="outlined" size="large">
|
||||
<button class="my-auto" @click="handleUpgrade"><RocketIcon /> Upgrade</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<PanelSpinner />
|
||||
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">
|
||||
<PanelErrorIcon class="!size-5" /> Your server has been cancelled. Please update your
|
||||
billing information or contact Modrinth Support for more information.
|
||||
</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">
|
||||
<PanelErrorIcon class="!size-5" /> Your server has been suspended: {{ suspension_reason }}.
|
||||
Please update your billing information or contact Modrinth Support for more information.
|
||||
</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">
|
||||
<PanelErrorIcon class="!size-5" /> Your server has been suspended. Please update your
|
||||
billing information or contact Modrinth Support for more information.
|
||||
</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon, LockIcon, RocketIcon, SparklesIcon } from '@modrinth/assets'
|
||||
import { AutoLink, Avatar, ButtonStyled, CopyCode } from '@modrinth/ui'
|
||||
import type { Project, Server } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import dayjsDuration from 'dayjs/plugin/duration'
|
||||
|
||||
import MedalBackgroundImage from '~/components/ui/servers/marketing/MedalBackgroundImage.vue'
|
||||
|
||||
import PanelErrorIcon from '../icons/PanelErrorIcon.vue'
|
||||
import PanelSpinner from '../PanelSpinner.vue'
|
||||
import ServerInfoLabels from '../ServerInfoLabels.vue'
|
||||
|
||||
dayjs.extend(dayjsDuration)
|
||||
|
||||
const props = defineProps<Partial<Server>>()
|
||||
const emit = defineEmits<{ (e: 'upgrade'): void }>()
|
||||
|
||||
const showGameLabel = computed(() => !!props.game)
|
||||
const showLoaderLabel = computed(() => !!props.loader)
|
||||
|
||||
let projectData: Ref<Project | null>
|
||||
if (props.upstream) {
|
||||
const { data } = await useAsyncData<Project>(
|
||||
`server-project-${props.server_id}`,
|
||||
async (): Promise<Project> => {
|
||||
const result = await useBaseFetch(`project/${props.upstream?.project_id}`)
|
||||
return result as Project
|
||||
},
|
||||
)
|
||||
projectData = data
|
||||
} else {
|
||||
projectData = ref(null)
|
||||
}
|
||||
|
||||
const iconUrl = computed(() => projectData.value?.icon_url || undefined)
|
||||
const isConfiguring = computed(() => props.flows?.intro)
|
||||
|
||||
const timeLeftCountdown = ref({ days: 0, hours: 0, minutes: 0, seconds: 0 })
|
||||
const expiryDate = computed(() => (props.medal_expires ? dayjs(props.medal_expires) : null))
|
||||
|
||||
function handleUpgrade(event: Event) {
|
||||
event.stopPropagation()
|
||||
emit('upgrade')
|
||||
}
|
||||
|
||||
function updateCountdown() {
|
||||
if (!expiryDate.value) {
|
||||
timeLeftCountdown.value = { days: 0, hours: 0, minutes: 0, seconds: 0 }
|
||||
return
|
||||
}
|
||||
|
||||
const now = dayjs()
|
||||
const diff = expiryDate.value.diff(now)
|
||||
|
||||
if (diff <= 0) {
|
||||
timeLeftCountdown.value = { days: 0, hours: 0, minutes: 0, seconds: 0 }
|
||||
return
|
||||
}
|
||||
|
||||
const duration = dayjs.duration(diff)
|
||||
timeLeftCountdown.value = {
|
||||
days: duration.days(),
|
||||
hours: duration.hours(),
|
||||
minutes: duration.minutes(),
|
||||
seconds: duration.seconds(),
|
||||
}
|
||||
}
|
||||
|
||||
watch(expiryDate, () => updateCountdown(), { immediate: true })
|
||||
|
||||
const intervalId = ref<NodeJS.Timeout | null>(null)
|
||||
onMounted(() => {
|
||||
intervalId.value = setInterval(updateCountdown, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId.value) clearInterval(intervalId.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.medal-promotion {
|
||||
position: relative;
|
||||
border: 1px solid var(--medal-promotion-bg-orange);
|
||||
background: inherit; // allows overlay + pattern to take over
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.text-medal-orange {
|
||||
color: var(--medal-promotion-text-orange);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.border-medal-orange {
|
||||
border-color: var(--medal-promotion-bg-orange);
|
||||
}
|
||||
</style>
|
||||
@@ -25,6 +25,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
|
||||
// Feature toggles
|
||||
projectTypesPrimaryNav: false,
|
||||
enableMedalPromotion: false,
|
||||
hidePlusPromoInUserMenu: false,
|
||||
oldProjectCards: true,
|
||||
newProjectCards: false,
|
||||
|
||||
@@ -34,6 +34,8 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
||||
node!: { token: string; instance: string }
|
||||
flows?: { intro?: boolean }
|
||||
|
||||
is_medal?: boolean
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
const data = await useServersFetch<ServerGeneral>(`servers/${this.serverId}`, {}, 'general')
|
||||
|
||||
|
||||
@@ -20,7 +20,14 @@
|
||||
class="flex flex-col gap-4 text-primary"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<Avatar :src="server.general.image" size="48px" />
|
||||
<Avatar
|
||||
:src="
|
||||
server.general.is_medal
|
||||
? 'https://cdn-raw.modrinth.com/medal_icon.webp'
|
||||
: server.general.image
|
||||
"
|
||||
size="48px"
|
||||
/>
|
||||
<span class="flex flex-col gap-2">
|
||||
<span class="bold font-extrabold text-contrast">
|
||||
{{ server.general.name }}
|
||||
|
||||
@@ -392,7 +392,7 @@
|
||||
<div class="relative flex flex-col gap-4 rounded-2xl bg-bg p-6 text-left md:p-12">
|
||||
<h1 class="m-0 text-lg font-bold">Frequently Asked Questions</h1>
|
||||
<div class="details-hide flex flex-col gap-1">
|
||||
<details pyro-hash="cpus" class="group" :open="$route.hash === '#cpus'">
|
||||
<details nav-hash="cpus" class="group" :open="$route.hash === '#cpus'">
|
||||
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
@@ -404,7 +404,7 @@
|
||||
GHz, paired with DDR5 memory.
|
||||
</p>
|
||||
</details>
|
||||
<details pyro-hash="cpu-burst" class="group" :open="$route.hash === '#cpu-burst'">
|
||||
<details nav-hash="cpu-burst" class="group" :open="$route.hash === '#cpu-burst'">
|
||||
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
@@ -420,7 +420,7 @@
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details pyro-hash="ddos" class="group" :open="$route.hash === '#ddos'">
|
||||
<details nav-hash="ddos" class="group" :open="$route.hash === '#ddos'">
|
||||
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
@@ -433,7 +433,7 @@
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details pyro-hash="region" class="group" :open="$route.hash === '#region'">
|
||||
<details nav-hash="region" class="group" :open="$route.hash === '#region'">
|
||||
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
@@ -447,7 +447,7 @@
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details pyro-hash="storage" class="group" :open="$route.hash === '#storage'">
|
||||
<details nav-hash="storage" class="group" :open="$route.hash === '#storage'">
|
||||
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
@@ -460,7 +460,7 @@
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details pyro-hash="performance" class="group" :open="$route.hash === '#performance'">
|
||||
<details nav-hash="performance" class="group" :open="$route.hash === '#performance'">
|
||||
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
@@ -481,7 +481,7 @@
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details pyro-hash="prices" class="group" :open="$route.hash === '#prices'">
|
||||
<details nav-hash="prices" class="group" :open="$route.hash === '#prices'">
|
||||
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
@@ -493,7 +493,7 @@
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details pyro-hash="versions" class="group" :open="$route.hash === '#versions'">
|
||||
<details nav-hash="versions" class="group" :open="$route.hash === '#versions'">
|
||||
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
@@ -516,12 +516,13 @@
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="plan"
|
||||
pyro-hash="plan"
|
||||
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
|
||||
>
|
||||
<div class="faded-brand-line absolute left-0 top-0 h-[1px] w-full"></div>
|
||||
<div class="mx-auto flex w-full max-w-7xl flex-col items-center gap-8 text-center">
|
||||
<div
|
||||
nav-hash="plan"
|
||||
class="mx-auto flex w-full max-w-7xl flex-col items-center gap-8 text-center"
|
||||
>
|
||||
<h1 class="relative m-0 text-4xl leading-[120%] md:text-7xl">
|
||||
There's a server for everyone
|
||||
</h1>
|
||||
@@ -551,6 +552,8 @@
|
||||
<span v-else></span>
|
||||
</div>
|
||||
|
||||
<MedalPlanPromotion v-if="flags.enableMedalPromotion" />
|
||||
|
||||
<ul class="m-0 flex w-full grid-cols-3 flex-col gap-8 p-0 lg:grid">
|
||||
<ServerPlanSelector
|
||||
:capacity="capacityStatuses?.small?.available"
|
||||
@@ -651,12 +654,14 @@ import { computed } from 'vue'
|
||||
import { useBaseFetch } from '@/composables/fetch.js'
|
||||
import OptionGroup from '~/components/ui/OptionGroup.vue'
|
||||
import LoaderIcon from '~/components/ui/servers/icons/LoaderIcon.vue'
|
||||
import MedalPlanPromotion from '~/components/ui/servers/marketing/MedalPlanPromotion.vue'
|
||||
import ServerPlanSelector from '~/components/ui/servers/marketing/ServerPlanSelector.vue'
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
import { products } from '~/generated/state.json'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { locale } = useVIntl()
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
const billingPeriods = ref(['monthly', 'quarterly'])
|
||||
const billingPeriod = ref(billingPeriods.value.includes('quarterly') ? 'quarterly' : 'monthly')
|
||||
@@ -850,8 +855,8 @@ const isAtCapacity = computed(
|
||||
|
||||
const scrollToFaq = () => {
|
||||
if (route.hash) {
|
||||
// where pyro-hash === route.hash
|
||||
const faq = document.querySelector(`[pyro-hash="${route.hash.slice(1)}"]`)
|
||||
// where nav-hash === route.hash
|
||||
const faq = document.querySelector(`[nav-hash="${route.hash.slice(1)}"]`)
|
||||
if (faq) {
|
||||
faq.open = true
|
||||
const top = faq.getBoundingClientRect().top
|
||||
@@ -923,16 +928,20 @@ const selectProduct = async (product) => {
|
||||
await nextTick()
|
||||
|
||||
if (product === 'custom') {
|
||||
purchaseModal.value?.show(billingPeriod.value, undefined, selectedProjectId.value)
|
||||
purchaseModal.value?.show(billingPeriod.value, null, selectedProjectId.value)
|
||||
} else {
|
||||
purchaseModal.value?.show(billingPeriod.value, selectedProduct.value, selectedProjectId.value)
|
||||
}
|
||||
}
|
||||
|
||||
const planQuery = () => {
|
||||
if (route.query.plan) {
|
||||
document.getElementById('plan').scrollIntoView()
|
||||
selectProduct(route.query.plan)
|
||||
const planQuery = async () => {
|
||||
if ('plan' in route.query) {
|
||||
await nextTick()
|
||||
const planElement = document.querySelector(`[nav-hash="plan"]`)
|
||||
if (planElement) {
|
||||
planElement.scrollIntoView({ behavior: 'smooth' })
|
||||
await selectProduct(route.query.plan)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,58 +115,63 @@
|
||||
: `linear-gradient(180deg, rgba(153,153,153,1) 0%, rgba(87,87,87,1) 100%)`,
|
||||
}"
|
||||
>
|
||||
<div class="flex w-full min-w-0 select-none flex-col items-center gap-6 pt-4 sm:flex-row">
|
||||
<ServerIcon :image="serverData.image" class="drop-shadow-lg sm:drop-shadow-none" />
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col-reverse items-center gap-2 sm:flex-col sm:items-start"
|
||||
>
|
||||
<div class="hidden shrink-0 flex-row items-center gap-1 sm:flex">
|
||||
<NuxtLink to="/servers/manage" class="breadcrumb goto-link flex w-fit items-center">
|
||||
<LeftArrowIcon />
|
||||
All servers
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="flex w-full flex-col items-center gap-4 sm:flex-row">
|
||||
<h1
|
||||
class="m-0 w-screen flex-shrink gap-3 truncate px-3 text-center text-4xl font-bold text-contrast sm:w-full sm:p-0 sm:text-left"
|
||||
>
|
||||
{{ serverData.name }}
|
||||
</h1>
|
||||
<div
|
||||
v-if="isConnected"
|
||||
data-pyro-server-action-buttons
|
||||
class="server-action-buttons-anim flex w-fit flex-shrink-0"
|
||||
>
|
||||
<PanelServerActionButton
|
||||
v-if="!serverData.flows?.intro"
|
||||
class="flex-shrink-0"
|
||||
:is-online="isServerRunning"
|
||||
:is-actioning="isActioning"
|
||||
:is-installing="serverData.status === 'installing'"
|
||||
:disabled="isActioning || !!error"
|
||||
:server-name="serverData.name"
|
||||
:server-data="serverData"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
@action="sendPowerAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="serverData.flows?.intro"
|
||||
class="flex items-center gap-2 font-semibold text-secondary"
|
||||
>
|
||||
<SettingsIcon /> Configuring server...
|
||||
</div>
|
||||
<ServerInfoLabels
|
||||
v-else
|
||||
:server-data="serverData"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
:linked="true"
|
||||
class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
<div>
|
||||
<NuxtLink to="/servers/manage" class="breadcrumb goto-link flex w-fit items-center">
|
||||
<LeftArrowIcon />
|
||||
All servers
|
||||
</NuxtLink>
|
||||
<div class="flex w-full min-w-0 select-none flex-col items-center gap-4 pt-4 sm:flex-row">
|
||||
<ServerIcon
|
||||
:image="
|
||||
serverData.is_medal ? 'https://cdn-raw.modrinth.com/medal_icon.webp' : serverData.image
|
||||
"
|
||||
class="drop-shadow-lg sm:drop-shadow-none"
|
||||
/>
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col-reverse items-center gap-2 sm:flex-col sm:items-start"
|
||||
>
|
||||
<div class="flex w-full flex-col items-center gap-4 sm:flex-row">
|
||||
<h1
|
||||
class="m-0 w-screen flex-shrink gap-3 truncate px-3 text-center text-2xl font-bold text-contrast sm:w-full sm:p-0 sm:text-left"
|
||||
>
|
||||
{{ serverData.name }}
|
||||
</h1>
|
||||
<div
|
||||
v-if="isConnected"
|
||||
data-pyro-server-action-buttons
|
||||
class="server-action-buttons-anim flex w-fit flex-shrink-0"
|
||||
>
|
||||
<PanelServerActionButton
|
||||
v-if="!serverData.flows?.intro"
|
||||
class="flex-shrink-0"
|
||||
:is-online="isServerRunning"
|
||||
:is-actioning="isActioning"
|
||||
:is-installing="serverData.status === 'installing'"
|
||||
:disabled="isActioning || !!error"
|
||||
:server-name="serverData.name"
|
||||
:server-data="serverData"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
@action="sendPowerAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="serverData.flows?.intro"
|
||||
class="flex items-center gap-2 font-semibold text-secondary"
|
||||
>
|
||||
<SettingsIcon /> Configuring server...
|
||||
</div>
|
||||
<ServerInfoLabels
|
||||
v-else
|
||||
:server-data="serverData"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
:linked="true"
|
||||
class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -290,6 +295,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="serverData.is_medal" class="mb-4">
|
||||
<MedalServerCountdown :server-id="server.serverId" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isConnected && !isReconnecting && !isLoading"
|
||||
data-pyro-server-ws-error
|
||||
@@ -385,6 +394,7 @@ import { reloadNuxtApp } from '#app'
|
||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||
import PanelErrorIcon from '~/components/ui/servers/icons/PanelErrorIcon.vue'
|
||||
import InstallingTicker from '~/components/ui/servers/InstallingTicker.vue'
|
||||
import MedalServerCountdown from '~/components/ui/servers/marketing/MedalServerCountdown.vue'
|
||||
import PanelServerActionButton from '~/components/ui/servers/PanelServerActionButton.vue'
|
||||
import PanelSpinner from '~/components/ui/servers/PanelSpinner.vue'
|
||||
import ServerIcon from '~/components/ui/servers/ServerIcon.vue'
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div v-if="!data.is_medal" class="card flex flex-col gap-4">
|
||||
<label for="server-icon-field" class="flex flex-col gap-2">
|
||||
<span class="text-lg font-bold text-contrast">Server icon</span>
|
||||
<span> This icon will be visible on the Minecraft server list and on Modrinth. </span>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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 ref="upgradeModal" />
|
||||
<div
|
||||
v-if="hasError || fetchError"
|
||||
class="mx-auto flex h-full min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 text-left"
|
||||
@@ -93,8 +94,17 @@
|
||||
v-if="filteredData.length > 0 || isPollingForNewServers"
|
||||
class="m-0 flex flex-col gap-4 p-0"
|
||||
>
|
||||
<ServerListing v-for="server in filteredData" :key="server.server_id" v-bind="server" />
|
||||
<ServerListingSkeleton v-if="isPollingForNewServers" />
|
||||
<MedalServerListing
|
||||
v-for="server in filteredData.filter((s) => s.is_medal)"
|
||||
:key="server.server_id"
|
||||
v-bind="server"
|
||||
@upgrade="openUpgradeModal(server.server_id)"
|
||||
/>
|
||||
<ServerListing
|
||||
v-for="server in filteredData.filter((s) => !s.is_medal)"
|
||||
:key="server.server_id"
|
||||
v-bind="server"
|
||||
/>
|
||||
</ul>
|
||||
<div v-else class="flex h-full items-center justify-center">
|
||||
<p class="text-contrast">No servers found.</p>
|
||||
@@ -107,13 +117,16 @@
|
||||
import { HammerIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, CopyCode } from '@modrinth/ui'
|
||||
import type { ModrinthServersFetchError, Server } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { reloadNuxtApp } from '#app'
|
||||
import MedalServerListing from '~/components/ui/servers/marketing/MedalServerListing.vue'
|
||||
import ServerListing from '~/components/ui/servers/ServerListing.vue'
|
||||
import ServerListingSkeleton from '~/components/ui/servers/ServerListingSkeleton.vue'
|
||||
import ServerManageEmptyState from '~/components/ui/servers/ServerManageEmptyState.vue'
|
||||
import ServersUpgradeModalWrapper from '~/components/ui/servers/ServersUpgradeModalWrapper.vue'
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
|
||||
definePageMeta({
|
||||
@@ -137,15 +150,40 @@ const {
|
||||
data: serverResponse,
|
||||
error: fetchError,
|
||||
refresh,
|
||||
} = await useAsyncData<ServerResponse>('ServerList', () =>
|
||||
useServersFetch<ServerResponse>('servers'),
|
||||
)
|
||||
} = await useAsyncData<ServerResponse>('ServerList', async () => {
|
||||
const serverResponse = await useServersFetch<ServerResponse>('servers')
|
||||
|
||||
let subscriptions: any[] | undefined
|
||||
|
||||
for (const server of serverResponse.servers) {
|
||||
if (server.is_medal) {
|
||||
// Inject end date into server object.
|
||||
const serverID = server.server_id
|
||||
|
||||
if (!subscriptions) {
|
||||
subscriptions = (await useBaseFetch(`billing/subscriptions`, {
|
||||
internal: true,
|
||||
})) as any[]
|
||||
}
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
if (subscription.metadata?.id === serverID) {
|
||||
server.medal_expires = dayjs(subscription.created as string)
|
||||
.add(5, 'days')
|
||||
.toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return serverResponse
|
||||
})
|
||||
|
||||
watch([fetchError, serverResponse], ([error, response]) => {
|
||||
hasError.value = !!error || !response
|
||||
})
|
||||
|
||||
const serverList = computed(() => {
|
||||
const serverList = computed<Server[]>(() => {
|
||||
if (!serverResponse.value) return []
|
||||
return serverResponse.value.servers
|
||||
})
|
||||
@@ -167,7 +205,7 @@ function introToTop(array: Server[]): Server[] {
|
||||
})
|
||||
}
|
||||
|
||||
const filteredData = computed(() => {
|
||||
const filteredData = computed<Server[]>(() => {
|
||||
if (!searchInput.value.trim()) {
|
||||
return introToTop(serverList.value)
|
||||
}
|
||||
@@ -207,4 +245,13 @@ onUnmounted(() => {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
})
|
||||
|
||||
type ServersUpgradeModalWrapperRef = ComponentPublicInstance<{
|
||||
open: (id: string) => void | Promise<void>
|
||||
}>
|
||||
|
||||
const upgradeModal = ref<ServersUpgradeModalWrapperRef | null>(null)
|
||||
function openUpgradeModal(serverId: string) {
|
||||
upgradeModal.value?.open(serverId)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<template>
|
||||
<ServersUpgradeModalWrapper ref="upgradeModal" />
|
||||
<section class="universal-card experimental-styles-within">
|
||||
<h2>{{ formatMessage(messages.subscriptionTitle) }}</h2>
|
||||
<p>{{ formatMessage(messages.subscriptionDescription) }}</p>
|
||||
@@ -51,18 +52,35 @@
|
||||
{{
|
||||
formatPrice(
|
||||
vintl.locale,
|
||||
midasSubscriptionPrice.prices.intervals[midasCharge.subscription_interval],
|
||||
midasSubscriptionPrice.prices.intervals[midasSubscription.interval],
|
||||
midasSubscriptionPrice.currency_code,
|
||||
)
|
||||
}}
|
||||
/
|
||||
{{ midasCharge.subscription_interval }}
|
||||
{{ midasSubscription.interval }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatPrice(vintl.locale, price.prices.intervals.monthly, price.currency_code) }}
|
||||
/ month
|
||||
</template>
|
||||
</span>
|
||||
<!-- Next charge preview for Midas when interval is changing -->
|
||||
<div
|
||||
v-if="
|
||||
midasCharge &&
|
||||
midasCharge.status === 'open' &&
|
||||
midasSubscription &&
|
||||
midasSubscription.interval &&
|
||||
midasCharge.subscription_interval !== midasSubscription.interval
|
||||
"
|
||||
class="-mt-1 flex items-baseline gap-2 text-sm text-secondary"
|
||||
>
|
||||
<span class="opacity-70">Next:</span>
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatPrice(vintl.locale, midasCharge.amount, midasCharge.currency_code) }}
|
||||
</span>
|
||||
<span>/{{ midasCharge.subscription_interval.replace('ly', '') }}</span>
|
||||
</div>
|
||||
<template v-if="midasCharge">
|
||||
<span
|
||||
v-if="
|
||||
@@ -88,12 +106,24 @@
|
||||
<span v-else-if="midasCharge.status === 'cancelled'" class="text-sm text-secondary">
|
||||
Expires {{ $dayjs(midasCharge.due).format('MMMM D, YYYY') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
midasCharge.status === 'open' &&
|
||||
midasSubscription &&
|
||||
midasSubscription.interval &&
|
||||
midasCharge.subscription_interval !== midasSubscription.interval
|
||||
"
|
||||
class="text-sm text-secondary"
|
||||
>
|
||||
Switches to {{ midasCharge.subscription_interval }} billing on
|
||||
{{ $dayjs(midasCharge.due).format('MMMM D, YYYY') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<span v-else class="text-sm text-secondary">
|
||||
Or
|
||||
{{ formatPrice(vintl.locale, price.prices.intervals.yearly, price.currency_code) }}
|
||||
/ year (save
|
||||
{{ formatPrice(vintl.locale, price.prices.intervals.yearly, price.currency_code) }} /
|
||||
year (save
|
||||
{{
|
||||
calculateSavings(price.prices.intervals.monthly, price.prices.intervals.yearly)
|
||||
}}%)!
|
||||
@@ -168,8 +198,7 @@
|
||||
@click="switchMidasInterval(oppositeInterval)"
|
||||
>
|
||||
<SpinnerIcon v-if="changingInterval" class="animate-spin" />
|
||||
<TransferIcon v-else />
|
||||
{{ changingInterval ? 'Switching' : 'Switch' }} to
|
||||
<TransferIcon v-else /> {{ changingInterval ? 'Switching' : 'Switch' }} to
|
||||
{{ oppositeInterval }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -207,7 +236,11 @@
|
||||
<div class="flex flex-col gap-4">
|
||||
<ModrinthServersIcon class="flex h-8 w-fit" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<ServerListing v-if="subscription.serverInfo" v-bind="subscription.serverInfo" />
|
||||
<ServerListing
|
||||
v-if="subscription.serverInfo"
|
||||
v-bind="subscription.serverInfo"
|
||||
:pending-change="getPendingChange(subscription)"
|
||||
/>
|
||||
<div v-else class="w-fit">
|
||||
<p>
|
||||
A linked server couldn't be found for this subscription. There are a few possible
|
||||
@@ -233,9 +266,8 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckCircleIcon class="h-5 w-5 text-brand" />
|
||||
<span>
|
||||
{{ getPyroProduct(subscription)?.metadata?.cpu / 2 }}
|
||||
Shared CPUs (Bursts up to
|
||||
{{ getPyroProduct(subscription)?.metadata?.cpu }} CPUs)
|
||||
{{ getPyroProduct(subscription)?.metadata?.cpu / 2 }} Shared CPUs (Bursts up
|
||||
to {{ getPyroProduct(subscription)?.metadata?.cpu }} CPUs)
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -285,16 +317,60 @@
|
||||
</span>
|
||||
<span>/{{ subscription.interval.replace('ly', '') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
getPyroCharge(subscription) &&
|
||||
getPyroCharge(subscription).status === 'open' &&
|
||||
((getPyroCharge(subscription).price_id &&
|
||||
getPyroCharge(subscription).price_id !== subscription.price_id) ||
|
||||
(getPyroCharge(subscription).subscription_interval &&
|
||||
getPyroCharge(subscription).subscription_interval !==
|
||||
subscription.interval))
|
||||
"
|
||||
class="-mt-1 flex items-baseline gap-2 text-sm text-secondary"
|
||||
>
|
||||
<span class="opacity-70">Next:</span>
|
||||
<span class="font-semibold text-contrast">
|
||||
{{
|
||||
formatPrice(
|
||||
vintl.locale,
|
||||
getPyroCharge(subscription).amount,
|
||||
getPyroCharge(subscription).currency_code,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span>
|
||||
/
|
||||
{{
|
||||
(
|
||||
getPyroCharge(subscription).subscription_interval ||
|
||||
subscription.interval
|
||||
).replace('ly', '')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="getPyroCharge(subscription)" class="mb-4 flex flex-col items-end">
|
||||
<span class="text-sm text-secondary">
|
||||
Since
|
||||
{{ $dayjs(subscription.created).format('MMMM D, YYYY') }}
|
||||
Since {{ $dayjs(subscription.created).format('MMMM D, YYYY') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getPyroCharge(subscription).status === 'open'"
|
||||
class="text-sm text-secondary"
|
||||
>
|
||||
Renews
|
||||
Renews {{ $dayjs(getPyroCharge(subscription).due).format('MMMM D, YYYY') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
getPyroCharge(subscription).status === 'open' &&
|
||||
getPyroCharge(subscription).subscription_interval &&
|
||||
getPyroCharge(subscription).subscription_interval !==
|
||||
subscription.interval
|
||||
"
|
||||
class="text-sm text-secondary"
|
||||
>
|
||||
Switches to
|
||||
{{ getPyroCharge(subscription).subscription_interval }}
|
||||
billing on
|
||||
{{ $dayjs(getPyroCharge(subscription).due).format('MMMM D, YYYY') }}
|
||||
</span>
|
||||
<span
|
||||
@@ -308,8 +384,7 @@
|
||||
v-else-if="getPyroCharge(subscription).status === 'cancelled'"
|
||||
class="text-sm text-secondary"
|
||||
>
|
||||
Expires
|
||||
{{ $dayjs(getPyroCharge(subscription).due).format('MMMM D, YYYY') }}
|
||||
Expires {{ $dayjs(getPyroCharge(subscription).due).format('MMMM D, YYYY') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="getPyroCharge(subscription).status === 'failed'"
|
||||
@@ -404,37 +479,6 @@
|
||||
:payment-methods="paymentMethods"
|
||||
:return-url="`${config.public.siteUrl}/settings/billing`"
|
||||
/>
|
||||
<PurchaseModal
|
||||
ref="pyroPurchaseModal"
|
||||
:product="upgradeProducts"
|
||||
:country="country"
|
||||
custom-server
|
||||
:existing-subscription="currentSubscription"
|
||||
:existing-plan="currentProduct"
|
||||
:publishable-key="config.public.stripePublishableKey"
|
||||
:send-billing-request="
|
||||
async (body) =>
|
||||
await useBaseFetch(`billing/subscription/${currentSubscription.id}`, {
|
||||
internal: true,
|
||||
method: `PATCH`,
|
||||
body: body,
|
||||
})
|
||||
"
|
||||
:renewal-date="currentSubRenewalDate"
|
||||
:on-error="
|
||||
(err) =>
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
type: 'error',
|
||||
text: err.message ?? (err.data ? err.data.description : err),
|
||||
})
|
||||
"
|
||||
:fetch-capacity-statuses="fetchCapacityStatuses"
|
||||
:customer="customer"
|
||||
:payment-methods="paymentMethods"
|
||||
:return-url="`${config.public.siteUrl}/servers/manage`"
|
||||
:server-name="`${auth?.user?.username}'s server`"
|
||||
/>
|
||||
<AddPaymentMethodModal
|
||||
ref="addPaymentMethodModal"
|
||||
:publishable-key="config.public.stripePublishableKey"
|
||||
@@ -588,6 +632,7 @@ import { computed, ref } from 'vue'
|
||||
import { useBaseFetch } from '@/composables/fetch.js'
|
||||
import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue'
|
||||
import ServerListing from '~/components/ui/servers/ServerListing.vue'
|
||||
import ServersUpgradeModalWrapper from '~/components/ui/servers/ServersUpgradeModalWrapper.vue'
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
import { products } from '~/generated/state.json'
|
||||
|
||||
@@ -903,6 +948,12 @@ const getPyroProduct = (subscription) => {
|
||||
return productsData.value.find((p) => p.prices?.some((x) => x.id === subscription.price_id))
|
||||
}
|
||||
|
||||
// Get product by a price ID (useful for pending next-charge changes)
|
||||
const getProductFromPriceId = (priceId) => {
|
||||
if (!priceId || !productsData.value) return null
|
||||
return productsData.value.find((p) => p.prices?.some((x) => x.id === priceId))
|
||||
}
|
||||
|
||||
const getPyroCharge = (subscription) => {
|
||||
if (!subscription || !charges.value) return null
|
||||
return charges.value.find(
|
||||
@@ -931,76 +982,18 @@ const getProductPrice = (product, interval) => {
|
||||
)
|
||||
}
|
||||
|
||||
const modalCancel = ref(null)
|
||||
const getPlanChangeVerb = (currentProduct, nextProduct) => {
|
||||
const curRam = currentProduct?.metadata?.ram ?? 0
|
||||
const nextRam = nextProduct?.metadata?.ram ?? 0
|
||||
|
||||
const pyroPurchaseModal = ref()
|
||||
const currentSubscription = ref(null)
|
||||
const currentProduct = ref(null)
|
||||
const upgradeProducts = ref([])
|
||||
upgradeProducts.value.metadata = { type: 'pyro' }
|
||||
|
||||
const currentSubRenewalDate = ref()
|
||||
|
||||
const showPyroUpgradeModal = async (subscription) => {
|
||||
currentSubscription.value = subscription
|
||||
currentSubRenewalDate.value = getPyroCharge(subscription).due
|
||||
currentProduct.value = getPyroProduct(subscription)
|
||||
upgradeProducts.value = products.filter(
|
||||
(p) =>
|
||||
p.metadata.type === 'pyro' &&
|
||||
(!currentProduct.value || p.metadata.ram > currentProduct.value.metadata.ram),
|
||||
)
|
||||
upgradeProducts.value.metadata = { type: 'pyro' }
|
||||
|
||||
await nextTick()
|
||||
|
||||
if (!currentProduct.value) {
|
||||
console.error('Could not find product for current subscription')
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
text: 'Could not find product for current subscription',
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!pyroPurchaseModal.value) {
|
||||
console.error('pyroPurchaseModal ref is undefined')
|
||||
return
|
||||
}
|
||||
|
||||
pyroPurchaseModal.value.show()
|
||||
return nextRam < curRam ? 'downgrade' : 'upgrade'
|
||||
}
|
||||
|
||||
async function fetchCapacityStatuses(serverId, product) {
|
||||
if (product) {
|
||||
try {
|
||||
return {
|
||||
custom: await useServersFetch(`servers/${serverId}/upgrade-stock`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
cpu: product.metadata.cpu,
|
||||
memory_mb: product.metadata.ram,
|
||||
swap_mb: product.metadata.swap,
|
||||
storage_mb: product.metadata.storage,
|
||||
},
|
||||
}),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking server capacities:', error)
|
||||
addNotification({
|
||||
title: 'Error checking server capacities',
|
||||
text: error,
|
||||
type: 'error',
|
||||
})
|
||||
return {
|
||||
custom: { available: 0 },
|
||||
small: { available: 0 },
|
||||
medium: { available: 0 },
|
||||
large: { available: 0 },
|
||||
}
|
||||
}
|
||||
}
|
||||
const modalCancel = ref(null)
|
||||
|
||||
const upgradeModal = ref(null)
|
||||
const showPyroUpgradeModal = (subscription) => {
|
||||
upgradeModal.value?.open(subscription?.metadata?.id)
|
||||
}
|
||||
|
||||
const resubscribePyro = async (subscriptionId, wasSuspended) => {
|
||||
@@ -1093,6 +1086,7 @@ function showCancellationSurvey(subscription) {
|
||||
window.Tally.openPopup(formId, popupOptions)
|
||||
} else {
|
||||
console.warn('Tally script not yet loaded')
|
||||
cancelSubscription(subscription.id, true)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error opening Tally popup:', e)
|
||||
@@ -1107,4 +1101,50 @@ useHead({
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const getPendingChange = (subscription) => {
|
||||
const charge = getPyroCharge(subscription)
|
||||
if (!charge || charge.status !== 'open') return null
|
||||
|
||||
const nextProduct = getProductFromPriceId(charge.price_id)
|
||||
if (!nextProduct || charge.price_id === subscription.price_id) {
|
||||
// Not a plan change, but interval could change
|
||||
if (charge.subscription_interval && charge.subscription_interval !== subscription.interval) {
|
||||
return {
|
||||
planSize: getProductSize(getPyroProduct(subscription)),
|
||||
cpu: getPyroProduct(subscription)?.metadata?.cpu / 2,
|
||||
cpuBurst: getPyroProduct(subscription)?.metadata?.cpu,
|
||||
ramGb: (getPyroProduct(subscription)?.metadata?.ram || 0) / 1024,
|
||||
swapGb: (getPyroProduct(subscription)?.metadata?.swap || 0) / 1024 || undefined,
|
||||
storageGb: (getPyroProduct(subscription)?.metadata?.storage || 0) / 1024 || undefined,
|
||||
date: charge.due,
|
||||
intervalChange: charge.subscription_interval,
|
||||
verb: 'Switches',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const curProduct = getPyroProduct(subscription)
|
||||
const verb = getPlanChangeVerb(curProduct, nextProduct)
|
||||
const cpu = nextProduct?.metadata?.cpu ?? 0
|
||||
const ram = nextProduct?.metadata?.ram ?? 0
|
||||
const swap = nextProduct?.metadata?.swap ?? 0
|
||||
const storage = nextProduct?.metadata?.storage ?? 0
|
||||
|
||||
return {
|
||||
planSize: getProductSize(nextProduct),
|
||||
cpu: cpu / 2,
|
||||
cpuBurst: cpu,
|
||||
ramGb: ram / 1024,
|
||||
swapGb: swap ? swap / 1024 : undefined,
|
||||
storageGb: storage ? storage / 1024 : undefined,
|
||||
date: charge.due,
|
||||
intervalChange:
|
||||
charge.subscription_interval && charge.subscription_interval !== subscription.interval
|
||||
? charge.subscription_interval
|
||||
: null,
|
||||
verb,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user