Servers new purchase flow (#3719)
* New purchase flow for servers, region selector, etc. * Lint * Lint * Fix expanding total
This commit is contained in:
parent
7223c2b197
commit
c0accb42fa
@ -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({
|
||||
|
||||
128
apps/frontend/src/components/ui/OptionGroup.vue
Normal file
128
apps/frontend/src/components/ui/OptionGroup.vue
Normal file
@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<nav
|
||||
ref="scrollContainer"
|
||||
class="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
>
|
||||
<button
|
||||
v-for="(option, index) in options"
|
||||
:key="`option-group-${index}`"
|
||||
ref="optionButtons"
|
||||
class="button-animation z-[1] flex flex-row items-center gap-2 rounded-full bg-transparent px-4 py-2 font-semibold"
|
||||
:class="{
|
||||
'text-button-textSelected': modelValue === option,
|
||||
'text-primary': modelValue !== option,
|
||||
}"
|
||||
@click="setOption(option)"
|
||||
>
|
||||
<slot :option="option" :selected="modelValue === option" />
|
||||
</button>
|
||||
<div
|
||||
class="navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full bg-button-bgSelected p-1"
|
||||
:style="{
|
||||
left: sliderLeftPx,
|
||||
top: sliderTopPx,
|
||||
right: sliderRightPx,
|
||||
bottom: sliderBottomPx,
|
||||
opacity: initialized ? 1 : 0,
|
||||
}"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { 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>
|
||||
@ -63,6 +63,7 @@ const props = defineProps<{
|
||||
loader: string | null;
|
||||
loader_version: string | null;
|
||||
};
|
||||
ignoreCurrentInstallation?: boolean;
|
||||
isInstalling?: boolean;
|
||||
}>();
|
||||
|
||||
|
||||
@ -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") {
|
||||
|
||||
277
apps/frontend/src/components/ui/servers/ServerInstallation.vue
Normal file
277
apps/frontend/src/components/ui/servers/ServerInstallation.vue
Normal 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>
|
||||
@ -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 1–5 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 6–15 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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>;
|
||||
|
||||
|
||||
@ -330,6 +330,9 @@ interface General {
|
||||
token: string;
|
||||
instance: string;
|
||||
};
|
||||
flows?: {
|
||||
intro?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface Allocation {
|
||||
|
||||
@ -159,7 +159,7 @@
|
||||
"message": "Subscribe to updates about Modrinth"
|
||||
},
|
||||
"auth.welcome.description": {
|
||||
"message": "You’re now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with amazing mods."
|
||||
"message": "You’re 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 6–15 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 1–5 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"
|
||||
},
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 |
1
packages/assets/icons/database.svg
Normal file
1
packages/assets/icons/database.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-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 |
1
packages/assets/icons/memory-stick.svg
Normal file
1
packages/assets/icons/memory-stick.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-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 |
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
104
packages/ui/src/components/billing/AddPaymentMethod.vue
Normal file
104
packages/ui/src/components/billing/AddPaymentMethod.vue
Normal 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>
|
||||
72
packages/ui/src/components/billing/AddPaymentMethodModal.vue
Normal file
72
packages/ui/src/components/billing/AddPaymentMethodModal.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
37
packages/ui/src/components/billing/PaymentMethodOption.vue
Normal file
37
packages/ui/src/components/billing/PaymentMethodOption.vue
Normal 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>
|
||||
229
packages/ui/src/components/billing/ServersPurchase1Region.vue
Normal file
229
packages/ui/src/components/billing/ServersPurchase1Region.vue
Normal 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>
|
||||
@ -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>
|
||||
264
packages/ui/src/components/billing/ServersPurchase3Review.vue
Normal file
264
packages/ui/src/components/billing/ServersPurchase3Review.vue
Normal 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>
|
||||
93
packages/ui/src/components/billing/ServersRegionButton.vue
Normal file
93
packages/ui/src/components/billing/ServersRegionButton.vue
Normal 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>
|
||||
60
packages/ui/src/components/billing/ServersSpecs.vue
Normal file
60
packages/ui/src/components/billing/ServersSpecs.vue
Normal 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>
|
||||
@ -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'
|
||||
|
||||
35
packages/ui/src/components/modal/ModalLoadingIndicator.vue
Normal file
35
packages/ui/src/components/modal/ModalLoadingIndicator.vue
Normal 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>
|
||||
376
packages/ui/src/composables/stripe.ts
Normal file
376
packages/ui/src/composables/stripe.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
},
|
||||
|
||||
101
packages/ui/src/utils/billing.ts
Normal file
101
packages/ui/src/utils/billing.ts
Normal 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
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
16
packages/ui/src/utils/regions.ts
Normal file
16
packages/ui/src/utils/regions.ts
Normal 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 }>
|
||||
@ -5,5 +5,6 @@
|
||||
"compilerOptions": {
|
||||
"lib": ["esnext", "dom"],
|
||||
"noImplicitAny": false
|
||||
}
|
||||
},
|
||||
"types": ["@stripe/stripe-js"]
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
205
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user