diff --git a/apps/frontend/src/pages/servers/index.vue b/apps/frontend/src/pages/servers/index.vue index 9030c0e01..9ccd98e89 100644 --- a/apps/frontend/src/pages/servers/index.vue +++ b/apps/frontend/src/pages/servers/index.vue @@ -520,27 +520,19 @@ Save 16% with quarterly billing! - - Saving 16% with quarterly billing! - Save 16% with yearly billing! - - Saving 16% with yearly billing! - @@ -639,7 +631,7 @@ import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue"; import ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue"; import OptionGroup from "~/components/ui/OptionGroup.vue"; -const billingPeriods = ref(["monthly", "yearly"]); +const billingPeriods = ref(["monthly", "quarterly"]); const billingPeriod = ref(billingPeriods.value.includes("quarterly") ? "quarterly" : "monthly"); const pyroProducts = products.filter((p) => p.metadata.type === "pyro"); diff --git a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue index bdd3fc9ed..b988105f8 100644 --- a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue +++ b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue @@ -8,6 +8,7 @@ import { RightArrowIcon, XIcon, CheckCircleIcon, + SpinnerIcon, } from '@modrinth/assets' import type { CreatePaymentIntentRequest, @@ -27,6 +28,7 @@ import RegionSelector from './ServersPurchase1Region.vue' import PaymentMethodSelector from './ServersPurchase2PaymentMethod.vue' import ConfirmPurchase from './ServersPurchase3Review.vue' import { useStripe } from '../../composables/stripe' +import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue' const { formatMessage } = useVIntl() @@ -49,11 +51,12 @@ const props = defineProps<{ initiatePayment: ( body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest, ) => Promise + onError: (err: Error) => void }>() const modal = useTemplateRef>('modal') const selectedPlan = ref() -const selectedInterval = ref() +const selectedInterval = ref('quarterly') const loading = ref(false) const { @@ -72,21 +75,22 @@ const { reloadPaymentIntent, hasPaymentMethod, submitPayment, + completingPurchase, } = useStripe( props.publishableKey, props.customer, props.paymentMethods, - props.clientSecret, props.currency, selectedPlan, selectedInterval, props.initiatePayment, - console.error, + props.onError, ) const selectedRegion = ref() const customServer = ref(false) const acceptedEula = ref(false) +const firstTimeThru = ref(true) type Step = 'region' | 'payment' | 'review' @@ -111,9 +115,13 @@ const currentPing = computed(() => { const currentStep = ref() -const currentStepIndex = computed(() => steps.indexOf(currentStep.value)) -const previousStep = computed(() => steps[steps.indexOf(currentStep.value) - 1]) -const nextStep = computed(() => steps[steps.indexOf(currentStep.value) + 1]) +const currentStepIndex = computed(() => (currentStep.value ? steps.indexOf(currentStep.value) : -1)) +const previousStep = computed(() => + currentStep.value ? steps[steps.indexOf(currentStep.value) - 1] : undefined, +) +const nextStep = computed(() => + currentStep.value ? steps[steps.indexOf(currentStep.value) + 1] : undefined, +) const canProceed = computed(() => { switch (currentStep.value) { @@ -122,7 +130,7 @@ const canProceed = computed(() => { case 'payment': return selectedPaymentMethod.value || !loadingElements.value case 'review': - return acceptedEula.value && hasPaymentMethod.value + return acceptedEula.value && hasPaymentMethod.value && !completingPurchase.value default: return false } @@ -135,13 +143,14 @@ async function beforeProceed(step: string) { case 'payment': await initializeStripe() - if (primaryPaymentMethodId.value) { + if (primaryPaymentMethodId.value && firstTimeThru.value) { const paymentMethod = await props.paymentMethods.find( (x) => x.id === primaryPaymentMethodId.value, ) await selectPaymentMethod(paymentMethod) await setStep('review', true) - return true + firstTimeThru.value = false + return false } return true case 'review': @@ -166,13 +175,13 @@ async function afterProceed(step: string) { } } -async function setStep(step: Step, skipValidation = false) { +async function setStep(step: Step | undefined, skipValidation = false) { if (!step) { await submitPayment(props.returnUrl) return } - if (!canProceed.value || skipValidation) { + if (!skipValidation && !canProceed.value) { return } @@ -191,6 +200,7 @@ function begin(interval: ServerBillingInterval, plan?: ServerPlan) { customServer.value = !selectedPlan.value selectedPaymentMethod.value = undefined currentStep.value = steps[0] + firstTimeThru.value = true modal.value?.show() } @@ -206,7 +216,7 @@ defineExpose({ {{ formatMessage(title) }} @@ -249,31 +259,48 @@ defineExpose({ v-else-if=" currentStep === 'review' && hasPaymentMethod && - selectedRegion && + currentRegion && selectedInterval && selectedPlan " - ref="currentStepRef" v-model:interval="selectedInterval" v-model:accepted-eula="acceptedEula" :currency="currency" :plan="selectedPlan" - :region="regions.find((x) => x.shortcode === selectedRegion)" + :region="currentRegion" :ping="currentPing" :loading="paymentMethodLoading" :selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod" :tax="tax" :total="total" - :on-error="console.error" - @change-payment-method="setStep('payment')" + @change-payment-method="setStep('payment', true)" @reload-payment-intent="reloadPaymentIntent" - @error="console.error" /> Something went wrong + + + + Loading... + Error loading Stripe payment UI. + + + + + + + - + {{ formatMessage(commonMessages.backButton) }} @@ -282,9 +309,10 @@ defineExpose({ - + - + + Subscribe diff --git a/packages/ui/src/components/billing/PurchaseModal.vue b/packages/ui/src/components/billing/PurchaseModal.vue index 65d92dae3..a47499e3d 100644 --- a/packages/ui/src/components/billing/PurchaseModal.vue +++ b/packages/ui/src/components/billing/PurchaseModal.vue @@ -214,10 +214,10 @@ {{ interval }} - SAVE {{ calculateSavings(price.prices.intervals.monthly, rawPrice) }}% + SAVE {{ calculateSavings(price.prices.intervals.monthly, rawPrice, interval === 'quarterly' ? 3 : 12) }}% {{ formatPrice(locale, rawPrice, price.currency_code) }} diff --git a/packages/ui/src/components/billing/ServersPurchase2PaymentMethod.vue b/packages/ui/src/components/billing/ServersPurchase2PaymentMethod.vue index f13a31ca9..ce9ff4730 100644 --- a/packages/ui/src/components/billing/ServersPurchase2PaymentMethod.vue +++ b/packages/ui/src/components/billing/ServersPurchase2PaymentMethod.vue @@ -51,19 +51,4 @@ const messages = defineMessages({ @select="emit('select', undefined)" /> - - - - Loading... - Error loading Stripe payment UI. - - - - - - - diff --git a/packages/ui/src/components/billing/ServersPurchase3Review.vue b/packages/ui/src/components/billing/ServersPurchase3Review.vue index 40f7d712e..5f17efc18 100644 --- a/packages/ui/src/components/billing/ServersPurchase3Review.vue +++ b/packages/ui/src/components/billing/ServersPurchase3Review.vue @@ -38,11 +38,10 @@ const props = defineProps<{ ping?: number loading?: boolean selectedPaymentMethod: Stripe.PaymentMethod | undefined - onError: (error: Error) => void }>() -const interval = defineModel('interval') -const acceptedEula = defineModel('accepted-eula', { required: true }) +const interval = defineModel('interval', { required: true }) +const acceptedEula = defineModel('acceptedEula', { required: true }) const prices = computed(() => { return props.plan.prices.find((x) => x.currency_code === props.currency) @@ -143,14 +142,14 @@ function setInterval(newInterval: ServerBillingInterval) { - + @@ -167,27 +166,27 @@ function setInterval(newInterval: ServerBillingInterval) { - + - Pay yearly + Pay quarterly {{ interval === 'quarterly' ? 'Saving' : 'Save' }} 16%{{ interval === 'quarterly' ? 'Saving' : 'Save' }} 16% {{ + >{{ formatPrice( locale, - prices?.prices?.intervals?.['yearly'] ?? 0 / monthsInInterval['yearly'], + prices?.prices?.intervals?.['quarterly'] ?? 0 / monthsInInterval['quarterly'], currency, true, ) diff --git a/packages/ui/src/composables/stripe.ts b/packages/ui/src/composables/stripe.ts index 12144bb4a..38afecb7e 100644 --- a/packages/ui/src/composables/stripe.ts +++ b/packages/ui/src/composables/stripe.ts @@ -1,11 +1,8 @@ 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' @@ -19,28 +16,28 @@ import type { ServerBillingInterval, UpdatePaymentIntentRequest, UpdatePaymentIntentResponse, -} from '../../utils/billing' +} from '../utils/billing.ts' -export type CreateElements = ( - paymentMethods: Stripe.PaymentMethod[], - options: StripeElementsOptionsMode, -) => { - elements: StripeElements - paymentElement: StripePaymentElement - addressElement: StripeAddressElement -} +// 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, + product: Ref, interval: Ref, initiatePayment: ( body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest, ) => Promise, + onError: (err: Error) => void, ) => { const stripe = ref(null) @@ -57,6 +54,8 @@ export const useStripe = ( const submittingPayment = ref(false) const selectedPaymentMethod = ref() const inputtedPaymentMethod = ref() + const clientSecret = ref() + const completingPurchase = ref(false) async function initialize() { stripe.value = await loadStripe(publishableKey) @@ -71,10 +70,10 @@ export const useStripe = ( } const planPrices = computed(() => { - return product.value.prices.find((x) => x.currency_code === currency) + return product.value?.prices.find((x) => x.currency_code === currency) }) - const createElements: CreateElements = (options) => { + const createElements = (options) => { const styles = getComputedStyle(document.body) if (!stripe.value) { @@ -158,11 +157,11 @@ export const useStripe = ( }) const loadStripeElements = async () => { - loadingFailed.value = false + loadingFailed.value = undefined try { - if (!customer) { + if (!customer && primaryPaymentMethodId.value) { paymentMethodLoading.value = true - await props.refreshPaymentMethods() + await refreshPaymentIntent(primaryPaymentMethodId.value, false) paymentMethodLoading.value = false } @@ -176,7 +175,7 @@ export const useStripe = ( } = createElements({ mode: 'payment', currency: currency.toLowerCase(), - amount: product.value.prices.find((x) => x.currency_code === currency)?.prices.intervals[ + amount: product.value?.prices.find((x) => x.currency_code === currency)?.prices.intervals[ interval.value ], paymentMethodCreation: 'manual', @@ -214,6 +213,10 @@ export const useStripe = ( id: id, } + if (!product.value) { + return handlePaymentError('No product selected') + } + const charge: ChargeRequestType = { type: 'new', product_id: product.value?.id, @@ -232,7 +235,7 @@ export const useStripe = ( } else { ;({ payment_intent_id: paymentIntentId.value, - client_secret: clientSecret, + client_secret: clientSecret.value, ...result } = await createIntent({ ...requestType, @@ -251,7 +254,7 @@ export const useStripe = ( } } } catch (err) { - emit('error', err) + handlePaymentError(err as string) } paymentMethodLoading.value = false } @@ -260,13 +263,16 @@ export const useStripe = ( if (!elements) { return handlePaymentError('No elements') } + if (!stripe.value) { + return handlePaymentError('No stripe') + } const { error, confirmationToken: confirmation } = await stripe.value.createConfirmationToken({ elements, }) if (error) { - emit('error', error) + handlePaymentError(error.message ?? 'Unknown error creating confirmation token') return } @@ -275,7 +281,8 @@ export const useStripe = ( function handlePaymentError(err: string | Error) { paymentMethodLoading.value = false - emit('error', typeof err === 'string' ? new Error(err) : err) + completingPurchase.value = false + onError(typeof err === 'string' ? new Error(err) : err) } async function createNewPaymentMethod() { @@ -288,7 +295,7 @@ export const useStripe = ( const { error: submitError } = await elements.submit() if (submitError) { - return handlePaymentError(submitError) + return handlePaymentError(submitError.message ?? 'Unknown error creating payment method') } const token = await createConfirmationToken() @@ -325,9 +332,20 @@ export const useStripe = ( const loadingElements = computed(() => elementsLoaded.value < 2) async function submitPayment(returnUrl: string) { + completingPurchase.value = true + const secert = clientSecret.value + + if (!secert) { + return handlePaymentError('No client secret') + } + + if (!stripe.value) { + return handlePaymentError('No stripe') + } + submittingPayment.value = true const { error } = await stripe.value.confirmPayment({ - clientSecret, + clientSecret: secert, confirmParams: { confirmation_token: confirmationToken.value, return_url: `${returnUrl}?priceId=${product.value?.prices.find((x) => x.currency_code === currency)?.id}&plan=${interval.value}`, @@ -335,10 +353,11 @@ export const useStripe = ( }) if (error) { - props.onError(error) + handlePaymentError(error.message ?? 'Unknown error submitting payment') return false } submittingPayment.value = false + completingPurchase.value = false return true } @@ -372,5 +391,6 @@ export const useStripe = ( tax, total, submitPayment, + completingPurchase } } diff --git a/packages/utils/billing.ts b/packages/utils/billing.ts index db2372dd7..a24e51ad9 100644 --- a/packages/utils/billing.ts +++ b/packages/utils/billing.ts @@ -84,10 +84,10 @@ export const formatPrice = (locale, price, currency, trimZeros = false) => { return formatter.format(convertedPrice) } -export const calculateSavings = (monthlyPlan, annualPlan) => { - const monthlyAnnualized = monthlyPlan * 12 +export const calculateSavings = (monthlyPlan, plan, months = 12) => { + const monthlyAnnualized = monthlyPlan * months - return Math.floor(((monthlyAnnualized - annualPlan) / monthlyAnnualized) * 100) + return Math.floor(((monthlyAnnualized - plan) / monthlyAnnualized) * 100) } export const createStripeElements = (stripe, paymentMethods, options) => {