From c793b68aedd392b0e80ae6ad1bd10fddc647daa3 Mon Sep 17 00:00:00 2001 From: Prospector <6166773+Prospector@users.noreply.github.com> Date: Thu, 26 Jun 2025 08:38:42 -0700 Subject: [PATCH] Add quick server button, dynamic price preview for custom server modal (#3815) * Add quick server creation button, and dynamic pricing to custom server selection * Remove test in compatibility card * Lint + remove duplicate file * Adjust z-index of popup * $6 -> $5 * Dismiss prompt if the button is clicked * Make "Create a server" disabled for now * Use existing loaders type --- apps/frontend/src/composables/featureFlags.ts | 3 + apps/frontend/src/pages/[type]/[id].vue | 107 +++++++++++++ apps/frontend/src/pages/servers/index.vue | 36 ++++- apps/frontend/src/plugins/floating-vue.js | 4 + packages/assets/icons/badge-check.svg | 1 + packages/assets/icons/server-plus.svg | 1 + packages/assets/index.ts | 4 + packages/assets/styles/classes.scss | 144 ++++++++++-------- .../billing/ModrinthServersPurchaseModal.vue | 7 +- .../billing/ServersPurchase1Region.vue | 26 +++- .../billing/ServersPurchase3Review.vue | 13 +- packages/ui/src/components/index.ts | 1 + .../src/components/servers/ServersPromo.vue | 60 ++++++++ packages/ui/src/composables/stripe.ts | 23 +-- packages/ui/src/utils/billing.ts | 24 ++- 15 files changed, 362 insertions(+), 92 deletions(-) create mode 100644 packages/assets/icons/badge-check.svg create mode 100644 packages/assets/icons/server-plus.svg create mode 100644 packages/ui/src/components/servers/ServersPromo.vue diff --git a/apps/frontend/src/composables/featureFlags.ts b/apps/frontend/src/composables/featureFlags.ts index 7832eb992..bf17955c5 100644 --- a/apps/frontend/src/composables/featureFlags.ts +++ b/apps/frontend/src/composables/featureFlags.ts @@ -31,6 +31,9 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({ projectBackground: false, searchBackground: false, advancedDebugInfo: false, + showProjectPageDownloadModalServersPromo: true, + showProjectPageCreateServersTooltip: true, + showProjectPageQuickServerButton: false, // advancedRendering: true, // externalLinksNewTab: true, // notUsingBlockers: false, diff --git a/apps/frontend/src/pages/[type]/[id].vue b/apps/frontend/src/pages/[type]/[id].vue index 7edde26fe..4b5721203 100644 --- a/apps/frontend/src/pages/[type]/[id].vue +++ b/apps/frontend/src/pages/[type]/[id].vue @@ -452,6 +452,16 @@ {{ formatCategory(currentPlatform) }}.

+ @@ -495,6 +505,64 @@ + + + + + + + x.is_owner)?.user?.username || "a Creator"} on Modrinth`, ); +const canCreateServerFrom = computed(() => { + return project.value.project_type === "modpack" && project.value.server_side !== "unsupported"; +}); + if (!route.name.startsWith("type-id-settings")) { useSeoMeta({ title: () => title.value, @@ -1679,4 +1757,33 @@ const navLinks = computed(() => { display: none; } } + +.servers-popup { + box-shadow: + 0 0 12px 1px rgba(0, 175, 92, 0.6), + var(--shadow-floating); + + &::before { + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid var(--color-button-bg); + content: " "; + position: absolute; + top: -7px; + left: 17px; + } + &::after { + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-bottom: 5px solid var(--color-raised-bg); + content: " "; + position: absolute; + top: -5px; + left: 18px; + } +} diff --git a/apps/frontend/src/pages/servers/index.vue b/apps/frontend/src/pages/servers/index.vue index b2eaa8117..9b3c363a8 100644 --- a/apps/frontend/src/pages/servers/index.vue +++ b/apps/frontend/src/pages/servers/index.vue @@ -500,6 +500,7 @@
@@ -603,7 +604,9 @@ -

Starting at $3/GB RAM

+

+ Starting at {{ formatPrice(locale, lowestPrice, selectedCurrency, true) }} / month +

@@ -622,20 +625,34 @@ import { VersionIcon, ServerIcon, } from "@modrinth/assets"; +import { computed } from "vue"; +import { monthsInInterval } from "@modrinth/ui/src/utils/billing.ts"; +import { formatPrice } from "@modrinth/utils"; +import { useVIntl } from "@vintl/vintl"; import { products } from "~/generated/state.json"; import { useServersFetch } from "~/composables/servers/servers-fetch.ts"; import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue"; import ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue"; import OptionGroup from "~/components/ui/OptionGroup.vue"; +const { locale } = useVIntl(); + const billingPeriods = ref(["monthly", "quarterly"]); const billingPeriod = ref(billingPeriods.value.includes("quarterly") ? "quarterly" : "monthly"); -const pyroProducts = products.filter((p) => p.metadata.type === "pyro"); +const pyroProducts = products + .filter((p) => p.metadata.type === "pyro") + .sort((a, b) => a.metadata.ram - b.metadata.ram); const pyroPlanProducts = pyroProducts.filter( (p) => p.metadata.ram === 4096 || p.metadata.ram === 6144 || p.metadata.ram === 8192, ); -pyroPlanProducts.sort((a, b) => a.metadata.ram - b.metadata.ram); + +const lowestPrice = computed(() => { + const amount = pyroProducts[0]?.prices?.find( + (price) => price.currency_code === selectedCurrency.value, + )?.prices?.intervals?.[billingPeriod.value]; + return amount ? amount / monthsInInterval[billingPeriod.value] : undefined; +}); const title = "Modrinth Servers"; const description = @@ -799,6 +816,8 @@ async function fetchPaymentData() { } } +const selectedProjectId = ref(); + const route = useRoute(); const isAtCapacity = computed( () => isSmallAtCapacity.value && isMediumAtCapacity.value && isLargeAtCapacity.value, @@ -817,7 +836,12 @@ const scrollToFaq = () => { } }; -onMounted(scrollToFaq); +onMounted(() => { + scrollToFaq(); + if (route.query?.project) { + selectedProjectId.value = route.query?.project; + } +}); watch(() => route.hash, scrollToFaq); @@ -876,9 +900,9 @@ const selectProduct = async (product) => { await nextTick(); if (product === "custom") { - purchaseModal.value?.show(billingPeriod.value); + purchaseModal.value?.show(billingPeriod.value, undefined, selectedProjectId.value); } else { - purchaseModal.value?.show(billingPeriod.value, selectedProduct.value); + purchaseModal.value?.show(billingPeriod.value, selectedProduct.value, selectedProjectId.value); } }; diff --git a/apps/frontend/src/plugins/floating-vue.js b/apps/frontend/src/plugins/floating-vue.js index 8ea6c4c8d..c32d2b66d 100644 --- a/apps/frontend/src/plugins/floating-vue.js +++ b/apps/frontend/src/plugins/floating-vue.js @@ -10,6 +10,10 @@ export default defineNuxtPlugin((nuxtApp) => { instantMove: true, distance: 8, }, + "dismissable-prompt": { + $extend: "dropdown", + placement: "bottom-start", + }, }, }); }); diff --git a/packages/assets/icons/badge-check.svg b/packages/assets/icons/badge-check.svg new file mode 100644 index 000000000..ad45322e3 --- /dev/null +++ b/packages/assets/icons/badge-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/assets/icons/server-plus.svg b/packages/assets/icons/server-plus.svg new file mode 100644 index 000000000..b9bab1bd8 --- /dev/null +++ b/packages/assets/icons/server-plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/assets/index.ts b/packages/assets/index.ts index df7df4247..dbb8141b3 100644 --- a/packages/assets/index.ts +++ b/packages/assets/index.ts @@ -44,6 +44,7 @@ import _AlignLeftIcon from './icons/align-left.svg?component' import _ArchiveIcon from './icons/archive.svg?component' import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component' import _AsteriskIcon from './icons/asterisk.svg?component' +import _BadgeCheckIcon from './icons/badge-check.svg?component' import _BanIcon from './icons/ban.svg?component' import _BellIcon from './icons/bell.svg?component' import _BellRingIcon from './icons/bell-ring.svg?component' @@ -163,6 +164,7 @@ import _ScanEyeIcon from './icons/scan-eye.svg?component' import _SearchIcon from './icons/search.svg?component' import _SendIcon from './icons/send.svg?component' import _ServerIcon from './icons/server.svg?component' +import _ServerPlusIcon from './icons/server-plus.svg?component' import _SettingsIcon from './icons/settings.svg?component' import _ShareIcon from './icons/share.svg?component' import _ShieldIcon from './icons/shield.svg?component' @@ -264,6 +266,7 @@ export const AlignLeftIcon = _AlignLeftIcon export const ArchiveIcon = _ArchiveIcon export const ArrowBigUpDashIcon = _ArrowBigUpDashIcon export const AsteriskIcon = _AsteriskIcon +export const BadgeCheckIcon = _BadgeCheckIcon export const BanIcon = _BanIcon export const BellIcon = _BellIcon export const BellRingIcon = _BellRingIcon @@ -383,6 +386,7 @@ export const ScanEyeIcon = _ScanEyeIcon export const SearchIcon = _SearchIcon export const SendIcon = _SendIcon export const ServerIcon = _ServerIcon +export const ServerPlusIcon = _ServerPlusIcon export const SettingsIcon = _SettingsIcon export const ShareIcon = _ShareIcon export const ShieldIcon = _ShieldIcon diff --git a/packages/assets/styles/classes.scss b/packages/assets/styles/classes.scss index 9b22f853d..6082cb094 100644 --- a/packages/assets/styles/classes.scss +++ b/packages/assets/styles/classes.scss @@ -822,10 +822,69 @@ a, // TOOLTIPS +.v-popper--theme-dropdown, +.v-popper--theme-dropdown.v-popper--theme-ribbit-popout { + .v-popper__inner { + border: 1px solid var(--color-button-bg) !important; + padding: var(--gap-sm) !important; + width: fit-content !important; + border-radius: var(--radius-md) !important; + background-color: var(--color-raised-bg) !important; + box-shadow: var(--shadow-floating) !important; + } + + .v-popper__arrow-outer { + border-color: var(--color-button-bg) !important; + } + + .v-popper__arrow-inner { + border-color: var(--color-raised-bg) !important; + } +} + +.v-popper__popper[data-popper-placement='bottom-end'] .v-popper__wrapper { + transform-origin: top right; +} + +.v-popper__popper[data-popper-placement='top-end'] .v-popper__wrapper { + transform-origin: bottom right; +} + +.v-popper__popper[data-popper-placement='bottom-start'] .v-popper__wrapper { + transform-origin: top left; +} + +.v-popper__popper[data-popper-placement='top-start'] .v-popper__wrapper { + transform-origin: bottom left; +} + +.v-popper__popper.v-popper__popper--show-from .v-popper__wrapper { + transform: scale(0.85); + opacity: 0; +} + +.v-popper__popper.v-popper__popper--show-to .v-popper__wrapper { + transform: scale(1); + opacity: 1; + transition: + transform 0.125s ease-in-out, + opacity 0.125s ease-in-out; +} + +.v-popper__popper.v-popper__popper--hide-from .v-popper__wrapper { + transform: none; + opacity: 1; + transition: transform 0.0625s; +} + +.v-popper__popper.v-popper__popper--hide-to .v-popper__wrapper { + //transform: scale(.9); +} + .v-popper--theme-tooltip { .v-popper__inner { background: var(--color-tooltip-bg) !important; - color: var(--color-tooltip-text) !important; + color: initial !important; padding: 0.5rem 0.5rem !important; border-radius: var(--radius-sm) !important; filter: drop-shadow(5px 5px 0.8rem rgba(0, 0, 0, 0.35)); @@ -840,6 +899,30 @@ a, } } +.v-popper--theme-dismissable-prompt { + z-index: 10; + + .v-popper__inner { + background: var(--color-raised-bg) !important; + border: 1px solid var(--color-button-border); + color: var(--color-tooltip-text) !important; + padding: 0.75rem 1rem !important; + border-radius: 0.75rem !important; + filter: drop-shadow(5px 5px 0.8rem rgba(0, 0, 0, 0.35)); + font-size: 0.9rem; + font-weight: bold; + line-height: 1; + } + + .v-popper__arrow-outer { + border-color: var(--color-button-border); + } + + .v-popper__arrow-inner { + border-color: var(--color-raised-bg); + } +} + // MARKDOWN .markdown-body { @@ -1205,65 +1288,6 @@ select { border-top-right-radius: var(--radius-md) !important; } -.v-popper--theme-dropdown, -.v-popper--theme-dropdown.v-popper--theme-ribbit-popout { - .v-popper__inner { - border: 1px solid var(--color-button-bg) !important; - padding: var(--gap-sm) !important; - width: fit-content !important; - border-radius: var(--radius-md) !important; - background-color: var(--color-raised-bg) !important; - box-shadow: var(--shadow-floating) !important; - } - - .v-popper__arrow-outer { - border-color: var(--color-button-bg) !important; - } - - .v-popper__arrow-inner { - border-color: var(--color-raised-bg) !important; - } -} - -.v-popper__popper[data-popper-placement='bottom-end'] .v-popper__wrapper { - transform-origin: top right; -} - -.v-popper__popper[data-popper-placement='top-end'] .v-popper__wrapper { - transform-origin: bottom right; -} - -.v-popper__popper[data-popper-placement='bottom-start'] .v-popper__wrapper { - transform-origin: top left; -} - -.v-popper__popper[data-popper-placement='top-start'] .v-popper__wrapper { - transform-origin: bottom left; -} - -.v-popper__popper.v-popper__popper--show-from .v-popper__wrapper { - transform: scale(0.85); - opacity: 0; -} - -.v-popper__popper.v-popper__popper--show-to .v-popper__wrapper { - transform: scale(1); - opacity: 1; - transition: - transform 0.125s ease-in-out, - opacity 0.125s ease-in-out; -} - -.v-popper__popper.v-popper__popper--hide-from .v-popper__wrapper { - transform: none; - opacity: 1; - transition: transform 0.0625s; -} - -.v-popper__popper.v-popper__popper--hide-to .v-popper__wrapper { - //transform: scale(.9); -} - .preview-radio { width: 100% !important; border-radius: var(--radius-md); diff --git a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue index 74e618fe6..c7b9ab3a2 100644 --- a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue +++ b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue @@ -59,6 +59,7 @@ const selectedPlan = ref() const selectedInterval = ref('quarterly') const loading = ref(false) const selectedRegion = ref() +const projectId = ref() const { initializeStripe, @@ -85,6 +86,7 @@ const { selectedPlan, selectedInterval, selectedRegion, + projectId, props.initiatePayment, props.onError, ) @@ -201,7 +203,7 @@ watch(selectedPlan, () => { console.log(selectedPlan.value) }) -function begin(interval: ServerBillingInterval, plan?: ServerPlan) { +function begin(interval: ServerBillingInterval, plan?: ServerPlan, project?: string) { loading.value = false selectedPlan.value = plan selectedInterval.value = interval @@ -209,6 +211,7 @@ function begin(interval: ServerBillingInterval, plan?: ServerPlan) { selectedPaymentMethod.value = undefined currentStep.value = steps[0] skipPaymentMethods.value = true + projectId.value = project modal.value?.show() } @@ -253,6 +256,8 @@ defineExpose({ :pings="pings" :custom="customServer" :available-products="availableProducts" + :currency="currency" + :interval="selectedInterval" :fetch-stock="fetchStock" /> Promise custom: boolean + currency: string + interval: ServerBillingInterval availableProducts: ServerPlan[] }>() @@ -25,6 +34,12 @@ const checkingCustomStock = ref(false) const selectedPlan = defineModel('plan') const selectedRegion = defineModel('region') +const selectedPrice = computed(() => { + const amount = selectedPlan.value?.prices?.find((price) => price.currency_code === props.currency) + ?.prices?.intervals?.[props.interval] + return amount ? amount / monthsInInterval[props.interval] : undefined +}) + const regionOrder: string[] = ['us-vin', 'eu-lim'] const sortedRegions = computed(() => { @@ -216,7 +231,12 @@ onMounted(() => {
-
+

+ {{ formatPrice(locale, selectedPrice, currency, true) }} / month, billed {{ interval }} +

+
Checking availability...
diff --git a/packages/ui/src/components/billing/ServersPurchase3Review.vue b/packages/ui/src/components/billing/ServersPurchase3Review.vue index 3978e2366..814600009 100644 --- a/packages/ui/src/components/billing/ServersPurchase3Review.vue +++ b/packages/ui/src/components/billing/ServersPurchase3Review.vue @@ -1,6 +1,11 @@ + + diff --git a/packages/ui/src/composables/stripe.ts b/packages/ui/src/composables/stripe.ts index 02b6d2f1e..e3ed0ca94 100644 --- a/packages/ui/src/composables/stripe.ts +++ b/packages/ui/src/composables/stripe.ts @@ -31,6 +31,7 @@ export const useStripe = ( product: Ref, interval: Ref, region: Ref, + project: Ref, initiatePayment: ( body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest, ) => Promise, @@ -222,16 +223,22 @@ export const useStripe = ( let result: BasePaymentIntentResponse + const metadata: CreatePaymentIntentRequest['metadata'] = { + type: 'pyro', + server_region: region.value, + source: project.value + ? { + project_id: project.value, + } + : {}, + } + if (paymentIntentId.value) { result = await updateIntent({ ...requestType, charge, existing_payment_intent: paymentIntentId.value, - metadata: { - type: 'pyro', - server_region: region.value, - source: {}, - }, + metadata, }) console.log(`Updated payment intent: ${interval.value} for ${result.total}`) } else { @@ -242,11 +249,7 @@ export const useStripe = ( } = await createIntent({ ...requestType, charge, - metadata: { - type: 'pyro', - server_region: region.value, - source: {}, - }, + metadata: metadata, })) console.log(`Created payment intent: ${interval.value} for ${result.total}`) } diff --git a/packages/ui/src/utils/billing.ts b/packages/ui/src/utils/billing.ts index da2c7c0c6..e07f71de1 100644 --- a/packages/ui/src/utils/billing.ts +++ b/packages/ui/src/utils/billing.ts @@ -1,7 +1,14 @@ import type Stripe from 'stripe' +import type { Loaders } from '@modrinth/utils' export type ServerBillingInterval = 'monthly' | 'yearly' | 'quarterly' +export const monthsInInterval: Record = { + monthly: 1, + quarterly: 3, + yearly: 12, +} + export interface ServerPlan { id: string name: string @@ -72,11 +79,18 @@ export type CreatePaymentIntentRequest = PaymentRequestType & { type: 'pyro' server_name?: string server_region?: string - source: { - loader?: string - game_version?: string - loader_version?: string - } + source: + | { + loader: Loaders + game_version?: string + loader_version?: string + } + | { + project_id: string + version_id?: string + } + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + | {} } }