From 74d2d85cb51d514d2cd9af632fc3e8df8cadad39 Mon Sep 17 00:00:00 2001 From: ThatGravyBoat Date: Sun, 17 Aug 2025 08:17:29 -0230 Subject: [PATCH 001/273] fix: wsrv param rename (#4202) --- packages/utils/parse.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/utils/parse.ts b/packages/utils/parse.ts index 5fb3cc27..5a4fcbf1 100644 --- a/packages/utils/parse.ts +++ b/packages/utils/parse.ts @@ -87,6 +87,7 @@ export const configuredXss = new FilterXSS({ if (url.hostname.includes('wsrv.nl')) { url.searchParams.delete('errorredirect') + url.searchParams.delete('default') } const allowedHostnames = [ -- 2.49.1 From 3e735b99eb992ef44dc05102de996857ac7eba51 Mon Sep 17 00:00:00 2001 From: "Cal H." Date: Sun, 17 Aug 2025 12:15:49 +0100 Subject: [PATCH 002/273] feat: frontend explicit imports + error page fix (#4184) * feat: frontend explicit imports * fix: error handling * fix: dashboard missing import * fix: error page issues * fix: exclude RouterView * feat: fix lint issues * fix: lint issues * fix: import issues * add getVersionLink * make articles.json use tabs on generation so it doesn't have to be reformatted * fix: lint issues --------- Signed-off-by: Cal H. Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com> --- apps/docs/src/styles/modrinth.css | 5 +- .../src/components/ui/NotificationItem.vue | 14 +- .../ui/servers/FileManagerError.vue | 4 +- .../components/ui/servers/FileVirtualList.vue | 4 +- .../ui/servers/FilesImageViewer.vue | 4 +- .../ui/servers/FilesUploadDropdown.vue | 4 +- .../components/ui/servers/LoaderSelector.vue | 7 +- .../ui/servers/LoaderSelectorCard.vue | 4 +- .../ui/servers/PanelServerActionButton.vue | 14 +- .../components/ui/servers/PanelTerminal.vue | 10 +- .../ui/servers/PlatformVersionSelectModal.vue | 11 +- .../ui/servers/ServerInfoLabels.vue | 13 +- .../ui/servers/ServerInstallation.vue | 13 +- .../components/ui/servers/ServerListing.vue | 24 +- .../ui/servers/ServerListingSkeleton.vue | 6 +- .../ui/servers/ServerLoaderLabel.vue | 3 +- .../ui/servers/ServerUptimeLabel.vue | 4 +- apps/frontend/src/error.vue | 8 + apps/frontend/src/layouts/default.vue | 75 +- .../pages/[type]/[id]/version/[version].vue | 1137 ++++++++--------- apps/frontend/src/pages/auth/authorize.vue | 1 + apps/frontend/src/pages/auth/sign-in.vue | 1 + apps/frontend/src/pages/auth/sign-up.vue | 1 + apps/frontend/src/pages/auth/welcome.vue | 1 + apps/frontend/src/pages/collection/[id].vue | 1 + apps/frontend/src/pages/report.vue | 1 + .../src/pages/servers/manage/[id].vue | 24 +- .../servers/manage/[id]/content/index.vue | 23 +- .../src/pages/servers/manage/[id]/files.vue | 49 +- .../src/pages/servers/manage/[id]/index.vue | 17 +- .../src/pages/servers/manage/[id]/options.vue | 3 +- .../servers/manage/[id]/options/index.vue | 6 +- .../servers/manage/[id]/options/network.vue | 3 +- .../manage/[id]/options/preferences.vue | 3 +- .../manage/[id]/options/properties.vue | 5 +- .../servers/manage/[id]/options/startup.vue | 3 +- .../src/pages/servers/manage/index.vue | 13 +- .../src/pages/settings/billing/index.vue | 9 +- apps/frontend/src/pages/settings/index.vue | 2 + apps/frontend/src/pages/settings/language.vue | 1 + apps/frontend/src/pages/settings/pats.vue | 1 + apps/frontend/src/pages/settings/profile.vue | 1 + apps/frontend/src/pages/user/[id].vue | 1 + apps/frontend/src/plugins/error-handling.ts | 5 + .../src/public/news/feed/articles.json | 368 +++--- packages/assets/generated-icons.ts | 34 +- packages/blog/compile.ts | 2 +- packages/blog/compiled/index.ts | 48 +- packages/tooling-config/eslint/nuxt.mjs | 20 + .../src/components/billing/PurchaseModal.vue | 3 +- .../billing/ServersPurchase3Review.vue | 3 +- .../ui/src/components/servers/LoaderIcon.vue | 232 ++++ .../servers/ModrinthServersIcon.vue | 53 + .../components/skin/SkinPreviewRenderer.vue | 13 +- 54 files changed, 1295 insertions(+), 1020 deletions(-) create mode 100644 apps/frontend/src/plugins/error-handling.ts create mode 100644 packages/ui/src/components/servers/LoaderIcon.vue create mode 100644 packages/ui/src/components/servers/ModrinthServersIcon.vue diff --git a/apps/docs/src/styles/modrinth.css b/apps/docs/src/styles/modrinth.css index 4f149c26..b61c7fbe 100644 --- a/apps/docs/src/styles/modrinth.css +++ b/apps/docs/src/styles/modrinth.css @@ -2,8 +2,9 @@ ::backdrop, :root[data-theme='light'], [data-theme='light'] ::backdrop { - --sl-font-system: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, - Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + --sl-font-system: + Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, + Fira Sans, Droid Sans, Helvetica Neue, sans-serif; --sl-color-white: var(--color-contrast); /* “white” */ --sl-color-gray-1: var(--color-base); diff --git a/apps/frontend/src/components/ui/NotificationItem.vue b/apps/frontend/src/components/ui/NotificationItem.vue index 25a377bc..516a9ada 100644 --- a/apps/frontend/src/components/ui/NotificationItem.vue +++ b/apps/frontend/src/components/ui/NotificationItem.vue @@ -331,7 +331,19 @@ import { VersionIcon, XIcon, } from '@modrinth/assets' -import { injectNotificationManager, useRelativeTime } from '@modrinth/ui' +import { + Avatar, + Categories, + CopyCode, + DoubleIcon, + injectNotificationManager, + ProjectStatusBadge, + useRelativeTime, +} from '@modrinth/ui' + +import { getProjectLink, getVersionLink } from '~/helpers/projects' + +import ThreadSummary from './thread/ThreadSummary.vue' const { addNotification } = injectNotificationManager() const emit = defineEmits(['update:notifications']) diff --git a/apps/frontend/src/components/ui/servers/FileManagerError.vue b/apps/frontend/src/components/ui/servers/FileManagerError.vue index 682aad50..e451008a 100644 --- a/apps/frontend/src/components/ui/servers/FileManagerError.vue +++ b/apps/frontend/src/components/ui/servers/FileManagerError.vue @@ -9,7 +9,7 @@
@@ -28,6 +28,8 @@ import { FileIcon, HomeIcon } from '@modrinth/assets' import { ButtonStyled } from '@modrinth/ui' +import LoadingIcon from './icons/LoadingIcon.vue' + defineProps<{ title: string message: string diff --git a/apps/frontend/src/components/ui/servers/FileVirtualList.vue b/apps/frontend/src/components/ui/servers/FileVirtualList.vue index 9025693f..9381fb77 100644 --- a/apps/frontend/src/components/ui/servers/FileVirtualList.vue +++ b/apps/frontend/src/components/ui/servers/FileVirtualList.vue @@ -18,7 +18,7 @@ }" data-pyro-files-virtual-list > - import { computed, onMounted, onUnmounted, ref } from 'vue' +import FileItem from './FileItem.vue' + const props = defineProps<{ items: any[] }>() diff --git a/apps/frontend/src/components/ui/servers/FilesImageViewer.vue b/apps/frontend/src/components/ui/servers/FilesImageViewer.vue index e88cd839..3d23470a 100644 --- a/apps/frontend/src/components/ui/servers/FilesImageViewer.vue +++ b/apps/frontend/src/components/ui/servers/FilesImageViewer.vue @@ -14,7 +14,7 @@ v-if="state.hasError" class="flex h-full w-full flex-col items-center justify-center gap-8" > - +

{{ state.errorMessage || 'Invalid or empty image file.' }}

- - - - diff --git a/apps/frontend/src/components/ui/servers/ServerLoaderLabel.vue b/apps/frontend/src/components/ui/servers/ServerLoaderLabel.vue index 0ec2ab5a..c90267e4 100644 --- a/apps/frontend/src/components/ui/servers/ServerLoaderLabel.vue +++ b/apps/frontend/src/components/ui/servers/ServerLoaderLabel.vue @@ -2,7 +2,7 @@
- +
diff --git a/apps/frontend/src/components/ui/servers/marketing/MedalServerCountdown.vue b/apps/frontend/src/components/ui/servers/marketing/MedalServerCountdown.vue new file mode 100644 index 00000000..f3c8939c --- /dev/null +++ b/apps/frontend/src/components/ui/servers/marketing/MedalServerCountdown.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/apps/frontend/src/components/ui/servers/marketing/MedalServerListing.vue b/apps/frontend/src/components/ui/servers/marketing/MedalServerListing.vue new file mode 100644 index 00000000..81c4b2b4 --- /dev/null +++ b/apps/frontend/src/components/ui/servers/marketing/MedalServerListing.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/apps/frontend/src/composables/featureFlags.ts b/apps/frontend/src/composables/featureFlags.ts index 79054649..7d6d4827 100644 --- a/apps/frontend/src/composables/featureFlags.ts +++ b/apps/frontend/src/composables/featureFlags.ts @@ -25,6 +25,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({ // Feature toggles projectTypesPrimaryNav: false, + enableMedalPromotion: true, hidePlusPromoInUserMenu: false, oldProjectCards: true, newProjectCards: false, diff --git a/apps/frontend/src/composables/servers/modules/general.ts b/apps/frontend/src/composables/servers/modules/general.ts index 8ab0bd6a..0562e38c 100644 --- a/apps/frontend/src/composables/servers/modules/general.ts +++ b/apps/frontend/src/composables/servers/modules/general.ts @@ -34,6 +34,8 @@ export class GeneralModule extends ServerModule implements ServerGeneral { node!: { token: string; instance: string } flows?: { intro?: boolean } + is_medal?: boolean + async fetch(): Promise { const data = await useServersFetch(`servers/${this.serverId}`, {}, 'general') diff --git a/apps/frontend/src/pages/servers/index.vue b/apps/frontend/src/pages/servers/index.vue index 50758489..58017c21 100644 --- a/apps/frontend/src/pages/servers/index.vue +++ b/apps/frontend/src/pages/servers/index.vue @@ -392,7 +392,7 @@

Frequently Asked Questions

-
+
@@ -404,7 +404,7 @@ GHz, paired with DDR5 memory.

-
+
@@ -420,7 +420,7 @@

-
+
@@ -433,7 +433,7 @@

-
+
@@ -447,7 +447,7 @@

-
+
@@ -460,7 +460,7 @@

-
+
@@ -481,7 +481,7 @@

-
+
@@ -493,7 +493,7 @@

-
+
@@ -516,12 +516,13 @@
-
+

There's a server for everyone

@@ -551,6 +552,8 @@
+ +
    { if (route.hash) { - // where pyro-hash === route.hash - const faq = document.querySelector(`[pyro-hash="${route.hash.slice(1)}"]`) + // where nav-hash === route.hash + const faq = document.querySelector(`[nav-hash="${route.hash.slice(1)}"]`) if (faq) { faq.open = true const top = faq.getBoundingClientRect().top @@ -923,16 +928,20 @@ const selectProduct = async (product) => { await nextTick() if (product === 'custom') { - purchaseModal.value?.show(billingPeriod.value, undefined, selectedProjectId.value) + purchaseModal.value?.show(billingPeriod.value, null, selectedProjectId.value) } else { purchaseModal.value?.show(billingPeriod.value, selectedProduct.value, selectedProjectId.value) } } -const planQuery = () => { - if (route.query.plan) { - document.getElementById('plan').scrollIntoView() - selectProduct(route.query.plan) +const planQuery = async () => { + if ('plan' in route.query) { + await nextTick() + const planElement = document.querySelector(`[nav-hash="plan"]`) + if (planElement) { + planElement.scrollIntoView({ behavior: 'smooth' }) + await selectProduct(route.query.plan) + } } } diff --git a/apps/frontend/src/pages/servers/manage/[id].vue b/apps/frontend/src/pages/servers/manage/[id].vue index 0955e91a..7674be16 100644 --- a/apps/frontend/src/pages/servers/manage/[id].vue +++ b/apps/frontend/src/pages/servers/manage/[id].vue @@ -116,7 +116,15 @@ }" >
    - + +
    @@ -290,6 +298,10 @@
    +
    + +
    +
    -
    +

No servers found.

@@ -107,13 +117,16 @@ import { HammerIcon, PlusIcon, SearchIcon } from '@modrinth/assets' import { ButtonStyled, CopyCode } from '@modrinth/ui' import type { ModrinthServersFetchError, Server } from '@modrinth/utils' +import dayjs from 'dayjs' import Fuse from 'fuse.js' +import type { ComponentPublicInstance } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { reloadNuxtApp } from '#app' +import MedalServerListing from '~/components/ui/servers/marketing/MedalServerListing.vue' import ServerListing from '~/components/ui/servers/ServerListing.vue' -import ServerListingSkeleton from '~/components/ui/servers/ServerListingSkeleton.vue' import ServerManageEmptyState from '~/components/ui/servers/ServerManageEmptyState.vue' +import ServersUpgradeModalWrapper from '~/components/ui/servers/ServersUpgradeModalWrapper.vue' import { useServersFetch } from '~/composables/servers/servers-fetch.ts' definePageMeta({ @@ -137,15 +150,40 @@ const { data: serverResponse, error: fetchError, refresh, -} = await useAsyncData('ServerList', () => - useServersFetch('servers'), -) +} = await useAsyncData('ServerList', async () => { + const serverResponse = await useServersFetch('servers') + + let subscriptions: any[] | undefined + + for (const server of serverResponse.servers) { + if (server.is_medal) { + // Inject end date into server object. + const serverID = server.server_id + + if (!subscriptions) { + subscriptions = (await useBaseFetch(`billing/subscriptions`, { + internal: true, + })) as any[] + } + + for (const subscription of subscriptions) { + if (subscription.metadata?.id === serverID) { + server.medal_expires = dayjs(subscription.created as string) + .add(5, 'days') + .toISOString() + } + } + } + } + + return serverResponse +}) watch([fetchError, serverResponse], ([error, response]) => { hasError.value = !!error || !response }) -const serverList = computed(() => { +const serverList = computed(() => { if (!serverResponse.value) return [] return serverResponse.value.servers }) @@ -167,7 +205,7 @@ function introToTop(array: Server[]): Server[] { }) } -const filteredData = computed(() => { +const filteredData = computed(() => { if (!searchInput.value.trim()) { return introToTop(serverList.value) } @@ -207,4 +245,13 @@ onUnmounted(() => { clearInterval(intervalId) } }) + +type ServersUpgradeModalWrapperRef = ComponentPublicInstance<{ + open: (id: string) => void | Promise +}> + +const upgradeModal = ref(null) +function openUpgradeModal(serverId: string) { + upgradeModal.value?.open(serverId) +} diff --git a/apps/frontend/src/pages/settings/billing/index.vue b/apps/frontend/src/pages/settings/billing/index.vue index a24fef48..ec48f790 100644 --- a/apps/frontend/src/pages/settings/billing/index.vue +++ b/apps/frontend/src/pages/settings/billing/index.vue @@ -1,4 +1,5 @@ + +
+ Next: + + {{ formatPrice(vintl.locale, midasCharge.amount, midasCharge.currency_code) }} + + /{{ midasCharge.subscription_interval.replace('ly', '') }} +
Or - {{ formatPrice(vintl.locale, price.prices.intervals.yearly, price.currency_code) }} - / year (save + {{ formatPrice(vintl.locale, price.prices.intervals.yearly, price.currency_code) }} / + year (save {{ calculateSavings(price.prices.intervals.monthly, price.prices.intervals.yearly) }}%)! @@ -168,8 +198,7 @@ @click="switchMidasInterval(oppositeInterval)" > - - {{ changingInterval ? 'Switching' : 'Switch' }} to + {{ changingInterval ? 'Switching' : 'Switch' }} to {{ oppositeInterval }} @@ -207,7 +236,11 @@
- +

A linked server couldn't be found for this subscription. There are a few possible @@ -233,9 +266,8 @@

- {{ getPyroProduct(subscription)?.metadata?.cpu / 2 }} - Shared CPUs (Bursts up to - {{ getPyroProduct(subscription)?.metadata?.cpu }} CPUs) + {{ getPyroProduct(subscription)?.metadata?.cpu / 2 }} Shared CPUs (Bursts up + to {{ getPyroProduct(subscription)?.metadata?.cpu }} CPUs)
@@ -285,16 +317,60 @@ /{{ subscription.interval.replace('ly', '') }}
+
+ Next: + + {{ + formatPrice( + vintl.locale, + getPyroCharge(subscription).amount, + getPyroCharge(subscription).currency_code, + ) + }} + + + / + {{ + ( + getPyroCharge(subscription).subscription_interval || + subscription.interval + ).replace('ly', '') + }} + +
- Since - {{ $dayjs(subscription.created).format('MMMM D, YYYY') }} + Since {{ $dayjs(subscription.created).format('MMMM D, YYYY') }} - Renews + Renews {{ $dayjs(getPyroCharge(subscription).due).format('MMMM D, YYYY') }} + + + Switches to + {{ getPyroCharge(subscription).subscription_interval }} + billing on {{ $dayjs(getPyroCharge(subscription).due).format('MMMM D, YYYY') }} - Expires - {{ $dayjs(getPyroCharge(subscription).due).format('MMMM D, YYYY') }} + Expires {{ $dayjs(getPyroCharge(subscription).due).format('MMMM D, YYYY') }} - { return productsData.value.find((p) => p.prices?.some((x) => x.id === subscription.price_id)) } +// Get product by a price ID (useful for pending next-charge changes) +const getProductFromPriceId = (priceId) => { + if (!priceId || !productsData.value) return null + return productsData.value.find((p) => p.prices?.some((x) => x.id === priceId)) +} + const getPyroCharge = (subscription) => { if (!subscription || !charges.value) return null return charges.value.find( @@ -931,76 +982,18 @@ const getProductPrice = (product, interval) => { ) } -const modalCancel = ref(null) +const getPlanChangeVerb = (currentProduct, nextProduct) => { + const curRam = currentProduct?.metadata?.ram ?? 0 + const nextRam = nextProduct?.metadata?.ram ?? 0 -const pyroPurchaseModal = ref() -const currentSubscription = ref(null) -const currentProduct = ref(null) -const upgradeProducts = ref([]) -upgradeProducts.value.metadata = { type: 'pyro' } - -const currentSubRenewalDate = ref() - -const showPyroUpgradeModal = async (subscription) => { - currentSubscription.value = subscription - currentSubRenewalDate.value = getPyroCharge(subscription).due - currentProduct.value = getPyroProduct(subscription) - upgradeProducts.value = products.filter( - (p) => - p.metadata.type === 'pyro' && - (!currentProduct.value || p.metadata.ram > currentProduct.value.metadata.ram), - ) - upgradeProducts.value.metadata = { type: 'pyro' } - - await nextTick() - - if (!currentProduct.value) { - console.error('Could not find product for current subscription') - addNotification({ - title: 'An error occurred', - text: 'Could not find product for current subscription', - type: 'error', - }) - return - } - - if (!pyroPurchaseModal.value) { - console.error('pyroPurchaseModal ref is undefined') - return - } - - pyroPurchaseModal.value.show() + return nextRam < curRam ? 'downgrade' : 'upgrade' } -async function fetchCapacityStatuses(serverId, product) { - if (product) { - try { - return { - custom: await useServersFetch(`servers/${serverId}/upgrade-stock`, { - method: 'POST', - body: { - cpu: product.metadata.cpu, - memory_mb: product.metadata.ram, - swap_mb: product.metadata.swap, - storage_mb: product.metadata.storage, - }, - }), - } - } catch (error) { - console.error('Error checking server capacities:', error) - addNotification({ - title: 'Error checking server capacities', - text: error, - type: 'error', - }) - return { - custom: { available: 0 }, - small: { available: 0 }, - medium: { available: 0 }, - large: { available: 0 }, - } - } - } +const modalCancel = ref(null) + +const upgradeModal = ref(null) +const showPyroUpgradeModal = (subscription) => { + upgradeModal.value?.open(subscription?.metadata?.id) } const resubscribePyro = async (subscriptionId, wasSuspended) => { @@ -1093,6 +1086,7 @@ function showCancellationSurvey(subscription) { window.Tally.openPopup(formId, popupOptions) } else { console.warn('Tally script not yet loaded') + cancelSubscription(subscription.id, true) } } catch (e) { console.error('Error opening Tally popup:', e) @@ -1107,4 +1101,50 @@ useHead({ }, ], }) + +const getPendingChange = (subscription) => { + const charge = getPyroCharge(subscription) + if (!charge || charge.status !== 'open') return null + + const nextProduct = getProductFromPriceId(charge.price_id) + if (!nextProduct || charge.price_id === subscription.price_id) { + // Not a plan change, but interval could change + if (charge.subscription_interval && charge.subscription_interval !== subscription.interval) { + return { + planSize: getProductSize(getPyroProduct(subscription)), + cpu: getPyroProduct(subscription)?.metadata?.cpu / 2, + cpuBurst: getPyroProduct(subscription)?.metadata?.cpu, + ramGb: (getPyroProduct(subscription)?.metadata?.ram || 0) / 1024, + swapGb: (getPyroProduct(subscription)?.metadata?.swap || 0) / 1024 || undefined, + storageGb: (getPyroProduct(subscription)?.metadata?.storage || 0) / 1024 || undefined, + date: charge.due, + intervalChange: charge.subscription_interval, + verb: 'Switches', + } + } + return null + } + + const curProduct = getPyroProduct(subscription) + const verb = getPlanChangeVerb(curProduct, nextProduct) + const cpu = nextProduct?.metadata?.cpu ?? 0 + const ram = nextProduct?.metadata?.ram ?? 0 + const swap = nextProduct?.metadata?.swap ?? 0 + const storage = nextProduct?.metadata?.storage ?? 0 + + return { + planSize: getProductSize(nextProduct), + cpu: cpu / 2, + cpuBurst: cpu, + ramGb: ram / 1024, + swapGb: swap ? swap / 1024 : undefined, + storageGb: storage ? storage / 1024 : undefined, + date: charge.due, + intervalChange: + charge.subscription_interval && charge.subscription_interval !== subscription.interval + ? charge.subscription_interval + : null, + verb, + } +} diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts index f0f66b10..4adb0cf0 100644 --- a/packages/assets/generated-icons.ts +++ b/packages/assets/generated-icons.ts @@ -33,6 +33,7 @@ import _ChevronRightIcon from './icons/chevron-right.svg?component' import _ClearIcon from './icons/clear.svg?component' import _ClientIcon from './icons/client.svg?component' import _ClipboardCopyIcon from './icons/clipboard-copy.svg?component' +import _ClockIcon from './icons/clock.svg?component' import _CloudIcon from './icons/cloud.svg?component' import _CodeIcon from './icons/code.svg?component' import _CoffeeIcon from './icons/coffee.svg?component' @@ -139,6 +140,7 @@ import _ReplyIcon from './icons/reply.svg?component' import _ReportIcon from './icons/report.svg?component' import _RestoreIcon from './icons/restore.svg?component' import _RightArrowIcon from './icons/right-arrow.svg?component' +import _RocketIcon from './icons/rocket.svg?component' import _RotateClockwiseIcon from './icons/rotate-clockwise.svg?component' import _RotateCounterClockwiseIcon from './icons/rotate-counter-clockwise.svg?component' import _RssIcon from './icons/rss.svg?component' @@ -229,6 +231,7 @@ export const ChevronRightIcon = _ChevronRightIcon export const ClearIcon = _ClearIcon export const ClientIcon = _ClientIcon export const ClipboardCopyIcon = _ClipboardCopyIcon +export const ClockIcon = _ClockIcon export const CloudIcon = _CloudIcon export const CodeIcon = _CodeIcon export const CoffeeIcon = _CoffeeIcon @@ -335,6 +338,7 @@ export const ReplyIcon = _ReplyIcon export const ReportIcon = _ReportIcon export const RestoreIcon = _RestoreIcon export const RightArrowIcon = _RightArrowIcon +export const RocketIcon = _RocketIcon export const RotateClockwiseIcon = _RotateClockwiseIcon export const RotateCounterClockwiseIcon = _RotateCounterClockwiseIcon export const RssIcon = _RssIcon diff --git a/packages/assets/icons/clock.svg b/packages/assets/icons/clock.svg new file mode 100644 index 00000000..98c2fac7 --- /dev/null +++ b/packages/assets/icons/clock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/assets/icons/rocket.svg b/packages/assets/icons/rocket.svg new file mode 100644 index 00000000..df6c40f3 --- /dev/null +++ b/packages/assets/icons/rocket.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/components/base/OptionGroup.vue b/packages/ui/src/components/base/OptionGroup.vue new file mode 100644 index 00000000..556e2cdb --- /dev/null +++ b/packages/ui/src/components/base/OptionGroup.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/packages/ui/src/components/billing/ModalBasedServerPlan.vue b/packages/ui/src/components/billing/ModalBasedServerPlan.vue new file mode 100644 index 00000000..5df81320 --- /dev/null +++ b/packages/ui/src/components/billing/ModalBasedServerPlan.vue @@ -0,0 +1,138 @@ + + + diff --git a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue index ed0b89a5..a9662ea1 100644 --- a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue +++ b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue @@ -7,6 +7,7 @@ import { SpinnerIcon, XIcon, } from '@modrinth/assets' +import type { UserSubscription } from '@modrinth/utils' import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl' import type Stripe from 'stripe' import { computed, nextTick, ref, useTemplateRef, watch } from 'vue' @@ -26,6 +27,7 @@ import type { import { ButtonStyled } from '../index' import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue' import NewModal from '../modal/NewModal.vue' +import PlanSelector from './ServersPurchase0Plan.vue' import RegionSelector from './ServersPurchase1Region.vue' import PaymentMethodSelector from './ServersPurchase2PaymentMethod.vue' import ConfirmPurchase from './ServersPurchase3Review.vue' @@ -46,12 +48,16 @@ const props = defineProps<{ pings: RegionPing[] regions: ServerRegion[] availableProducts: ServerPlan[] + planStage?: boolean + existingPlan?: ServerPlan + existingSubscription?: UserSubscription refreshPaymentMethods: () => Promise fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise initiatePayment: ( body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest, - ) => Promise + ) => Promise onError: (err: Error) => void + onFinalizeNoPaymentChange?: () => Promise }>() const modal = useTemplateRef>('modal') @@ -78,6 +84,7 @@ const { hasPaymentMethod, submitPayment, completingPurchase, + noPaymentRequired, } = useStripe( props.publishableKey, props.customer, @@ -95,11 +102,14 @@ const customServer = ref(false) const acceptedEula = ref(false) const skipPaymentMethods = ref(true) -type Step = 'region' | 'payment' | 'review' +type Step = 'plan' | 'region' | 'payment' | 'review' -const steps: Step[] = ['region', 'payment', 'review'] +const steps: Step[] = props.planStage + ? (['plan', 'region', 'payment', 'review'] as Step[]) + : (['region', 'payment', 'review'] as Step[]) const titles: Record = { + plan: defineMessage({ id: 'servers.purchase.step.plan.title', defaultMessage: 'Plan' }), region: defineMessage({ id: 'servers.purchase.step.region.title', defaultMessage: 'Region' }), payment: defineMessage({ id: 'servers.purchase.step.payment.title', @@ -132,12 +142,26 @@ const nextStep = computed(() => const canProceed = computed(() => { switch (currentStep.value) { + case 'plan': + console.log('Plan step:', { + customServer: customServer.value, + selectedPlan: selectedPlan.value, + existingPlan: props.existingPlan, + }) + return ( + customServer.value || + (!!selectedPlan.value && + (!props.existingPlan || selectedPlan.value.id !== props.existingPlan.id)) + ) case 'region': return selectedRegion.value && selectedPlan.value && selectedInterval.value case 'payment': return selectedPaymentMethod.value || !loadingElements.value case 'review': - return acceptedEula.value && hasPaymentMethod.value && !completingPurchase.value + return ( + (noPaymentRequired.value || (acceptedEula.value && hasPaymentMethod.value)) && + !completingPurchase.value + ) default: return false } @@ -145,6 +169,8 @@ const canProceed = computed(() => { async function beforeProceed(step: string) { switch (step) { + case 'plan': + return true case 'region': return true case 'payment': @@ -160,6 +186,9 @@ async function beforeProceed(step: string) { } return true case 'review': + if (noPaymentRequired.value) { + return true + } if (selectedPaymentMethod.value) { return true } else { @@ -200,12 +229,31 @@ async function setStep(step: Step | undefined, skipValidation = false) { } watch(selectedPlan, () => { - console.log(selectedPlan.value) + if (currentStep.value === 'plan') { + customServer.value = !selectedPlan.value + } +}) + +const defaultPlan = computed(() => { + return ( + props.availableProducts.find((p) => p?.metadata?.type === 'pyro' && p.metadata.ram === 6144) ?? + props.availableProducts.find((p) => p?.metadata?.type === 'pyro') ?? + props.availableProducts[0] + ) }) function begin(interval: ServerBillingInterval, plan?: ServerPlan, project?: string) { loading.value = false - selectedPlan.value = plan + + if (plan === null) { + // Explicitly open in custom mode + selectedPlan.value = undefined + customServer.value = true + } else { + selectedPlan.value = plan ?? defaultPlan.value + customServer.value = !selectedPlan.value + } + selectedInterval.value = interval customServer.value = !selectedPlan.value selectedPaymentMethod.value = undefined @@ -218,16 +266,42 @@ function begin(interval: ServerBillingInterval, plan?: ServerPlan, project?: str defineExpose({ show: begin, }) + +defineEmits<{ + (e: 'hide'): void +}>() + +function handleChooseCustom() { + customServer.value = true + selectedPlan.value = undefined +} + +// When the user explicitly wants to change or add a payment method from Review +// we must disable the auto-skip behavior, clear any selected method, and +// navigate to the Payment step so Stripe Elements can mount. +async function changePaymentMethod() { + skipPaymentMethods.value = false + selectedPaymentMethod.value = undefined + await setStep('payment', true) +} + +function goToBreadcrumbStep(id: string) { + if (id === 'payment') { + return changePaymentMethod() + } + + return setStep(id as Step, true) +}