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 3516a3b1..2062b673 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 00000000..f00cd327 --- /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 ec37299a..b5f52655 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 cdc71fa1..ed281e08 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 00000000..cd11fec3 --- /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 7c08bdf9..8aa18f3e 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 0d3dd6a8..cde074ff 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 1c2e45a9..210135be 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 9c758e11..ac55ac47 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 5e1ecbe6..6c261632 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 e96a8c8f..9030c0e0 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 4b3fc7a8..517a41c9 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 00000000..8175501e --- /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 00000000..d586464c --- /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 00000000..617adf81 --- /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 00000000..bdd3fc9e --- /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 00000000..a600054e --- /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 00000000..af8f4304 --- /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 00000000..f13a31ca --- /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 00000000..40f7d712 --- /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 00000000..bf364f85 --- /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 00000000..f9a58124 --- /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 899a8932..a1c39233 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 00000000..8815d74d --- /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 00000000..12144bb4 --- /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 44ba1c6e..e6088508 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 00000000..8b473609 --- /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 d878f11d..424d33ca 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 00000000..5167a291 --- /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 a5d165a0..3c734084 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 7e27099c..db2372dd 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 56f34523..7ac4ed66 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 70096d6d..be9ad0a0 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: {}