Servers new purchase flow (#3719)

* New purchase flow for servers, region selector, etc.

* Lint

* Lint

* Fix expanding total
This commit is contained in:
Prospector
2025-06-03 09:20:53 -07:00
committed by GitHub
parent 7223c2b197
commit c0accb42fa
43 changed files with 3021 additions and 800 deletions

View 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 { ref, computed, onMounted } 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>

View File

@@ -63,6 +63,7 @@ const props = defineProps<{
loader: string | null;
loader_version: string | null;
};
ignoreCurrentInstallation?: boolean;
isInstalling?: boolean;
}>();

View File

@@ -313,7 +313,7 @@ const selectedLoaderVersions = computed<string[]>(() => {
const loader = selectedLoader.value.toLowerCase();
if (loader === "paper") {
return paperVersions.value[selectedMCVersion.value].map((x) => `${x}`) || [];
return paperVersions.value[selectedMCVersion.value]?.map((x) => `${x}`) || [];
}
if (loader === "purpur") {

View File

@@ -0,0 +1,277 @@
<template>
<LazyUiServersPlatformVersionSelectModal
ref="versionSelectModal"
:server="props.server"
:current-loader="ignoreCurrentInstallation ? null : (data?.loader as Loaders)"
:backup-in-progress="backupInProgress"
@reinstall="emit('reinstall', $event)"
/>
<LazyUiServersPlatformMrpackModal
ref="mrpackModal"
:server="props.server"
@reinstall="emit('reinstall', $event)"
/>
<LazyUiServersPlatformChangeModpackVersionModal
ref="modpackVersionModal"
:server="props.server"
:project="data?.project"
:versions="Array.isArray(versions) ? versions : []"
:current-version="currentVersion"
:current-version-id="data?.upstream?.version_id"
:server-status="data?.status"
@reinstall="emit('reinstall')"
/>
<div class="flex h-full w-full flex-col">
<div v-if="data && versions" class="flex w-full flex-col">
<div class="card flex flex-col gap-4">
<div class="flex select-none flex-col items-center justify-between gap-2 lg:flex-row">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Modpack</h2>
<div
v-if="updateAvailable"
class="rounded-full bg-bg-orange px-2 py-1 text-xs font-medium text-orange"
>
<span>Update available</span>
</div>
</div>
<div v-if="data.upstream" class="flex gap-4">
<ButtonStyled>
<button
class="!w-full sm:!w-auto"
:disabled="isInstalling"
@click="mrpackModal.show()"
>
<UploadIcon class="size-4" /> Import .mrpack
</button>
</ButtonStyled>
<!-- dumb hack to make a button link not a link -->
<ButtonStyled>
<template v-if="isInstalling">
<button :disabled="isInstalling">
<TransferIcon class="size-4" />
Switch modpack
</button>
</template>
<nuxt-link v-else :to="`/modpacks?sid=${props.server.serverId}`">
<TransferIcon class="size-4" />
Switch modpack
</nuxt-link>
</ButtonStyled>
</div>
</div>
<div v-if="data.upstream" class="flex flex-col gap-2">
<div
v-if="versionsError || currentVersionError"
class="rounded-2xl border border-solid border-red p-4 text-contrast"
>
<p class="m-0 font-bold">Something went wrong while loading your modpack.</p>
<p class="m-0 mb-2 mt-1 text-sm">
{{ versionsError || currentVersionError }}
</p>
<ButtonStyled>
<button :disabled="isInstalling" @click="refreshData">Retry</button>
</ButtonStyled>
</div>
<NewProjectCard
v-if="!versionsError && !currentVersionError"
class="!cursor-default !bg-bg !filter-none"
:project="projectCardData"
:categories="data.project?.categories || []"
>
<template #actions>
<ButtonStyled color="brand">
<button :disabled="isInstalling" @click="modpackVersionModal.show()">
<SettingsIcon class="size-4" />
Change version
</button>
</ButtonStyled>
</template>
</NewProjectCard>
</div>
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
<ButtonStyled>
<nuxt-link
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:class="{ disabled: backupInProgress }"
class="!w-full sm:!w-auto"
:to="`/modpacks?sid=${props.server.serverId}`"
>
<CompassIcon class="size-4" /> Find a modpack
</nuxt-link>
</ButtonStyled>
<span class="hidden sm:block">or</span>
<ButtonStyled>
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="!!backupInProgress"
class="!w-full sm:!w-auto"
@click="mrpackModal.show()"
>
<UploadIcon class="size-4" /> Upload .mrpack file
</button>
</ButtonStyled>
</div>
</div>
<div class="card flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Platform</h2>
<p class="m-0">Your server's platform is the software that runs mods and plugins.</p>
<div v-if="data.upstream" class="mt-2 flex items-center gap-2">
<InfoIcon class="hidden sm:block" />
<span class="text-sm text-secondary">
The current platform was automatically selected based on your modpack.
</span>
</div>
</div>
<div
class="flex w-full flex-col gap-1 rounded-2xl"
:class="{
'pointer-events-none cursor-not-allowed select-none opacity-50':
props.server.general?.status === 'installing',
}"
:tabindex="props.server.general?.status === 'installing' ? -1 : 0"
>
<UiServersLoaderSelector
:data="
ignoreCurrentInstallation
? {
loader: null,
loader_version: null,
}
: data
"
:is-installing="isInstalling"
@select-loader="selectLoader"
/>
</div>
</div>
</div>
<div v-else />
</div>
</template>
<script setup lang="ts">
import { ButtonStyled, NewProjectCard } from "@modrinth/ui";
import { TransferIcon, UploadIcon, InfoIcon, CompassIcon, SettingsIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
import type { Loaders } from "~/types/servers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
const { formatMessage } = useVIntl();
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
ignoreCurrentInstallation?: boolean;
backupInProgress?: BackupInProgressReason;
}>();
const emit = defineEmits<{
reinstall: [any?];
}>();
const isInstalling = computed(() => props.server.general?.status === "installing");
const versionSelectModal = ref();
const mrpackModal = ref();
const modpackVersionModal = ref();
const data = computed(() => props.server.general);
const {
data: versions,
error: versionsError,
refresh: refreshVersions,
} = await useAsyncData(
`content-loader-versions-${data.value?.upstream?.project_id}`,
async () => {
if (!data.value?.upstream?.project_id) return [];
try {
const result = await useBaseFetch(`project/${data.value.upstream.project_id}/version`);
return result || [];
} catch (e) {
console.error("couldnt fetch all versions:", e);
throw new Error("Failed to load modpack versions.");
}
},
{ default: () => [] },
);
const {
data: currentVersion,
error: currentVersionError,
refresh: refreshCurrentVersion,
} = await useAsyncData(
`content-loader-version-${data.value?.upstream?.version_id}`,
async () => {
if (!data.value?.upstream?.version_id) return null;
try {
const result = await useBaseFetch(`version/${data.value.upstream.version_id}`);
return result || null;
} catch (e) {
console.error("couldnt fetch version:", e);
throw new Error("Failed to load modpack version.");
}
},
{ default: () => null },
);
const projectCardData = computed(() => ({
icon_url: data.value?.project?.icon_url,
title: data.value?.project?.title,
description: data.value?.project?.description,
downloads: data.value?.project?.downloads,
follows: data.value?.project?.followers,
// @ts-ignore
date_modified: currentVersion.value?.date_published || data.value?.project?.updated,
}));
const selectLoader = (loader: string) => {
versionSelectModal.value?.show(loader as Loaders);
};
const refreshData = async () => {
await Promise.all([refreshVersions(), refreshCurrentVersion()]);
};
const updateAvailable = computed(() => {
// so sorry
// @ts-ignore
if (!data.value?.upstream || !versions.value?.length || !currentVersion.value) {
return false;
}
// @ts-ignore
const latestVersion = versions.value[0];
// @ts-ignore
return latestVersion.id !== currentVersion.value.id;
});
watch(
() => props.server.general?.status,
async (newStatus, oldStatus) => {
if (oldStatus === "installing" && newStatus === "available") {
await Promise.all([
refreshVersions(),
refreshCurrentVersion(),
props.server.refresh(["general"]),
]);
}
},
);
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
.button-base:active {
scale: none !important;
}
</style>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { RightArrowIcon, SparklesIcon, UnknownIcon } from "@modrinth/assets";
import { ButtonStyled, ServersSpecs } from "@modrinth/ui";
import type { MessageDescriptor } from "@vintl/vintl";
import { formatPrice } from "../../../../../../../packages/utils";
const { formatMessage } = useVIntl();
const { formatMessage, locale } = useVIntl();
const emit = defineEmits<{
(e: "select" | "scroll-to-faq"): void;
@@ -18,8 +18,8 @@ const plans: Record<
accentText: string;
accentBg: string;
name: MessageDescriptor;
symbol: MessageDescriptor;
description: MessageDescriptor;
mostPopular: boolean;
}
> = {
small: {
@@ -30,15 +30,11 @@ const plans: Record<
id: "servers.plan.small.name",
defaultMessage: "Small",
}),
symbol: defineMessage({
id: "servers.plan.small.symbol",
defaultMessage: "S",
}),
description: defineMessage({
id: "servers.plan.small.description",
defaultMessage:
"Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding.",
defaultMessage: "Perfect for 15 friends with a few light mods.",
}),
mostPopular: false,
},
medium: {
buttonColor: "green",
@@ -48,14 +44,11 @@ const plans: Record<
id: "servers.plan.medium.name",
defaultMessage: "Medium",
}),
symbol: defineMessage({
id: "servers.plan.medium.symbol",
defaultMessage: "M",
}),
description: defineMessage({
id: "servers.plan.medium.description",
defaultMessage: "Great for modded multiplayer and small communities.",
defaultMessage: "Great for 615 players and multiple mods.",
}),
mostPopular: true,
},
large: {
buttonColor: "purple",
@@ -65,14 +58,11 @@ const plans: Record<
id: "servers.plan.large.name",
defaultMessage: "Large",
}),
symbol: defineMessage({
id: "servers.plan.large.symbol",
defaultMessage: "L",
}),
description: defineMessage({
id: "servers.plan.large.description",
defaultMessage: "Ideal for larger communities, modpacks, and heavy modding.",
defaultMessage: "Ideal for 15-25 players, modpacks, or heavy modding.",
}),
mostPopular: false,
},
};
@@ -83,42 +73,30 @@ const props = defineProps<{
storage: number;
cpus: number;
price: number;
interval: "monthly" | "quarterly" | "yearly";
currency: string;
isUsa: boolean;
}>();
const outOfStock = computed(() => {
return !props.capacity || props.capacity === 0;
});
const lowStock = computed(() => {
return !props.capacity || props.capacity < 8;
});
const formattedRam = computed(() => {
return props.ram / 1024;
});
const formattedStorage = computed(() => {
return props.storage / 1024;
});
const sharedCpus = computed(() => {
return props.cpus / 2;
const billingMonths = computed(() => {
if (props.interval === "yearly") {
return 12;
} else if (props.interval === "quarterly") {
return 3;
}
return 1;
});
</script>
<template>
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
<div
v-if="lowStock"
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl p-4 text-center font-bold"
:class="outOfStock ? 'bg-bg-red' : 'bg-bg-orange'"
>
<template v-if="outOfStock"> Out of stock! </template>
<template v-else> Only {{ capacity }} left in stock! </template>
</div>
<li class="relative flex w-full flex-col justify-between">
<div
:style="
plan === 'medium'
plans[plan].mostPopular
? {
background: `radial-gradient(
86.12% 101.64% at 95.97% 94.07%,
@@ -131,55 +109,41 @@ const sharedCpus = computed(() => {
: undefined
"
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
:class="{ '!rounded-t-none': lowStock }"
>
<div class="flex flex-col gap-4">
<div class="flex flex-row items-center justify-between">
<div class="flex flex-col gap-2">
<div class="flex flex-row flex-wrap items-center gap-3">
<h1 class="m-0">{{ formatMessage(plans[plan].name) }}</h1>
<div
class="grid size-8 place-content-center rounded-full text-xs font-bold"
:class="`${plans[plan].accentBg} ${plans[plan].accentText}`"
v-if="plans[plan].mostPopular"
class="rounded-full bg-brand-highlight px-2 py-1 text-xs font-bold text-brand"
>
{{ formatMessage(plans[plan].symbol) }}
Most popular
</div>
</div>
<p class="m-0">{{ formatMessage(plans[plan].description) }}</p>
<div
class="flex flex-row flex-wrap items-center gap-2 text-nowrap text-secondary xl:justify-between"
>
<p class="m-0">{{ formattedRam }} GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">{{ formattedStorage }} GB SSD</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">{{ sharedCpus }} Shared CPUs</p>
</div>
<div class="flex items-center gap-2 text-secondary">
<SparklesIcon /> Bursts up to {{ cpus }} CPUs
<nuxt-link
v-tooltip="
`CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. Click for more info.`
"
to="/servers#cpu-burst"
@click="() => emit('scroll-to-faq')"
>
<UnknownIcon class="h-4 w-4 text-secondary opacity-80" />
</nuxt-link>
</div>
<span class="m-0 text-2xl font-bold text-contrast">
${{ price / 100 }}<span class="text-lg font-semibold text-secondary">/month</span>
{{ formatPrice(locale, price / billingMonths, currency, true) }}
{{ isUsa ? "" : currency }}
<span class="text-lg font-semibold text-secondary">
/ month<template v-if="interval !== 'monthly'">, billed {{ interval }}</template>
</span>
</span>
<p class="m-0 max-w-[18rem]">{{ formatMessage(plans[plan].description) }}</p>
</div>
<ButtonStyled
:color="plans[plan].buttonColor"
:type="plan === 'medium' ? 'standard' : 'highlight-colored-text'"
:type="plans[plan].mostPopular ? 'standard' : 'highlight-colored-text'"
size="large"
>
<span v-if="outOfStock" class="button-like disabled"> Out of Stock </span>
<button v-else @click="() => emit('select')">
Get Started
<RightArrowIcon class="shrink-0" />
</button>
<button v-else @click="() => emit('select')">Select plan</button>
</ButtonStyled>
<ServersSpecs
:ram="ram"
:storage="storage"
:cpus="cpus"
:bursting-link="'/servers#cpu-burst'"
@click-bursting-link="() => emit('scroll-to-faq')"
/>
</div>
</li>
</template>