Servers marketing enhancements (#3252)

* feat: locations page + stock callouts

* feat: misalligned but spirits there!!

* fix readability on colors on globe

* Enhancements to globe

* Fix out of stock indicator styling

* Start globe near US and slow speed

* Remove debug statement

* Switch from capacity to stock API

* Make custom use its own stock checker

* Fix lint, add changelog entries

---------

Co-authored-by: Elizabeth <checksum@pyro.host>
Co-authored-by: Lio <git@lio.cat>
This commit is contained in:
Prospector 2025-02-12 12:06:51 -08:00 committed by GitHub
parent 098519dea1
commit 6d810a421a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 678 additions and 145 deletions

View File

@ -57,6 +57,8 @@
"pinia": "^2.1.7",
"qrcode.vue": "^3.4.0",
"semver": "^7.5.4",
"three": "^0.172.0",
"@types/three": "^0.172.0",
"vue-multiselect": "3.0.0-alpha.2",
"vue-typed-virtual-list": "^1.0.10",
"vue3-ace-editor": "^2.2.4",

View File

@ -0,0 +1,308 @@
<template>
<div ref="container" class="relative h-[400px] w-full cursor-move lg:h-[600px]">
<div
v-for="location in locations"
:key="location.name"
:class="{
'opacity-0': !showLabels,
hidden: !isLocationVisible(location),
'z-40': location.clicked,
}"
:style="{
position: 'absolute',
left: `${location.screenPosition?.x || 0}px`,
top: `${location.screenPosition?.y || 0}px`,
}"
class="location-button center-on-top-left flex transform cursor-pointer items-center rounded-full bg-bg px-3 outline-1 outline-red transition-opacity duration-200 hover:z-50"
@click="toggleLocationClicked(location)"
>
<div
:class="{
'animate-pulse': location.active,
'border-gray-400': !location.active,
'border-purple bg-purple': location.active,
'border-dashed': !location.active,
'opacity-40': !location.active,
}"
class="my-3 size-2.5 shrink-0 rounded-full border-2"
></div>
<div
class="expanding-item"
:class="{
expanded: location.clicked,
}"
>
<div class="whitespace-nowrap text-sm">
<span class="ml-2"> {{ location.name }} </span>
<span v-if="!location.active" class="ml-1 text-xs text-secondary">(Coming Soon)</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { ref, onMounted, onUnmounted } from "vue";
const container = ref(null);
const showLabels = ref(false);
const locations = ref([
// Active locations
{ name: "New York", lat: 40.7128, lng: -74.006, active: true, clicked: false },
{ name: "Los Angeles", lat: 34.0522, lng: -118.2437, active: true, clicked: false },
{ name: "Miami", lat: 25.7617, lng: -80.1918, active: true, clicked: false },
{ name: "Seattle", lat: 47.608013, lng: -122.3321, active: true, clicked: false },
// Future Locations
// { name: "London", lat: 51.5074, lng: -0.1278, active: false, clicked: false },
// { name: "Frankfurt", lat: 50.1109, lng: 8.6821, active: false, clicked: false },
// { name: "Amsterdam", lat: 52.3676, lng: 4.9041, active: false, clicked: false },
// { name: "Paris", lat: 48.8566, lng: 2.3522, active: false, clicked: false },
// { name: "Singapore", lat: 1.3521, lng: 103.8198, active: false, clicked: false },
// { name: "Tokyo", lat: 35.6762, lng: 139.6503, active: false, clicked: false },
// { name: "Sydney", lat: -33.8688, lng: 151.2093, active: false, clicked: false },
// { name: "São Paulo", lat: -23.5505, lng: -46.6333, active: false, clicked: false },
// { name: "Toronto", lat: 43.6532, lng: -79.3832, active: false, clicked: false },
]);
const isLocationVisible = (location) => {
if (!location.screenPosition || !globe) return false;
const vector = latLngToVector3(location.lat, location.lng).clone();
vector.applyMatrix4(globe.matrixWorld);
const cameraVector = new THREE.Vector3();
camera.getWorldPosition(cameraVector);
const viewVector = vector.clone().sub(cameraVector).normalize();
const normal = vector.clone().normalize();
const dotProduct = normal.dot(viewVector);
return dotProduct < -0.15;
};
const toggleLocationClicked = (location) => {
console.log("clicked", location.name);
locations.value.find((loc) => loc.name === location.name).clicked = !location.clicked;
};
let scene, camera, renderer, globe, controls;
let animationFrame;
const init = () => {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(
45,
container.value.clientWidth / container.value.clientHeight,
0.1,
1000,
);
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: "low-power",
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(container.value.clientWidth, container.value.clientHeight);
container.value.appendChild(renderer.domElement);
const geometry = new THREE.SphereGeometry(5, 64, 64);
const outlineTexture = new THREE.TextureLoader().load("/earth-outline.png");
outlineTexture.minFilter = THREE.LinearFilter;
outlineTexture.magFilter = THREE.LinearFilter;
const material = new THREE.ShaderMaterial({
uniforms: {
outlineTexture: { value: outlineTexture },
globeColor: { value: new THREE.Color("#60fbb5") },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D outlineTexture;
uniform vec3 globeColor;
varying vec2 vUv;
void main() {
vec4 texColor = texture2D(outlineTexture, vUv);
float brightness = max(max(texColor.r, texColor.g), texColor.b);
gl_FragColor = vec4(globeColor, brightness * 0.8);
}
`,
transparent: true,
side: THREE.FrontSide,
});
globe = new THREE.Mesh(geometry, material);
scene.add(globe);
const atmosphereGeometry = new THREE.SphereGeometry(5.2, 64, 64);
const atmosphereMaterial = new THREE.ShaderMaterial({
transparent: true,
side: THREE.BackSide,
uniforms: {
color: { value: new THREE.Color("#56f690") },
viewVector: { value: camera.position },
},
vertexShader: `
uniform vec3 viewVector;
varying float intensity;
void main() {
vec3 vNormal = normalize(normalMatrix * normal);
vec3 vNormel = normalize(normalMatrix * viewVector);
intensity = pow(0.7 - dot(vNormal, vNormel), 2.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 color;
varying float intensity;
void main() {
gl_FragColor = vec4(color, intensity * 0.4);
}
`,
});
const atmosphere = new THREE.Mesh(atmosphereGeometry, atmosphereMaterial);
scene.add(atmosphere);
const ambientLight = new THREE.AmbientLight(0x404040, 0.5);
scene.add(ambientLight);
camera.position.z = 15;
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.rotateSpeed = 0.3;
controls.enableZoom = false;
controls.enablePan = false;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.05;
controls.minPolarAngle = Math.PI * 0.3;
controls.maxPolarAngle = Math.PI * 0.7;
globe.rotation.y = Math.PI * 1.9;
globe.rotation.x = Math.PI * 0.15;
};
const animate = () => {
animationFrame = requestAnimationFrame(animate);
controls.update();
locations.value.forEach((location) => {
const position = latLngToVector3(location.lat, location.lng);
const vector = position.clone();
vector.applyMatrix4(globe.matrixWorld);
const coords = vector.project(camera);
const screenPosition = {
x: (coords.x * 0.5 + 0.5) * container.value.clientWidth,
y: (-coords.y * 0.5 + 0.5) * container.value.clientHeight,
};
location.screenPosition = screenPosition;
});
renderer.render(scene, camera);
};
const latLngToVector3 = (lat, lng) => {
const phi = (90 - lat) * (Math.PI / 180);
const theta = (lng + 180) * (Math.PI / 180);
const radius = 5;
return new THREE.Vector3(
-radius * Math.sin(phi) * Math.cos(theta),
radius * Math.cos(phi),
radius * Math.sin(phi) * Math.sin(theta),
);
};
const handleResize = () => {
if (!container.value) return;
camera.aspect = container.value.clientWidth / container.value.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.value.clientWidth, container.value.clientHeight);
};
onMounted(() => {
init();
animate();
window.addEventListener("resize", handleResize);
setTimeout(() => {
showLabels.value = true;
}, 1000);
});
onUnmounted(() => {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
window.removeEventListener("resize", handleResize);
if (renderer) {
renderer.dispose();
}
if (container.value) {
container.value.innerHTML = "";
}
});
</script>
<style scoped>
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(27, 217, 106, 0.3);
}
70% {
box-shadow: 0 0 0 4px rgba(27, 217, 106, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(27, 217, 106, 0);
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.center-on-top-left {
transform: translate(-50%, -50%);
}
.expanding-item.expanded {
grid-template-columns: 1fr;
}
@media (hover: hover) {
.location-button:hover .expanding-item {
grid-template-columns: 1fr;
}
}
.expanding-item {
display: grid;
grid-template-columns: 0fr;
transition: grid-template-columns 0.15s ease-in-out;
overflow: hidden;
> div {
overflow: hidden;
}
}
@media (prefers-reduced-motion) {
.expanding-item {
transition: none !important;
}
}
</style>

View File

@ -494,6 +494,97 @@
</div>
</section>
<section
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
>
<div class="faded-brand-line absolute left-0 top-0 h-[1px] w-full"></div>
<div class="mx-auto flex w-full max-w-7xl flex-col gap-8">
<div class="grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<div
class="relative w-fit rounded-full bg-highlight-green px-3 py-1 text-sm font-bold text-brand backdrop-blur-lg"
>
Server Locations
</div>
<h1 class="relative m-0 max-w-2xl text-4xl leading-[120%] md:text-7xl">
Coast-to-Coast Coverage
</h1>
</div>
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<div class="grid size-8 place-content-center rounded-full bg-highlight-green">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-brand"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<h2 class="relative m-0 text-xl font-medium leading-[155%] md:text-2xl">
US Coverage
</h2>
</div>
<p
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
>
With strategically placed servers in New York, Los Angeles, Seattle, and Miami, we
ensure low latency connections for players across North America. Each location is
equipped with high-performance hardware and DDoS protection.
</p>
</div>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<div class="grid size-8 place-content-center rounded-full bg-highlight-blue">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-blue"
>
<path d="M12 2a10 10 0 1 0 10 10" />
<path d="M18 13a6 6 0 0 0-6-6" />
<path d="M13 2.05a10 10 0 0 1 2 2" />
<path d="M19.5 8.5a10 10 0 0 1 2 2" />
</svg>
</div>
<h2 class="relative m-0 text-xl font-medium leading-[155%] md:text-2xl">
Global Expansion
</h2>
</div>
<p
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
>
We're expanding to Europe and Asia-Pacific regions soon, bringing Modrinth's
seamless hosting experience worldwide. Join our Discord to stay updated on new
region launches.
</p>
</div>
</div>
</div>
<Globe />
</div>
</div>
</section>
<section
id="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"
@ -511,147 +602,180 @@
? "We are currently at capacity. Please try again later."
: "There's a plan for everyone! Choose the one that fits your needs."
}}
<span class="font-bold">
Servers are currently US only, in New York, Los Angeles, Seattle, and Miami. More
regions coming soon!
</span>
</h2>
<ul class="m-0 flex w-full flex-col gap-8 p-0 lg:flex-row">
<li class="flex w-full flex-col gap-4 rounded-2xl bg-bg p-8 text-left lg:w-1/3">
<div class="flex flex-row items-center justify-between">
<h1 class="m-0">Small</h1>
<div
class="grid size-8 place-content-center rounded-full bg-highlight-blue text-xs font-bold text-blue"
>
S
<ul class="m-0 mt-8 flex w-full flex-col gap-8 p-0 lg:flex-row">
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
<div
v-if="isSmallLowStock"
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
>
Only {{ capacityStatuses?.small?.available }} left in stock!
</div>
<div
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
:class="{ '!rounded-t-none': isSmallLowStock }"
>
<div class="flex flex-col gap-4">
<div class="flex flex-row items-center justify-between">
<h1 class="m-0">Small</h1>
<div
class="grid size-8 place-content-center rounded-full bg-highlight-blue text-xs font-bold text-blue"
>
S
</div>
</div>
<p class="m-0">
Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding.
</p>
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">4 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">4 vCPUs</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">32 GB Storage</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$12<span class="text-sm font-normal text-secondary">/month</span>
</h2>
</div>
<ButtonStyled color="blue" size="large">
<NuxtLink
v-if="!loggedOut && isSmallAtCapacity"
:to="outOfStockUrl"
target="_blank"
class="flex items-center gap-2 !bg-highlight-blue !font-medium !text-blue"
>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-blue" />
</NuxtLink>
<button
v-else
class="!bg-highlight-blue !font-medium !text-blue"
@click="selectProduct('small')"
>
Get Started
<RightArrowIcon class="!min-h-4 !min-w-4 !text-blue" />
</button>
</ButtonStyled>
</div>
<p class="m-0">
Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding.
</p>
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">4 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">4 vCPUs</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">32 GB Storage</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$12<span class="text-sm font-normal text-secondary">/month</span>
</h2>
<ButtonStyled color="blue" size="large">
<NuxtLink
v-if="!loggedOut && isSmallAtCapacity"
:to="outOfStockUrl"
target="_blank"
class="!bg-highlight-blue !font-medium !text-blue"
>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-blue" />
</NuxtLink>
<button
v-else
class="!bg-highlight-blue !font-medium !text-blue"
@click="selectProduct('small')"
>
Get Started
<RightArrowIcon class="!min-h-4 !min-w-4 !text-blue" />
</button>
</ButtonStyled>
</li>
<li
style="
background: 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%
);
border: 1px solid rgba(12, 107, 52, 0.55);
box-shadow: 0px 12px 38.1px rgba(27, 217, 106, 0.13);
"
class="flex w-full flex-col gap-4 rounded-2xl bg-bg p-8 text-left lg:w-1/3"
>
<div class="flex flex-row items-center justify-between">
<h1 class="m-0">Medium</h1>
<div
class="grid size-8 place-content-center rounded-full bg-highlight-green text-xs font-bold text-brand"
>
M
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
<div
v-if="isMediumLowStock"
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
>
Only {{ capacityStatuses?.medium?.available }} left in stock!
</div>
<div
style="
background: 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%
);
border: 1px solid rgba(12, 107, 52, 0.55);
box-shadow: 0px 12px 38.1px rgba(27, 217, 106, 0.13);
"
class="flex w-full flex-col justify-between gap-4 rounded-2xl p-8 text-left"
:class="{ '!rounded-t-none': isMediumLowStock }"
>
<div class="flex flex-col gap-4">
<div class="flex flex-row items-center justify-between">
<h1 class="m-0">Medium</h1>
<div
class="grid size-8 place-content-center rounded-full bg-highlight-green text-xs font-bold text-brand"
>
M
</div>
</div>
<p class="m-0">Great for modded multiplayer and small communities.</p>
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">6 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">6 vCPUs</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">48 GB Storage</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$18<span class="text-sm font-normal text-secondary">/month</span>
</h2>
</div>
<ButtonStyled color="brand" size="large">
<NuxtLink
v-if="!loggedOut && isMediumAtCapacity"
:to="outOfStockUrl"
target="_blank"
class="flex items-center gap-2 !bg-highlight-green !font-medium !text-green"
>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-green" />
</NuxtLink>
<button
v-else
class="!bg-highlight-green !font-medium !text-green"
@click="selectProduct('medium')"
>
Get Started
<RightArrowIcon class="!min-h-4 !min-w-4 !text-green" />
</button>
</ButtonStyled>
</div>
<p class="m-0">Great for modded multiplayer and small communities.</p>
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">6 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">6 vCPUs</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">48 GB Storage</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$18<span class="text-sm font-normal text-secondary">/month</span>
</h2>
<ButtonStyled color="brand" size="large">
<NuxtLink
v-if="!loggedOut && isMediumAtCapacity"
:to="outOfStockUrl"
target="_blank"
class="!bg-highlight-green !font-medium !text-green"
>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-green" />
</NuxtLink>
<button
v-else
class="!bg-highlight-green !font-medium !text-green"
@click="selectProduct('medium')"
>
Get Started
<RightArrowIcon class="!min-h-4 !min-w-4 !text-green" />
</button>
</ButtonStyled>
</li>
<li class="flex w-full flex-col gap-4 rounded-2xl bg-bg p-8 text-left lg:w-1/3">
<div class="flex flex-row items-center justify-between">
<h1 class="m-0">Large</h1>
<div
class="grid size-8 place-content-center rounded-full bg-highlight-purple text-xs font-bold text-purple"
>
L
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
<div
v-if="isLargeLowStock"
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
>
Only {{ capacityStatuses?.large?.available }} left in stock!
</div>
<div
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
:class="{ '!rounded-t-none': isLargeLowStock }"
>
<div class="flex flex-col gap-4">
<div class="flex flex-row items-center justify-between">
<h1 class="m-0">Large</h1>
<div
class="grid size-8 place-content-center rounded-full bg-highlight-purple text-xs font-bold text-purple"
>
L
</div>
</div>
<p class="m-0">Ideal for larger communities, modpacks, and heavy modding.</p>
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">8 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">8 vCPUs</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">64 GB Storage</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$24<span class="text-sm font-normal text-secondary">/month</span>
</h2>
</div>
<ButtonStyled color="brand" size="large">
<NuxtLink
v-if="!loggedOut && isLargeAtCapacity"
:to="outOfStockUrl"
target="_blank"
class="flex items-center gap-2 !bg-highlight-purple !font-medium !text-purple"
>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-purple" />
</NuxtLink>
<button
v-else
class="!bg-highlight-purple !font-medium !text-purple"
@click="selectProduct('large')"
>
Get Started
<RightArrowIcon class="!min-h-4 !min-w-4 !text-purple" />
</button>
</ButtonStyled>
</div>
<p class="m-0">Ideal for larger communities, modpacks, and heavy modding.</p>
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">8 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">8 vCPUs</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">64 GB Storage</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$24<span class="text-sm font-normal text-secondary">/month</span>
</h2>
<ButtonStyled color="brand" size="large">
<NuxtLink
v-if="!loggedOut && isLargeAtCapacity"
:to="outOfStockUrl"
target="_blank"
class="!bg-highlight-purple !font-medium !text-purple"
>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-purple" />
</NuxtLink>
<button
v-else
class="!bg-highlight-purple !font-medium !text-purple"
@click="selectProduct('large')"
>
Get Started
<RightArrowIcon class="!min-h-4 !min-w-4 !text-purple" />
</button>
</ButtonStyled>
</li>
</ul>
@ -697,6 +821,7 @@ import {
} from "@modrinth/assets";
import { products } from "~/generated/state.json";
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
import Globe from "~/components/ui/servers/Globe.vue";
const pyroProducts = products.filter((p) => p.metadata.type === "pyro");
const pyroPlanProducts = pyroProducts.filter(
@ -760,9 +885,16 @@ const { data: hasServers } = await useAsyncData("ServerListCountCheck", async ()
async function fetchCapacityStatuses(customProduct = null) {
try {
const productsToCheck = customProduct?.metadata ? [customProduct] : pyroPlanProducts;
const productsToCheck = customProduct?.metadata
? [customProduct]
: [
...pyroPlanProducts,
pyroProducts.reduce((min, product) =>
product.metadata.ram < min.metadata.ram ? product : min,
),
];
const capacityChecks = productsToCheck.map((product) =>
usePyroFetch("capacity", {
usePyroFetch("stock", {
method: "POST",
body: {
cpu: product.metadata.cpu,
@ -774,6 +906,7 @@ async function fetchCapacityStatuses(customProduct = null) {
);
const results = await Promise.all(capacityChecks);
if (customProduct?.metadata) {
return {
custom: results[0],
@ -783,6 +916,7 @@ async function fetchCapacityStatuses(customProduct = null) {
small: results[0],
medium: results[1],
large: results[2],
custom: results[3],
};
}
} catch (error) {
@ -804,6 +938,22 @@ const { data: capacityStatuses, refresh: refreshCapacity } = await useAsyncData(
const isSmallAtCapacity = computed(() => capacityStatuses.value?.small?.available === 0);
const isMediumAtCapacity = computed(() => capacityStatuses.value?.medium?.available === 0);
const isLargeAtCapacity = computed(() => capacityStatuses.value?.large?.available === 0);
const isCustomAtCapacity = computed(() => capacityStatuses.value?.custom?.available === 0);
const isSmallLowStock = computed(() => {
const available = capacityStatuses.value?.small?.available;
return available !== undefined && available > 0 && available < 8;
});
const isMediumLowStock = computed(() => {
const available = capacityStatuses.value?.medium?.available;
return available !== undefined && available > 0 && available < 8;
});
const isLargeLowStock = computed(() => {
const available = capacityStatuses.value?.large?.available;
return available !== undefined && available > 0 && available < 8;
});
const startTyping = () => {
const currentWord = words[currentWordIndex.value];
@ -907,7 +1057,9 @@ const selectProduct = async (product) => {
}
await refreshCapacity();
if (isAtCapacity.value) {
console.log(capacityStatuses.value);
if ((product === "custom" && isCustomAtCapacity.value) || isAtCapacity.value) {
addNotification({
group: "main",
title: "Server Capacity Full",

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

View File

@ -6,10 +6,12 @@
"ui:intl:extract": "pnpm run --filter=@modrinth/ui intl:extract",
"web:dev": "turbo run dev --filter=@modrinth/frontend",
"web:build": "turbo run build --filter=@modrinth/frontend",
"web:fix": "turbo run fix --filter=@modrinth/frontend",
"web:intl:extract": "pnpm run --filter=@modrinth/frontend intl:extract",
"app:dev": "turbo run dev --filter=@modrinth/app",
"docs:dev": "turbo run dev --filter=@modrinth/docs",
"app:build": "turbo run build --filter=@modrinth/app",
"app:fix": "turbo run fix --filter=@modrinth/app",
"app:intl:extract": "pnpm run --filter=@modrinth/app-frontend intl:extract",
"pages:build": "NITRO_PRESET=cloudflare-pages pnpm --filter frontend run build",
"build": "turbo run build --continue",

View File

@ -18,11 +18,16 @@
<div class="w-2 h-2 rounded-full bg-secondary" />
</template>
<span :class="{ 'text-primary font-bold': showType }">
{{ entry.version ?? formattedDate }}
{{ versionName }}
</span>
</h2>
</AutoLink>
<div v-if="recent" v-tooltip="dateTooltip" class="hidden sm:flex" :class="{ 'cursor-help': dateTooltip }">
<div
v-if="recent"
v-tooltip="dateTooltip"
class="hidden sm:flex"
:class="{ 'cursor-help': dateTooltip }"
>
{{ relativeDate }}
</div>
<div v-else-if="entry.version" :class="{ 'cursor-help': dateTooltip }">
@ -61,11 +66,12 @@ const props = withDefaults(
)
const currentDate = ref(dayjs())
const recent = computed(() => props.entry.date.isAfter(currentDate.value.subtract(1, 'week')))
const dateTooltip = computed(() => props.entry.date.format('MMMM D, YYYY [at] h:mm A'))
const formattedDate = computed(() =>
props.entry.version ? props.entry.date.fromNow() : props.entry.date.format('MMMM D, YYYY'),
const recent = computed(
() =>
props.entry.date.isAfter(currentDate.value.subtract(1, 'week')) &&
props.entry.date.isBefore(currentDate.value),
)
const dateTooltip = computed(() => props.entry.date.format('MMMM D, YYYY [at] h:mm A'))
const relativeDate = computed(() => props.entry.date.fromNow())
const longDate = computed(() => props.entry.date.format('MMMM D, YYYY'))

View File

@ -10,6 +10,16 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2025-02-12T12:10:00-08:00`,
product: 'web',
body: `### Added
- Added a 3D globe to visualize node locations to Modrinth Servers marketing page.
- Added an indicator to show when certain server plans are running low on availability.
### Improvements
- Improved out-of-stock notifications on Modrinth Servers page to be more accurate.`,
},
{
date: `2025-02-11T13:00:00-08:00`,
product: 'web',

67
pnpm-lock.yaml generated
View File

@ -209,6 +209,9 @@ importers:
'@pinia/nuxt':
specifier: ^0.5.1
version: 0.5.1(magicast@0.3.5)(rollup@4.28.1)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
'@types/three':
specifier: ^0.172.0
version: 0.172.0
'@vintl/vintl':
specifier: ^4.4.1
version: 4.4.1(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
@ -260,6 +263,9 @@ importers:
semver:
specifier: ^7.5.4
version: 7.6.3
three:
specifier: ^0.172.0
version: 0.172.0
vue-multiselect:
specifier: 3.0.0-alpha.2
version: 3.0.0-alpha.2
@ -2146,6 +2152,9 @@ packages:
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
engines: {node: '>=10.13.0'}
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
'@types/acorn@4.0.6':
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
@ -2242,6 +2251,12 @@ packages:
'@types/semver@7.5.8':
resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
'@types/stats.js@0.17.3':
resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
'@types/three@0.172.0':
resolution: {integrity: sha512-LrUtP3FEG26Zg5WiF0nbg8VoXiKokBLTcqM2iLvM9vzcfEiYmmBAPGdBgV0OYx9fvWlY3R/3ERTZcD9X5sc0NA==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@ -2257,6 +2272,9 @@ packages:
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@types/webxr@0.5.21':
resolution: {integrity: sha512-geZIAtLzjGmgY2JUi6VxXdCrTb99A7yP49lxLr2Nm/uIK0PkkxcEi4OGhoGDO4pxCf3JwGz2GiJL2Ej4K2bKaA==}
'@typescript-eslint/eslint-plugin@6.21.0':
resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==}
engines: {node: ^16.0.0 || >=18.0.0}
@ -2708,6 +2726,9 @@ packages:
'@webassemblyjs/wast-printer@1.12.1':
resolution: {integrity: sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==}
'@webgpu/types@0.1.54':
resolution: {integrity: sha512-81oaalC8LFrXjhsczomEQ0u3jG+TqE6V9QHLA8GNZq/Rnot0KDugu3LhSYSlie8tSdooAN1Hov05asrUUp9qgg==}
'@xtuc/ieee754@1.2.0':
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@ -4026,6 +4047,9 @@ packages:
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
file-entry-cache@6.0.1:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0}
@ -5008,6 +5032,9 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
meshoptimizer@0.18.1:
resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==}
micromark-core-commonmark@2.0.1:
resolution: {integrity: sha512-CUQyKr1e///ZODyD1U3xit6zXwy1a8q2a1S1HKtIlmgvurrEpaw/Y9y6KSIbF8P59cn/NjzHyO+Q2fAyYLQrAA==}
@ -6613,6 +6640,9 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
three@0.172.0:
resolution: {integrity: sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@ -8913,7 +8943,7 @@ snapshots:
'@nuxtjs/eslint-config-typescript@12.1.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)':
dependencies:
'@nuxtjs/eslint-config': 12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1))
'@nuxtjs/eslint-config': 12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1))
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)
'@typescript-eslint/parser': 6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)
eslint: 9.13.0(jiti@2.4.1)
@ -8926,10 +8956,10 @@ snapshots:
- supports-color
- typescript
'@nuxtjs/eslint-config@12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1))':
'@nuxtjs/eslint-config@12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1))':
dependencies:
eslint: 9.13.0(jiti@2.4.1)
eslint-config-standard: 17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.1)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1))
eslint-config-standard: 17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1)))(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.1)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1))
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1))
eslint-plugin-n: 15.7.0(eslint@9.13.0(jiti@2.4.1))
eslint-plugin-node: 11.1.0(eslint@9.13.0(jiti@2.4.1))
@ -9369,6 +9399,8 @@ snapshots:
'@trysound/sax@0.2.0': {}
'@tweenjs/tween.js@23.1.3': {}
'@types/acorn@4.0.6':
dependencies:
'@types/estree': 1.0.6
@ -9482,6 +9514,17 @@ snapshots:
'@types/semver@7.5.8': {}
'@types/stats.js@0.17.3': {}
'@types/three@0.172.0':
dependencies:
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.3
'@types/webxr': 0.5.21
'@webgpu/types': 0.1.54
fflate: 0.8.2
meshoptimizer: 0.18.1
'@types/trusted-types@2.0.7': {}
'@types/unist@2.0.11': {}
@ -9492,6 +9535,8 @@ snapshots:
'@types/web-bluetooth@0.0.20': {}
'@types/webxr@0.5.21': {}
'@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)':
dependencies:
'@eslint-community/regexpp': 4.11.0
@ -10260,6 +10305,8 @@ snapshots:
'@xtuc/long': 4.2.2
optional: true
'@webgpu/types@0.1.54': {}
'@xtuc/ieee754@1.2.0':
optional: true
@ -11376,10 +11423,10 @@ snapshots:
dependencies:
eslint: 9.13.0(jiti@2.4.1)
eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.1)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1)):
eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1)))(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.1)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1)):
dependencies:
eslint: 9.13.0(jiti@2.4.1)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1))
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1))
eslint-plugin-n: 15.7.0(eslint@9.13.0(jiti@2.4.1))
eslint-plugin-promise: 6.4.0(eslint@9.13.0(jiti@2.4.1))
@ -11405,7 +11452,7 @@ snapshots:
debug: 4.4.0(supports-color@9.4.0)
enhanced-resolve: 5.17.1
eslint: 9.13.0(jiti@2.4.1)
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1))
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1))
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1))
fast-glob: 3.3.2
get-tsconfig: 4.7.5
@ -11417,7 +11464,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1)):
eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
@ -11932,6 +11979,8 @@ snapshots:
fflate@0.4.8: {}
fflate@0.8.2: {}
file-entry-cache@6.0.1:
dependencies:
flat-cache: 3.2.0
@ -13156,6 +13205,8 @@ snapshots:
merge2@1.4.1: {}
meshoptimizer@0.18.1: {}
micromark-core-commonmark@2.0.1:
dependencies:
decode-named-character-reference: 1.0.2
@ -15226,6 +15277,8 @@ snapshots:
dependencies:
any-promise: 1.3.0
three@0.172.0: {}
tiny-invariant@1.3.3: {}
tinyexec@0.3.1: {}