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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 3021 additions and 800 deletions

View File

@ -6,7 +6,7 @@ import {
getWorldIdentifier,
showWorldInFolder,
} from '@/helpers/worlds.ts'
import { formatNumber } from '@modrinth/utils'
import { formatNumber, getPingLevel } from '@modrinth/utils'
import {
useRelativeTime,
Avatar,
@ -108,20 +108,6 @@ const serverIncompatible = computed(
props.serverStatus.version.protocol !== props.currentProtocol,
)
function getPingLevel(ping: number) {
if (ping < 150) {
return 5
} else if (ping < 300) {
return 4
} else if (ping < 600) {
return 3
} else if (ping < 1000) {
return 2
} else {
return 1
}
}
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
const messages = defineMessages({

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>

View File

@ -24,6 +24,7 @@ export const useUserCountry = () => {
if (import.meta.client) {
onMounted(() => {
if (fromServer.value) return;
// @ts-expect-error - ignore TS not knowing about navigator.userLanguage
const lang = navigator.language || navigator.userLanguage || "";
const region = lang.split("-")[1];
if (region) {

View File

@ -49,7 +49,9 @@ export async function usePyroFetch<T>(path: string, options: PyroFetchOptions =
const fullUrl = override?.url
? `https://${override.url}/${path.replace(/^\//, "")}`
: `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`;
: version === 0
? `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`
: `${base}/v${version}/${path.replace(/^\//, "")}`;
type HeadersRecord = Record<string, string>;

View File

@ -330,6 +330,9 @@ interface General {
token: string;
instance: string;
};
flows?: {
intro?: boolean;
};
}
interface Allocation {

View File

@ -159,7 +159,7 @@
"message": "Subscribe to updates about Modrinth"
},
"auth.welcome.description": {
"message": "Youre now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with amazing mods."
"message": "Youre now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with awazing mods."
},
"auth.welcome.label.tos": {
"message": "By creating an account, you have agreed to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>."
@ -350,11 +350,14 @@
"layout.banner.add-email.button": {
"message": "Visit account settings"
},
"layout.banner.add-email.description": {
"message": "For security reasons, Modrinth needs you to register an email address to your account."
},
"layout.banner.build-fail.description": {
"message": "This deploy of Modrinth's frontend failed to generate state from the API. This may be due to an outage or an error in configuration. Rebuild when the API is available. Error codes: {errors}; Current API URL is: {url}"
},
"layout.banner.build-fail.title": {
"message": "Error generating state from API when building"
"message": "Error generating state from API when building."
},
"layout.banner.staging.description": {
"message": "The staging environment is completely separate from the production Modrinth database. This is used for testing and debugging purposes, and may be running in-development versions of the Modrinth backend or frontend newer than the production instance."
@ -365,12 +368,12 @@
"layout.banner.subscription-payment-failed.button": {
"message": "Update billing info"
},
"layout.banner.subscription-payment-failed.title": {
"message": "Billing action required"
},
"layout.banner.subscription-payment-failed.description": {
"message": "One or more subscriptions failed to renew. Please update your payment method to prevent losing access!"
},
"layout.banner.subscription-payment-failed.title": {
"message": "Billing action required."
},
"layout.banner.verify-email.action": {
"message": "Re-send verification email"
},
@ -1047,32 +1050,23 @@
"message": "No notices"
},
"servers.plan.large.description": {
"message": "Ideal for larger communities, modpacks, and heavy modding."
"message": "Ideal for 15-25 players, modpacks, or heavy modding."
},
"servers.plan.large.name": {
"message": "Large"
},
"servers.plan.large.symbol": {
"message": "L"
},
"servers.plan.medium.description": {
"message": "Great for modded multiplayer and small communities."
"message": "Great for 615 players and multiple mods."
},
"servers.plan.medium.name": {
"message": "Medium"
},
"servers.plan.medium.symbol": {
"message": "M"
},
"servers.plan.small.description": {
"message": "Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding."
"message": "Perfect for 15 friends with a few light mods."
},
"servers.plan.small.name": {
"message": "Small"
},
"servers.plan.small.symbol": {
"message": "S"
},
"settings.billing.modal.cancel.action": {
"message": "Cancel subscription"
},

View File

@ -4,27 +4,28 @@
data-pyro
class="servers-hero relative isolate -mt-44 h-full min-h-screen pt-8"
>
<PurchaseModal
v-if="showModal && selectedProduct && customer"
:key="selectedProduct.id"
<ModrinthServersPurchaseModal
v-if="customer"
:key="`purchase-modal-${customer.id}`"
ref="purchaseModal"
:product="selectedProduct"
:country="country"
:custom-server="customServer"
:publishable-key="config.public.stripePublishableKey"
:send-billing-request="
:initiate-payment="
async (body) =>
await useBaseFetch('billing/payment', { internal: true, method: 'POST', body })
"
:fetch-payment-data="fetchPaymentData"
:available-products="pyroProducts"
:on-error="handleError"
:customer="customer"
:payment-methods="paymentMethods"
:currency="selectedCurrency"
:return-url="`${config.public.siteUrl}/servers/manage`"
:server-name="`${auth?.user?.username}'s server`"
:fetch-capacity-statuses="fetchCapacityStatuses"
:out-of-stock-url="outOfStockUrl"
@hidden="handleModalHidden"
:fetch-capacity-statuses="fetchCapacityStatuses"
:pings="regionPings"
:regions="regions"
:refresh-payment-methods="fetchPaymentData"
:fetch-stock="fetchStock"
/>
<section
@ -497,98 +498,6 @@
</div>
</section>
<section
v-if="false"
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, California, Texas, Florida, and
Washington, 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"
@ -596,19 +505,47 @@
<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">
<h1 class="relative m-0 text-4xl leading-[120%] md:text-7xl">
Start your server on Modrinth
There's a server for everyone
</h1>
<h2
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
>
{{
isAtCapacity && !loggedOut
? "We are currently at capacity. Please try again later."
: "There's a plan for everyone! Choose the one that fits your needs."
}}
</h2>
<p class="m-0 flex items-center gap-1">
Available in North America and Europe for wide coverage.
</p>
<ul class="m-0 mt-8 flex w-full flex-col gap-8 p-0 lg:flex-row">
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<span></span>
<OptionGroup v-slot="{ option }" v-model="billingPeriod" :options="billingPeriods">
<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>
<template v-if="billingPeriods.includes('quarterly')">
<button
v-if="billingPeriod !== 'quarterly'"
class="bg-transparent p-0 text-sm font-medium text-brand hover:underline active:scale-95"
@click="billingPeriod = 'quarterly'"
>
Save 16% with quarterly billing!
</button>
<span v-else class="text-sm font-medium text-primary">
Saving 16% with quarterly billing!
</span>
</template>
<template v-else-if="billingPeriods.includes('yearly')">
<button
v-if="billingPeriod !== 'yearly'"
class="bg-transparent p-0 text-sm font-medium text-brand hover:underline active:scale-95"
@click="billingPeriod = 'yearly'"
>
Save 16% with yearly billing!
</button>
<span v-else class="text-sm font-medium text-primary">
Saving 16% with yearly billing!
</span>
</template>
<span v-else></span>
</div>
<ul class="m-0 flex w-full grid-cols-3 flex-col gap-8 p-0 lg:grid">
<ServerPlanSelector
:capacity="capacityStatuses?.small?.available"
plan="small"
@ -616,9 +553,12 @@
:storage="plans.small.metadata.storage"
:cpus="plans.small.metadata.cpu"
:price="
plans.small?.prices?.find((x) => x.currency_code === 'USD')?.prices?.intervals
?.monthly
plans.small?.prices?.find((x) => x.currency_code === selectedCurrency)?.prices
?.intervals?.[billingPeriod]
"
:interval="billingPeriod"
:currency="selectedCurrency"
:is-usa="country.toLowerCase() === 'us'"
@select="selectProduct('small')"
@scroll-to-faq="scrollToFaq()"
/>
@ -629,9 +569,12 @@
:storage="plans.medium.metadata.storage"
:cpus="plans.medium.metadata.cpu"
:price="
plans.medium?.prices?.find((x) => x.currency_code === 'USD')?.prices?.intervals
?.monthly
plans.medium?.prices?.find((x) => x.currency_code === selectedCurrency)?.prices
?.intervals?.[billingPeriod]
"
:interval="billingPeriod"
:currency="selectedCurrency"
:is-usa="country.toLowerCase() === 'us'"
@select="selectProduct('medium')"
@scroll-to-faq="scrollToFaq()"
/>
@ -641,10 +584,13 @@
:storage="plans.large.metadata.storage"
:cpus="plans.large.metadata.cpu"
:price="
plans.large?.prices?.find((x) => x.currency_code === 'USD')?.prices?.intervals
?.monthly
plans.large?.prices?.find((x) => x.currency_code === selectedCurrency)?.prices
?.intervals?.[billingPeriod]
"
:currency="selectedCurrency"
:is-usa="country.toLowerCase() === 'us'"
plan="large"
:interval="billingPeriod"
@select="selectProduct('large')"
@scroll-to-faq="scrollToFaq()"
/>
@ -654,10 +600,9 @@
class="mb-24 flex w-full flex-col items-start justify-between gap-4 rounded-2xl bg-bg p-8 text-left lg:flex-row lg:gap-0"
>
<div class="flex flex-col gap-4">
<h1 class="m-0">Build your own</h1>
<h1 class="m-0">Know exactly what you need?</h1>
<h2 class="m-0 text-base font-normal text-primary">
If you're a more technical server administrator, you can pick your own RAM and storage
options.
Pick a customized plan with just the specs you need.
</h2>
</div>
@ -666,7 +611,7 @@
>
<ButtonStyled color="standard" size="large">
<button class="w-full md:w-fit" @click="selectProduct('custom')">
Build your own
Get started
<RightArrowIcon class="shrink-0" />
</button>
</ButtonStyled>
@ -679,7 +624,7 @@
</template>
<script setup>
import { ButtonStyled, PurchaseModal } from "@modrinth/ui";
import { ButtonStyled, ModrinthServersPurchaseModal } from "@modrinth/ui";
import {
BoxIcon,
GameIcon,
@ -691,8 +636,11 @@ 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";
import ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue";
import OptionGroup from "~/components/ui/OptionGroup.vue";
const billingPeriods = ref(["monthly", "yearly"]);
const billingPeriod = ref(billingPeriods.value.includes("quarterly") ? "quarterly" : "monthly");
const pyroProducts = products.filter((p) => p.metadata.type === "pyro");
const pyroPlanProducts = pyroProducts.filter(
@ -711,16 +659,6 @@ useSeoMeta({
ogDescription: description,
});
useHead({
script: [
{
src: "https://js.stripe.com/v3/",
defer: true,
async: true,
},
],
});
const auth = await useAuth();
const data = useNuxtApp();
const config = useRuntimeConfig();
@ -740,6 +678,7 @@ const isDeleting = ref(false);
const typingSpeed = 75;
const deletingSpeed = 25;
const pauseTime = 2000;
const selectedCurrency = ref("USD");
const loggedOut = computed(() => !auth.value.user);
const outOfStockUrl = "https://discord.modrinth.com";
@ -754,6 +693,16 @@ const { data: hasServers } = await useAsyncData("ServerListCountCheck", async ()
}
});
function fetchStock(region, request) {
return usePyroFetch(`stock?region=${region.shortcode}`, {
method: "POST",
body: {
...request,
},
bypassAuth: true,
}).then((res) => res.available);
}
async function fetchCapacityStatuses(customProduct = null) {
try {
const productsToCheck = customProduct?.metadata
@ -841,23 +790,6 @@ const handleError = (err) => {
});
};
const handleModalHidden = () => {
showModal.value = false;
};
watch(selectedProduct, async (newProduct) => {
if (newProduct) {
showModal.value = false;
await nextTick();
showModal.value = true;
modalKey.value++;
await nextTick();
if (purchaseModal.value && purchaseModal.value.show) {
purchaseModal.value.show();
}
}
});
async function fetchPaymentData() {
if (!auth.value.user) return;
try {
@ -954,8 +886,10 @@ const selectProduct = async (product) => {
modalKey.value++;
await nextTick();
if (purchaseModal.value && purchaseModal.value.show) {
purchaseModal.value.show();
if (product === "custom") {
purchaseModal.value?.show(billingPeriod.value);
} else {
purchaseModal.value?.show(billingPeriod.value, selectedProduct.value);
}
};
@ -966,9 +900,82 @@ const planQuery = () => {
}
};
const regions = ref([]);
const regionPings = ref([]);
function pingRegions() {
usePyroFetch("regions", {
method: "GET",
version: 1,
bypassAuth: true,
}).then((res) => {
regions.value = res;
regions.value.forEach((region) => {
runPingTest(region);
});
});
}
const PING_COUNT = 20;
const PING_INTERVAL = 400;
const MAX_PING_TIME = 1000;
function runPingTest(region, 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 = [];
socket.onopen = () => {
for (let i = 0; i < PING_COUNT; i++) {
setTimeout(() => {
socket.send(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) => {
pings.push(performance.now() - event.data);
};
socket.onerror = (event) => {
console.error(
`Failed to connect pingtest WebSocket with ${wsUrl}, trying index ${index + 1}:`,
event,
);
runPingTest(region, index + 1);
};
} catch (error) {
console.error(`Failed to connect pingtest WebSocket with ${wsUrl}:`, error);
}
}
onMounted(() => {
startTyping();
planQuery();
pingRegions();
});
watch(customer, (newCustomer) => {

View File

@ -207,6 +207,7 @@
class="server-action-buttons-anim flex w-fit flex-shrink-0"
>
<UiServersPanelServerActionButton
v-if="!serverData.flows?.intro"
class="flex-shrink-0"
:is-online="isServerRunning"
:is-actioning="isActioning"
@ -220,7 +221,14 @@
</div>
</div>
<div
v-if="serverData.flows?.intro"
class="flex items-center gap-2 font-semibold text-secondary"
>
<SettingsIcon /> Configuring server...
</div>
<UiServersServerInfoLabels
v-else
:server-data="serverData"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
@ -231,149 +239,181 @@
</div>
</div>
<div
data-pyro-navigation
class="isolate flex w-full select-none flex-col justify-between gap-4 overflow-auto md:flex-row md:items-center"
>
<UiNavTabs :links="navLinks" />
</div>
<div v-if="serverData.flows?.intro">
<h2 class="my-4 text-xl font-extrabold">
What would you like to install on your new server?
</h2>
<div data-pyro-mount class="h-full w-full flex-1">
<div
v-if="error"
class="mx-auto mb-4 flex justify-between gap-2 rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
>
<div class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 shrink-0 text-red sm:block" />
<div class="flex flex-col gap-2 leading-[150%]">
<div class="flex items-center gap-3">
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
<div class="flex gap-2 text-2xl font-bold">{{ errorTitle }}</div>
</div>
<div v-if="errorTitle.toLocaleLowerCase() === 'installation error'" class="font-normal">
<div
v-if="errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'"
>
An invalid loader or Minecraft version was specified and could not be installed.
<ul class="m-0 mt-4 p-0 pl-4">
<li>
If this version of Minecraft was released recently, please check if Modrinth
Servers supports it.
</li>
<li>
If you've installed a modpack, it may have been packaged incorrectly or may not
be compatible with the loader.
</li>
<li>
Your server may need to be reinstalled with a valid mod loader and version. You
can change the loader by clicking the "Change Loader" button.
</li>
<li>
If you're stuck, please contact Modrinth Support with the information below:
</li>
</ul>
<ButtonStyled>
<button class="mt-2" @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
</div>
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
An internal error occurred while installing your server. Don't fret try
reinstalling your server, and if the problem persists, please contact Modrinth
support with your server's debug information.
</div>
<div v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'">
An error occurred while installing your server because Modrinth Servers does not
support the version of Minecraft or the loader you specified. Try reinstalling your
server with a different version or loader, and if the problem persists, please
contact Modrinth Support with your server's debug information.
</div>
<div
v-if="errorTitle === 'Installation error'"
class="mt-2 flex flex-col gap-4 sm:flex-row"
>
<ButtonStyled v-if="errorLog">
<button @click="openInstallLog"><FileIcon />Open Installation Log</button>
</ButtonStyled>
<ButtonStyled>
<button @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
<ButtonStyled color="red" type="standard">
<NuxtLink
class="whitespace-pre"
:to="`/servers/manage/${serverId}/options/loader`"
>
<RightArrowIcon />
Change Loader
</NuxtLink>
</ButtonStyled>
</div>
</div>
</div>
</div>
</div>
<div
v-if="!isConnected && !isReconnecting && !isLoading"
data-pyro-server-ws-error
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-red p-4 text-contrast"
>
<IssuesIcon class="size-5 text-red" />
Something went wrong...
</div>
<div
v-if="isReconnecting"
data-pyro-server-ws-reconnecting
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
>
<UiServersPanelSpinner />
Hang on, we're reconnecting to your server.
</div>
<div
v-if="serverData.status === 'installing'"
data-pyro-server-installing
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
>
<UiServersServerIcon :image="serverData.image" class="!h-10 !w-10" />
<div class="flex flex-col gap-1">
<span class="text-lg font-bold"> We're preparing your server! </span>
<div class="flex flex-row items-center gap-2">
<UiServersPanelSpinner class="!h-3 !w-3" /> <LazyUiServersInstallingTicker />
</div>
</div>
</div>
<NuxtPage
:route="route"
:is-connected="isConnected"
:is-ws-auth-incorrect="isWSAuthIncorrect"
:is-server-running="isServerRunning"
:stats="stats"
:server-power-state="serverPowerState"
:power-state-details="powerStateDetails"
:socket="socket"
<ServerInstallation
:server="server"
:backup-in-progress="backupInProgress"
ignore-current-installation
@reinstall="onReinstall"
/>
</div>
<template v-else>
<div
data-pyro-navigation
class="isolate flex w-full select-none flex-col justify-between gap-4 overflow-auto md:flex-row md:items-center"
>
<UiNavTabs :links="navLinks" />
</div>
<div data-pyro-mount class="h-full w-full flex-1">
<div
v-if="error"
class="mx-auto mb-4 flex justify-between gap-2 rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
>
<div class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 shrink-0 text-red sm:block" />
<div class="flex flex-col gap-2 leading-[150%]">
<div class="flex items-center gap-3">
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
<div class="flex gap-2 text-2xl font-bold">{{ errorTitle }}</div>
</div>
<div
v-if="errorTitle.toLocaleLowerCase() === 'installation error'"
class="font-normal"
>
<div
v-if="
errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'
"
>
An invalid loader or Minecraft version was specified and could not be installed.
<ul class="m-0 mt-4 p-0 pl-4">
<li>
If this version of Minecraft was released recently, please check if Modrinth
Servers supports it.
</li>
<li>
If you've installed a modpack, it may have been packaged incorrectly or may
not be compatible with the loader.
</li>
<li>
Your server may need to be reinstalled with a valid mod loader and version.
You can change the loader by clicking the "Change Loader" button.
</li>
<li>
If you're stuck, please contact Modrinth Support with the information below:
</li>
</ul>
<ButtonStyled>
<button class="mt-2" @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
</div>
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
An internal error occurred while installing your server. Don't fret try
reinstalling your server, and if the problem persists, please contact Modrinth
support with your server's debug information.
</div>
<div
v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'"
>
An error occurred while installing your server because Modrinth Servers does not
support the version of Minecraft or the loader you specified. Try reinstalling
your server with a different version or loader, and if the problem persists,
please contact Modrinth Support with your server's debug information.
</div>
<div
v-if="errorTitle === 'Installation error'"
class="mt-2 flex flex-col gap-4 sm:flex-row"
>
<ButtonStyled v-if="errorLog">
<button @click="openInstallLog"><FileIcon />Open Installation Log</button>
</ButtonStyled>
<ButtonStyled>
<button @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
<ButtonStyled color="red" type="standard">
<NuxtLink
class="whitespace-pre"
:to="`/servers/manage/${serverId}/options/loader`"
>
<RightArrowIcon />
Change Loader
</NuxtLink>
</ButtonStyled>
</div>
</div>
</div>
</div>
</div>
<div
v-if="!isConnected && !isReconnecting && !isLoading"
data-pyro-server-ws-error
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-red p-4 text-contrast"
>
<IssuesIcon class="size-5 text-red" />
Something went wrong...
</div>
<div
v-if="isReconnecting"
data-pyro-server-ws-reconnecting
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
>
<UiServersPanelSpinner />
Hang on, we're reconnecting to your server.
</div>
<div
v-if="serverData.status === 'installing'"
data-pyro-server-installing
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
>
<UiServersServerIcon :image="serverData.image" class="!h-10 !w-10" />
<div class="flex flex-col gap-1">
<span class="text-lg font-bold"> We're preparing your server! </span>
<div class="flex flex-row items-center gap-2">
<UiServersPanelSpinner class="!h-3 !w-3" /> <LazyUiServersInstallingTicker />
</div>
</div>
</div>
<NuxtPage
:route="route"
:is-connected="isConnected"
:is-ws-auth-incorrect="isWSAuthIncorrect"
:is-server-running="isServerRunning"
:stats="stats"
:server-power-state="serverPowerState"
:power-state-details="powerStateDetails"
:socket="socket"
:server="server"
:backup-in-progress="backupInProgress"
@reinstall="onReinstall"
/>
</div>
</template>
</div>
<div
v-if="flags.advancedDebugInfo"
class="experimental-styles-within relative mx-auto mt-6 box-border w-full min-w-0 max-w-[1280px] px-6"
>
<h2 class="m-0 text-lg font-extrabold text-contrast">Server data</h2>
<pre class="markdown-body w-full overflow-auto rounded-2xl bg-bg-raised p-4 text-sm">{{
JSON.stringify(server, null, " ")
}}</pre>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import {
SettingsIcon,
CopyIcon,
IssuesIcon,
LeftArrowIcon,
@ -392,6 +432,7 @@ import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/t
import { usePyroConsole } from "~/store/console.ts";
import { type Backup } from "~/composables/pyroServers.ts";
import { usePyroFetch } from "~/composables/pyroFetch.ts";
import ServerInstallation from "~/components/ui/servers/ServerInstallation.vue";
const app = useNuxtApp() as unknown as { $notify: any };
@ -401,6 +442,7 @@ const isLoading = ref(true);
const reconnectInterval = ref<ReturnType<typeof setInterval> | null>(null);
const isFirstMount = ref(true);
const isMounted = ref(true);
const flags = useFeatureFlags();
const INTERCOM_APP_ID = ref("ykeritl9");
const auth = (await useAuth()) as unknown as {
@ -812,6 +854,13 @@ const newLoaderVersion = ref<string | null>(null);
const newMCVersion = ref<string | null>(null);
const onReinstall = (potentialArgs: any) => {
if (serverData.value?.flows?.intro) {
usePyroFetch(`servers/${server.serverId}/flows/intro`, {
method: "DELETE",
version: 1,
});
}
if (!serverData.value) return;
serverData.value.status = "installing";

View File

@ -1,162 +1,15 @@
<template>
<LazyUiServersPlatformVersionSelectModal
ref="versionSelectModal"
<ServerInstallation
:server="props.server"
:current-loader="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"
:backup-in-progress="props.backupInProgress"
@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="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();
import ServerInstallation from "~/components/ui/servers/ServerInstallation.vue";
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
@ -166,104 +19,4 @@ const props = defineProps<{
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

@ -444,39 +444,13 @@
:return-url="`${config.public.siteUrl}/servers/manage`"
:server-name="`${auth?.user?.username}'s server`"
/>
<NewModal ref="addPaymentMethodModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">
{{ formatMessage(messages.paymentMethodTitle) }}
</span>
</template>
<div class="min-h-[16rem] md:w-[600px]">
<div
v-show="loadingPaymentMethodModal !== 2"
class="flex min-h-[16rem] items-center justify-center"
>
<AnimatedLogo class="w-[80px]" />
</div>
<div v-show="loadingPaymentMethodModal === 2" class="min-h-[16rem] p-1">
<div id="address-element"></div>
<div id="payment-element" class="mt-4"></div>
</div>
<div v-show="loadingPaymentMethodModal === 2" class="input-group mt-auto pt-4">
<ButtonStyled color="brand">
<button :disabled="loadingAddMethod" @click="submit">
<PlusIcon />
{{ formatMessage(messages.paymentMethodAdd) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="$refs.addPaymentMethodModal.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<AddPaymentMethodModal
ref="addPaymentMethodModal"
:publishable-key="config.public.stripePublishableKey"
:return-url="`${config.public.siteUrl}/settings/billing`"
:create-setup-intent="createSetupIntent"
:on-error="handleError"
/>
<div class="header__row">
<div class="header__title">
<h2 class="text-2xl">{{ formatMessage(messages.paymentMethodTitle) }}</h2>
@ -590,9 +564,8 @@
<script setup>
import {
ConfirmModal,
NewModal,
AddPaymentMethodModal,
OverflowMenu,
AnimatedLogo,
PurchaseModal,
ButtonStyled,
CopyCode,
@ -617,7 +590,7 @@ import {
UpdatedIcon,
HistoryIcon,
} from "@modrinth/assets";
import { calculateSavings, formatPrice, createStripeElements, getCurrency } from "@modrinth/utils";
import { calculateSavings, formatPrice, getCurrency } from "@modrinth/utils";
import { ref, computed } from "vue";
import { products } from "~/generated/state.json";
@ -754,19 +727,6 @@ const paymentMethodTypes = defineMessages({
},
});
let stripe = null;
let elements = null;
function loadStripe() {
try {
if (!stripe) {
stripe = Stripe(config.public.stripePublishableKey);
}
} catch (error) {
console.error("Error loading Stripe:", error);
}
}
const [
{ data: paymentMethods, refresh: refreshPaymentMethods },
{ data: charges, refresh: refreshCharges },
@ -842,69 +802,16 @@ const primaryPaymentMethodId = computed(() => {
});
const addPaymentMethodModal = ref();
const loadingPaymentMethodModal = ref(0);
async function addPaymentMethod() {
try {
loadingPaymentMethodModal.value = 0;
addPaymentMethodModal.value.show();
const result = await useBaseFetch("billing/payment_method", {
internal: true,
method: "POST",
});
loadStripe();
const {
elements: elementsVal,
addressElement,
paymentElement,
} = createStripeElements(stripe, paymentMethods.value, {
clientSecret: result.client_secret,
});
elements = elementsVal;
paymentElement.on("ready", () => {
loadingPaymentMethodModal.value += 1;
});
addressElement.on("ready", () => {
loadingPaymentMethodModal.value += 1;
});
} catch (err) {
data.$notify({
group: "main",
title: "An error occurred",
text: err.data ? err.data.description : err,
type: "error",
});
}
function addPaymentMethod() {
addPaymentMethodModal.value.show(paymentMethods.value);
}
const loadingAddMethod = ref(false);
async function submit() {
startLoading();
loadingAddMethod.value = true;
loadStripe();
const { error } = await stripe.confirmSetup({
elements,
confirmParams: {
return_url: `${config.public.siteUrl}/settings/billing`,
},
async function createSetupIntent() {
return await useBaseFetch("billing/payment_method", {
internal: true,
method: "POST",
});
if (error && error.type !== "validation_error") {
data.$notify({
group: "main",
title: "An error occurred",
text: error.message,
type: "error",
});
} else if (!error) {
await refresh();
addPaymentMethodModal.value.close();
}
loadingAddMethod.value = false;
stopLoading();
}
const removePaymentMethodIndex = ref();

View File

@ -1,14 +1 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="absolute right-8 top-8 size-8"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z"
/>
</svg>
<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-cpu-icon lucide-cpu"><path d="M12 20v2"/><path d="M12 2v2"/><path d="M17 20v2"/><path d="M17 2v2"/><path d="M2 12h2"/><path d="M2 17h2"/><path d="M2 7h2"/><path d="M20 12h2"/><path d="M20 17h2"/><path d="M20 7h2"/><path d="M7 20v2"/><path d="M7 2v2"/><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="8" y="8" width="8" height="8" rx="1"/></svg>

Before

Width:  |  Height:  |  Size: 527 B

After

Width:  |  Height:  |  Size: 555 B

View 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-database-icon lucide-database"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg>

After

Width:  |  Height:  |  Size: 348 B

View 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-memory-stick-icon lucide-memory-stick"><path d="M6 19v-3"/><path d="M10 19v-3"/><path d="M14 19v-3"/><path d="M18 19v-3"/><path d="M8 11V9"/><path d="M16 11V9"/><path d="M12 11V9"/><path d="M2 15h20"/><path d="M2 7a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v1.1a2 2 0 0 0 0 3.837V17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5.1a2 2 0 0 0 0-3.837Z"/></svg>

After

Width:  |  Height:  |  Size: 535 B

View File

@ -77,6 +77,7 @@ import _CopyrightIcon from './icons/copyright.svg?component'
import _CrownIcon from './icons/crown.svg?component'
import _CurrencyIcon from './icons/currency.svg?component'
import _DashboardIcon from './icons/dashboard.svg?component'
import _DatabaseIcon from './icons/database.svg?component'
import _DownloadIcon from './icons/download.svg?component'
import _DropdownIcon from './icons/dropdown.svg?component'
import _EditIcon from './icons/edit.svg?component'
@ -125,6 +126,7 @@ import _LogOutIcon from './icons/log-out.svg?component'
import _MailIcon from './icons/mail.svg?component'
import _ManageIcon from './icons/manage.svg?component'
import _MaximizeIcon from './icons/maximize.svg?component'
import _MemoryStickIcon from './icons/memory-stick.svg?component'
import _MessageIcon from './icons/message.svg?component'
import _MicrophoneIcon from './icons/microphone.svg?component'
import _MinimizeIcon from './icons/minimize.svg?component'
@ -295,6 +297,7 @@ export const CopyrightIcon = _CopyrightIcon
export const CrownIcon = _CrownIcon
export const CurrencyIcon = _CurrencyIcon
export const DashboardIcon = _DashboardIcon
export const DatabaseIcon = _DatabaseIcon
export const DownloadIcon = _DownloadIcon
export const DropdownIcon = _DropdownIcon
export const EditIcon = _EditIcon
@ -344,6 +347,7 @@ export const LogOutIcon = _LogOutIcon
export const MailIcon = _MailIcon
export const ManageIcon = _ManageIcon
export const MaximizeIcon = _MaximizeIcon
export const MemoryStickIcon = _MemoryStickIcon
export const MessageIcon = _MessageIcon
export const MicrophoneIcon = _MicrophoneIcon
export const MinimizeIcon = _MinimizeIcon

View File

@ -200,6 +200,8 @@ html {
--color-platform-sponge: #f9e580;
--hover-brightness: 1.25;
--experimental-color-button-bg: #33363d;
}
.oled-mode {
@ -257,7 +259,7 @@ html {
}
.dark-experiments {
--color-button-bg: #33363d;
--color-button-bg: var(--experimental-color-button-bg);
}
.dark-mode:not(.oled-mode),

View File

@ -11,10 +11,12 @@
},
"devDependencies": {
"@formatjs/cli": "^6.2.12",
"@stripe/stripe-js": "^7.3.1",
"@vintl/unplugin": "^1.5.1",
"@vintl/vintl": "^4.4.1",
"eslint": "^8.57.0",
"eslint-config-custom": "workspace:*",
"stripe": "^18.1.1",
"tsconfig": "workspace:*",
"typescript": "^5.4.5",
"vue": "^3.5.13",

View File

@ -3,9 +3,9 @@
<button
v-for="(item, index) in items"
:key="`radio-button-${index}`"
class="p-0 py-2 px-2 border-0 flex gap-2 transition-all items-center cursor-pointer active:scale-95 hover:bg-button-bg rounded-xl"
class="p-0 py-2 px-2 border-0 font-medium flex gap-2 transition-all items-center cursor-pointer active:scale-95 hover:bg-button-bg rounded-xl"
:class="{
'text-contrast font-medium bg-button-bg': selected === item,
'text-contrast bg-button-bg': selected === item,
'text-primary bg-transparent': selected !== item,
}"
@click="selected = item"

View File

@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref } from 'vue'
import { createStripeElements } from '@modrinth/utils'
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
import { loadStripe, type Stripe as StripsJs, type StripeElements } from '@stripe/stripe-js'
const emit = defineEmits<{
(e: 'startLoading' | 'stopLoading'): void
}>()
export type SetupIntentResponse = {
client_secret: string
}
export type AddPaymentMethodProps = {
publishableKey: string
createSetupIntent: () => Promise<SetupIntentResponse>
returnUrl: string
onError: (error: Error) => void
}
const props = defineProps<AddPaymentMethodProps>()
const elementsLoaded = ref<0 | 1 | 2>(0)
const stripe = ref<StripsJs>()
const elements = ref<StripeElements>()
const error = ref(false)
function handleError(error: Error) {
props.onError(error)
error.value = true
}
async function reload(paymentMethods: Stripe.PaymentMethod[]) {
try {
elementsLoaded.value = 0
error.value = false
const result = await props.createSetupIntent()
stripe.value = await loadStripe(props.publishableKey)
const {
elements: newElements,
addressElement,
paymentElement,
} = createStripeElements(stripe.value, paymentMethods, {
clientSecret: result.client_secret,
})
elements.value = newElements
paymentElement.on('ready', () => {
elementsLoaded.value += 1
})
addressElement.on('ready', () => {
elementsLoaded.value += 1
})
} catch (err) {
handleError(err)
}
}
async function submit(): Promise<boolean> {
emit('startLoading')
const result = await stripe.value.confirmSetup({
elements: elements.value,
confirmParams: {
return_url: props.returnUrl,
},
})
console.log(result)
const { error } = result
emit('stopLoading')
if (error && error.type !== 'validation_error') {
handleError(error.message)
return false
} else if (!error) {
return true
}
}
defineExpose({
reload,
submit,
})
</script>
<template>
<div class="min-h-[16rem] flex flex-col gap-2 justify-center items-center">
<div v-show="elementsLoaded < 2">
<ModalLoadingIndicator :error="error">
Loading...
<template #error> Error loading Stripe payment UI. </template>
</ModalLoadingIndicator>
</div>
<div class="w-full">
<div id="address-element"></div>
<div id="payment-element" class="mt-4"></div>
</div>
</div>
</template>

View File

@ -0,0 +1,72 @@
<script setup lang="ts">
import { ButtonStyled, NewModal } from '../index'
import { defineMessages, useVIntl } from '@vintl/vintl'
import AddPaymentMethod from './AddPaymentMethod.vue'
import type { AddPaymentMethodProps } from './AddPaymentMethod.vue'
import { commonMessages } from '../../utils'
import { PlusIcon, XIcon } from '@modrinth/assets'
const { formatMessage } = useVIntl()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const addPaymentMethod = useTemplateRef<InstanceType<typeof AddPaymentMethod>>('addPaymentMethod')
const props = defineProps<AddPaymentMethodProps>()
const loading = ref(false)
async function open(paymentMethods: Stripe.PaymentMethod[]) {
modal.value?.show()
await nextTick()
await addPaymentMethod.value?.reload(paymentMethods)
}
const messages = defineMessages({
addingPaymentMethod: {
id: 'modal.add-payment-method.title',
defaultMessage: 'Adding a payment method',
},
paymentMethodAdd: {
id: 'modal.add-payment-method.action',
defaultMessage: 'Add payment method',
},
})
defineExpose({
show: open,
})
</script>
<template>
<NewModal ref="modal">
<template #title>
<span class="text-lg font-extrabold text-contrast">
{{ formatMessage(messages.addingPaymentMethod) }}
</span>
</template>
<div class="w-[40rem] max-w-full">
<AddPaymentMethod
ref="addPaymentMethod"
:publishable-key="props.publishableKey"
:return-url="props.returnUrl"
:create-setup-intent="props.createSetupIntent"
:on-error="props.onError"
@start-loading="loading = true"
@stop-loading="loading = false"
/>
<div class="input-group mt-auto pt-4">
<ButtonStyled color="brand">
<button :disabled="loading" @click="addPaymentMethod.submit()">
<PlusIcon />
{{ formatMessage(messages.paymentMethodAdd) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>

View File

@ -0,0 +1,65 @@
<script setup lang="ts">
import Accordion from '../base/Accordion.vue'
import { formatPrice } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import { SpinnerIcon } from '@modrinth/assets'
import { computed } from 'vue'
const { locale } = useVIntl()
export type BillingItem = {
title: string
amount: number
}
const props = defineProps<{
period?: string
currency: string
total: number
billingItems: BillingItem[]
loading?: boolean
}>()
const periodSuffix = computed(() => {
return props.period ? ` / ${props.period}` : ''
})
</script>
<template>
<Accordion
class="rounded-2xl overflow-hidden bg-bg"
button-class="bg-transparent p-0 w-full p-4 active:scale-[0.98] transition-transform duration-100"
>
<template #title>
<div class="w-full flex items-center justify-between">
<div class="flex items-center gap-2 text-contrast font-bold">Total</div>
<div class="text-right mr-1">
<span class="text-primary font-bold">
<template v-if="loading">
<SpinnerIcon class="animate-spin size-4" />
</template>
<template v-else> {{ formatPrice(locale, total, currency) }} </template
><span class="text-xs text-secondary">{{ periodSuffix }}</span>
</span>
</div>
</div>
</template>
<div class="p-4 flex flex-col gap-4 bg-table-alternateRow">
<div
v-for="{ title, amount } in billingItems"
:key="title"
class="flex items-center justify-between"
>
<div class="font-semibold">
{{ title }}
</div>
<div class="text-right">
<template v-if="loading">
<SpinnerIcon class="animate-spin size-4" />
</template>
<template v-else> {{ formatPrice(locale, amount, currency) }} </template
><span class="text-xs text-secondary">{{ periodSuffix }}</span>
</div>
</div>
</div>
</Accordion>
</template>

View File

@ -0,0 +1,43 @@
<script setup lang="ts">
import { CardIcon, CurrencyIcon, PayPalIcon, UnknownIcon } from '@modrinth/assets'
import { commonMessages, paymentMethodMessages } from '../../utils'
import type Stripe from 'stripe'
import { useVIntl } from '@vintl/vintl'
const { formatMessage } = useVIntl()
defineProps<{
method: Stripe.PaymentMethod
}>()
</script>
<template>
<template v-if="'type' in method">
<CardIcon v-if="method.type === 'card'" class="size-[1.5em]" />
<CurrencyIcon v-else-if="method.type === 'cashapp'" class="size-[1.5em]" />
<PayPalIcon v-else-if="method.type === 'paypal'" class="size-[1.5em]" />
<UnknownIcon v-else class="size-[1.5em]" />
<span v-if="method.type === 'card' && 'card' in method && method.card">
{{
formatMessage(commonMessages.paymentMethodCardDisplay, {
card_brand:
formatMessage(paymentMethodMessages[method.card.brand]) ??
formatMessage(paymentMethodMessages.unknown),
last_four: method.card.last4,
})
}}
</span>
<template v-else>
{{
formatMessage(paymentMethodMessages[method.type]) ??
formatMessage(paymentMethodMessages.unknown)
}}
</template>
<span v-if="method.type === 'cashapp' && 'cashapp' in method && method.cashapp">
({{ method.cashapp.cashtag }})
</span>
<span v-else-if="method.type === 'paypal' && 'paypal' in method && method.paypal">
({{ method.paypal.payer_email }})
</span>
</template>
</template>

View File

@ -0,0 +1,297 @@
<script setup lang="ts">
import { ref, computed, useTemplateRef, nextTick } from 'vue'
import NewModal from '../modal/NewModal.vue'
import { type MessageDescriptor, useVIntl, defineMessage } from '@vintl/vintl'
import {
ChevronRightIcon,
LeftArrowIcon,
RightArrowIcon,
XIcon,
CheckCircleIcon,
} from '@modrinth/assets'
import type {
CreatePaymentIntentRequest,
CreatePaymentIntentResponse,
ServerBillingInterval,
ServerPlan,
ServerRegion,
ServerStockRequest,
UpdatePaymentIntentRequest,
UpdatePaymentIntentResponse,
} from '../../utils/billing'
import { ButtonStyled } from '../index'
import type Stripe from 'stripe'
import { commonMessages } from '../../utils'
import RegionSelector from './ServersPurchase1Region.vue'
import PaymentMethodSelector from './ServersPurchase2PaymentMethod.vue'
import ConfirmPurchase from './ServersPurchase3Review.vue'
import { useStripe } from '../../composables/stripe'
const { formatMessage } = useVIntl()
export type RegionPing = {
region: string
ping: number
}
const props = defineProps<{
publishableKey: string
returnUrl: string
paymentMethods: Stripe.PaymentMethod[]
customer: Stripe.Customer
currency: string
pings: RegionPing[]
regions: ServerRegion[]
availableProducts: ServerPlan[]
refreshPaymentMethods: () => Promise<void>
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
initiatePayment: (
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
) => Promise<UpdatePaymentIntentResponse | CreatePaymentIntentResponse>
}>()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const selectedPlan = ref<ServerPlan>()
const selectedInterval = ref<ServerBillingInterval>()
const loading = ref(false)
const {
initializeStripe,
selectPaymentMethod,
primaryPaymentMethodId,
loadStripeElements,
selectedPaymentMethod,
inputtedPaymentMethod,
createNewPaymentMethod,
loadingElements,
loadingElementsFailed,
tax,
total,
paymentMethodLoading,
reloadPaymentIntent,
hasPaymentMethod,
submitPayment,
} = useStripe(
props.publishableKey,
props.customer,
props.paymentMethods,
props.clientSecret,
props.currency,
selectedPlan,
selectedInterval,
props.initiatePayment,
console.error,
)
const selectedRegion = ref<string>()
const customServer = ref<boolean>(false)
const acceptedEula = ref<boolean>(false)
type Step = 'region' | 'payment' | 'review'
const steps: Step[] = ['region', 'payment', 'review']
const titles: Record<Step, MessageDescriptor> = {
region: defineMessage({ id: 'servers.purchase.step.region.title', defaultMessage: 'Region' }),
payment: defineMessage({
id: 'servers.purchase.step.payment.title',
defaultMessage: 'Payment method',
}),
review: defineMessage({ id: 'servers.purchase.step.review.title', defaultMessage: 'Review' }),
}
const currentRegion = computed(() => {
return props.regions.find((region) => region.shortcode === selectedRegion.value)
})
const currentPing = computed(() => {
return props.pings.find((ping) => ping.region === currentRegion.value?.shortcode)?.ping
})
const currentStep = ref<Step>()
const currentStepIndex = computed(() => steps.indexOf(currentStep.value))
const previousStep = computed(() => steps[steps.indexOf(currentStep.value) - 1])
const nextStep = computed(() => steps[steps.indexOf(currentStep.value) + 1])
const canProceed = computed(() => {
switch (currentStep.value) {
case 'region':
return selectedRegion.value && selectedPlan.value && selectedInterval.value
case 'payment':
return selectedPaymentMethod.value || !loadingElements.value
case 'review':
return acceptedEula.value && hasPaymentMethod.value
default:
return false
}
})
async function beforeProceed(step: string) {
switch (step) {
case 'region':
return true
case 'payment':
await initializeStripe()
if (primaryPaymentMethodId.value) {
const paymentMethod = await props.paymentMethods.find(
(x) => x.id === primaryPaymentMethodId.value,
)
await selectPaymentMethod(paymentMethod)
await setStep('review', true)
return true
}
return true
case 'review':
if (selectedPaymentMethod.value) {
return true
} else {
const token = await createNewPaymentMethod()
return !!token
}
}
}
async function afterProceed(step: string) {
switch (step) {
case 'region':
break
case 'payment':
await loadStripeElements()
break
case 'review':
break
}
}
async function setStep(step: Step, skipValidation = false) {
if (!step) {
await submitPayment(props.returnUrl)
return
}
if (!canProceed.value || skipValidation) {
return
}
if (await beforeProceed(step)) {
currentStep.value = step
await nextTick()
await afterProceed(step)
}
}
function begin(interval: ServerBillingInterval, plan?: ServerPlan) {
loading.value = false
selectedPlan.value = plan
selectedInterval.value = interval
customServer.value = !selectedPlan.value
selectedPaymentMethod.value = undefined
currentStep.value = steps[0]
modal.value?.show()
}
defineExpose({
show: begin,
})
</script>
<template>
<NewModal ref="modal">
<template #title>
<div class="flex items-center gap-1 font-bold text-secondary">
<template v-for="(title, id, index) in titles" :key="id">
<button
v-if="index < currentStepIndex"
class="bg-transparent active:scale-95 font-bold text-secondary p-0"
@click="setStep(id)"
>
{{ formatMessage(title) }}
</button>
<span
v-else
:class="{
'text-contrast': index === currentStepIndex,
}"
>
{{ formatMessage(title) }}
</span>
<ChevronRightIcon
v-if="index < steps.length - 1"
class="h-5 w-5 text-secondary"
stroke-width="3"
/>
</template>
</div>
</template>
<div class="w-[40rem] max-w-full">
<RegionSelector
v-if="currentStep === 'region'"
v-model:region="selectedRegion"
v-model:plan="selectedPlan"
:regions="regions"
:pings="pings"
:custom="customServer"
:available-products="availableProducts"
:fetch-stock="fetchStock"
/>
<PaymentMethodSelector
v-else-if="currentStep === 'payment' && selectedPlan && selectedInterval"
:payment-methods="paymentMethods"
:selected="selectedPaymentMethod"
:loading-elements="loadingElements"
:loading-elements-failed="loadingElementsFailed"
@select="selectPaymentMethod"
/>
<ConfirmPurchase
v-else-if="
currentStep === 'review' &&
hasPaymentMethod &&
selectedRegion &&
selectedInterval &&
selectedPlan
"
ref="currentStepRef"
v-model:interval="selectedInterval"
v-model:accepted-eula="acceptedEula"
:currency="currency"
:plan="selectedPlan"
:region="regions.find((x) => x.shortcode === selectedRegion)"
:ping="currentPing"
:loading="paymentMethodLoading"
:selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod"
:tax="tax"
:total="total"
:on-error="console.error"
@change-payment-method="setStep('payment')"
@reload-payment-intent="reloadPaymentIntent"
@error="console.error"
/>
<div v-else>Something went wrong</div>
</div>
<div class="flex gap-2 justify-between mt-4">
<ButtonStyled>
<button v-if="previousStep" @click="previousStep && setStep(previousStep)">
<LeftArrowIcon /> {{ formatMessage(commonMessages.backButton) }}
</button>
<button v-else @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canProceed" @click="setStep(nextStep)">
<template v-if="currentStep === 'review'">
<CheckCircleIcon />
Subscribe
</template>
<template v-else>
{{ formatMessage(commonMessages.nextButton) }} <RightArrowIcon />
</template>
</button>
</ButtonStyled>
</div>
</NewModal>
</template>

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
import { RadioButtonIcon, RadioButtonCheckedIcon, SpinnerIcon } from '@modrinth/assets'
import type Stripe from 'stripe'
import FormattedPaymentMethod from './FormattedPaymentMethod.vue'
const emit = defineEmits<{
(e: 'select'): void
}>()
withDefaults(
defineProps<{
item: Stripe.PaymentMethod | undefined
selected: boolean
loading?: boolean
}>(),
{
loading: false,
},
)
</script>
<template>
<button
class="flex items-center w-full gap-2 border-none p-3 text-primary rounded-xl transition-all duration-200 hover:bg-button-bg hover:brightness-[--hover-brightness] active:scale-[0.98] hover:cursor-pointer"
:class="selected ? 'bg-button-bg' : 'bg-transparent'"
@click="emit('select')"
>
<RadioButtonCheckedIcon v-if="selected" class="size-6 text-brand" />
<RadioButtonIcon v-else class="size-6 text-secondary" />
<template v-if="item === undefined">
<span>New payment method</span>
</template>
<FormattedPaymentMethod v-else-if="item" :method="item" />
<SpinnerIcon v-if="loading" class="ml-auto size-4 text-secondary animate-spin" />
</button>
</template>

View File

@ -0,0 +1,229 @@
<script setup lang="ts">
import ServersRegionButton from './ServersRegionButton.vue'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { onMounted, ref, computed, watch } from 'vue'
import type { RegionPing } from './ModrinthServersPurchaseModal.vue'
import type { ServerPlan, ServerRegion, ServerStockRequest } from '../../utils/billing'
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
import Slider from '../base/Slider.vue'
import { SpinnerIcon, XIcon, InfoIcon } from '@modrinth/assets'
import ServersSpecs from './ServersSpecs.vue'
const { formatMessage } = useVIntl()
const props = defineProps<{
regions: ServerRegion[]
pings: RegionPing[]
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
custom: boolean
availableProducts: ServerPlan[]
}>()
const loading = ref(true)
const checkingCustomStock = ref(false)
const selectedPlan = defineModel<ServerPlan>('plan')
const selectedRegion = defineModel<string>('region')
const selectedRam = ref<number>(-1)
const ramOptions = computed(() => {
return props.availableProducts
.map((product) => (product.metadata.ram ?? 0) / 1024)
.filter((x) => x > 0)
})
const minRam = computed(() => {
return Math.min(...ramOptions.value)
})
const maxRam = computed(() => {
return Math.max(...ramOptions.value)
})
const lowestProduct = computed(() => {
return (
props.availableProducts.find(
(product) => (product.metadata.ram ?? 0) / 1024 === minRam.value,
) ?? props.availableProducts[0]
)
})
function updateRamStock(regionToCheck: string, newRam: number) {
if (newRam > 0) {
checkingCustomStock.value = true
const plan = props.availableProducts.find(
(product) => (product.metadata.ram ?? 0) / 1024 === newRam,
)
if (plan) {
const region = props.regions.find((region) => region.shortcode === regionToCheck)
if (region) {
props
.fetchStock(region, {
cpu: plan.metadata.cpu ?? 0,
memory_mb: plan.metadata.ram ?? 0,
swap_mb: plan.metadata.swap ?? 0,
storage_mb: plan.metadata.storage ?? 0,
})
.then((stock: number) => {
if (stock > 0) {
selectedPlan.value = plan
} else {
selectedPlan.value = undefined
}
})
.finally(() => {
checkingCustomStock.value = false
})
} else {
checkingCustomStock.value = false
}
}
}
}
watch(selectedRam, (newRam: number) => {
updateRamStock(selectedRegion.value, newRam)
})
watch(selectedRegion, (newRegion: number) => {
updateRamStock(newRegion, selectedRam.value)
})
const currentStock = ref<{ [region: string]: number }>({})
const bestPing = ref<string>()
const messages = defineMessages({
prompt: {
id: 'servers.region.prompt',
defaultMessage: 'Where would you like your server to be located?',
},
regionUnsupported: {
id: 'servers.region.region-unsupported',
defaultMessage: `Region not listed? <link>Let us know where you'd like to see Modrinth Servers next!</link>`,
},
customPrompt: {
id: 'servers.region.custom.prompt',
defaultMessage: `How much RAM do you want your server to have?`,
},
})
async function updateStock() {
currentStock.value = {}
const capacityChecks = props.regions.map((region) =>
props.fetchStock(
region,
selectedPlan.value
? {
cpu: selectedPlan.value?.metadata.cpu ?? 0,
memory_mb: selectedPlan.value?.metadata.ram ?? 0,
swap_mb: selectedPlan.value?.metadata.swap ?? 0,
storage_mb: selectedPlan.value?.metadata.storage ?? 0,
}
: {
cpu: lowestProduct.value.metadata.cpu ?? 0,
memory_mb: lowestProduct.value.metadata.ram ?? 0,
swap_mb: lowestProduct.value.metadata.swap ?? 0,
storage_mb: lowestProduct.value.metadata.storage ?? 0,
},
),
)
const results = await Promise.all(capacityChecks)
results.forEach((result, index) => {
currentStock.value[props.regions[index].shortcode] = result
})
}
onMounted(() => {
// auto select region with lowest ping
loading.value = true
bestPing.value =
props.pings.length > 0
? props.pings.reduce((acc, cur) => {
return acc.ping < cur.ping ? acc : cur
})?.region
: undefined
selectedRam.value = minRam.value
checkingCustomStock.value = true
updateStock().then(() => {
const firstWithStock = props.regions.find((region) => currentStock.value[region.shortcode] > 0)
let stockedRegion = selectedRegion.value
if (!stockedRegion) {
stockedRegion =
bestPing.value && currentStock.value[bestPing.value] > 0
? bestPing.value
: firstWithStock?.shortcode
}
selectedRegion.value = stockedRegion
updateRamStock(stockedRegion, minRam.value)
loading.value = false
})
})
</script>
<template>
<ModalLoadingIndicator v-if="loading" class="flex py-40 justify-center">
Checking availability...
</ModalLoadingIndicator>
<template v-else>
<h2 class="mt-0 mb-4 text-xl font-bold text-contrast">
{{ formatMessage(messages.prompt) }}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ServersRegionButton
v-for="region in regions"
:key="region.shortcode"
v-model="selectedRegion"
:region="region"
:out-of-stock="currentStock[region.shortcode] === 0"
:ping="pings.find((p) => p.region === region.shortcode)?.ping"
:best-ping="bestPing === region.shortcode"
/>
</div>
<div class="mt-3 text-sm">
<IntlFormatted :message-id="messages.regionUnsupported">
<template #link="{ children }">
<a
class="text-link"
target="_blank"
rel="noopener noreferrer"
href="https://surveys.modrinth.com/servers-region-waitlist"
>
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</div>
<template v-if="custom">
<h2 class="mt-4 mb-2 text-xl font-bold text-contrast">
{{ formatMessage(messages.customPrompt) }}
</h2>
<div>
<Slider v-model="selectedRam" :min="minRam" :max="maxRam" :step="2" unit="GB" />
<div class="bg-bg rounded-xl p-4 mt-4 text-secondary">
<div v-if="checkingCustomStock" class="flex gap-2 items-center">
<SpinnerIcon class="size-5 shrink-0 animate-spin" /> Checking availability...
</div>
<div v-else-if="selectedPlan">
<ServersSpecs
class="!flex-row justify-between"
:ram="selectedPlan.metadata.ram ?? 0"
:storage="selectedPlan.metadata.storage ?? 0"
:cpus="selectedPlan.metadata.cpu ?? 0"
/>
</div>
<div v-else class="flex gap-2 items-center">
<XIcon class="size-5 shrink-0 text-red" /> Sorry, we don't have any plans available with
{{ selectedRam }} GB RAM in this region.
</div>
</div>
<div class="flex gap-2 mt-2">
<InfoIcon class="hidden sm:block shrink-0 mt-1" />
<span class="text-sm text-secondary">
Storage and shared CPU count are currently not configurable independently, and are based
on the amount of RAM you select.
</span>
</div>
</div>
</template>
</template>
</template>

View File

@ -0,0 +1,69 @@
<script setup lang="ts">
import type Stripe from 'stripe'
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
import { useVIntl, defineMessages } from '@vintl/vintl'
import PaymentMethodOption from './PaymentMethodOption.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: 'select', paymentMethod: Stripe.PaymentMethod | undefined): void
}>()
defineProps<{
paymentMethods: Stripe.PaymentMethod[]
selected?: Stripe.PaymentMethod
loadingElements: boolean
loadingElementsFailed: boolean
}>()
const messages = defineMessages({
prompt: {
id: 'servers.purchase.step.payment.prompt',
defaultMessage: 'Select a payment method',
},
description: {
id: 'servers.purchase.step.payment.description',
defaultMessage: `You won't be charged yet.`,
},
})
</script>
<template>
<h2 class="mt-0 mb-1 text-xl font-bold text-contrast">
{{ formatMessage(messages.prompt) }}
</h2>
<p class="mt-0 mb-4 text-secondary">
{{ formatMessage(messages.description) }}
</p>
<div class="flex flex-col gap-1">
<PaymentMethodOption
v-for="method in paymentMethods"
:key="method.id"
:item="method"
:selected="selected?.id === method.id"
@select="emit('select', method)"
/>
<PaymentMethodOption
:loading="false"
:item="undefined"
:selected="selected === undefined"
@select="emit('select', undefined)"
/>
</div>
<div
v-show="selected === undefined"
class="min-h-[16rem] flex flex-col gap-2 mt-2 p-4 bg-table-alternateRow rounded-xl justify-center items-center"
>
<div v-show="loadingElements">
<ModalLoadingIndicator :error="loadingElementsFailed">
Loading...
<template #error> Error loading Stripe payment UI. </template>
</ModalLoadingIndicator>
</div>
<div class="w-full">
<div id="address-element"></div>
<div id="payment-element" class="mt-4"></div>
</div>
</div>
</template>

View File

@ -0,0 +1,264 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ServerBillingInterval, ServerPlan, ServerRegion } from '../../utils/billing'
import TagItem from '../base/TagItem.vue'
import ServersSpecs from './ServersSpecs.vue'
import { formatPrice, getPingLevel } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import { regionOverrides } from '../../utils/regions'
import {
EditIcon,
RightArrowIcon,
SignalIcon,
SpinnerIcon,
XIcon,
RadioButtonIcon,
RadioButtonCheckedIcon,
ExternalIcon,
} from '@modrinth/assets'
import type Stripe from 'stripe'
import FormattedPaymentMethod from './FormattedPaymentMethod.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import Checkbox from '../base/Checkbox.vue'
import ExpandableInvoiceTotal from './ExpandableInvoiceTotal.vue'
const vintl = useVIntl()
const { locale, formatMessage } = vintl
const emit = defineEmits<{
(e: 'changePaymentMethod' | 'reloadPaymentIntent'): void
}>()
const props = defineProps<{
plan: ServerPlan
region: ServerRegion
tax?: number
total?: number
currency: string
ping?: number
loading?: boolean
selectedPaymentMethod: Stripe.PaymentMethod | undefined
onError: (error: Error) => void
}>()
const interval = defineModel<ServerBillingInterval>('interval')
const acceptedEula = defineModel<boolean>('accepted-eula', { required: true })
const prices = computed(() => {
return props.plan.prices.find((x) => x.currency_code === props.currency)
})
const planName = computed(() => {
if (!props.plan || !props.plan.metadata || props.plan.metadata.type !== 'pyro') return 'Unknown'
const ram = props.plan.metadata.ram
if (ram === 4096) return 'Small'
if (ram === 6144) return 'Medium'
if (ram === 8192) return 'Large'
return 'Custom'
})
const flag = computed(
() =>
regionOverrides[props.region.shortcode]?.flag ??
`https://flagcdn.com/${props.region.country_code}.svg`,
)
const overrideTitle = computed(() => regionOverrides[props.region.shortcode]?.name)
const title = computed(() =>
overrideTitle.value ? formatMessage(overrideTitle.value) : props.region.display_name,
)
const locationSubtitle = computed(() =>
overrideTitle.value ? props.region.display_name : undefined,
)
const pingLevel = computed(() => getPingLevel(props.ping ?? 0))
const period = computed(() => {
if (interval.value === 'monthly') return 'month'
if (interval.value === 'quarterly') return '3 months'
if (interval.value === 'yearly') return 'year'
return '???'
})
const monthsInInterval: Record<ServerBillingInterval, number> = {
monthly: 1,
quarterly: 3,
yearly: 12,
}
function setInterval(newInterval: ServerBillingInterval) {
interval.value = newInterval
emit('reloadPaymentIntent')
}
</script>
<template>
<div class="grid sm:grid-cols-[3fr_2fr] gap-4">
<div class="bg-table-alternateRow p-4 rounded-2xl">
<div class="flex items-center gap-2 mb-3">
<LazyUiServersModrinthServersIcon class="flex h-5 w-fit" />
<TagItem>{{ planName }}</TagItem>
</div>
<div>
<ServersSpecs
v-if="plan.metadata && plan.metadata.ram && plan.metadata.storage && plan.metadata.cpu"
class="!grid sm:grid-cols-2"
:ram="plan.metadata.ram"
:storage="plan.metadata.storage"
:cpus="plan.metadata.cpu"
/>
</div>
</div>
<div
class="bg-table-alternateRow p-4 rounded-2xl flex flex-col gap-2 items-center justify-center"
>
<img
v-if="flag"
class="aspect-[16/10] max-w-12 w-full object-cover rounded-md border-1 border-button-border border-solid"
:src="flag"
alt=""
aria-hidden="true"
/>
<span class="font-semibold">
{{ title }}
</span>
<span class="text-xs flex items-center gap-1 text-secondary font-medium">
<template v-if="locationSubtitle">
<span>
{{ locationSubtitle }}
</span>
<span v-if="ping !== -1"></span>
</template>
<template v-if="ping !== -1">
<SignalIcon
v-if="ping"
aria-hidden="true"
:style="`--_signal-${pingLevel}: ${pingLevel <= 2 ? 'var(--color-red)' : pingLevel <= 4 ? 'var(--color-orange)' : 'var(--color-green)'}`"
stroke-width="3px"
class="shrink-0"
/>
<SpinnerIcon v-else class="animate-spin" />
<template v-if="ping"> {{ ping }}ms </template>
<span v-else> Testing connection... </span>
</template>
</span>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<button
:class="
interval === 'monthly'
? 'bg-button-bg border-transparent'
: 'bg-transparent border-button-border'
"
class="mt-4 rounded-2xl active:scale-[0.98] transition-transform duration-100 border-2 border-solid p-4 flex items-center gap-2"
@click="setInterval('monthly')"
>
<RadioButtonCheckedIcon v-if="interval === 'monthly'" class="size-6 text-brand" />
<RadioButtonIcon v-else class="size-6 text-secondary" />
<div class="flex flex-col items-start gap-1 font-medium text-primary">
<span class="flex items-center gap-1" :class="{ 'text-contrast': interval === 'monthly' }"
>Pay monthly</span
>
<span class="text-sm text-secondary flex items-center gap-1"
>{{ formatPrice(locale, prices?.prices.intervals['monthly'], currency, true) }} /
month</span
>
</div>
</button>
<button
:class="
interval === 'yearly'
? 'bg-button-bg border-transparent'
: 'bg-transparent border-button-border'
"
class="mt-4 rounded-2xl active:scale-[0.98] transition-transform duration-100 border-2 border-solid p-4 flex items-center gap-2"
@click="setInterval('yearly')"
>
<RadioButtonCheckedIcon v-if="interval === 'yearly'" class="size-6 text-brand" />
<RadioButtonIcon v-else class="size-6 text-secondary" />
<div class="flex flex-col items-start gap-1 font-medium text-primary">
<span class="flex items-center gap-1" :class="{ 'text-contrast': interval === 'yearly' }"
>Pay yearly
<span class="text-xs font-bold text-brand px-1.5 py-0.5 rounded-full bg-brand-highlight"
>{{ interval === 'quarterly' ? 'Saving' : 'Save' }} 16%</span
></span
>
<span class="text-sm text-secondary flex items-center gap-1"
>{{
formatPrice(
locale,
prices?.prices?.intervals?.['yearly'] ?? 0 / monthsInInterval['yearly'],
currency,
true,
)
}}
/ month</span
>
</div>
</button>
</div>
<div class="mt-2">
<ExpandableInvoiceTotal
:period="period"
:currency="currency"
:loading="loading"
:total="total ?? -1"
:billing-items="
total !== undefined && tax !== undefined
? [
{
title: `Modrinth Servers (${planName})`,
amount: total - tax,
},
{
title: 'Tax',
amount: tax,
},
]
: []
"
/>
</div>
<div class="mt-2 flex items-center pl-4 pr-2 py-3 bg-bg rounded-2xl gap-2 text-secondary">
<template v-if="selectedPaymentMethod">
<FormattedPaymentMethod :method="selectedPaymentMethod" />
</template>
<template v-else>
<div class="flex items-center gap-2 text-red">
<XIcon />
No payment method selected
</div>
</template>
<ButtonStyled size="small" type="transparent">
<button class="ml-auto" @click="emit('changePaymentMethod')">
<template v-if="selectedPaymentMethod"> <EditIcon /> Change </template>
<template v-else> Select payment method <RightArrowIcon /> </template>
</button>
</ButtonStyled>
</div>
<p class="m-0 mt-4 text-sm text-secondary">
<span class="font-semibold"
>By clicking "Subscribe", you are purchasing a recurring subscription.</span
>
<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>
<div class="mt-2 flex items-center gap-1 text-sm">
<Checkbox
v-model="acceptedEula"
label="I acknowledge that I have read and agree to the"
description="I acknowledge that I have read and agree to the Minecraft EULA"
/>
<a
href="https://www.minecraft.net/en-us/eula"
target="_blank"
class="text-brand underline hover:brightness-[--hover-brightness]"
>Minecraft EULA<ExternalIcon class="size-3 shrink-0 ml-0.5 mb-0.5"
/></a>
</div>
</template>

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import { useVIntl } from '@vintl/vintl'
import { getPingLevel } from '@modrinth/utils'
import { SignalIcon, SpinnerIcon } from '@modrinth/assets'
import { computed } from 'vue'
import type { ServerRegion } from '../../utils/billing'
import { regionOverrides } from '../../utils/regions'
const { formatMessage } = useVIntl()
const currentRegion = defineModel<string | undefined>({ required: true })
const props = defineProps<{
region: ServerRegion
ping?: number
bestPing?: boolean
outOfStock?: boolean
}>()
const isCurrentRegion = computed(() => currentRegion.value === props.region.shortcode)
const flag = computed(
() =>
regionOverrides[props.region.shortcode]?.flag ??
`https://flagcdn.com/${props.region.country_code}.svg`,
)
const overrideTitle = computed(() => regionOverrides[props.region.shortcode]?.name)
const title = computed(() =>
overrideTitle.value ? formatMessage(overrideTitle.value) : props.region.display_name,
)
const locationSubtitle = computed(() =>
overrideTitle.value ? props.region.display_name : undefined,
)
const pingLevel = computed(() => getPingLevel(props.ping ?? 0))
function setRegion() {
currentRegion.value = props.region.shortcode
}
</script>
<template>
<button
:disabled="outOfStock"
class="rounded-2xl p-4 font-semibold transition-all border-2 border-solid flex flex-col items-center gap-3"
:class="{
'bg-button-bg border-transparent text-primary': !isCurrentRegion,
'bg-brand-highlight border-brand text-contrast': isCurrentRegion,
'opacity-50 cursor-not-allowed': outOfStock,
'hover:text-contrast active:scale-95 hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] ':
!outOfStock,
}"
@click="setRegion"
>
<img
v-if="flag"
class="aspect-[16/10] max-w-16 w-full object-cover rounded-md border-1 border-solid"
:class="[
isCurrentRegion ? 'border-brand' : 'border-button-border',
{ 'saturate-[0.25]': outOfStock },
]"
:src="flag"
alt=""
aria-hidden="true"
/>
<span class="flex flex-col gap-1 items-center">
<span class="flex items-center gap-1 flex-wrap justify-center">
{{ title }} <span v-if="outOfStock" class="text-sm text-secondary">(Out of stock)</span>
</span>
<span class="text-xs flex items-center gap-1 text-secondary font-medium">
<template v-if="locationSubtitle">
<span>
{{ locationSubtitle }}
</span>
<span v-if="ping !== -1"></span>
</template>
<template v-if="ping !== -1">
<SignalIcon
v-if="ping"
aria-hidden="true"
:style="`--_signal-${pingLevel}: ${pingLevel <= 2 ? 'var(--color-red)' : pingLevel <= 4 ? 'var(--color-orange)' : 'var(--color-green)'}`"
stroke-width="3px"
class="shrink-0"
/>
<SpinnerIcon v-else class="animate-spin" />
<span v-if="bestPing" :class="bestPing ? 'text-brand' : 'text-primary'">
Lowest latency ({{ ping }}ms)
</span>
<template v-else-if="ping"> {{ ping }}ms </template>
<span v-else> Testing connection... </span>
</template>
</span>
</span>
</button>
</template>

View File

@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from 'vue'
import AutoLink from '../base/AutoLink.vue'
import { MemoryStickIcon, DatabaseIcon, CPUIcon, SparklesIcon, UnknownIcon } from '@modrinth/assets'
const emit = defineEmits<{
(e: 'click-bursting-link'): void
}>()
const props = withDefaults(
defineProps<{
ram: number
storage: number
cpus: number
burstingLink?: string
}>(),
{
burstingLink: undefined,
},
)
const formattedRam = computed(() => {
return props.ram / 1024
})
const formattedStorage = computed(() => {
return props.storage / 1024
})
const sharedCpus = computed(() => {
return props.cpus / 2
})
</script>
<template>
<ul class="m-0 flex list-none flex-col gap-2 px-0 text-sm leading-normal text-secondary">
<li class="flex items-center gap-2">
<MemoryStickIcon class="h-5 w-5 shrink-0" /> {{ formattedRam }} GB RAM
</li>
<li class="flex items-center gap-2">
<DatabaseIcon class="h-5 w-5 shrink-0" /> {{ formattedStorage }} GB Storage
</li>
<li class="flex items-center gap-2">
<CPUIcon class="h-5 w-5 shrink-0" /> {{ sharedCpus }} Shared CPUs
</li>
<li class="flex items-center gap-2">
<SparklesIcon class="h-5 w-5 shrink-0" /> Bursts up to {{ cpus }} CPUs
<AutoLink
v-if="burstingLink"
v-tooltip="
`CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. Click for more info.`
"
class="flex"
:to="burstingLink"
@click="() => emit('click-bursting-link')"
>
<UnknownIcon class="h-4 w-4 text-secondary opacity-80" />
</AutoLink>
</li>
</ul>
</template>

View File

@ -96,6 +96,8 @@ export { default as SearchSidebarFilter } from './search/SearchSidebarFilter.vue
// Billing
export { default as PurchaseModal } from './billing/PurchaseModal.vue'
export { default as AddPaymentMethodModal } from './billing/AddPaymentMethodModal.vue'
export { default as ModrinthServersPurchaseModal } from './billing/ModrinthServersPurchaseModal.vue'
// Version
export { default as VersionChannelIndicator } from './version/VersionChannelIndicator.vue'
@ -107,3 +109,4 @@ export { default as ThemeSelector } from './settings/ThemeSelector.vue'
// Servers
export { default as BackupWarning } from './servers/backups/BackupWarning.vue'
export { default as ServersSpecs } from './billing/ServersSpecs.vue'

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
import { SpinnerIcon, XCircleIcon } from '@modrinth/assets'
withDefaults(
defineProps<{
error?: boolean
}>(),
{
error: false,
},
)
</script>
<template>
<div class="flex items-center gap-2 font-semibold" :class="error ? 'text-red' : 'animate-pulse'">
<XCircleIcon v-if="error" class="w-6 h-6" />
<SpinnerIcon v-else class="w-6 h-6 animate-spin" />
<slot v-if="error" name="error" />
<slot v-else />
</div>
</template>
<style scoped>
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%,
100% {
scale: 1;
}
50% {
scale: 0.95;
}
}
</style>

View File

@ -0,0 +1,376 @@
import type Stripe from 'stripe'
import type { StripeElementsOptionsMode } from '@stripe/stripe-js/dist/stripe-js/elements-group'
import {
type Stripe as StripeJs,
loadStripe,
type StripeAddressElement,
type StripeElements,
type StripePaymentElement,
} from '@stripe/stripe-js'
import { computed, ref, type Ref } from 'vue'
import type { ContactOption } from '@stripe/stripe-js/dist/stripe-js/elements/address'
import type {
ServerPlan,
BasePaymentIntentResponse,
ChargeRequestType,
CreatePaymentIntentRequest,
CreatePaymentIntentResponse,
PaymentRequestType,
ServerBillingInterval,
UpdatePaymentIntentRequest,
UpdatePaymentIntentResponse,
} from '../../utils/billing'
export type CreateElements = (
paymentMethods: Stripe.PaymentMethod[],
options: StripeElementsOptionsMode,
) => {
elements: StripeElements
paymentElement: StripePaymentElement
addressElement: StripeAddressElement
}
export const useStripe = (
publishableKey: string,
customer: Stripe.Customer,
paymentMethods: Stripe.PaymentMethod[],
clientSecret: string,
currency: string,
product: Ref<ServerPlan>,
interval: Ref<ServerBillingInterval>,
initiatePayment: (
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
) => Promise<CreatePaymentIntentResponse | UpdatePaymentIntentResponse>,
) => {
const stripe = ref<StripeJs | null>(null)
let elements: StripeElements | undefined = undefined
const elementsLoaded = ref<0 | 1 | 2>(0)
const loadingElementsFailed = ref<boolean>(false)
const paymentMethodLoading = ref(false)
const loadingFailed = ref<string>()
const paymentIntentId = ref<string>()
const tax = ref<number>()
const total = ref<number>()
const confirmationToken = ref<string>()
const submittingPayment = ref(false)
const selectedPaymentMethod = ref<Stripe.PaymentMethod>()
const inputtedPaymentMethod = ref<Stripe.PaymentMethod>()
async function initialize() {
stripe.value = await loadStripe(publishableKey)
}
function createIntent(body: CreatePaymentIntentRequest): Promise<CreatePaymentIntentResponse> {
return initiatePayment(body) as Promise<CreatePaymentIntentResponse>
}
function updateIntent(body: UpdatePaymentIntentRequest): Promise<UpdatePaymentIntentResponse> {
return initiatePayment(body) as Promise<UpdatePaymentIntentResponse>
}
const planPrices = computed(() => {
return product.value.prices.find((x) => x.currency_code === currency)
})
const createElements: CreateElements = (options) => {
const styles = getComputedStyle(document.body)
if (!stripe.value) {
throw new Error('Stripe API not yet loaded')
}
elements = stripe.value.elements({
appearance: {
variables: {
colorPrimary: styles.getPropertyValue('--color-brand'),
colorBackground: styles.getPropertyValue('--experimental-color-button-bg'),
colorText: styles.getPropertyValue('--color-base'),
colorTextPlaceholder: styles.getPropertyValue('--color-secondary'),
colorDanger: styles.getPropertyValue('--color-red'),
fontFamily: styles.getPropertyValue('--font-standard'),
spacingUnit: '0.25rem',
borderRadius: '0.75rem',
},
},
loader: 'never',
...options,
})
const paymentElement = elements.create('payment', {
layout: {
type: 'tabs',
defaultCollapsed: false,
},
})
paymentElement.mount('#payment-element')
const contacts: ContactOption[] = []
paymentMethods.forEach((method) => {
const addr = method.billing_details?.address
if (
addr &&
addr.line1 &&
addr.city &&
addr.postal_code &&
addr.country &&
addr.state &&
method.billing_details.name
) {
contacts.push({
address: {
line1: addr.line1,
line2: addr.line2 ?? undefined,
city: addr.city,
state: addr.state,
postal_code: addr.postal_code,
country: addr.country,
},
name: method.billing_details.name,
})
}
})
const addressElement = elements.create('address', {
mode: 'billing',
contacts: contacts.length > 0 ? contacts : undefined,
})
addressElement.mount('#address-element')
return { elements, paymentElement, addressElement }
}
const primaryPaymentMethodId = computed<string | null>(() => {
if (customer && customer.invoice_settings && customer.invoice_settings.default_payment_method) {
const method = customer.invoice_settings.default_payment_method
if (typeof method === 'string') {
return method
} else {
return method.id
}
} else if (paymentMethods && paymentMethods[0] && paymentMethods[0].id) {
return paymentMethods[0].id
} else {
return null
}
})
const loadStripeElements = async () => {
loadingFailed.value = false
try {
if (!customer) {
paymentMethodLoading.value = true
await props.refreshPaymentMethods()
paymentMethodLoading.value = false
}
if (!selectedPaymentMethod.value) {
elementsLoaded.value = 0
const {
elements: newElements,
addressElement,
paymentElement,
} = createElements({
mode: 'payment',
currency: currency.toLowerCase(),
amount: product.value.prices.find((x) => x.currency_code === currency)?.prices.intervals[
interval.value
],
paymentMethodCreation: 'manual',
setupFutureUsage: 'off_session',
})
elements = newElements
paymentElement.on('ready', () => {
elementsLoaded.value += 1
})
addressElement.on('ready', () => {
elementsLoaded.value += 1
})
}
} catch (err) {
loadingFailed.value = String(err)
console.log(err)
}
}
async function refreshPaymentIntent(id: string, confirmation: boolean) {
try {
paymentMethodLoading.value = true
if (!confirmation) {
selectedPaymentMethod.value = paymentMethods.find((x) => x.id === id)
}
const requestType: PaymentRequestType = confirmation
? {
type: 'confirmation_token',
token: id,
}
: {
type: 'payment_method',
id: id,
}
const charge: ChargeRequestType = {
type: 'new',
product_id: product.value?.id,
interval: interval.value,
}
let result: BasePaymentIntentResponse
if (paymentIntentId.value) {
result = await updateIntent({
...requestType,
charge,
existing_payment_intent: paymentIntentId.value,
})
console.log(`Updated payment intent: ${interval.value} for ${result.total}`)
} else {
;({
payment_intent_id: paymentIntentId.value,
client_secret: clientSecret,
...result
} = await createIntent({
...requestType,
charge,
}))
console.log(`Created payment intent: ${interval.value} for ${result.total}`)
}
tax.value = result.tax
total.value = result.total
if (confirmation) {
confirmationToken.value = id
if (result.payment_method) {
inputtedPaymentMethod.value = result.payment_method
}
}
} catch (err) {
emit('error', err)
}
paymentMethodLoading.value = false
}
async function createConfirmationToken() {
if (!elements) {
return handlePaymentError('No elements')
}
const { error, confirmationToken: confirmation } = await stripe.value.createConfirmationToken({
elements,
})
if (error) {
emit('error', error)
return
}
return confirmation.id
}
function handlePaymentError(err: string | Error) {
paymentMethodLoading.value = false
emit('error', typeof err === 'string' ? new Error(err) : err)
}
async function createNewPaymentMethod() {
paymentMethodLoading.value = true
if (!elements) {
return handlePaymentError('No elements')
}
const { error: submitError } = await elements.submit()
if (submitError) {
return handlePaymentError(submitError)
}
const token = await createConfirmationToken()
if (!token) {
return handlePaymentError('Failed to create confirmation token')
}
await refreshPaymentIntent(token, true)
if (!planPrices.value) {
return handlePaymentError('No plan prices')
}
if (!total.value) {
return handlePaymentError('No total amount')
}
elements.update({ currency: planPrices.value.currency_code.toLowerCase(), amount: total.value })
elementsLoaded.value = 0
confirmationToken.value = token
paymentMethodLoading.value = false
return token
}
async function selectPaymentMethod(paymentMethod: Stripe.PaymentMethod | undefined) {
selectedPaymentMethod.value = paymentMethod
if (paymentMethod === undefined) {
await loadStripeElements()
} else {
refreshPaymentIntent(paymentMethod.id, false)
}
}
const loadingElements = computed(() => elementsLoaded.value < 2)
async function submitPayment(returnUrl: string) {
submittingPayment.value = true
const { error } = await stripe.value.confirmPayment({
clientSecret,
confirmParams: {
confirmation_token: confirmationToken.value,
return_url: `${returnUrl}?priceId=${product.value?.prices.find((x) => x.currency_code === currency)?.id}&plan=${interval.value}`,
},
})
if (error) {
props.onError(error)
return false
}
submittingPayment.value = false
return true
}
async function reloadPaymentIntent() {
console.log('selected:', selectedPaymentMethod.value)
console.log('token:', confirmationToken.value)
if (selectedPaymentMethod.value) {
await refreshPaymentIntent(selectedPaymentMethod.value.id, false)
} else if (confirmationToken.value) {
await refreshPaymentIntent(confirmationToken.value, true)
} else {
throw new Error('No payment method selected')
}
}
const hasPaymentMethod = computed(() => selectedPaymentMethod.value || confirmationToken.value)
return {
initializeStripe: initialize,
selectPaymentMethod,
reloadPaymentIntent,
primaryPaymentMethodId,
selectedPaymentMethod,
inputtedPaymentMethod,
hasPaymentMethod,
createNewPaymentMethod,
loadingElements,
loadingElementsFailed,
paymentMethodLoading,
loadStripeElements,
tax,
total,
submitPayment,
}
}

View File

@ -1,4 +1,7 @@
{
"button.back": {
"defaultMessage": "Back"
},
"button.cancel": {
"defaultMessage": "Cancel"
},
@ -23,6 +26,9 @@
"button.edit": {
"defaultMessage": "Edit"
},
"button.next": {
"defaultMessage": "Next"
},
"button.open-folder": {
"defaultMessage": "Open folder"
},
@ -173,6 +179,12 @@
"label.visit-your-profile": {
"defaultMessage": "Visit your profile"
},
"modal.add-payment-method.action": {
"defaultMessage": "Add payment method"
},
"modal.add-payment-method.title": {
"defaultMessage": "Adding a payment method"
},
"notification.error.title": {
"defaultMessage": "An error occurred"
},
@ -485,6 +497,36 @@
"servers.notice.undismissable": {
"defaultMessage": "Undismissable"
},
"servers.purchase.step.payment.description": {
"defaultMessage": "You won't be charged yet."
},
"servers.purchase.step.payment.prompt": {
"defaultMessage": "Select a payment method"
},
"servers.purchase.step.payment.title": {
"defaultMessage": "Payment method"
},
"servers.purchase.step.region.title": {
"defaultMessage": "Region"
},
"servers.purchase.step.review.title": {
"defaultMessage": "Review"
},
"servers.region.custom.prompt": {
"defaultMessage": "How much RAM do you want your server to have?"
},
"servers.region.europe": {
"defaultMessage": "Europe"
},
"servers.region.north-america": {
"defaultMessage": "North America"
},
"servers.region.prompt": {
"defaultMessage": "Where would you like your server to be located?"
},
"servers.region.region-unsupported": {
"defaultMessage": "Region not listed? <link>Let us know where you'd like to see Modrinth Servers next!</link>"
},
"settings.account.title": {
"defaultMessage": "Account and security"
},

View File

@ -0,0 +1,101 @@
import type Stripe from 'stripe'
export type ServerBillingInterval = 'monthly' | 'yearly' | 'quarterly'
export interface ServerPlan {
id: string
name: string
description: string
metadata: {
type: string
ram?: number
cpu?: number
storage?: number
swap?: number
}
prices: {
id: string
currency_code: string
prices: {
intervals: {
monthly: number
yearly: number
}
}
}[]
}
export interface ServerStockRequest {
cpu?: number
memory_mb?: number
swap_mb?: number
storage_mb?: number
}
export interface ServerRegion {
shortcode: string
country_code: string
display_name: string
lat: number
lon: number
}
/*
Request types
*/
export type PaymentMethodRequest = {
type: 'payment_method'
id: string
}
export type ConfirmationTokenRequest = {
type: 'confirmation_token'
token: string
}
export type PaymentRequestType = PaymentMethodRequest | ConfirmationTokenRequest
export type ChargeRequestType =
| {
type: 'existing'
id: string
}
| {
type: 'new'
product_id: string
interval?: ServerBillingInterval
}
export type CreatePaymentIntentRequest = PaymentRequestType & {
charge: ChargeRequestType
metadata?: {
type: 'pyro'
server_name?: string
source: {
loader: string
game_version?: string
loader_version?: string
}
}
}
export type UpdatePaymentIntentRequest = CreatePaymentIntentRequest & {
existing_payment_intent: string
}
/*
Response types
*/
export type BasePaymentIntentResponse = {
price_id: string
tax: number
total: number
payment_method: Stripe.PaymentMethod
}
export type UpdatePaymentIntentResponse = BasePaymentIntentResponse
export type CreatePaymentIntentResponse = BasePaymentIntentResponse & {
payment_intent_id: string
client_secret: string
}

View File

@ -17,6 +17,14 @@ export const commonMessages = defineMessages({
id: 'button.continue',
defaultMessage: 'Continue',
},
nextButton: {
id: 'button.next',
defaultMessage: 'Next',
},
backButton: {
id: 'button.back',
defaultMessage: 'Back',
},
copyIdButton: {
id: 'button.copy-id',
defaultMessage: 'Copy ID',
@ -205,6 +213,10 @@ export const commonMessages = defineMessages({
id: 'label.visit-your-profile',
defaultMessage: 'Visit your profile',
},
paymentMethodCardDisplay: {
id: 'omorphia.component.purchase_modal.payment_method_card_display',
defaultMessage: '{card_brand} ending in {last_four}',
},
})
export const commonSettingsMessages = defineMessages({
@ -245,3 +257,51 @@ export const commonSettingsMessages = defineMessages({
defaultMessage: 'Billing and subscriptions',
},
})
export const paymentMethodMessages = defineMessages({
visa: {
id: 'omorphia.component.purchase_modal.payment_method_type.visa',
defaultMessage: 'Visa',
},
amex: {
id: 'omorphia.component.purchase_modal.payment_method_type.amex',
defaultMessage: 'American Express',
},
diners: {
id: 'omorphia.component.purchase_modal.payment_method_type.diners',
defaultMessage: 'Diners Club',
},
discover: {
id: 'omorphia.component.purchase_modal.payment_method_type.discover',
defaultMessage: 'Discover',
},
eftpos: {
id: 'omorphia.component.purchase_modal.payment_method_type.eftpos',
defaultMessage: 'EFTPOS',
},
jcb: { id: 'omorphia.component.purchase_modal.payment_method_type.jcb', defaultMessage: 'JCB' },
mastercard: {
id: 'omorphia.component.purchase_modal.payment_method_type.mastercard',
defaultMessage: 'MasterCard',
},
unionpay: {
id: 'omorphia.component.purchase_modal.payment_method_type.unionpay',
defaultMessage: 'UnionPay',
},
paypal: {
id: 'omorphia.component.purchase_modal.payment_method_type.paypal',
defaultMessage: 'PayPal',
},
cashapp: {
id: 'omorphia.component.purchase_modal.payment_method_type.cashapp',
defaultMessage: 'Cash App',
},
amazon_pay: {
id: 'omorphia.component.purchase_modal.payment_method_type.amazon_pay',
defaultMessage: 'Amazon Pay',
},
unknown: {
id: 'omorphia.component.purchase_modal.payment_method_type.unknown',
defaultMessage: 'Unknown payment method',
},
})

View File

@ -0,0 +1,16 @@
import { defineMessage, type MessageDescriptor } from '@vintl/vintl'
export const regionOverrides = {
'us-vin': {
name: defineMessage({ id: 'servers.region.north-america', defaultMessage: 'North America' }),
flag: 'https://flagcdn.com/us.svg',
},
'eu-lim': {
name: defineMessage({ id: 'servers.region.europe', defaultMessage: 'Europe' }),
flag: 'https://flagcdn.com/eu.svg',
},
'de-fra': {
name: defineMessage({ id: 'servers.region.europe', defaultMessage: 'Europe' }),
flag: 'https://flagcdn.com/eu.svg',
},
} satisfies Record<string, { name?: MessageDescriptor; flag?: string }>

View File

@ -5,5 +5,6 @@
"compilerOptions": {
"lib": ["esnext", "dom"],
"noImplicitAny": false
}
},
"types": ["@stripe/stripe-js"]
}

View File

@ -61,16 +61,26 @@ export const getCurrency = (userCountry) => {
return countryCurrency[userCountry] ?? 'USD'
}
export const formatPrice = (locale, price, currency) => {
const formatter = new Intl.NumberFormat(locale, {
export const formatPrice = (locale, price, currency, trimZeros = false) => {
let formatter = new Intl.NumberFormat(locale, {
style: 'currency',
currency,
})
const maxDigits = formatter.resolvedOptions().maximumFractionDigits
const convertedPrice = price / Math.pow(10, maxDigits)
let minimumFractionDigits = maxDigits
if (trimZeros && Number.isInteger(convertedPrice)) {
minimumFractionDigits = 0
}
formatter = new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits,
})
return formatter.format(convertedPrice)
}
@ -87,13 +97,13 @@ export const createStripeElements = (stripe, paymentMethods, options) => {
appearance: {
variables: {
colorPrimary: styles.getPropertyValue('--color-brand'),
colorBackground: styles.getPropertyValue('--color-bg'),
colorBackground: styles.getPropertyValue('--experimental-color-button-bg'),
colorText: styles.getPropertyValue('--color-base'),
colorTextPlaceholder: styles.getPropertyValue('--color-secondary'),
colorDanger: styles.getPropertyValue('--color-red'),
fontFamily: styles.getPropertyValue('--font-standard'),
spacingUnit: '0.25rem',
borderRadius: '1rem',
borderRadius: '0.75rem',
},
},
loader: 'never',

View File

@ -341,3 +341,17 @@ export const getArrayOrString = (x: string[] | string): string[] => {
return x
}
}
export function getPingLevel(ping: number) {
if (ping < 150) {
return 5
} else if (ping < 300) {
return 4
} else if (ping < 600) {
return 3
} else if (ping < 1000) {
return 2
} else {
return 1
}
}

205
pnpm-lock.yaml generated
View File

@ -460,6 +460,9 @@ importers:
'@formatjs/cli':
specifier: ^6.2.12
version: 6.2.12(@vue/compiler-core@3.5.13)(vue@3.5.13(typescript@5.5.4))
'@stripe/stripe-js':
specifier: ^7.3.1
version: 7.3.1
'@vintl/unplugin':
specifier: ^1.5.1
version: 1.5.2(@vue/compiler-core@3.5.13)(rollup@3.29.4)(vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1)
@ -472,6 +475,9 @@ importers:
eslint-config-custom:
specifier: workspace:*
version: link:../eslint-config-custom
stripe:
specifier: ^18.1.1
version: 18.1.1(@types/node@22.4.1)
tsconfig:
specifier: workspace:*
version: link:../tsconfig
@ -2338,6 +2344,10 @@ packages:
resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==}
engines: {node: '>=18'}
'@stripe/stripe-js@7.3.1':
resolution: {integrity: sha512-pTzb864TQWDRQBPLgSPFRoyjSDUqpCkbEgTzpsjiTjGz1Z5SxZNXJek28w1s6Dyry4CyW4/Izj5jHE/J9hCJYQ==}
engines: {node: '>=12.16'}
'@stylistic/eslint-plugin@2.9.0':
resolution: {integrity: sha512-OrDyFAYjBT61122MIY1a3SfEgy3YCMgt2vL4eoPmvTwDBwyQhAXurxNQznlRD/jESNfYWfID8Ej+31LljvF7Xg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -3313,10 +3323,18 @@ packages:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
call-bind@1.0.7:
resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==}
engines: {node: '>= 0.4'}
call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
call-me-maybe@1.0.2:
resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==}
@ -3828,6 +3846,10 @@ packages:
resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==}
engines: {node: '>=4'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
duplexer@0.1.2:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
@ -3895,6 +3917,10 @@ packages:
resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==}
engines: {node: '>= 0.4'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
@ -3909,6 +3935,10 @@ packages:
resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.0.3:
resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==}
engines: {node: '>= 0.4'}
@ -4446,9 +4476,17 @@ packages:
resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==}
engines: {node: '>= 0.4'}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-port-please@3.1.2:
resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
get-stream@6.0.1:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'}
@ -4536,6 +4574,10 @@ packages:
gopd@1.0.1:
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@ -4573,6 +4615,10 @@ packages:
resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==}
engines: {node: '>= 0.4'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
@ -5211,6 +5257,10 @@ packages:
markdown-table@3.0.3:
resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mdast-util-definitions@6.0.0:
resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==}
@ -5646,6 +5696,10 @@ packages:
resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==}
engines: {node: '>= 0.4'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
object-keys@1.1.1:
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
engines: {node: '>= 0.4'}
@ -6209,6 +6263,10 @@ packages:
peerDependencies:
vue: ^3.0.0
qs@6.14.0:
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
engines: {node: '>=0.6'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@ -6563,10 +6621,26 @@ packages:
shiki@1.29.2:
resolution: {integrity: sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==}
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
side-channel-map@1.0.1:
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
engines: {node: '>= 0.4'}
side-channel-weakmap@1.0.2:
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
engines: {node: '>= 0.4'}
side-channel@1.0.6:
resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==}
engines: {node: '>= 0.4'}
side-channel@1.1.0:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@ -6736,6 +6810,15 @@ packages:
strip-literal@2.1.1:
resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==}
stripe@18.1.1:
resolution: {integrity: sha512-hlF0ripc2nJrihpsJZQDl3xirS7tpdpS7DlmSNLEDRW8j7Qr215y5DHOI3+aEY/lq6PG8y4GR1RZPtEoIoAs/g==}
engines: {node: '>=12.*'}
peerDependencies:
'@types/node': '>=12.x.x'
peerDependenciesMeta:
'@types/node':
optional: true
style-mod@4.1.2:
resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==}
@ -9373,7 +9456,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)
@ -9386,10 +9469,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))
@ -9852,6 +9935,8 @@ snapshots:
'@sindresorhus/merge-streams@2.3.0': {}
'@stripe/stripe-js@7.3.1': {}
'@stylistic/eslint-plugin@2.9.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)':
dependencies:
'@typescript-eslint/utils': 8.10.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)
@ -11242,7 +11327,7 @@ snapshots:
c12@2.0.1(magicast@0.3.5):
dependencies:
chokidar: 4.0.1
chokidar: 4.0.3
confbox: 0.1.8
defu: 6.1.4
dotenv: 16.4.5
@ -11259,6 +11344,11 @@ snapshots:
cac@6.7.14: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
call-bind@1.0.7:
dependencies:
es-define-property: 1.0.0
@ -11267,6 +11357,11 @@ snapshots:
get-intrinsic: 1.2.4
set-function-length: 1.2.2
call-bound@1.0.4:
dependencies:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
call-me-maybe@1.0.2: {}
callsites@3.1.0: {}
@ -11704,6 +11799,12 @@ snapshots:
dset@3.1.4: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
duplexer@0.1.2: {}
eastasianwidth@0.2.0: {}
@ -11801,6 +11902,8 @@ snapshots:
dependencies:
get-intrinsic: 1.2.4
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-module-lexer@1.5.4: {}
@ -11811,6 +11914,10 @@ snapshots:
dependencies:
es-errors: 1.3.0
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.0.3:
dependencies:
get-intrinsic: 1.2.4
@ -11968,10 +12075,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))
@ -11997,7 +12104,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
@ -12009,7 +12116,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:
@ -12020,16 +12127,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint@9.13.0(jiti@2.4.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@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-import-resolver-node: 0.3.9
transitivePeerDependencies:
- supports-color
eslint-plugin-es@3.0.1(eslint@9.13.0(jiti@2.4.1)):
dependencies:
eslint: 9.13.0(jiti@2.4.1)
@ -12069,7 +12166,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.13.0(jiti@2.4.1)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(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))
hasown: 2.0.2
is-core-module: 2.15.0
is-glob: 4.0.3
@ -12096,7 +12193,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.13.0(jiti@2.4.1)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(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))
hasown: 2.0.2
is-core-module: 2.15.0
is-glob: 4.0.3
@ -12672,8 +12769,26 @@ snapshots:
has-symbols: 1.0.3
hasown: 2.0.2
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
get-port-please@3.1.2: {}
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
get-stream@6.0.1: {}
get-stream@8.0.1: {}
@ -12693,7 +12808,7 @@ snapshots:
citty: 0.1.6
consola: 3.2.3
defu: 6.1.4
node-fetch-native: 1.6.4
node-fetch-native: 1.6.6
nypm: 0.3.12
ohash: 1.1.4
pathe: 1.1.2
@ -12782,6 +12897,8 @@ snapshots:
dependencies:
get-intrinsic: 1.2.4
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
grapheme-splitter@1.0.4: {}
@ -12829,6 +12946,8 @@ snapshots:
has-symbols@1.0.3: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.0.3
@ -13573,6 +13692,8 @@ snapshots:
markdown-table@3.0.3: {}
math-intrinsics@1.1.0: {}
mdast-util-definitions@6.0.0:
dependencies:
'@types/mdast': 4.0.4
@ -14431,6 +14552,8 @@ snapshots:
object-inspect@1.13.2: {}
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
object.assign@4.1.5:
@ -14932,6 +15055,10 @@ snapshots:
dependencies:
vue: 3.5.13(typescript@5.5.4)
qs@6.14.0:
dependencies:
side-channel: 1.1.0
queue-microtask@1.2.3: {}
queue-tick@1.0.1: {}
@ -15486,6 +15613,26 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-map@1.0.1:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-weakmap@1.0.2:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-map: 1.0.1
side-channel@1.0.6:
dependencies:
call-bind: 1.0.7
@ -15493,6 +15640,14 @@ snapshots:
get-intrinsic: 1.2.4
object-inspect: 1.13.2
side-channel@1.1.0:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-list: 1.0.0
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
@ -15671,6 +15826,12 @@ snapshots:
dependencies:
js-tokens: 9.0.1
stripe@18.1.1(@types/node@22.4.1):
dependencies:
qs: 6.14.0
optionalDependencies:
'@types/node': 22.4.1
style-mod@4.1.2: {}
style-to-object@0.4.4:
@ -15994,7 +16155,7 @@ snapshots:
dependencies:
acorn: 8.14.0
estree-walker: 3.0.3
magic-string: 0.30.14
magic-string: 0.30.17
unplugin: 1.16.0
undici-types@5.26.5: {}