Files
Modrinth-plus/packages/ui/src/composables/stripe.ts
Truman Gao 693a371d61 feat: server management in app (#5628)
* start new server settings tabs

* update properties tab to match design

* better stying in general tab

* feat: add suffix input for hostname field

* implement tables for allocations and DNS records

* add tags for dns record type

* small gap adjustment

* polish advanced page

* adjust properties page hierarchy

* fix searching properties, empty state and projection radius appearing

* pnpm prepr

* update copy to match designs

* fix suffix input component

* style fixes and match heading size

* small fix

* fix search allocations placeholder

* adjust table styles

* move all installation settings helper text to below input

* update icon to use overflow menu buttons

* fix modal to be consistent

* open advanced properties when search

* remove other and custom properties, and update styles

* remove hide/show all java versions

* handle mc 26

* refactor: move server settings pages into /ui and add app ServerSettingsModal

* hook up server pages for app

* add server page header to app

* hook up server settings modal

* use large size

* fix card box shadow style

* fix hostname input for app

* fix app/website card containers

* implement external tabs for billing and admin billing

* fix save banner fixed to parent instead of page body

* remove unused prop to FriendsList causing warning in app

* fix client-only not available for app

* fix bottom cut off

* wire node auth

* implement full copy buttons

* dedup copy button tailwind styles

* fix hover class not working in @apply

* fix spacing

* fix error validation styles

* apply consistent styles and spacing

* feat: update hosting server card (#5609)

* fix type errors

* fix some stylesheets not imported for storybook

* add server listing stories

* add fix for frontend stylesheet imports

* remove props.

* convert copy code to use tailwind

* update server listing component styles

* update server info label styles

* start status/player count info label, more style updates and fixes

* add new server card buttons

* hook up server cards and implement updated styles

* hook up on download button

* fix tauri throwing error when api returns 204 No Content

* hook up purchase server modal in app

* fix upgrading state loading icon

* pnpm prepr

* filter out servers past 30 days after cancellation

* do not apply opacity on lock or spiner icons

* fix disabled server icon background

* update pending change stage

* handle known suspension states

* refactor: reduce code duplication for server listing

* update disabled state text color

* fix loading icon color

* clean up copy

* fix disabled opacity for server card

* update server listing files kept to be countdown

* implement resubscribe modal

* implement proper provisioning state for resubscribe

* fix duplicate attribute and pnpm prepr

* feat: add shared UI package auth DI

* feat: update purchase server flow (#5714)

* implement server list empty state component

* fix stories and adjust spacing

* implement select plan design refresh

* implement auth for empty server list

* use refs instead of reactive

* pnpm prepr

* fix auth usage for empty servers list

* move app auth provider setup to src/providers/setup

* pnpm prepr

* fix max height

* style fix

* fix getCreds no auth is blocking api client

* implement servers guest plan modal and signin which redirects back to modal's next step

* refactor guest plan select logic into provider

* implement sign in or create account popup

* remove force empty serverList

* add download button for suspended mod and generic

* add handling for when user logs out

* QA pass style fixes

* more consistent page styles

* fix duplicate export

* refactor: remove all fallback stuff from resubscribe modal

* implement shared download latest backup util

* i18n pass

* pnpm prepr

* fix region being selected if ping failed

* pnpm prepr

* feat: servers in app finalization (#5744)

* feat: start on shared console implementation into logs and overview pages

* fix: terminal gap issues

* feat: swap word wrap for full screen

* fix: stats cards alignment

* fix: stats

* feat: fix console clear + remove copy

* fix: lint

* fix: use reset not clear

* feat: shared server header & overview page for app and website (#5736)

* feat: implement shared server header for app and website

* feat: implement wrapped overview page with shared composable and hook it up

* pnpm prepr

* fix: bugs

* qa: cleanup

* feat: root.vue shared layout

* feat: delete old options pages + fix discovery frontend

* fix: discovery

* fix: misc style/layout issues

* fix page padding

* fix: modal height jankiness

* feat: implement server install content in app and server setup modal with DI

* fix: spacing

* remove servers in app feature flag

* Revert "remove servers in app feature flag"

This reverts commit 86e284c4bdd6fa42c3c8fbaf1efbec41f4d1c6d2.

* fix: qa

* feat: remove legacy components from apps/frontend/src/components/ui/servers

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>

* qa pass (#5738)

* fix: qa

* feat: qa

* fix: server icon fetch fails due to global node auth race condition overriding each other

* fix: lint

* fix: server icon upload/sync and centralize logic

* fix: server settings modal not closing for server reset

* fix: better server sorting

* feat: copy address in server listing card

* fix: notification panel in modal and when overlapping with action bar

* fix: empty server list empty state flashing when refresh, fixed by adding isReady auth flag

* feat: use floating action bar for save banner

* fix: saving state in save bar

* fix: edit server icon styling

* fix: confirm modal to have consistent buttons

* feat: loading animation for server panel + caching improvements for app

* pnpm prepr

* feat: search page deduplication (#5754)

* fix: action bar behind modal

* fix: remove warning modal for stopping

* fix: server cards states

* we hate webkit we hate webkit

* fix: update allocation creation to not use modal

* fix: properties tab spacing and styles

* feat: add files tab copy

* fix: advanced properties icon

* fix: remove back to all servers link

* feat: add files tab link in copy

* fix: server header styles to be consistent with instance

* fix: add header icons back

* feat: update instance settings icon to be consistent

* fix: icon container

* feat: upload state persistence across tabs

* fix: server labels text wrapping

* fix: use surface-5 border

* fix: loading spinner showing with onboarding below

* feat: new server button shows purchase modal in website

* fix: billing page not showing quarterly interval

* fix: server downgrade not showing updated subscription notification

* fix: server settings invalidate saved state and remove server context provider since its already provided in the page

* pnpm prepr

* add stripe publishable key to app build

* feat: console highlighting

* fix: rename servers title to modrinth hosting

* feat: search fix

* fix: qa/styles

* fix: ip click active and remove power dont ask again

* fix: qa

* feat: highlighting fix console

* fix: disable conflicts action

* fix: error dismiss bug

* feat: modal clarification

* fix: files perms issue

* fix: lint

* feat: modal fix

* enable show uptime

* fix: add loading state to edit server icon

* fix: notification panel take in has sidebar from settings

* fix: consistency pass on app settings

* fix: consistency pass on instance settings

* pnpm prepr

* fix: nagivate to billing button in app to go to website

* fix: stripe return url in app causing app to open modrinth.com in tauri

* refactor: better show polling UI code

* fix: new server polling comparison to use server ids instead of length

* fix: buttonstyled story

* fix: button styling

* fix: content.vue regression

* feat: project url redirects

* fix: breadcrumbs

* fix: purchase with newly added card

* fix: console ordering problems

* fix: app-frontend missing env config and staging environment

* fix: log syncing for instances and server panel accidentally

* fix: QA issues

* fix: server page loading state

* fix: stats card logic

* fix: lint

* fix: qa

* fix: console height padding

* fix: terminal padding + loading indicator

* feat: update medal server listing styling

* fix: no upgrade button for medal server listing in app

* fix: go to overview instead of content tab after onboarding

* fix: qa

* fix: teleport modals to body

* fix: logs tab + qa

* fix: local storage for user preferences

* fix: qa loading indic

* feat: considitonal debug and trace

* fix: jump to top on install bug

* feat: swap out server hard drive icon to server stack icon

* feat: servers in app feature flag default true

* fix: highlight row ufll

* fix: webkit thing onto a tag

* fix: input field

* fix: clear fix

* fix: lint

* fix: fmt

* feat: improve share modal and bring it back for sharing log

* pnpm prepr

* fix: menu overflowing

* feat: remove servers in app feature flag

* fix: server stat charts no longer showing color

* fix: library nav no primary state

* fix: better modal height and width

* fix: highlighting bugs

* fix: empty states

* fix: delay import to fix overview page slow load on MacOS

* fix: medal server listing too bright on light mode

* fix: admon analysis + fix logs

* fix: bug

* fix: clear purchase intent from sign-in after closing modal

* performance: improve server manage stats loading by splitting reactivity

* fix: deploy + admon + disable highlighting

* fix: clippy

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>

* feat: temp wrangler

* fix: lint

* fix: logs upload

* fix: console empty state and admon regressions

* fix: fields

* feat: log deleting + prefetch for Logs.vue

* feat: move delete before share

* feat: clear endpoint

* feat: we ball!

---------

Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-04-12 21:38:08 +00:00

423 lines
11 KiB
TypeScript

import type { Labrinth } from '@modrinth/api-client'
import { loadStripe, type Stripe as StripeJs, type StripeElements } from '@stripe/stripe-js'
import type { ContactOption } from '@stripe/stripe-js/dist/stripe-js/elements/address'
import type Stripe from 'stripe'
import { computed, type Ref, ref } from 'vue'
import type { ServerBillingInterval } from '../components/billing/ModrinthServersPurchaseModal.vue'
import { getPriceForInterval } from '../utils/product-utils'
// 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[],
currency: string,
product: Ref<Labrinth.Billing.Internal.Product | undefined>,
interval: Ref<ServerBillingInterval>,
region: Ref<string | undefined>,
project: Ref<string | undefined>,
initiatePayment: (
body: Labrinth.Billing.Internal.InitiatePaymentRequest,
) => Promise<
| Labrinth.Billing.Internal.InitiatePaymentResponse
| Labrinth.Billing.Internal.EditSubscriptionResponse
| null
>,
onError: (err: Error) => void,
affiliateCode?: Ref<string | null>,
) => {
const stripe = ref<StripeJs | null>(null)
let elements: StripeElements | undefined = undefined
const elementsLoaded = ref<0 | 1 | 2>(0)
const loadingElementsFailed = ref<boolean>(false)
const paymentMethodLoading = ref(false)
const loadingFailed = ref<string>()
const paymentIntentId = ref<string>()
const tax = ref<number>()
const total = ref<number>()
const confirmationToken = ref<string>()
const submittingPayment = ref(false)
const selectedPaymentMethod = ref<Stripe.PaymentMethod>()
const inputtedPaymentMethod = ref<Stripe.PaymentMethod>()
const clientSecret = ref<string>()
const completingPurchase = ref<boolean>(false)
const noPaymentRequired = ref<boolean>(false)
async function initialize() {
stripe.value = await loadStripe(publishableKey)
}
const planPrices = computed(() => {
return product.value?.prices.find((x) => x.currency_code === currency)
})
const 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('--color-button-bg'),
colorText: styles.getPropertyValue('--color-base'),
colorTextPlaceholder: styles.getPropertyValue('--color-secondary'),
colorDanger: styles.getPropertyValue('--color-red'),
fontFamily: styles.getPropertyValue('--font-standard'),
spacingUnit: '0.25rem',
borderRadius: '0.75rem',
},
},
loader: 'never',
...options,
})
const paymentElement = elements.create('payment', {
layout: {
type: 'tabs',
defaultCollapsed: false,
},
})
paymentElement.mount('#payment-element')
const contacts: ContactOption[] = []
paymentMethods.forEach((method) => {
const addr = method.billing_details?.address
if (
addr &&
addr.line1 &&
addr.city &&
addr.postal_code &&
addr.country &&
addr.state &&
method.billing_details.name
) {
contacts.push({
address: {
line1: addr.line1,
line2: addr.line2 ?? undefined,
city: addr.city,
state: addr.state,
postal_code: addr.postal_code,
country: addr.country,
},
name: method.billing_details.name,
})
}
})
const addressElement = elements.create('address', {
mode: 'billing',
contacts: contacts.length > 0 ? contacts : undefined,
})
addressElement.mount('#address-element')
return { elements, paymentElement, addressElement }
}
const primaryPaymentMethodId = computed<string | null>(() => {
if (customer && customer.invoice_settings && customer.invoice_settings.default_payment_method) {
const method = customer.invoice_settings.default_payment_method
if (typeof method === 'string') {
return method
} else {
return method.id
}
} else if (paymentMethods && paymentMethods[0] && paymentMethods[0].id) {
return paymentMethods[0].id
} else {
return null
}
})
const loadStripeElements = async () => {
loadingFailed.value = undefined
try {
if (!customer && primaryPaymentMethodId.value) {
paymentMethodLoading.value = true
await refreshPaymentIntent(primaryPaymentMethodId.value, false)
paymentMethodLoading.value = false
}
if (!selectedPaymentMethod.value) {
elementsLoaded.value = 0
const {
elements: newElements,
addressElement,
paymentElement,
} = createElements({
mode: 'payment',
currency: currency.toLowerCase(),
amount: product.value
? getPriceForInterval(product.value, currency, interval.value)
: undefined,
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)
}
if (!product.value) {
return handlePaymentError('No product selected')
}
const request: Labrinth.Billing.Internal.InitiatePaymentRequest = {
type: confirmation ? 'confirmation_token' : 'payment_method',
...(confirmation ? { token: id } : { id }),
charge: {
type: 'new',
product_id: product.value.id,
interval: interval.value as Labrinth.Billing.Internal.PriceDuration,
},
...(paymentIntentId.value ? { existing_payment_intent: paymentIntentId.value } : {}),
metadata: {
type: 'pyro',
server_region: region.value,
source: project.value
? {
project_id: project.value,
}
: {},
...(affiliateCode?.value ? { affiliate_code: affiliateCode.value } : {}),
},
}
const result = await initiatePayment(request)
if (!result) {
tax.value = 0
total.value = 0
noPaymentRequired.value = true
} else {
if (result.payment_intent_id) {
paymentIntentId.value = result.payment_intent_id
}
if (result.client_secret) {
clientSecret.value = result.client_secret
}
tax.value = result.tax
total.value = result.total
noPaymentRequired.value = false
console.log(
`${paymentIntentId.value ? 'Updated' : 'Created'} payment intent: ${interval.value} for ${result.total}`,
)
}
if (confirmation) {
confirmationToken.value = id
if (result && 'payment_method' in result && result.payment_method) {
const paymentMethod = (
result as {
payment_method?: string | Stripe.PaymentMethod
}
).payment_method
if (typeof paymentMethod === 'string') {
const method = paymentMethods.find((x) => x.id === paymentMethod)
if (method) {
inputtedPaymentMethod.value = method
}
} else if (paymentMethod) {
inputtedPaymentMethod.value = paymentMethod
}
}
}
} catch (err) {
handlePaymentError(err as string)
}
paymentMethodLoading.value = false
}
async function createConfirmationToken() {
if (!elements) {
return handlePaymentError('No elements')
}
if (!stripe.value) {
return handlePaymentError('No stripe')
}
const { error, confirmationToken: confirmation } = await stripe.value.createConfirmationToken({
elements,
})
if (error) {
handlePaymentError(error.message ?? 'Unknown error creating confirmation token')
return
}
return confirmation.id
}
function handlePaymentError(err: string | Error) {
paymentMethodLoading.value = false
completingPurchase.value = false
onError(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.message ?? 'Unknown error creating payment method')
}
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): Promise<boolean> {
if (noPaymentRequired.value) {
completingPurchase.value = false
return true
}
completingPurchase.value = true
const secret = clientSecret.value
if (!secret) {
handlePaymentError('No client secret')
return false
}
if (!stripe.value) {
handlePaymentError('No stripe')
return false
}
submittingPayment.value = true
const productPrice = product.value?.prices.find((x) => x.currency_code === currency)
const { error } = returnUrl
? await stripe.value.confirmPayment({
clientSecret: secret,
confirmParams: {
confirmation_token: confirmationToken.value,
return_url: `${returnUrl}?priceId=${productPrice?.id}&plan=${interval.value}`,
},
})
: await stripe.value.confirmPayment({
clientSecret: secret,
redirect: 'if_required',
confirmParams: {
confirmation_token: confirmationToken.value,
},
})
if (error) {
handlePaymentError(error.message ?? 'Unknown error submitting payment')
return false
}
submittingPayment.value = false
completingPurchase.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 || noPaymentRequired.value,
)
return {
initializeStripe: initialize,
selectPaymentMethod,
reloadPaymentIntent,
primaryPaymentMethodId,
selectedPaymentMethod,
inputtedPaymentMethod,
hasPaymentMethod,
createNewPaymentMethod,
loadingElements,
loadingElementsFailed,
paymentMethodLoading,
loadStripeElements,
tax,
total,
submitPayment,
completingPurchase,
noPaymentRequired,
}
}