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,
|
::backdrop,
|
||||||
:root[data-theme='light'],
|
:root[data-theme='light'],
|
||||||
[data-theme='light'] ::backdrop {
|
[data-theme='light'] ::backdrop {
|
||||||
--sl-font-system:
|
--sl-font-system: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
|
||||||
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell,
|
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||||
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
|
||||||
|
|
||||||
--sl-color-white: var(--color-contrast); /* “white” */
|
--sl-color-white: var(--color-contrast); /* “white” */
|
||||||
--sl-color-gray-1: var(--color-base);
|
--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;
|
--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-bg: #fee2e2;
|
||||||
--banner-error-text: #991b1b;
|
--banner-error-text: #991b1b;
|
||||||
--banner-error-border: #ef4444;
|
--banner-error-border: #ef4444;
|
||||||
@@ -237,6 +246,15 @@ html {
|
|||||||
|
|
||||||
--landing-raw-bg: #000;
|
--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%);
|
--hover-filter: brightness(120%);
|
||||||
--active-filter: brightness(140%);
|
--active-filter: brightness(140%);
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
|
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
|
||||||
<nuxt-link
|
<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]"
|
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
|
||||||
>
|
>
|
||||||
<img
|
<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"
|
alt="Host your next server with Modrinth Servers"
|
||||||
class="light-image hidden rounded-[inherit]"
|
class="light-image hidden rounded-[inherit]"
|
||||||
/>
|
/>
|
||||||
<img
|
<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"
|
alt="Host your next server with Modrinth Servers"
|
||||||
class="dark-image rounded-[inherit]"
|
class="dark-image rounded-[inherit]"
|
||||||
/>
|
/>
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
const flags = useFeatureFlags()
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
script: [
|
script: [
|
||||||
// {
|
// {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<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>
|
<client-only>
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -1,74 +1,69 @@
|
|||||||
<template>
|
<template>
|
||||||
<NuxtLink
|
<div>
|
||||||
class="contents"
|
<NuxtLink :to="status === 'suspended' ? '' : `/servers/manage/${props.server_id}`">
|
||||||
: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
|
<div
|
||||||
v-else
|
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="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="{
|
||||||
|
'!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" />
|
<ServerIcon v-if="status !== 'suspended'" :image="image" />
|
||||||
</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>
|
|
||||||
|
|
||||||
<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-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
|
v-else
|
||||||
:server-data="{ game, mc_version, loader, loader_version, net }"
|
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"
|
||||||
:show-game-label="showGameLabel"
|
>
|
||||||
:show-loader-label="showLoaderLabel"
|
<LockIcon class="size-12 text-secondary" />
|
||||||
:linked="false"
|
</div>
|
||||||
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
<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>
|
||||||
</div>
|
</NuxtLink>
|
||||||
<div
|
<div
|
||||||
v-if="status === 'suspended' && suspension_reason === 'upgrading'"
|
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 />
|
<PanelSpinner />
|
||||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="status === 'suspended' && suspension_reason === 'cancelled'"
|
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">
|
<div class="flex flex-row gap-2">
|
||||||
<PanelErrorIcon class="!size-5" /> Your server has been cancelled. Please update your
|
<PanelErrorIcon class="!size-5" /> Your server has been cancelled. Please update your
|
||||||
@@ -78,7 +73,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="status === 'suspended' && suspension_reason"
|
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">
|
<div class="flex flex-row gap-2">
|
||||||
<PanelErrorIcon class="!size-5" /> Your server has been suspended: {{ suspension_reason }}.
|
<PanelErrorIcon class="!size-5" /> Your server has been suspended: {{ suspension_reason }}.
|
||||||
@@ -88,7 +83,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="status === 'suspended'"
|
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">
|
<div class="flex flex-row gap-2">
|
||||||
<PanelErrorIcon class="!size-5" /> Your server has been suspended. Please update your
|
<PanelErrorIcon class="!size-5" /> Your server has been suspended. Please update your
|
||||||
@@ -96,13 +91,31 @@
|
|||||||
</div>
|
</div>
|
||||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ChevronRightIcon, LockIcon, SparklesIcon } from '@modrinth/assets'
|
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 type { Project, Server } from '@modrinth/utils'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
|
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
|
||||||
|
|
||||||
@@ -111,7 +124,19 @@ import PanelSpinner from './PanelSpinner.vue'
|
|||||||
import ServerIcon from './ServerIcon.vue'
|
import ServerIcon from './ServerIcon.vue'
|
||||||
import ServerInfoLabels from './ServerInfoLabels.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') {
|
if (props.server_id && props.status === 'available') {
|
||||||
// Necessary only to get server icon
|
// 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 image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined)
|
||||||
const iconUrl = computed(() => projectData.value?.icon_url || undefined)
|
const iconUrl = computed(() => projectData.value?.icon_url || undefined)
|
||||||
const isConfiguring = computed(() => props.flows?.intro)
|
const isConfiguring = computed(() => props.flows?.intro)
|
||||||
|
|
||||||
|
const formatDate = (d: unknown) => {
|
||||||
|
try {
|
||||||
|
return dayjs(d as any).format('MMMM D, YYYY')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</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
|
// Feature toggles
|
||||||
projectTypesPrimaryNav: false,
|
projectTypesPrimaryNav: false,
|
||||||
|
enableMedalPromotion: false,
|
||||||
hidePlusPromoInUserMenu: false,
|
hidePlusPromoInUserMenu: false,
|
||||||
oldProjectCards: true,
|
oldProjectCards: true,
|
||||||
newProjectCards: false,
|
newProjectCards: false,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
|||||||
node!: { token: string; instance: string }
|
node!: { token: string; instance: string }
|
||||||
flows?: { intro?: boolean }
|
flows?: { intro?: boolean }
|
||||||
|
|
||||||
|
is_medal?: boolean
|
||||||
|
|
||||||
async fetch(): Promise<void> {
|
async fetch(): Promise<void> {
|
||||||
const data = await useServersFetch<ServerGeneral>(`servers/${this.serverId}`, {}, 'general')
|
const data = await useServersFetch<ServerGeneral>(`servers/${this.serverId}`, {}, 'general')
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,14 @@
|
|||||||
class="flex flex-col gap-4 text-primary"
|
class="flex flex-col gap-4 text-primary"
|
||||||
>
|
>
|
||||||
<span class="flex items-center gap-2">
|
<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="flex flex-col gap-2">
|
||||||
<span class="bold font-extrabold text-contrast">
|
<span class="bold font-extrabold text-contrast">
|
||||||
{{ server.general.name }}
|
{{ 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">
|
<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>
|
<h1 class="m-0 text-lg font-bold">Frequently Asked Questions</h1>
|
||||||
<div class="details-hide flex flex-col gap-1">
|
<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">
|
<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">
|
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||||
<RightArrowIcon />
|
<RightArrowIcon />
|
||||||
@@ -404,7 +404,7 @@
|
|||||||
GHz, paired with DDR5 memory.
|
GHz, paired with DDR5 memory.
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</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">
|
<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">
|
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||||
<RightArrowIcon />
|
<RightArrowIcon />
|
||||||
@@ -420,7 +420,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</details>
|
</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">
|
<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">
|
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||||
<RightArrowIcon />
|
<RightArrowIcon />
|
||||||
@@ -433,7 +433,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</details>
|
</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">
|
<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">
|
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||||
<RightArrowIcon />
|
<RightArrowIcon />
|
||||||
@@ -447,7 +447,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</details>
|
</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">
|
<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">
|
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||||
<RightArrowIcon />
|
<RightArrowIcon />
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</details>
|
</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">
|
<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">
|
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||||
<RightArrowIcon />
|
<RightArrowIcon />
|
||||||
@@ -481,7 +481,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</details>
|
</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">
|
<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">
|
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||||
<RightArrowIcon />
|
<RightArrowIcon />
|
||||||
@@ -493,7 +493,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</details>
|
</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">
|
<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">
|
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||||
<RightArrowIcon />
|
<RightArrowIcon />
|
||||||
@@ -516,12 +516,13 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<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"
|
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="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">
|
<h1 class="relative m-0 text-4xl leading-[120%] md:text-7xl">
|
||||||
There's a server for everyone
|
There's a server for everyone
|
||||||
</h1>
|
</h1>
|
||||||
@@ -551,6 +552,8 @@
|
|||||||
<span v-else></span>
|
<span v-else></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MedalPlanPromotion v-if="flags.enableMedalPromotion" />
|
||||||
|
|
||||||
<ul class="m-0 flex w-full grid-cols-3 flex-col gap-8 p-0 lg:grid">
|
<ul class="m-0 flex w-full grid-cols-3 flex-col gap-8 p-0 lg:grid">
|
||||||
<ServerPlanSelector
|
<ServerPlanSelector
|
||||||
:capacity="capacityStatuses?.small?.available"
|
:capacity="capacityStatuses?.small?.available"
|
||||||
@@ -651,12 +654,14 @@ import { computed } from 'vue'
|
|||||||
import { useBaseFetch } from '@/composables/fetch.js'
|
import { useBaseFetch } from '@/composables/fetch.js'
|
||||||
import OptionGroup from '~/components/ui/OptionGroup.vue'
|
import OptionGroup from '~/components/ui/OptionGroup.vue'
|
||||||
import LoaderIcon from '~/components/ui/servers/icons/LoaderIcon.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 ServerPlanSelector from '~/components/ui/servers/marketing/ServerPlanSelector.vue'
|
||||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||||
import { products } from '~/generated/state.json'
|
import { products } from '~/generated/state.json'
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
const { locale } = useVIntl()
|
const { locale } = useVIntl()
|
||||||
|
const flags = useFeatureFlags()
|
||||||
|
|
||||||
const billingPeriods = ref(['monthly', 'quarterly'])
|
const billingPeriods = ref(['monthly', 'quarterly'])
|
||||||
const billingPeriod = ref(billingPeriods.value.includes('quarterly') ? 'quarterly' : 'monthly')
|
const billingPeriod = ref(billingPeriods.value.includes('quarterly') ? 'quarterly' : 'monthly')
|
||||||
@@ -850,8 +855,8 @@ const isAtCapacity = computed(
|
|||||||
|
|
||||||
const scrollToFaq = () => {
|
const scrollToFaq = () => {
|
||||||
if (route.hash) {
|
if (route.hash) {
|
||||||
// where pyro-hash === route.hash
|
// where nav-hash === route.hash
|
||||||
const faq = document.querySelector(`[pyro-hash="${route.hash.slice(1)}"]`)
|
const faq = document.querySelector(`[nav-hash="${route.hash.slice(1)}"]`)
|
||||||
if (faq) {
|
if (faq) {
|
||||||
faq.open = true
|
faq.open = true
|
||||||
const top = faq.getBoundingClientRect().top
|
const top = faq.getBoundingClientRect().top
|
||||||
@@ -923,16 +928,20 @@ const selectProduct = async (product) => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
if (product === 'custom') {
|
if (product === 'custom') {
|
||||||
purchaseModal.value?.show(billingPeriod.value, undefined, selectedProjectId.value)
|
purchaseModal.value?.show(billingPeriod.value, null, selectedProjectId.value)
|
||||||
} else {
|
} else {
|
||||||
purchaseModal.value?.show(billingPeriod.value, selectedProduct.value, selectedProjectId.value)
|
purchaseModal.value?.show(billingPeriod.value, selectedProduct.value, selectedProjectId.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const planQuery = () => {
|
const planQuery = async () => {
|
||||||
if (route.query.plan) {
|
if ('plan' in route.query) {
|
||||||
document.getElementById('plan').scrollIntoView()
|
await nextTick()
|
||||||
selectProduct(route.query.plan)
|
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%)`,
|
: `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">
|
<div>
|
||||||
<ServerIcon :image="serverData.image" class="drop-shadow-lg sm:drop-shadow-none" />
|
<NuxtLink to="/servers/manage" class="breadcrumb goto-link flex w-fit items-center">
|
||||||
<div
|
<LeftArrowIcon />
|
||||||
class="flex min-w-0 flex-1 flex-col-reverse items-center gap-2 sm:flex-col sm:items-start"
|
All servers
|
||||||
>
|
</NuxtLink>
|
||||||
<div class="hidden shrink-0 flex-row items-center gap-1 sm:flex">
|
<div class="flex w-full min-w-0 select-none flex-col items-center gap-4 pt-4 sm:flex-row">
|
||||||
<NuxtLink to="/servers/manage" class="breadcrumb goto-link flex w-fit items-center">
|
<ServerIcon
|
||||||
<LeftArrowIcon />
|
:image="
|
||||||
All servers
|
serverData.is_medal ? 'https://cdn-raw.modrinth.com/medal_icon.webp' : serverData.image
|
||||||
</NuxtLink>
|
"
|
||||||
</div>
|
class="drop-shadow-lg sm:drop-shadow-none"
|
||||||
<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
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -290,6 +295,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="serverData.is_medal" class="mb-4">
|
||||||
|
<MedalServerCountdown :server-id="server.serverId" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="!isConnected && !isReconnecting && !isLoading"
|
v-if="!isConnected && !isReconnecting && !isLoading"
|
||||||
data-pyro-server-ws-error
|
data-pyro-server-ws-error
|
||||||
@@ -385,6 +394,7 @@ import { reloadNuxtApp } from '#app'
|
|||||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||||
import PanelErrorIcon from '~/components/ui/servers/icons/PanelErrorIcon.vue'
|
import PanelErrorIcon from '~/components/ui/servers/icons/PanelErrorIcon.vue'
|
||||||
import InstallingTicker from '~/components/ui/servers/InstallingTicker.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 PanelServerActionButton from '~/components/ui/servers/PanelServerActionButton.vue'
|
||||||
import PanelSpinner from '~/components/ui/servers/PanelSpinner.vue'
|
import PanelSpinner from '~/components/ui/servers/PanelSpinner.vue'
|
||||||
import ServerIcon from '~/components/ui/servers/ServerIcon.vue'
|
import ServerIcon from '~/components/ui/servers/ServerIcon.vue'
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<label for="server-icon-field" class="flex flex-col gap-2">
|
||||||
<span class="text-lg font-bold text-contrast">Server icon</span>
|
<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>
|
<span> This icon will be visible on the Minecraft server list and on Modrinth. </span>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
data-pyro-server-list-root
|
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"
|
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
|
<div
|
||||||
v-if="hasError || fetchError"
|
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"
|
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"
|
v-if="filteredData.length > 0 || isPollingForNewServers"
|
||||||
class="m-0 flex flex-col gap-4 p-0"
|
class="m-0 flex flex-col gap-4 p-0"
|
||||||
>
|
>
|
||||||
<ServerListing v-for="server in filteredData" :key="server.server_id" v-bind="server" />
|
<MedalServerListing
|
||||||
<ServerListingSkeleton v-if="isPollingForNewServers" />
|
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>
|
</ul>
|
||||||
<div v-else class="flex h-full items-center justify-center">
|
<div v-else class="flex h-full items-center justify-center">
|
||||||
<p class="text-contrast">No servers found.</p>
|
<p class="text-contrast">No servers found.</p>
|
||||||
@@ -107,13 +117,16 @@
|
|||||||
import { HammerIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
|
import { HammerIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
|
||||||
import { ButtonStyled, CopyCode } from '@modrinth/ui'
|
import { ButtonStyled, CopyCode } from '@modrinth/ui'
|
||||||
import type { ModrinthServersFetchError, Server } from '@modrinth/utils'
|
import type { ModrinthServersFetchError, Server } from '@modrinth/utils'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
|
import type { ComponentPublicInstance } from 'vue'
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { reloadNuxtApp } from '#app'
|
import { reloadNuxtApp } from '#app'
|
||||||
|
import MedalServerListing from '~/components/ui/servers/marketing/MedalServerListing.vue'
|
||||||
import ServerListing from '~/components/ui/servers/ServerListing.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 ServerManageEmptyState from '~/components/ui/servers/ServerManageEmptyState.vue'
|
||||||
|
import ServersUpgradeModalWrapper from '~/components/ui/servers/ServersUpgradeModalWrapper.vue'
|
||||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
@@ -137,15 +150,40 @@ const {
|
|||||||
data: serverResponse,
|
data: serverResponse,
|
||||||
error: fetchError,
|
error: fetchError,
|
||||||
refresh,
|
refresh,
|
||||||
} = await useAsyncData<ServerResponse>('ServerList', () =>
|
} = await useAsyncData<ServerResponse>('ServerList', async () => {
|
||||||
useServersFetch<ServerResponse>('servers'),
|
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]) => {
|
watch([fetchError, serverResponse], ([error, response]) => {
|
||||||
hasError.value = !!error || !response
|
hasError.value = !!error || !response
|
||||||
})
|
})
|
||||||
|
|
||||||
const serverList = computed(() => {
|
const serverList = computed<Server[]>(() => {
|
||||||
if (!serverResponse.value) return []
|
if (!serverResponse.value) return []
|
||||||
return serverResponse.value.servers
|
return serverResponse.value.servers
|
||||||
})
|
})
|
||||||
@@ -167,7 +205,7 @@ function introToTop(array: Server[]): Server[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredData = computed(() => {
|
const filteredData = computed<Server[]>(() => {
|
||||||
if (!searchInput.value.trim()) {
|
if (!searchInput.value.trim()) {
|
||||||
return introToTop(serverList.value)
|
return introToTop(serverList.value)
|
||||||
}
|
}
|
||||||
@@ -207,4 +245,13 @@ onUnmounted(() => {
|
|||||||
clearInterval(intervalId)
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<ServersUpgradeModalWrapper ref="upgradeModal" />
|
||||||
<section class="universal-card experimental-styles-within">
|
<section class="universal-card experimental-styles-within">
|
||||||
<h2>{{ formatMessage(messages.subscriptionTitle) }}</h2>
|
<h2>{{ formatMessage(messages.subscriptionTitle) }}</h2>
|
||||||
<p>{{ formatMessage(messages.subscriptionDescription) }}</p>
|
<p>{{ formatMessage(messages.subscriptionDescription) }}</p>
|
||||||
@@ -51,18 +52,35 @@
|
|||||||
{{
|
{{
|
||||||
formatPrice(
|
formatPrice(
|
||||||
vintl.locale,
|
vintl.locale,
|
||||||
midasSubscriptionPrice.prices.intervals[midasCharge.subscription_interval],
|
midasSubscriptionPrice.prices.intervals[midasSubscription.interval],
|
||||||
midasSubscriptionPrice.currency_code,
|
midasSubscriptionPrice.currency_code,
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/
|
/
|
||||||
{{ midasCharge.subscription_interval }}
|
{{ midasSubscription.interval }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
{{ formatPrice(vintl.locale, price.prices.intervals.monthly, price.currency_code) }}
|
{{ formatPrice(vintl.locale, price.prices.intervals.monthly, price.currency_code) }}
|
||||||
/ month
|
/ month
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</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">
|
<template v-if="midasCharge">
|
||||||
<span
|
<span
|
||||||
v-if="
|
v-if="
|
||||||
@@ -88,12 +106,24 @@
|
|||||||
<span v-else-if="midasCharge.status === 'cancelled'" class="text-sm text-secondary">
|
<span v-else-if="midasCharge.status === 'cancelled'" class="text-sm text-secondary">
|
||||||
Expires {{ $dayjs(midasCharge.due).format('MMMM D, YYYY') }}
|
Expires {{ $dayjs(midasCharge.due).format('MMMM D, YYYY') }}
|
||||||
</span>
|
</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>
|
</template>
|
||||||
|
|
||||||
<span v-else class="text-sm text-secondary">
|
<span v-else class="text-sm text-secondary">
|
||||||
Or
|
Or
|
||||||
{{ formatPrice(vintl.locale, price.prices.intervals.yearly, price.currency_code) }}
|
{{ formatPrice(vintl.locale, price.prices.intervals.yearly, price.currency_code) }} /
|
||||||
/ year (save
|
year (save
|
||||||
{{
|
{{
|
||||||
calculateSavings(price.prices.intervals.monthly, price.prices.intervals.yearly)
|
calculateSavings(price.prices.intervals.monthly, price.prices.intervals.yearly)
|
||||||
}}%)!
|
}}%)!
|
||||||
@@ -168,8 +198,7 @@
|
|||||||
@click="switchMidasInterval(oppositeInterval)"
|
@click="switchMidasInterval(oppositeInterval)"
|
||||||
>
|
>
|
||||||
<SpinnerIcon v-if="changingInterval" class="animate-spin" />
|
<SpinnerIcon v-if="changingInterval" class="animate-spin" />
|
||||||
<TransferIcon v-else />
|
<TransferIcon v-else /> {{ changingInterval ? 'Switching' : 'Switch' }} to
|
||||||
{{ changingInterval ? 'Switching' : 'Switch' }} to
|
|
||||||
{{ oppositeInterval }}
|
{{ oppositeInterval }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
@@ -207,7 +236,11 @@
|
|||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<ModrinthServersIcon class="flex h-8 w-fit" />
|
<ModrinthServersIcon class="flex h-8 w-fit" />
|
||||||
<div class="flex flex-col gap-2">
|
<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">
|
<div v-else class="w-fit">
|
||||||
<p>
|
<p>
|
||||||
A linked server couldn't be found for this subscription. There are a few possible
|
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">
|
<div class="flex items-center gap-2">
|
||||||
<CheckCircleIcon class="h-5 w-5 text-brand" />
|
<CheckCircleIcon class="h-5 w-5 text-brand" />
|
||||||
<span>
|
<span>
|
||||||
{{ getPyroProduct(subscription)?.metadata?.cpu / 2 }}
|
{{ getPyroProduct(subscription)?.metadata?.cpu / 2 }} Shared CPUs (Bursts up
|
||||||
Shared CPUs (Bursts up to
|
to {{ getPyroProduct(subscription)?.metadata?.cpu }} CPUs)
|
||||||
{{ getPyroProduct(subscription)?.metadata?.cpu }} CPUs)
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -285,16 +317,60 @@
|
|||||||
</span>
|
</span>
|
||||||
<span>/{{ subscription.interval.replace('ly', '') }}</span>
|
<span>/{{ subscription.interval.replace('ly', '') }}</span>
|
||||||
</div>
|
</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">
|
<div v-if="getPyroCharge(subscription)" class="mb-4 flex flex-col items-end">
|
||||||
<span class="text-sm text-secondary">
|
<span class="text-sm text-secondary">
|
||||||
Since
|
Since {{ $dayjs(subscription.created).format('MMMM D, YYYY') }}
|
||||||
{{ $dayjs(subscription.created).format('MMMM D, YYYY') }}
|
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="getPyroCharge(subscription).status === 'open'"
|
v-if="getPyroCharge(subscription).status === 'open'"
|
||||||
class="text-sm text-secondary"
|
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') }}
|
{{ $dayjs(getPyroCharge(subscription).due).format('MMMM D, YYYY') }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -308,8 +384,7 @@
|
|||||||
v-else-if="getPyroCharge(subscription).status === 'cancelled'"
|
v-else-if="getPyroCharge(subscription).status === 'cancelled'"
|
||||||
class="text-sm text-secondary"
|
class="text-sm text-secondary"
|
||||||
>
|
>
|
||||||
Expires
|
Expires {{ $dayjs(getPyroCharge(subscription).due).format('MMMM D, YYYY') }}
|
||||||
{{ $dayjs(getPyroCharge(subscription).due).format('MMMM D, YYYY') }}
|
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-else-if="getPyroCharge(subscription).status === 'failed'"
|
v-else-if="getPyroCharge(subscription).status === 'failed'"
|
||||||
@@ -404,37 +479,6 @@
|
|||||||
:payment-methods="paymentMethods"
|
:payment-methods="paymentMethods"
|
||||||
:return-url="`${config.public.siteUrl}/settings/billing`"
|
: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
|
<AddPaymentMethodModal
|
||||||
ref="addPaymentMethodModal"
|
ref="addPaymentMethodModal"
|
||||||
:publishable-key="config.public.stripePublishableKey"
|
:publishable-key="config.public.stripePublishableKey"
|
||||||
@@ -588,6 +632,7 @@ import { computed, ref } from 'vue'
|
|||||||
import { useBaseFetch } from '@/composables/fetch.js'
|
import { useBaseFetch } from '@/composables/fetch.js'
|
||||||
import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue'
|
import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue'
|
||||||
import ServerListing from '~/components/ui/servers/ServerListing.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 { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||||
import { products } from '~/generated/state.json'
|
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))
|
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) => {
|
const getPyroCharge = (subscription) => {
|
||||||
if (!subscription || !charges.value) return null
|
if (!subscription || !charges.value) return null
|
||||||
return charges.value.find(
|
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()
|
return nextRam < curRam ? 'downgrade' : 'upgrade'
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCapacityStatuses(serverId, product) {
|
const modalCancel = ref(null)
|
||||||
if (product) {
|
|
||||||
try {
|
const upgradeModal = ref(null)
|
||||||
return {
|
const showPyroUpgradeModal = (subscription) => {
|
||||||
custom: await useServersFetch(`servers/${serverId}/upgrade-stock`, {
|
upgradeModal.value?.open(subscription?.metadata?.id)
|
||||||
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 resubscribePyro = async (subscriptionId, wasSuspended) => {
|
const resubscribePyro = async (subscriptionId, wasSuspended) => {
|
||||||
@@ -1093,6 +1086,7 @@ function showCancellationSurvey(subscription) {
|
|||||||
window.Tally.openPopup(formId, popupOptions)
|
window.Tally.openPopup(formId, popupOptions)
|
||||||
} else {
|
} else {
|
||||||
console.warn('Tally script not yet loaded')
|
console.warn('Tally script not yet loaded')
|
||||||
|
cancelSubscription(subscription.id, true)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error opening Tally popup:', 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>
|
</script>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import _ChevronRightIcon from './icons/chevron-right.svg?component'
|
|||||||
import _ClearIcon from './icons/clear.svg?component'
|
import _ClearIcon from './icons/clear.svg?component'
|
||||||
import _ClientIcon from './icons/client.svg?component'
|
import _ClientIcon from './icons/client.svg?component'
|
||||||
import _ClipboardCopyIcon from './icons/clipboard-copy.svg?component'
|
import _ClipboardCopyIcon from './icons/clipboard-copy.svg?component'
|
||||||
|
import _ClockIcon from './icons/clock.svg?component'
|
||||||
import _CloudIcon from './icons/cloud.svg?component'
|
import _CloudIcon from './icons/cloud.svg?component'
|
||||||
import _CodeIcon from './icons/code.svg?component'
|
import _CodeIcon from './icons/code.svg?component'
|
||||||
import _CoffeeIcon from './icons/coffee.svg?component'
|
import _CoffeeIcon from './icons/coffee.svg?component'
|
||||||
@@ -139,6 +140,7 @@ import _ReplyIcon from './icons/reply.svg?component'
|
|||||||
import _ReportIcon from './icons/report.svg?component'
|
import _ReportIcon from './icons/report.svg?component'
|
||||||
import _RestoreIcon from './icons/restore.svg?component'
|
import _RestoreIcon from './icons/restore.svg?component'
|
||||||
import _RightArrowIcon from './icons/right-arrow.svg?component'
|
import _RightArrowIcon from './icons/right-arrow.svg?component'
|
||||||
|
import _RocketIcon from './icons/rocket.svg?component'
|
||||||
import _RotateClockwiseIcon from './icons/rotate-clockwise.svg?component'
|
import _RotateClockwiseIcon from './icons/rotate-clockwise.svg?component'
|
||||||
import _RotateCounterClockwiseIcon from './icons/rotate-counter-clockwise.svg?component'
|
import _RotateCounterClockwiseIcon from './icons/rotate-counter-clockwise.svg?component'
|
||||||
import _RssIcon from './icons/rss.svg?component'
|
import _RssIcon from './icons/rss.svg?component'
|
||||||
@@ -229,6 +231,7 @@ export const ChevronRightIcon = _ChevronRightIcon
|
|||||||
export const ClearIcon = _ClearIcon
|
export const ClearIcon = _ClearIcon
|
||||||
export const ClientIcon = _ClientIcon
|
export const ClientIcon = _ClientIcon
|
||||||
export const ClipboardCopyIcon = _ClipboardCopyIcon
|
export const ClipboardCopyIcon = _ClipboardCopyIcon
|
||||||
|
export const ClockIcon = _ClockIcon
|
||||||
export const CloudIcon = _CloudIcon
|
export const CloudIcon = _CloudIcon
|
||||||
export const CodeIcon = _CodeIcon
|
export const CodeIcon = _CodeIcon
|
||||||
export const CoffeeIcon = _CoffeeIcon
|
export const CoffeeIcon = _CoffeeIcon
|
||||||
@@ -335,6 +338,7 @@ export const ReplyIcon = _ReplyIcon
|
|||||||
export const ReportIcon = _ReportIcon
|
export const ReportIcon = _ReportIcon
|
||||||
export const RestoreIcon = _RestoreIcon
|
export const RestoreIcon = _RestoreIcon
|
||||||
export const RightArrowIcon = _RightArrowIcon
|
export const RightArrowIcon = _RightArrowIcon
|
||||||
|
export const RocketIcon = _RocketIcon
|
||||||
export const RotateClockwiseIcon = _RotateClockwiseIcon
|
export const RotateClockwiseIcon = _RotateClockwiseIcon
|
||||||
export const RotateCounterClockwiseIcon = _RotateCounterClockwiseIcon
|
export const RotateCounterClockwiseIcon = _RotateCounterClockwiseIcon
|
||||||
export const RssIcon = _RssIcon
|
export const RssIcon = _RssIcon
|
||||||
|
|||||||
1
packages/assets/icons/clock.svg
Normal file
1
packages/assets/icons/clock.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock-icon lucide-clock"><path d="M12 6v6l4 2"/><circle cx="12" cy="12" r="10"/></svg>
|
||||||
|
After Width: | Height: | Size: 288 B |
1
packages/assets/icons/rocket.svg
Normal file
1
packages/assets/icons/rocket.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rocket-icon lucide-rocket"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg>
|
||||||
|
After Width: | Height: | Size: 544 B |
@@ -3,7 +3,7 @@ import { computed } from 'vue'
|
|||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'medal-promo'
|
||||||
size?: 'standard' | 'large' | 'small'
|
size?: 'standard' | 'large' | 'small'
|
||||||
circular?: boolean
|
circular?: boolean
|
||||||
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text'
|
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text'
|
||||||
@@ -34,6 +34,7 @@ const highlightedColorVar = computed(() => {
|
|||||||
return 'var(--color-orange-highlight)'
|
return 'var(--color-orange-highlight)'
|
||||||
case 'green':
|
case 'green':
|
||||||
return 'var(--color-green-highlight)'
|
return 'var(--color-green-highlight)'
|
||||||
|
case 'medal-promo':
|
||||||
case 'blue':
|
case 'blue':
|
||||||
return 'var(--color-blue-highlight)'
|
return 'var(--color-blue-highlight)'
|
||||||
case 'purple':
|
case 'purple':
|
||||||
@@ -58,6 +59,8 @@ const colorVar = computed(() => {
|
|||||||
return 'var(--color-blue)'
|
return 'var(--color-blue)'
|
||||||
case 'purple':
|
case 'purple':
|
||||||
return 'var(--color-purple)'
|
return 'var(--color-purple)'
|
||||||
|
case 'medal-promo':
|
||||||
|
return 'var(--medal-promotion-text-orange)'
|
||||||
case 'standard':
|
case 'standard':
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
|
|||||||
128
packages/ui/src/components/base/OptionGroup.vue
Normal file
128
packages/ui/src/components/base/OptionGroup.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<nav
|
||||||
|
ref="scrollContainer"
|
||||||
|
class="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="(option, index) in options"
|
||||||
|
:key="`option-group-${index}`"
|
||||||
|
ref="optionButtons"
|
||||||
|
class="button-animation z-[1] flex flex-row items-center gap-2 rounded-full bg-transparent px-4 py-2 font-semibold"
|
||||||
|
:class="{
|
||||||
|
'text-button-textSelected': modelValue === option,
|
||||||
|
'text-primary': modelValue !== option,
|
||||||
|
}"
|
||||||
|
@click="setOption(option)"
|
||||||
|
>
|
||||||
|
<slot :option="option" :selected="modelValue === option" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full bg-button-bgSelected p-1"
|
||||||
|
:style="{
|
||||||
|
left: sliderLeftPx,
|
||||||
|
top: sliderTopPx,
|
||||||
|
right: sliderRightPx,
|
||||||
|
bottom: sliderBottomPx,
|
||||||
|
opacity: initialized ? 1 : 0,
|
||||||
|
}"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" generic="T">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const modelValue = defineModel<T>({ required: true })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
options: T[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const scrollContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const sliderLeft = ref(4)
|
||||||
|
const sliderTop = ref(4)
|
||||||
|
const sliderRight = ref(4)
|
||||||
|
const sliderBottom = ref(4)
|
||||||
|
|
||||||
|
const sliderLeftPx = computed(() => `${sliderLeft.value}px`)
|
||||||
|
const sliderTopPx = computed(() => `${sliderTop.value}px`)
|
||||||
|
const sliderRightPx = computed(() => `${sliderRight.value}px`)
|
||||||
|
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
|
||||||
|
|
||||||
|
const optionButtons = ref()
|
||||||
|
|
||||||
|
const initialized = ref(false)
|
||||||
|
|
||||||
|
function setOption(option: T) {
|
||||||
|
modelValue.value = option
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(modelValue, () => {
|
||||||
|
startAnimation(props.options.indexOf(modelValue.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
function startAnimation(index: number) {
|
||||||
|
const el = optionButtons.value[index]
|
||||||
|
|
||||||
|
if (!el || !el.offsetParent) return
|
||||||
|
|
||||||
|
const newValues = {
|
||||||
|
left: el.offsetLeft,
|
||||||
|
top: el.offsetTop,
|
||||||
|
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
|
||||||
|
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sliderLeft.value === 4 && sliderRight.value === 4) {
|
||||||
|
sliderLeft.value = newValues.left
|
||||||
|
sliderRight.value = newValues.right
|
||||||
|
sliderTop.value = newValues.top
|
||||||
|
sliderBottom.value = newValues.bottom
|
||||||
|
} else {
|
||||||
|
const delay = 200
|
||||||
|
|
||||||
|
if (newValues.left < sliderLeft.value) {
|
||||||
|
sliderLeft.value = newValues.left
|
||||||
|
setTimeout(() => {
|
||||||
|
sliderRight.value = newValues.right
|
||||||
|
}, delay)
|
||||||
|
} else {
|
||||||
|
sliderRight.value = newValues.right
|
||||||
|
setTimeout(() => {
|
||||||
|
sliderLeft.value = newValues.left
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newValues.top < sliderTop.value) {
|
||||||
|
sliderTop.value = newValues.top
|
||||||
|
setTimeout(() => {
|
||||||
|
sliderBottom.value = newValues.bottom
|
||||||
|
}, delay)
|
||||||
|
} else {
|
||||||
|
sliderBottom.value = newValues.bottom
|
||||||
|
setTimeout(() => {
|
||||||
|
sliderTop.value = newValues.top
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initialized.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
startAnimation(props.options.indexOf(modelValue.value))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.navtabs-transition {
|
||||||
|
transition:
|
||||||
|
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-shadow {
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
136
packages/ui/src/components/billing/ModalBasedServerPlan.vue
Normal file
136
packages/ui/src/components/billing/ModalBasedServerPlan.vue
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { InfoIcon } from '@modrinth/assets'
|
||||||
|
import { formatPrice } from '@modrinth/utils'
|
||||||
|
import { type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||||
|
import { Menu } from 'floating-vue'
|
||||||
|
import { computed, inject, type Ref } from 'vue'
|
||||||
|
|
||||||
|
import { monthsInInterval, type ServerBillingInterval, type ServerPlan } from '../../utils/billing'
|
||||||
|
import ServersSpecs from './ServersSpecs.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
plan: ServerPlan
|
||||||
|
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: ServerPlan): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { formatMessage, locale } = useVIntl()
|
||||||
|
|
||||||
|
// 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 = props.plan.prices?.find((x) => x.currency_code === currency)?.prices?.intervals?.[
|
||||||
|
selectedInterval.value
|
||||||
|
]
|
||||||
|
if (!total) return undefined
|
||||||
|
return total / monthsInInterval[selectedInterval.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
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(locale, 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 class="w-fit rounded-md border border-contrast/10 p-3 shadow-lg">
|
||||||
|
<ServersSpecs
|
||||||
|
:ram="plan.metadata.ram!"
|
||||||
|
:storage="plan.metadata.storage!"
|
||||||
|
:cpus="plan.metadata.cpu!"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
SpinnerIcon,
|
SpinnerIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
|
import type { UserSubscription } from '@modrinth/utils'
|
||||||
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||||
import type Stripe from 'stripe'
|
import type Stripe from 'stripe'
|
||||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||||
@@ -26,6 +27,7 @@ import type {
|
|||||||
import { ButtonStyled } from '../index'
|
import { ButtonStyled } from '../index'
|
||||||
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
|
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
|
||||||
import NewModal from '../modal/NewModal.vue'
|
import NewModal from '../modal/NewModal.vue'
|
||||||
|
import PlanSelector from './ServersPurchase0Plan.vue'
|
||||||
import RegionSelector from './ServersPurchase1Region.vue'
|
import RegionSelector from './ServersPurchase1Region.vue'
|
||||||
import PaymentMethodSelector from './ServersPurchase2PaymentMethod.vue'
|
import PaymentMethodSelector from './ServersPurchase2PaymentMethod.vue'
|
||||||
import ConfirmPurchase from './ServersPurchase3Review.vue'
|
import ConfirmPurchase from './ServersPurchase3Review.vue'
|
||||||
@@ -46,12 +48,16 @@ const props = defineProps<{
|
|||||||
pings: RegionPing[]
|
pings: RegionPing[]
|
||||||
regions: ServerRegion[]
|
regions: ServerRegion[]
|
||||||
availableProducts: ServerPlan[]
|
availableProducts: ServerPlan[]
|
||||||
|
planStage?: boolean
|
||||||
|
existingPlan?: ServerPlan
|
||||||
|
existingSubscription?: UserSubscription
|
||||||
refreshPaymentMethods: () => Promise<void>
|
refreshPaymentMethods: () => Promise<void>
|
||||||
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
|
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
|
||||||
initiatePayment: (
|
initiatePayment: (
|
||||||
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
|
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
|
||||||
) => Promise<UpdatePaymentIntentResponse | CreatePaymentIntentResponse>
|
) => Promise<UpdatePaymentIntentResponse | CreatePaymentIntentResponse | null>
|
||||||
onError: (err: Error) => void
|
onError: (err: Error) => void
|
||||||
|
onFinalizeNoPaymentChange?: () => Promise<void>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||||
@@ -78,6 +84,7 @@ const {
|
|||||||
hasPaymentMethod,
|
hasPaymentMethod,
|
||||||
submitPayment,
|
submitPayment,
|
||||||
completingPurchase,
|
completingPurchase,
|
||||||
|
noPaymentRequired,
|
||||||
} = useStripe(
|
} = useStripe(
|
||||||
props.publishableKey,
|
props.publishableKey,
|
||||||
props.customer,
|
props.customer,
|
||||||
@@ -95,11 +102,14 @@ const customServer = ref<boolean>(false)
|
|||||||
const acceptedEula = ref<boolean>(false)
|
const acceptedEula = ref<boolean>(false)
|
||||||
const skipPaymentMethods = ref<boolean>(true)
|
const skipPaymentMethods = ref<boolean>(true)
|
||||||
|
|
||||||
type Step = 'region' | 'payment' | 'review'
|
type Step = 'plan' | 'region' | 'payment' | 'review'
|
||||||
|
|
||||||
const steps: Step[] = ['region', 'payment', 'review']
|
const steps: Step[] = props.planStage
|
||||||
|
? (['plan', 'region', 'payment', 'review'] as Step[])
|
||||||
|
: (['region', 'payment', 'review'] as Step[])
|
||||||
|
|
||||||
const titles: Record<Step, MessageDescriptor> = {
|
const titles: Record<Step, MessageDescriptor> = {
|
||||||
|
plan: defineMessage({ id: 'servers.purchase.step.plan.title', defaultMessage: 'Plan' }),
|
||||||
region: defineMessage({ id: 'servers.purchase.step.region.title', defaultMessage: 'Region' }),
|
region: defineMessage({ id: 'servers.purchase.step.region.title', defaultMessage: 'Region' }),
|
||||||
payment: defineMessage({
|
payment: defineMessage({
|
||||||
id: 'servers.purchase.step.payment.title',
|
id: 'servers.purchase.step.payment.title',
|
||||||
@@ -132,12 +142,26 @@ const nextStep = computed(() =>
|
|||||||
|
|
||||||
const canProceed = computed(() => {
|
const canProceed = computed(() => {
|
||||||
switch (currentStep.value) {
|
switch (currentStep.value) {
|
||||||
|
case 'plan':
|
||||||
|
console.log('Plan step:', {
|
||||||
|
customServer: customServer.value,
|
||||||
|
selectedPlan: selectedPlan.value,
|
||||||
|
existingPlan: props.existingPlan,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
customServer.value ||
|
||||||
|
(!!selectedPlan.value &&
|
||||||
|
(!props.existingPlan || selectedPlan.value.id !== props.existingPlan.id))
|
||||||
|
)
|
||||||
case 'region':
|
case 'region':
|
||||||
return selectedRegion.value && selectedPlan.value && selectedInterval.value
|
return selectedRegion.value && selectedPlan.value && selectedInterval.value
|
||||||
case 'payment':
|
case 'payment':
|
||||||
return selectedPaymentMethod.value || !loadingElements.value
|
return selectedPaymentMethod.value || !loadingElements.value
|
||||||
case 'review':
|
case 'review':
|
||||||
return acceptedEula.value && hasPaymentMethod.value && !completingPurchase.value
|
return (
|
||||||
|
(noPaymentRequired.value || (acceptedEula.value && hasPaymentMethod.value)) &&
|
||||||
|
!completingPurchase.value
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -145,6 +169,8 @@ const canProceed = computed(() => {
|
|||||||
|
|
||||||
async function beforeProceed(step: string) {
|
async function beforeProceed(step: string) {
|
||||||
switch (step) {
|
switch (step) {
|
||||||
|
case 'plan':
|
||||||
|
return true
|
||||||
case 'region':
|
case 'region':
|
||||||
return true
|
return true
|
||||||
case 'payment':
|
case 'payment':
|
||||||
@@ -160,6 +186,9 @@ async function beforeProceed(step: string) {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
case 'review':
|
case 'review':
|
||||||
|
if (noPaymentRequired.value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if (selectedPaymentMethod.value) {
|
if (selectedPaymentMethod.value) {
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
@@ -200,12 +229,31 @@ async function setStep(step: Step | undefined, skipValidation = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(selectedPlan, () => {
|
watch(selectedPlan, () => {
|
||||||
console.log(selectedPlan.value)
|
if (currentStep.value === 'plan') {
|
||||||
|
customServer.value = !selectedPlan.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const defaultPlan = computed<ServerPlan | undefined>(() => {
|
||||||
|
return (
|
||||||
|
props.availableProducts.find((p) => p?.metadata?.type === 'pyro' && p.metadata.ram === 6144) ??
|
||||||
|
props.availableProducts.find((p) => p?.metadata?.type === 'pyro') ??
|
||||||
|
props.availableProducts[0]
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function begin(interval: ServerBillingInterval, plan?: ServerPlan, project?: string) {
|
function begin(interval: ServerBillingInterval, plan?: ServerPlan, project?: string) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
selectedPlan.value = plan
|
|
||||||
|
if (plan === null) {
|
||||||
|
// Explicitly open in custom mode
|
||||||
|
selectedPlan.value = undefined
|
||||||
|
customServer.value = true
|
||||||
|
} else {
|
||||||
|
selectedPlan.value = plan ?? defaultPlan.value
|
||||||
|
customServer.value = !selectedPlan.value
|
||||||
|
}
|
||||||
|
|
||||||
selectedInterval.value = interval
|
selectedInterval.value = interval
|
||||||
customServer.value = !selectedPlan.value
|
customServer.value = !selectedPlan.value
|
||||||
selectedPaymentMethod.value = undefined
|
selectedPaymentMethod.value = undefined
|
||||||
@@ -218,16 +266,42 @@ function begin(interval: ServerBillingInterval, plan?: ServerPlan, project?: str
|
|||||||
defineExpose({
|
defineExpose({
|
||||||
show: begin,
|
show: begin,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'hide'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function handleChooseCustom() {
|
||||||
|
customServer.value = true
|
||||||
|
selectedPlan.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
async function changePaymentMethod() {
|
||||||
|
skipPaymentMethods.value = false
|
||||||
|
selectedPaymentMethod.value = undefined
|
||||||
|
await setStep('payment', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToBreadcrumbStep(id: string) {
|
||||||
|
if (id === 'payment') {
|
||||||
|
return changePaymentMethod()
|
||||||
|
}
|
||||||
|
|
||||||
|
return setStep(id as Step, true)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<NewModal ref="modal">
|
<NewModal ref="modal" @hide="$emit('hide')">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex items-center gap-1 font-bold text-secondary">
|
<div class="flex items-center gap-1 font-bold text-secondary">
|
||||||
<template v-for="(title, id, index) in titles" :key="id">
|
<template v-for="(title, id, index) in titles" :key="id">
|
||||||
<button
|
<button
|
||||||
v-if="index < currentStepIndex"
|
v-if="index < currentStepIndex"
|
||||||
class="bg-transparent active:scale-95 font-bold text-secondary p-0"
|
class="bg-transparent active:scale-95 font-bold text-secondary p-0"
|
||||||
@click="setStep(id, true)"
|
@click="goToBreadcrumbStep(id as string)"
|
||||||
>
|
>
|
||||||
{{ formatMessage(title) }}
|
{{ formatMessage(title) }}
|
||||||
</button>
|
</button>
|
||||||
@@ -248,8 +322,17 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="w-[40rem] max-w-full">
|
<div class="w-[40rem] max-w-full">
|
||||||
|
<PlanSelector
|
||||||
|
v-if="currentStep === 'plan'"
|
||||||
|
v-model:plan="selectedPlan"
|
||||||
|
v-model:interval="selectedInterval"
|
||||||
|
:existing-plan="existingPlan"
|
||||||
|
:available-products="availableProducts"
|
||||||
|
:currency="currency"
|
||||||
|
@choose-custom="handleChooseCustom"
|
||||||
|
/>
|
||||||
<RegionSelector
|
<RegionSelector
|
||||||
v-if="currentStep === 'region'"
|
v-else-if="currentStep === 'region'"
|
||||||
v-model:region="selectedRegion"
|
v-model:region="selectedRegion"
|
||||||
v-model:plan="selectedPlan"
|
v-model:plan="selectedPlan"
|
||||||
:regions="regions"
|
:regions="regions"
|
||||||
@@ -271,7 +354,7 @@ defineExpose({
|
|||||||
<ConfirmPurchase
|
<ConfirmPurchase
|
||||||
v-else-if="
|
v-else-if="
|
||||||
currentStep === 'review' &&
|
currentStep === 'review' &&
|
||||||
hasPaymentMethod &&
|
(hasPaymentMethod || noPaymentRequired) &&
|
||||||
currentRegion &&
|
currentRegion &&
|
||||||
selectedInterval &&
|
selectedInterval &&
|
||||||
selectedPlan
|
selectedPlan
|
||||||
@@ -284,14 +367,13 @@ defineExpose({
|
|||||||
:ping="currentPing"
|
:ping="currentPing"
|
||||||
:loading="paymentMethodLoading"
|
:loading="paymentMethodLoading"
|
||||||
:selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod"
|
:selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod"
|
||||||
|
:has-payment-method="hasPaymentMethod"
|
||||||
:tax="tax"
|
:tax="tax"
|
||||||
:total="total"
|
:total="total"
|
||||||
@change-payment-method="
|
:no-payment-required="noPaymentRequired"
|
||||||
() => {
|
:existing-plan="existingPlan"
|
||||||
skipPaymentMethods = false
|
:existing-subscription="existingSubscription"
|
||||||
setStep('payment', true)
|
@change-payment-method="changePaymentMethod"
|
||||||
}
|
|
||||||
"
|
|
||||||
@reload-payment-intent="reloadPaymentIntent"
|
@reload-payment-intent="reloadPaymentIntent"
|
||||||
/>
|
/>
|
||||||
<div v-else>Something went wrong</div>
|
<div v-else>Something went wrong</div>
|
||||||
@@ -329,17 +411,33 @@ defineExpose({
|
|||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button
|
<button
|
||||||
v-tooltip="
|
v-tooltip="
|
||||||
currentStep === 'review' && !acceptedEula
|
currentStep === 'review' && !acceptedEula && !noPaymentRequired
|
||||||
? 'You must accept the Minecraft EULA to proceed.'
|
? 'You must accept the Minecraft EULA to proceed.'
|
||||||
: undefined
|
: undefined
|
||||||
"
|
"
|
||||||
:disabled="!canProceed"
|
:disabled="!canProceed"
|
||||||
@click="setStep(nextStep)"
|
@click="
|
||||||
|
noPaymentRequired && currentStep === 'review'
|
||||||
|
? (async () => {
|
||||||
|
if (props.onFinalizeNoPaymentChange) {
|
||||||
|
try {
|
||||||
|
await props.onFinalizeNoPaymentChange()
|
||||||
|
} catch (e) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
modal?.hide()
|
||||||
|
})()
|
||||||
|
: setStep(nextStep)
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<template v-if="currentStep === 'review'">
|
<template v-if="currentStep === 'review'">
|
||||||
<SpinnerIcon v-if="completingPurchase" class="animate-spin" />
|
<template v-if="noPaymentRequired"><CheckCircleIcon /> Confirm Change</template>
|
||||||
<CheckCircleIcon v-else />
|
<template v-else>
|
||||||
Subscribe
|
<SpinnerIcon v-if="completingPurchase" class="animate-spin" />
|
||||||
|
<CheckCircleIcon v-else />
|
||||||
|
Subscribe
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
{{ formatMessage(commonMessages.nextButton) }} <RightArrowIcon />
|
{{ formatMessage(commonMessages.nextButton) }} <RightArrowIcon />
|
||||||
|
|||||||
226
packages/ui/src/components/billing/ServersPurchase0Plan.vue
Normal file
226
packages/ui/src/components/billing/ServersPurchase0Plan.vue
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { formatPrice } from '@modrinth/utils'
|
||||||
|
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
|
import { computed, provide } from 'vue'
|
||||||
|
|
||||||
|
import { monthsInInterval, type ServerBillingInterval, type ServerPlan } from '../../utils/billing'
|
||||||
|
import OptionGroup from '../base/OptionGroup.vue'
|
||||||
|
import ModalBasedServerPlan from './ModalBasedServerPlan.vue'
|
||||||
|
|
||||||
|
const { formatMessage, locale } = useVIntl()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
availableProducts: ServerPlan[]
|
||||||
|
currency: string
|
||||||
|
existingPlan?: ServerPlan
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const availableBillingIntervals = ['monthly', 'quarterly']
|
||||||
|
|
||||||
|
const selectedPlan = defineModel<ServerPlan>('plan')
|
||||||
|
const selectedInterval = defineModel<ServerBillingInterval>('interval')
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'choose-custom'): 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',
|
||||||
|
},
|
||||||
|
getStarted: {
|
||||||
|
id: 'servers.purchase.step.plan.get-started',
|
||||||
|
defaultMessage: 'Get started',
|
||||||
|
},
|
||||||
|
billed: {
|
||||||
|
id: 'servers.purchase.step.plan.billed',
|
||||||
|
defaultMessage: 'billed {interval}',
|
||||||
|
},
|
||||||
|
smallDesc: {
|
||||||
|
id: 'servers.purchase.step.plan.small.desc',
|
||||||
|
defaultMessage: 'Perfect for 1–5 friends with a few light mods.',
|
||||||
|
},
|
||||||
|
mediumDesc: {
|
||||||
|
id: 'servers.purchase.step.plan.medium.desc',
|
||||||
|
defaultMessage: 'Great for 6–15 players and multiple mods.',
|
||||||
|
},
|
||||||
|
largeDesc: {
|
||||||
|
id: 'servers.purchase.step.plan.large.desc',
|
||||||
|
defaultMessage: 'Ideal for 15–25 players, modpacks, or heavy modding.',
|
||||||
|
},
|
||||||
|
customDesc: {
|
||||||
|
id: 'servers.purchase.step.plan.custom.desc',
|
||||||
|
defaultMessage: 'Pick a customized plan with just the specs you need.',
|
||||||
|
},
|
||||||
|
mostPopular: {
|
||||||
|
id: 'servers.purchase.step.plan.most-popular',
|
||||||
|
defaultMessage: 'Most Popular',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSameAsExistingPlan = computed(() => {
|
||||||
|
return !!(
|
||||||
|
props.existingPlan &&
|
||||||
|
selectedPlan.value &&
|
||||||
|
props.existingPlan.id === selectedPlan.value.id
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const plansByRam = computed(() => {
|
||||||
|
const byName: Record<'small' | 'medium' | 'large', ServerPlan | undefined> = {
|
||||||
|
small: undefined,
|
||||||
|
medium: undefined,
|
||||||
|
large: undefined,
|
||||||
|
}
|
||||||
|
for (const p of props.availableProducts) {
|
||||||
|
if (p?.metadata?.type !== 'pyro') continue
|
||||||
|
if (p.metadata.ram === 4096) byName.small = p
|
||||||
|
else if (p.metadata.ram === 6144) byName.medium = p
|
||||||
|
else if (p.metadata.ram === 8192) byName.large = p
|
||||||
|
}
|
||||||
|
return byName
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleCustomPlan() {
|
||||||
|
emit('choose-custom')
|
||||||
|
}
|
||||||
|
|
||||||
|
function pricePerMonth(plan?: ServerPlan) {
|
||||||
|
if (!plan) return undefined
|
||||||
|
const total = plan.prices?.find((x) => x.currency_code === props.currency)?.prices?.intervals?.[
|
||||||
|
selectedInterval.value!
|
||||||
|
]
|
||||||
|
if (!total) return undefined
|
||||||
|
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 ramGb = (p?.metadata?.ram ?? 0) / 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) {
|
||||||
|
const perMonth = pricePerMonth(p)
|
||||||
|
if (perMonth && (min === undefined || perMonth < min)) min = perMonth
|
||||||
|
}
|
||||||
|
return min
|
||||||
|
})
|
||||||
|
|
||||||
|
provide('currency', props.currency)
|
||||||
|
provide('selectedInterval', selectedInterval)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 mb-5 !mt-0">
|
||||||
|
<span></span>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
enter-from-class="opacity-0 max-h-0"
|
||||||
|
enter-to-class="opacity-100 max-h-20"
|
||||||
|
leave-active-class="transition-all duration-200 ease-in"
|
||||||
|
leave-from-class="opacity-100 max-h-20"
|
||||||
|
leave-to-class="opacity-0 max-h-0"
|
||||||
|
>
|
||||||
|
<div v-if="isSameAsExistingPlan" class="text-orange mb-5 text-center" role="alert">
|
||||||
|
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
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<span class="m-0 text-lg font-bold text-contrast">
|
||||||
|
{{ formatPrice(locale, customStartingPrice, currency, true) }}
|
||||||
|
<span class="text-sm font-semibold text-secondary">
|
||||||
|
/ 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(locale, customPricePerGb, currency, true) }} / GB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -237,7 +237,7 @@ onMounted(() => {
|
|||||||
>{{ formatPrice(locale, selectedPrice, currency, true) }} / month</span
|
>{{ formatPrice(locale, selectedPrice, currency, true) }} / month</span
|
||||||
><span v-if="interval !== 'monthly'">, billed {{ interval }}</span>
|
><span v-if="interval !== 'monthly'">, billed {{ interval }}</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="bg-bg rounded-xl p-4 mt-2 text-secondary">
|
<div class="bg-bg rounded-xl p-4 mt-2 text-secondary h-14">
|
||||||
<div v-if="checkingCustomStock" class="flex gap-2 items-center">
|
<div v-if="checkingCustomStock" class="flex gap-2 items-center">
|
||||||
<SpinnerIcon class="size-5 shrink-0 animate-spin" /> Checking availability...
|
<SpinnerIcon class="size-5 shrink-0 animate-spin" /> Checking availability...
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import {
|
|||||||
SpinnerIcon,
|
SpinnerIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import { formatPrice, getPingLevel } from '@modrinth/utils'
|
import { formatPrice, getPingLevel, type UserSubscription } from '@modrinth/utils'
|
||||||
import { useVIntl } from '@vintl/vintl'
|
import { useVIntl } from '@vintl/vintl'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
import type Stripe from 'stripe'
|
import type Stripe from 'stripe'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
@@ -45,6 +46,10 @@ const props = defineProps<{
|
|||||||
ping?: number
|
ping?: number
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
selectedPaymentMethod: Stripe.PaymentMethod | undefined
|
selectedPaymentMethod: Stripe.PaymentMethod | undefined
|
||||||
|
hasPaymentMethod?: boolean
|
||||||
|
noPaymentRequired?: boolean
|
||||||
|
existingPlan?: ServerPlan
|
||||||
|
existingSubscription?: UserSubscription
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const interval = defineModel<ServerBillingInterval>('interval', { required: true })
|
const interval = defineModel<ServerBillingInterval>('interval', { required: true })
|
||||||
@@ -54,6 +59,75 @@ const prices = computed(() => {
|
|||||||
return props.plan.prices.find((x) => x.currency_code === props.currency)
|
return props.plan.prices.find((x) => x.currency_code === props.currency)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const selectedPlanPriceForInterval = computed<number | undefined>(() => {
|
||||||
|
return prices.value?.prices?.intervals?.[interval.value as keyof typeof monthsInInterval]
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingPlanPriceForInterval = computed<number | undefined>(() => {
|
||||||
|
if (!props.existingPlan) return undefined
|
||||||
|
const p = props.existingPlan.prices.find((x) => x.currency_code === props.currency)
|
||||||
|
return p?.prices?.intervals?.[interval.value as keyof typeof monthsInInterval]
|
||||||
|
})
|
||||||
|
|
||||||
|
const upgradeDeltaPrice = computed<number | undefined>(() => {
|
||||||
|
if (selectedPlanPriceForInterval.value == null || existingPlanPriceForInterval.value == null)
|
||||||
|
return undefined
|
||||||
|
return selectedPlanPriceForInterval.value - existingPlanPriceForInterval.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const isUpgrade = computed<boolean>(() => {
|
||||||
|
return (upgradeDeltaPrice.value ?? 0) > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const estimatedDaysInInterval = computed<number>(() => {
|
||||||
|
return monthsInInterval[interval.value] * 30
|
||||||
|
})
|
||||||
|
|
||||||
|
const estimatedProrationDays = computed<number | undefined>(() => {
|
||||||
|
if (!isUpgrade.value) return undefined
|
||||||
|
if (props.total == null || props.tax == null) return undefined
|
||||||
|
const subtotal = props.total - props.tax
|
||||||
|
const delta = upgradeDeltaPrice.value ?? 0
|
||||||
|
if (delta <= 0) return undefined
|
||||||
|
const fraction = Math.max(0, Math.min(1, subtotal / delta))
|
||||||
|
return Math.round(fraction * estimatedDaysInInterval.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isProratedCharge = computed<boolean>(() => {
|
||||||
|
return isUpgrade.value && (props.total ?? 0) > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const exactProrationDays = computed<number | undefined>(() => {
|
||||||
|
if (!props.existingSubscription) return undefined
|
||||||
|
const created = dayjs(props.existingSubscription.created)
|
||||||
|
if (!created.isValid()) return undefined
|
||||||
|
let next = created
|
||||||
|
const now = dayjs()
|
||||||
|
if (props.existingSubscription.interval === 'monthly') {
|
||||||
|
const cycles = now.diff(created, 'month')
|
||||||
|
next = created.add(cycles + 1, 'month')
|
||||||
|
} else if (props.existingSubscription.interval === 'quarterly') {
|
||||||
|
const months = now.diff(created, 'month')
|
||||||
|
const cycles = Math.floor(months / 3)
|
||||||
|
next = created.add((cycles + 1) * 3, 'month')
|
||||||
|
} else if (props.existingSubscription.interval === 'yearly') {
|
||||||
|
const cycles = now.diff(created, 'year')
|
||||||
|
next = created.add(cycles + 1, 'year')
|
||||||
|
} else if (props.existingSubscription.interval === 'five-days') {
|
||||||
|
const days = now.diff(created, 'day')
|
||||||
|
const cycles = Math.floor(days / 5)
|
||||||
|
next = created.add((cycles + 1) * 5, 'day')
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const days = next.diff(now, 'day')
|
||||||
|
return Math.max(0, days)
|
||||||
|
})
|
||||||
|
|
||||||
|
const prorationDays = computed<number | undefined>(
|
||||||
|
() => exactProrationDays.value ?? estimatedProrationDays.value,
|
||||||
|
)
|
||||||
|
|
||||||
const planName = computed(() => {
|
const planName = computed(() => {
|
||||||
if (!props.plan || !props.plan.metadata || props.plan.metadata.type !== 'pyro') return 'Unknown'
|
if (!props.plan || !props.plan.metadata || props.plan.metadata.type !== 'pyro') return 'Unknown'
|
||||||
const ram = props.plan.metadata.ram
|
const ram = props.plan.metadata.ram
|
||||||
@@ -198,57 +272,88 @@ function setInterval(newInterval: ServerBillingInterval) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<ExpandableInvoiceTotal
|
<template v-if="!noPaymentRequired">
|
||||||
:period="period"
|
<ExpandableInvoiceTotal
|
||||||
:currency="currency"
|
:period="isProratedCharge ? undefined : period"
|
||||||
:loading="loading"
|
:currency="currency"
|
||||||
:total="total ?? -1"
|
:loading="loading"
|
||||||
:billing-items="
|
:total="total ?? -1"
|
||||||
total !== undefined && tax !== undefined
|
:billing-items="
|
||||||
? [
|
total !== undefined && tax !== undefined
|
||||||
{
|
? [
|
||||||
title: `Modrinth Servers (${planName})`,
|
{
|
||||||
amount: total - tax,
|
title:
|
||||||
},
|
isProratedCharge && prorationDays
|
||||||
{
|
? `Modrinth Servers (${planName}) — prorated for ${prorationDays} day${
|
||||||
title: 'Tax',
|
prorationDays === 1 ? '' : 's'
|
||||||
amount: tax,
|
}`
|
||||||
},
|
: `Modrinth Servers (${planName})`,
|
||||||
]
|
amount: total - tax,
|
||||||
: []
|
},
|
||||||
"
|
{
|
||||||
/>
|
title: 'Tax',
|
||||||
|
amount: tax,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="p-4 rounded-2xl bg-table-alternateRow text-sm text-secondary leading-relaxed"
|
||||||
|
>
|
||||||
|
No payment required. Your downgrade will apply at the end of the current billing period.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex items-center pl-4 pr-2 py-3 bg-bg rounded-2xl gap-2 text-secondary">
|
<div
|
||||||
|
v-if="!noPaymentRequired"
|
||||||
|
class="mt-2 flex items-center pl-4 pr-2 py-3 bg-bg rounded-2xl gap-2 text-secondary"
|
||||||
|
>
|
||||||
<template v-if="selectedPaymentMethod">
|
<template v-if="selectedPaymentMethod">
|
||||||
<FormattedPaymentMethod :method="selectedPaymentMethod" />
|
<FormattedPaymentMethod :method="selectedPaymentMethod" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex items-center gap-2 text-red">
|
<div v-if="hasPaymentMethod" class="flex items-center gap-2 text-secondary">
|
||||||
|
<RadioButtonCheckedIcon class="text-brand" />
|
||||||
|
Using new payment method
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center gap-2 text-red">
|
||||||
<XIcon />
|
<XIcon />
|
||||||
No payment method selected
|
No payment method selected
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<ButtonStyled size="small" type="transparent">
|
<ButtonStyled size="small" type="transparent">
|
||||||
<button class="ml-auto" @click="emit('changePaymentMethod')">
|
<button class="ml-auto" @click="emit('changePaymentMethod')">
|
||||||
<template v-if="selectedPaymentMethod"> <EditIcon /> Change </template>
|
<template v-if="selectedPaymentMethod || hasPaymentMethod"> <EditIcon /> Change </template>
|
||||||
<template v-else> Select payment method <RightArrowIcon /> </template>
|
<template v-else> Select payment method <RightArrowIcon /> </template>
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
<p class="m-0 mt-4 text-sm text-secondary">
|
<p v-if="!noPaymentRequired" class="m-0 mt-4 text-sm text-secondary">
|
||||||
|
<template v-if="isUpgrade && (total ?? 0) > 0">
|
||||||
|
Today, you will be charged a prorated amount for the remainder of your current billing cycle.
|
||||||
|
<br />
|
||||||
|
Your subscription will renew at
|
||||||
|
{{ formatPrice(locale, selectedPlanPriceForInterval, currency) }} / {{ period }} plus
|
||||||
|
applicable taxes at the end of your current billing interval, until you cancel. You can cancel
|
||||||
|
anytime from your settings page.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
You'll be charged
|
||||||
|
<SpinnerIcon v-if="loading" class="animate-spin relative top-0.5 mx-2" /><template v-else>{{
|
||||||
|
formatPrice(locale, total, currency)
|
||||||
|
}}</template>
|
||||||
|
every {{ period }} plus applicable taxes starting today, until you cancel. You can cancel
|
||||||
|
anytime from your settings page.
|
||||||
|
</template>
|
||||||
|
<br />
|
||||||
<span class="font-semibold"
|
<span class="font-semibold"
|
||||||
>By clicking "Subscribe", you are purchasing a recurring subscription.</span
|
>By clicking "Subscribe", you are purchasing a recurring subscription.</span
|
||||||
>
|
>
|
||||||
<br />
|
<br />
|
||||||
You'll be charged
|
|
||||||
<SpinnerIcon v-if="loading" class="animate-spin relative top-0.5 mx-2" /><template v-else>{{
|
|
||||||
formatPrice(locale, total, currency)
|
|
||||||
}}</template>
|
|
||||||
every {{ period }} plus applicable taxes starting today, until you cancel. You can cancel
|
|
||||||
anytime from your settings page.
|
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-2 flex items-center gap-1 text-sm">
|
<div v-if="!noPaymentRequired" class="mt-2 flex items-center gap-1 text-sm">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
v-model="acceptedEula"
|
v-model="acceptedEula"
|
||||||
label="I acknowledge that I have read and agree to the"
|
label="I acknowledge that I have read and agree to the"
|
||||||
|
|||||||
@@ -8,17 +8,11 @@ const emit = defineEmits<{
|
|||||||
(e: 'click-bursting-link'): void
|
(e: 'click-bursting-link'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = defineProps<{
|
||||||
defineProps<{
|
ram: number
|
||||||
ram: number
|
storage: number
|
||||||
storage: number
|
cpus: number
|
||||||
cpus: number
|
}>()
|
||||||
burstingLink?: string
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
burstingLink: undefined,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const formattedRam = computed(() => {
|
const formattedRam = computed(() => {
|
||||||
return props.ram / 1024
|
return props.ram / 1024
|
||||||
@@ -46,12 +40,12 @@ const sharedCpus = computed(() => {
|
|||||||
<li class="flex items-center gap-2">
|
<li class="flex items-center gap-2">
|
||||||
<SparklesIcon class="h-5 w-5 shrink-0" /> Bursts up to {{ cpus }} CPUs
|
<SparklesIcon class="h-5 w-5 shrink-0" /> Bursts up to {{ cpus }} CPUs
|
||||||
<AutoLink
|
<AutoLink
|
||||||
v-if="burstingLink"
|
|
||||||
v-tooltip="
|
v-tooltip="
|
||||||
`CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. Click for more info.`
|
`CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. Click for more info.`
|
||||||
"
|
"
|
||||||
class="flex"
|
class="flex"
|
||||||
:to="burstingLink"
|
to="https://modrinth.com/servers#cpu-burst"
|
||||||
|
target="_blank"
|
||||||
@click="() => emit('click-bursting-link')"
|
@click="() => emit('click-bursting-link')"
|
||||||
>
|
>
|
||||||
<UnknownIcon class="h-4 w-4 text-secondary opacity-80" />
|
<UnknownIcon class="h-4 w-4 text-secondary opacity-80" />
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export { default as HeadingLink } from './base/HeadingLink.vue'
|
|||||||
export { default as LoadingIndicator } from './base/LoadingIndicator.vue'
|
export { default as LoadingIndicator } from './base/LoadingIndicator.vue'
|
||||||
export { default as ManySelect } from './base/ManySelect.vue'
|
export { default as ManySelect } from './base/ManySelect.vue'
|
||||||
export { default as MarkdownEditor } from './base/MarkdownEditor.vue'
|
export { default as MarkdownEditor } from './base/MarkdownEditor.vue'
|
||||||
|
export { default as OptionGroup } from './base/OptionGroup.vue'
|
||||||
export type { Option as OverflowMenuOption } from './base/OverflowMenu.vue'
|
export type { Option as OverflowMenuOption } from './base/OverflowMenu.vue'
|
||||||
export { default as OverflowMenu } from './base/OverflowMenu.vue'
|
export { default as OverflowMenu } from './base/OverflowMenu.vue'
|
||||||
export { default as Page } from './base/Page.vue'
|
export { default as Page } from './base/Page.vue'
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const useStripe = (
|
|||||||
project: Ref<string | undefined>,
|
project: Ref<string | undefined>,
|
||||||
initiatePayment: (
|
initiatePayment: (
|
||||||
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
|
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
|
||||||
) => Promise<CreatePaymentIntentResponse | UpdatePaymentIntentResponse>,
|
) => Promise<CreatePaymentIntentResponse | UpdatePaymentIntentResponse | null>,
|
||||||
onError: (err: Error) => void,
|
onError: (err: Error) => void,
|
||||||
) => {
|
) => {
|
||||||
const stripe = ref<StripeJs | null>(null)
|
const stripe = ref<StripeJs | null>(null)
|
||||||
@@ -55,17 +55,22 @@ export const useStripe = (
|
|||||||
const inputtedPaymentMethod = ref<Stripe.PaymentMethod>()
|
const inputtedPaymentMethod = ref<Stripe.PaymentMethod>()
|
||||||
const clientSecret = ref<string>()
|
const clientSecret = ref<string>()
|
||||||
const completingPurchase = ref<boolean>(false)
|
const completingPurchase = ref<boolean>(false)
|
||||||
|
const noPaymentRequired = ref<boolean>(false)
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
stripe.value = await loadStripe(publishableKey)
|
stripe.value = await loadStripe(publishableKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createIntent(body: CreatePaymentIntentRequest): Promise<CreatePaymentIntentResponse> {
|
function createIntent(
|
||||||
return initiatePayment(body) as Promise<CreatePaymentIntentResponse>
|
body: CreatePaymentIntentRequest,
|
||||||
|
): Promise<CreatePaymentIntentResponse | null> {
|
||||||
|
return initiatePayment(body) as Promise<CreatePaymentIntentResponse | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateIntent(body: UpdatePaymentIntentRequest): Promise<UpdatePaymentIntentResponse> {
|
function updateIntent(
|
||||||
return initiatePayment(body) as Promise<UpdatePaymentIntentResponse>
|
body: UpdatePaymentIntentRequest,
|
||||||
|
): Promise<UpdatePaymentIntentResponse | null> {
|
||||||
|
return initiatePayment(body) as Promise<UpdatePaymentIntentResponse | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
const planPrices = computed(() => {
|
const planPrices = computed(() => {
|
||||||
@@ -222,7 +227,7 @@ export const useStripe = (
|
|||||||
interval: interval.value,
|
interval: interval.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: BasePaymentIntentResponse
|
let result: BasePaymentIntentResponse | null = null
|
||||||
|
|
||||||
const metadata: CreatePaymentIntentRequest['metadata'] = {
|
const metadata: CreatePaymentIntentRequest['metadata'] = {
|
||||||
type: 'pyro',
|
type: 'pyro',
|
||||||
@@ -241,26 +246,34 @@ export const useStripe = (
|
|||||||
existing_payment_intent: paymentIntentId.value,
|
existing_payment_intent: paymentIntentId.value,
|
||||||
metadata,
|
metadata,
|
||||||
})
|
})
|
||||||
console.log(`Updated payment intent: ${interval.value} for ${result.total}`)
|
if (result) console.log(`Updated payment intent: ${interval.value} for ${result.total}`)
|
||||||
} else {
|
} else {
|
||||||
;({
|
const created = await createIntent({
|
||||||
payment_intent_id: paymentIntentId.value,
|
|
||||||
client_secret: clientSecret.value,
|
|
||||||
...result
|
|
||||||
} = await createIntent({
|
|
||||||
...requestType,
|
...requestType,
|
||||||
charge,
|
charge,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
}))
|
})
|
||||||
console.log(`Created payment intent: ${interval.value} for ${result.total}`)
|
if (created) {
|
||||||
|
paymentIntentId.value = created.payment_intent_id
|
||||||
|
clientSecret.value = created.client_secret
|
||||||
|
result = created
|
||||||
|
console.log(`Created payment intent: ${interval.value} for ${created.total}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tax.value = result.tax
|
if (!result) {
|
||||||
total.value = result.total
|
tax.value = 0
|
||||||
|
total.value = 0
|
||||||
|
noPaymentRequired.value = true
|
||||||
|
} else {
|
||||||
|
tax.value = result.tax
|
||||||
|
total.value = result.total
|
||||||
|
noPaymentRequired.value = false
|
||||||
|
}
|
||||||
|
|
||||||
if (confirmation) {
|
if (confirmation) {
|
||||||
confirmationToken.value = id
|
confirmationToken.value = id
|
||||||
if (result.payment_method) {
|
if (result && result.payment_method) {
|
||||||
inputtedPaymentMethod.value = result.payment_method
|
inputtedPaymentMethod.value = result.payment_method
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,6 +359,10 @@ export const useStripe = (
|
|||||||
const loadingElements = computed(() => elementsLoaded.value < 2)
|
const loadingElements = computed(() => elementsLoaded.value < 2)
|
||||||
|
|
||||||
async function submitPayment(returnUrl: string) {
|
async function submitPayment(returnUrl: string) {
|
||||||
|
if (noPaymentRequired.value) {
|
||||||
|
completingPurchase.value = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
completingPurchase.value = true
|
completingPurchase.value = true
|
||||||
const secert = clientSecret.value
|
const secert = clientSecret.value
|
||||||
|
|
||||||
@@ -387,7 +404,9 @@ export const useStripe = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPaymentMethod = computed(() => selectedPaymentMethod.value || confirmationToken.value)
|
const hasPaymentMethod = computed(
|
||||||
|
() => selectedPaymentMethod.value || confirmationToken.value || noPaymentRequired.value,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initializeStripe: initialize,
|
initializeStripe: initialize,
|
||||||
@@ -406,5 +425,6 @@ export const useStripe = (
|
|||||||
total,
|
total,
|
||||||
submitPayment,
|
submitPayment,
|
||||||
completingPurchase,
|
completingPurchase,
|
||||||
|
noPaymentRequired,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -506,6 +506,39 @@
|
|||||||
"servers.purchase.step.payment.title": {
|
"servers.purchase.step.payment.title": {
|
||||||
"defaultMessage": "Payment method"
|
"defaultMessage": "Payment method"
|
||||||
},
|
},
|
||||||
|
"servers.purchase.step.plan.billed": {
|
||||||
|
"defaultMessage": "billed {interval}"
|
||||||
|
},
|
||||||
|
"servers.purchase.step.plan.custom.desc": {
|
||||||
|
"defaultMessage": "Pick a customized plan with just the specs you need."
|
||||||
|
},
|
||||||
|
"servers.purchase.step.plan.get-started": {
|
||||||
|
"defaultMessage": "Get started"
|
||||||
|
},
|
||||||
|
"servers.purchase.step.plan.large.desc": {
|
||||||
|
"defaultMessage": "Ideal for 15–25 players, modpacks, or heavy modding."
|
||||||
|
},
|
||||||
|
"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.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.region.title": {
|
"servers.purchase.step.region.title": {
|
||||||
"defaultMessage": "Region"
|
"defaultMessage": "Region"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export type VersionEntry = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const VERSIONS: VersionEntry[] = [
|
const VERSIONS: VersionEntry[] = [
|
||||||
|
{
|
||||||
|
date: `2025-08-18T11:25:00-07:00`,
|
||||||
|
product: 'web',
|
||||||
|
body: `### Improvements
|
||||||
|
- Added Modrinth Servers free trial promotion in partnership with Medal.`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
date: `2025-08-18T09:10:00-07:00`,
|
date: `2025-08-18T09:10:00-07:00`,
|
||||||
product: 'servers',
|
product: 'servers',
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ export interface Server {
|
|||||||
flows: {
|
flows: {
|
||||||
intro?: boolean
|
intro?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is_medal?: boolean
|
||||||
|
medal_expires?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Servers {
|
export interface Servers {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface ServerGeneral {
|
|||||||
flows?: {
|
flows?: {
|
||||||
intro?: boolean
|
intro?: boolean
|
||||||
}
|
}
|
||||||
|
is_medal?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Allocation {
|
export interface Allocation {
|
||||||
|
|||||||
@@ -513,6 +513,38 @@ export interface ModerationJudgements {
|
|||||||
[sha1: string]: ModerationJudgement
|
[sha1: string]: ModerationJudgement
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscriptions
|
||||||
|
export interface UserSubscription {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
price_id: string
|
||||||
|
interval: 'five-days' | 'monthly' | 'quarterly' | 'yearly'
|
||||||
|
status: 'provisioned' | 'unprovisioned'
|
||||||
|
created: string // ISO date string
|
||||||
|
metadata?: SubscriptionMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Charge {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
price_id: string
|
||||||
|
amount: number
|
||||||
|
currency_code: string
|
||||||
|
status: 'open' | 'processing' | 'succeeded' | 'failed' | 'cancelled' | 'expiring'
|
||||||
|
due: string // ISO date string
|
||||||
|
last_attempt?: string // ISO date string
|
||||||
|
type: 'one-time' | 'subscription' | 'proration' | 'refund'
|
||||||
|
subscription_id?: string
|
||||||
|
subscription_interval?: 'five-days' | 'monthly' | 'quarterly' | 'yearly'
|
||||||
|
platform: 'stripe' | 'none'
|
||||||
|
parent_charge_id?: string
|
||||||
|
net?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubscriptionMetadata =
|
||||||
|
| { type: 'pyro'; id: string; region?: string }
|
||||||
|
| { type: 'medal'; id: string }
|
||||||
|
|
||||||
// Delphi
|
// Delphi
|
||||||
export interface DelphiReport {
|
export interface DelphiReport {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
Reference in New Issue
Block a user