From c0accb42fa08d0ed60ce00b71d08daa7016757a1 Mon Sep 17 00:00:00 2001 From: Prospector <6166773+Prospector@users.noreply.github.com> Date: Tue, 3 Jun 2025 09:20:53 -0700 Subject: [PATCH] Servers new purchase flow (#3719) * New purchase flow for servers, region selector, etc. * Lint * Lint * Fix expanding total --- .../src/components/ui/world/WorldItem.vue | 16 +- .../src/components/ui/OptionGroup.vue | 128 ++++++ .../components/ui/servers/LoaderSelector.vue | 1 + .../ui/servers/PlatformVersionSelectModal.vue | 2 +- .../ui/servers/ServerInstallation.vue | 277 +++++++++++++ .../servers/marketing/ServerPlanSelector.vue | 120 ++---- apps/frontend/src/composables/country.ts | 1 + apps/frontend/src/composables/pyroFetch.ts | 4 +- apps/frontend/src/composables/pyroServers.ts | 3 + apps/frontend/src/locales/en-US/index.json | 28 +- apps/frontend/src/pages/servers/index.vue | 315 ++++++++------- .../src/pages/servers/manage/[id].vue | 311 +++++++++------ .../servers/manage/[id]/options/loader.vue | 253 +----------- .../src/pages/settings/billing/index.vue | 123 +----- packages/assets/icons/cpu.svg | 15 +- packages/assets/icons/database.svg | 1 + packages/assets/icons/memory-stick.svg | 1 + packages/assets/index.ts | 4 + packages/assets/styles/variables.scss | 4 +- packages/ui/package.json | 2 + .../ui/src/components/base/RadioButtons.vue | 4 +- .../components/billing/AddPaymentMethod.vue | 104 +++++ .../billing/AddPaymentMethodModal.vue | 72 ++++ .../billing/ExpandableInvoiceTotal.vue | 65 +++ .../billing/FormattedPaymentMethod.vue | 43 ++ .../billing/ModrinthServersPurchaseModal.vue | 297 ++++++++++++++ .../billing/PaymentMethodOption.vue | 37 ++ .../billing/ServersPurchase1Region.vue | 229 +++++++++++ .../billing/ServersPurchase2PaymentMethod.vue | 69 ++++ .../billing/ServersPurchase3Review.vue | 264 ++++++++++++ .../billing/ServersRegionButton.vue | 93 +++++ .../src/components/billing/ServersSpecs.vue | 60 +++ packages/ui/src/components/index.ts | 3 + .../modal/ModalLoadingIndicator.vue | 35 ++ packages/ui/src/composables/stripe.ts | 376 ++++++++++++++++++ packages/ui/src/locales/en-US/index.json | 42 ++ packages/ui/src/utils/billing.ts | 101 +++++ packages/ui/src/utils/common-messages.ts | 60 +++ packages/ui/src/utils/regions.ts | 16 + packages/ui/tsconfig.json | 3 +- packages/utils/billing.ts | 20 +- packages/utils/utils.ts | 14 + pnpm-lock.yaml | 205 +++++++++- 43 files changed, 3021 insertions(+), 800 deletions(-) create mode 100644 apps/frontend/src/components/ui/OptionGroup.vue create mode 100644 apps/frontend/src/components/ui/servers/ServerInstallation.vue create mode 100644 packages/assets/icons/database.svg create mode 100644 packages/assets/icons/memory-stick.svg create mode 100644 packages/ui/src/components/billing/AddPaymentMethod.vue create mode 100644 packages/ui/src/components/billing/AddPaymentMethodModal.vue create mode 100644 packages/ui/src/components/billing/ExpandableInvoiceTotal.vue create mode 100644 packages/ui/src/components/billing/FormattedPaymentMethod.vue create mode 100644 packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue create mode 100644 packages/ui/src/components/billing/PaymentMethodOption.vue create mode 100644 packages/ui/src/components/billing/ServersPurchase1Region.vue create mode 100644 packages/ui/src/components/billing/ServersPurchase2PaymentMethod.vue create mode 100644 packages/ui/src/components/billing/ServersPurchase3Review.vue create mode 100644 packages/ui/src/components/billing/ServersRegionButton.vue create mode 100644 packages/ui/src/components/billing/ServersSpecs.vue create mode 100644 packages/ui/src/components/modal/ModalLoadingIndicator.vue create mode 100644 packages/ui/src/composables/stripe.ts create mode 100644 packages/ui/src/utils/billing.ts create mode 100644 packages/ui/src/utils/regions.ts diff --git a/apps/app-frontend/src/components/ui/world/WorldItem.vue b/apps/app-frontend/src/components/ui/world/WorldItem.vue index 3516a3b1f..2062b6730 100644 --- a/apps/app-frontend/src/components/ui/world/WorldItem.vue +++ b/apps/app-frontend/src/components/ui/world/WorldItem.vue @@ -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({ diff --git a/apps/frontend/src/components/ui/OptionGroup.vue b/apps/frontend/src/components/ui/OptionGroup.vue new file mode 100644 index 000000000..f00cd3270 --- /dev/null +++ b/apps/frontend/src/components/ui/OptionGroup.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/apps/frontend/src/components/ui/servers/LoaderSelector.vue b/apps/frontend/src/components/ui/servers/LoaderSelector.vue index ec37299a3..b5f526555 100644 --- a/apps/frontend/src/components/ui/servers/LoaderSelector.vue +++ b/apps/frontend/src/components/ui/servers/LoaderSelector.vue @@ -63,6 +63,7 @@ const props = defineProps<{ loader: string | null; loader_version: string | null; }; + ignoreCurrentInstallation?: boolean; isInstalling?: boolean; }>(); diff --git a/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue b/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue index cdc71fa1f..ed281e087 100644 --- a/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue +++ b/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue @@ -313,7 +313,7 @@ const selectedLoaderVersions = computed(() => { 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") { diff --git a/apps/frontend/src/components/ui/servers/ServerInstallation.vue b/apps/frontend/src/components/ui/servers/ServerInstallation.vue new file mode 100644 index 000000000..cd11fec39 --- /dev/null +++ b/apps/frontend/src/components/ui/servers/ServerInstallation.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/apps/frontend/src/components/ui/servers/marketing/ServerPlanSelector.vue b/apps/frontend/src/components/ui/servers/marketing/ServerPlanSelector.vue index 7c08bdf9e..8aa18f3e1 100644 --- a/apps/frontend/src/components/ui/servers/marketing/ServerPlanSelector.vue +++ b/apps/frontend/src/components/ui/servers/marketing/ServerPlanSelector.vue @@ -1,9 +1,9 @@ diff --git a/apps/frontend/src/composables/country.ts b/apps/frontend/src/composables/country.ts index 0d3dd6a81..cde074ff0 100644 --- a/apps/frontend/src/composables/country.ts +++ b/apps/frontend/src/composables/country.ts @@ -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) { diff --git a/apps/frontend/src/composables/pyroFetch.ts b/apps/frontend/src/composables/pyroFetch.ts index 1c2e45a98..210135bec 100644 --- a/apps/frontend/src/composables/pyroFetch.ts +++ b/apps/frontend/src/composables/pyroFetch.ts @@ -49,7 +49,9 @@ export async function usePyroFetch(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; diff --git a/apps/frontend/src/composables/pyroServers.ts b/apps/frontend/src/composables/pyroServers.ts index 9c758e110..ac55ac47d 100644 --- a/apps/frontend/src/composables/pyroServers.ts +++ b/apps/frontend/src/composables/pyroServers.ts @@ -330,6 +330,9 @@ interface General { token: string; instance: string; }; + flows?: { + intro?: boolean; + }; } interface Allocation { diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index 5e1ecbe68..6c261632c 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -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 and Privacy Policy." @@ -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" }, diff --git a/apps/frontend/src/pages/servers/index.vue b/apps/frontend/src/pages/servers/index.vue index e96a8c8f4..9030c0e01 100644 --- a/apps/frontend/src/pages/servers/index.vue +++ b/apps/frontend/src/pages/servers/index.vue @@ -4,27 +4,28 @@ data-pyro class="servers-hero relative isolate -mt-44 h-full min-h-screen pt-8" > - 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" />
-
-
-
-
-
-
-
- Server Locations -
-

- Coast-to-Coast Coverage -

-
- -
-
-
-
- - - - -
-

- US Coverage -

-
-

- 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. -

-
- -
-
-
- - - - - - -
-

- Global Expansion -

-
-

- 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. -

-
-
-
- -
-
-
-

- Start your server on Modrinth + There's a server for everyone

-

- {{ - isAtCapacity && !loggedOut - ? "We are currently at capacity. Please try again later." - : "There's a plan for everyone! Choose the one that fits your needs." - }} -

+

+ Available in North America and Europe for wide coverage. +

-
    +
    + + + + Pay quarterly + Pay yearly + + + + +
    + +
      @@ -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" >
      -

      Build your own

      +

      Know exactly what you need?

      - 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.

      @@ -666,7 +611,7 @@ > @@ -679,7 +624,7 @@ - - diff --git a/apps/frontend/src/pages/settings/billing/index.vue b/apps/frontend/src/pages/settings/billing/index.vue index 4b3fc7a86..517a41c99 100644 --- a/apps/frontend/src/pages/settings/billing/index.vue +++ b/apps/frontend/src/pages/settings/billing/index.vue @@ -444,39 +444,13 @@ :return-url="`${config.public.siteUrl}/servers/manage`" :server-name="`${auth?.user?.username}'s server`" /> - - -
      -
      - -
      -
      -
      -
      -
      -
      - - - - - - -
      -
      -
      +

      {{ formatMessage(messages.paymentMethodTitle) }}

      @@ -590,9 +564,8 @@ + + diff --git a/packages/ui/src/components/billing/AddPaymentMethodModal.vue b/packages/ui/src/components/billing/AddPaymentMethodModal.vue new file mode 100644 index 000000000..8175501e9 --- /dev/null +++ b/packages/ui/src/components/billing/AddPaymentMethodModal.vue @@ -0,0 +1,72 @@ + + + diff --git a/packages/ui/src/components/billing/ExpandableInvoiceTotal.vue b/packages/ui/src/components/billing/ExpandableInvoiceTotal.vue new file mode 100644 index 000000000..d586464c4 --- /dev/null +++ b/packages/ui/src/components/billing/ExpandableInvoiceTotal.vue @@ -0,0 +1,65 @@ + + diff --git a/packages/ui/src/components/billing/FormattedPaymentMethod.vue b/packages/ui/src/components/billing/FormattedPaymentMethod.vue new file mode 100644 index 000000000..617adf81e --- /dev/null +++ b/packages/ui/src/components/billing/FormattedPaymentMethod.vue @@ -0,0 +1,43 @@ + + + diff --git a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue new file mode 100644 index 000000000..bdd3fc9ed --- /dev/null +++ b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue @@ -0,0 +1,297 @@ + + diff --git a/packages/ui/src/components/billing/PaymentMethodOption.vue b/packages/ui/src/components/billing/PaymentMethodOption.vue new file mode 100644 index 000000000..a600054e8 --- /dev/null +++ b/packages/ui/src/components/billing/PaymentMethodOption.vue @@ -0,0 +1,37 @@ + + + diff --git a/packages/ui/src/components/billing/ServersPurchase1Region.vue b/packages/ui/src/components/billing/ServersPurchase1Region.vue new file mode 100644 index 000000000..af8f4304d --- /dev/null +++ b/packages/ui/src/components/billing/ServersPurchase1Region.vue @@ -0,0 +1,229 @@ + + + diff --git a/packages/ui/src/components/billing/ServersPurchase2PaymentMethod.vue b/packages/ui/src/components/billing/ServersPurchase2PaymentMethod.vue new file mode 100644 index 000000000..f13a31ca9 --- /dev/null +++ b/packages/ui/src/components/billing/ServersPurchase2PaymentMethod.vue @@ -0,0 +1,69 @@ + + + diff --git a/packages/ui/src/components/billing/ServersPurchase3Review.vue b/packages/ui/src/components/billing/ServersPurchase3Review.vue new file mode 100644 index 000000000..40f7d712e --- /dev/null +++ b/packages/ui/src/components/billing/ServersPurchase3Review.vue @@ -0,0 +1,264 @@ + + + diff --git a/packages/ui/src/components/billing/ServersRegionButton.vue b/packages/ui/src/components/billing/ServersRegionButton.vue new file mode 100644 index 000000000..bf364f85a --- /dev/null +++ b/packages/ui/src/components/billing/ServersRegionButton.vue @@ -0,0 +1,93 @@ + + + diff --git a/packages/ui/src/components/billing/ServersSpecs.vue b/packages/ui/src/components/billing/ServersSpecs.vue new file mode 100644 index 000000000..f9a581242 --- /dev/null +++ b/packages/ui/src/components/billing/ServersSpecs.vue @@ -0,0 +1,60 @@ + + diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 899a89320..a1c39233f 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -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' diff --git a/packages/ui/src/components/modal/ModalLoadingIndicator.vue b/packages/ui/src/components/modal/ModalLoadingIndicator.vue new file mode 100644 index 000000000..8815d74d8 --- /dev/null +++ b/packages/ui/src/components/modal/ModalLoadingIndicator.vue @@ -0,0 +1,35 @@ + + + diff --git a/packages/ui/src/composables/stripe.ts b/packages/ui/src/composables/stripe.ts new file mode 100644 index 000000000..12144bb4a --- /dev/null +++ b/packages/ui/src/composables/stripe.ts @@ -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, + interval: Ref, + initiatePayment: ( + body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest, + ) => Promise, +) => { + const stripe = ref(null) + + let elements: StripeElements | undefined = undefined + const elementsLoaded = ref<0 | 1 | 2>(0) + const loadingElementsFailed = ref(false) + + const paymentMethodLoading = ref(false) + const loadingFailed = ref() + const paymentIntentId = ref() + const tax = ref() + const total = ref() + const confirmationToken = ref() + const submittingPayment = ref(false) + const selectedPaymentMethod = ref() + const inputtedPaymentMethod = ref() + + async function initialize() { + stripe.value = await loadStripe(publishableKey) + } + + function createIntent(body: CreatePaymentIntentRequest): Promise { + return initiatePayment(body) as Promise + } + + function updateIntent(body: UpdatePaymentIntentRequest): Promise { + return initiatePayment(body) as Promise + } + + 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(() => { + 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, + } +} diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json index 44ba1c6e7..e60885082 100644 --- a/packages/ui/src/locales/en-US/index.json +++ b/packages/ui/src/locales/en-US/index.json @@ -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? Let us know where you'd like to see Modrinth Servers next!" + }, "settings.account.title": { "defaultMessage": "Account and security" }, diff --git a/packages/ui/src/utils/billing.ts b/packages/ui/src/utils/billing.ts new file mode 100644 index 000000000..8b473609a --- /dev/null +++ b/packages/ui/src/utils/billing.ts @@ -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 +} diff --git a/packages/ui/src/utils/common-messages.ts b/packages/ui/src/utils/common-messages.ts index d878f11d4..424d33cad 100644 --- a/packages/ui/src/utils/common-messages.ts +++ b/packages/ui/src/utils/common-messages.ts @@ -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', + }, +}) diff --git a/packages/ui/src/utils/regions.ts b/packages/ui/src/utils/regions.ts new file mode 100644 index 000000000..5167a2911 --- /dev/null +++ b/packages/ui/src/utils/regions.ts @@ -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 diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index a5d165a01..3c7340846 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -5,5 +5,6 @@ "compilerOptions": { "lib": ["esnext", "dom"], "noImplicitAny": false - } + }, + "types": ["@stripe/stripe-js"] } diff --git a/packages/utils/billing.ts b/packages/utils/billing.ts index 7e27099c2..db2372dd7 100644 --- a/packages/utils/billing.ts +++ b/packages/utils/billing.ts @@ -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', diff --git a/packages/utils/utils.ts b/packages/utils/utils.ts index 56f34523a..7ac4ed668 100644 --- a/packages/utils/utils.ts +++ b/packages/utils/utils.ts @@ -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 + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70096d6d2..be9ad0a06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}