mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 01:26:23 +00:00
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 86e284c4bd.
* 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>
This commit is contained in:
co-authored by
Calum H.
tdgao
Calum H.
parent
a2a97d1313
commit
693a371d61
@@ -1,149 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { InfoIcon } from '@modrinth/assets'
|
||||
import { Menu } from 'floating-vue'
|
||||
import { computed, inject, type Ref } from 'vue'
|
||||
|
||||
import { useFormatPrice } from '../../composables'
|
||||
import { type MessageDescriptor, useVIntl } from '../../composables/i18n'
|
||||
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
|
||||
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
|
||||
import ServersSpecs from './ServersSpecs.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
plan: Labrinth.Billing.Internal.Product
|
||||
title: MessageDescriptor
|
||||
description: MessageDescriptor
|
||||
buttonColor?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
mostPopular?: boolean
|
||||
selected?: boolean
|
||||
}>(),
|
||||
{
|
||||
buttonColor: 'standard',
|
||||
mostPopular: false,
|
||||
selected: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', plan: Labrinth.Billing.Internal.Product): void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatPrice = useFormatPrice()
|
||||
|
||||
// TODO: Use DI framework when merged.
|
||||
const selectedInterval = inject<Ref<ServerBillingInterval>>('selectedInterval')
|
||||
const currency = inject<string>('currency')
|
||||
|
||||
const perMonth = computed(() => {
|
||||
if (!props.plan || !currency || !selectedInterval?.value) return undefined
|
||||
const total = getPriceForInterval(props.plan, currency, selectedInterval.value)
|
||||
if (!total) return undefined
|
||||
return total / monthsInInterval[selectedInterval.value]
|
||||
})
|
||||
|
||||
const planSpecs = computed(() => {
|
||||
const metadata = props.plan.metadata
|
||||
if (metadata.type === 'pyro' || metadata.type === 'medal') {
|
||||
return {
|
||||
ram: metadata.ram,
|
||||
storage: metadata.storage,
|
||||
cpu: metadata.cpu,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const mostPopularStyle = computed(() => {
|
||||
if (!props.mostPopular) return undefined
|
||||
const style: Record<string, string> = {
|
||||
backgroundImage:
|
||||
'radial-gradient(86.12% 101.64% at 95.97% 94.07%, rgba(27, 217, 106, 0.23) 0%, rgba(14, 115, 56, 0.2) 100%)',
|
||||
boxShadow: '0px 12px 38.1px rgba(27, 217, 106, 0.13)',
|
||||
}
|
||||
|
||||
if (!props.selected) {
|
||||
style.borderColor = 'rgba(12, 107, 52, 0.55)'
|
||||
}
|
||||
|
||||
return style
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="rounded-2xl p-4 font-semibold transition-all duration-300 experimental-styles-within h-full border-2 border-solid cursor-pointer select-none"
|
||||
:class="{
|
||||
'bg-brand-highlight border-brand': selected,
|
||||
'bg-button-bg border-transparent': !selected,
|
||||
'!bg-bg': mostPopular,
|
||||
}"
|
||||
:style="mostPopularStyle"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-pressed="selected"
|
||||
@click="emit('select', plan)"
|
||||
@keydown.enter.prevent="emit('select', plan)"
|
||||
@keydown.space.prevent="emit('select', plan)"
|
||||
>
|
||||
<div class="flex h-full flex-col justify-between gap-2">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(title) }}
|
||||
</span>
|
||||
<div
|
||||
v-if="mostPopular"
|
||||
class="relative w-fit rounded-full bg-highlight-green px-3 py-1 text-sm font-bold text-brand backdrop-blur-lg"
|
||||
>
|
||||
Most Popular
|
||||
</div>
|
||||
</div>
|
||||
<span class="m-0 text-lg font-bold text-contrast">
|
||||
{{ formatPrice(perMonth, currency, true) }}
|
||||
<span class="text-sm font-semibold text-secondary">
|
||||
/ month{{ selectedInterval !== 'monthly' ? `, billed ${selectedInterval}` : '' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="text-sm">{{ formatMessage(description) }}</span>
|
||||
|
||||
<div class="w-fit">
|
||||
<Menu
|
||||
placement="bottom-start"
|
||||
:triggers="['hover', 'focus']"
|
||||
:auto-hide="true"
|
||||
:delay="{ show: 100, hide: 120 }"
|
||||
:distance="6"
|
||||
>
|
||||
<template #default="{ shown }">
|
||||
<div
|
||||
class="flex w-fit items-center gap-2 cursor-help text-sm font-medium cursor-default select-none outline-none"
|
||||
:class="shown ? 'text-primary' : 'text-secondary'"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="shown"
|
||||
>
|
||||
<InfoIcon />
|
||||
View plan details
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #popper>
|
||||
<div v-if="planSpecs" class="w-fit rounded-md border border-contrast/10 p-3 shadow-lg">
|
||||
<ServersSpecs
|
||||
:ram="planSpecs.ram"
|
||||
:storage="planSpecs.storage"
|
||||
:cpus="planSpecs.cpu"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import type Stripe from 'stripe'
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
|
||||
|
||||
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
|
||||
import { useStripe } from '../../composables/stripe'
|
||||
import { commonMessages } from '../../utils'
|
||||
@@ -23,6 +26,8 @@ import PaymentMethodSelector from './ServersPurchase2PaymentMethod.vue'
|
||||
import ConfirmPurchase from './ServersPurchase3Review.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
export type RegionPing = {
|
||||
region: string
|
||||
@@ -34,7 +39,7 @@ export type ServerBillingInterval = 'monthly' | 'quarterly' | 'yearly'
|
||||
|
||||
const props = defineProps<{
|
||||
publishableKey: string
|
||||
returnUrl: string
|
||||
returnUrl?: string
|
||||
paymentMethods: Stripe.PaymentMethod[]
|
||||
customer: Stripe.Customer
|
||||
currency: string
|
||||
@@ -121,6 +126,16 @@ const titles: Record<Step, MessageDescriptor> = {
|
||||
review: defineMessage({ id: 'servers.purchase.step.review.title', defaultMessage: 'Review' }),
|
||||
}
|
||||
|
||||
const purchaseSuccessTitle = defineMessage({
|
||||
id: 'servers.purchase.notification.success.title',
|
||||
defaultMessage: 'Purchase success',
|
||||
})
|
||||
|
||||
const purchaseSuccessText = defineMessage({
|
||||
id: 'servers.purchase.notification.success.text',
|
||||
defaultMessage: 'Your Modrinth Hosting purchase was completed successfully.',
|
||||
})
|
||||
|
||||
const currentRegion = computed(() => {
|
||||
return props.regions.find((region) => region.shortcode === selectedRegion.value)
|
||||
})
|
||||
@@ -215,7 +230,22 @@ async function afterProceed(step: string) {
|
||||
|
||||
async function setStep(step: Step | undefined, skipValidation = false) {
|
||||
if (!step) {
|
||||
await submitPayment(props.returnUrl)
|
||||
const success = await submitPayment(props.returnUrl)
|
||||
|
||||
if (success) {
|
||||
modal.value?.hide()
|
||||
addNotification({
|
||||
title: formatMessage(purchaseSuccessTitle),
|
||||
text: formatMessage(purchaseSuccessText),
|
||||
type: 'success',
|
||||
})
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['servers'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'v1'] }),
|
||||
])
|
||||
emit('purchase-success')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -264,7 +294,8 @@ function begin(
|
||||
selectedInterval.value = interval
|
||||
customServer.value = !selectedPlan.value
|
||||
selectedPaymentMethod.value = undefined
|
||||
currentStep.value = steps[0]
|
||||
const skipPlanStep = props.planStage && plan !== undefined
|
||||
currentStep.value = skipPlanStep ? (steps[1] ?? steps[0]) : steps[0]
|
||||
skipPaymentMethods.value = true
|
||||
projectId.value = project
|
||||
modal.value?.show()
|
||||
@@ -274,8 +305,8 @@ defineExpose({
|
||||
show: begin,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
(e: 'hide'): void
|
||||
const emit = defineEmits<{
|
||||
(e: 'hide' | 'purchase-success'): void
|
||||
}>()
|
||||
|
||||
function handleChooseCustom() {
|
||||
@@ -283,6 +314,10 @@ function handleChooseCustom() {
|
||||
selectedPlan.value = undefined
|
||||
}
|
||||
|
||||
function handleProceed() {
|
||||
setStep(nextStep.value)
|
||||
}
|
||||
|
||||
// When the user explicitly wants to change or add a payment method from Review
|
||||
// we must disable the auto-skip behavior, clear any selected method, and
|
||||
// navigate to the Payment step so Stripe Elements can mount.
|
||||
@@ -328,7 +363,7 @@ function goToBreadcrumbStep(id: string) {
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-[40rem] max-w-full">
|
||||
<div :class="currentStep === 'plan' ? 'w-[56rem] max-w-full' : 'w-[40rem] max-w-full'">
|
||||
<PlanSelector
|
||||
v-if="currentStep === 'plan'"
|
||||
v-model:plan="selectedPlan"
|
||||
@@ -337,6 +372,7 @@ function goToBreadcrumbStep(id: string) {
|
||||
:available-products="availableProducts"
|
||||
:currency="currency"
|
||||
@choose-custom="handleChooseCustom"
|
||||
@proceed="handleProceed"
|
||||
/>
|
||||
<RegionSelector
|
||||
v-else-if="currentStep === 'region'"
|
||||
@@ -374,7 +410,7 @@ function goToBreadcrumbStep(id: string) {
|
||||
:ping="currentPing"
|
||||
:loading="paymentMethodLoading"
|
||||
:selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod"
|
||||
:has-payment-method="hasPaymentMethod"
|
||||
:has-payment-method="!!hasPaymentMethod"
|
||||
:tax="tax"
|
||||
:total="total"
|
||||
:no-payment-required="noPaymentRequired"
|
||||
@@ -410,12 +446,12 @@ function goToBreadcrumbStep(id: string) {
|
||||
<button v-if="previousStep" @click="previousStep && setStep(previousStep, true)">
|
||||
<LeftArrowIcon /> {{ formatMessage(commonMessages.backButton) }}
|
||||
</button>
|
||||
<button v-else @click="modal?.hide()">
|
||||
<button v-else-if="currentStep !== 'plan'" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<ButtonStyled v-if="currentStep !== 'plan'" color="brand">
|
||||
<button
|
||||
v-tooltip="
|
||||
currentStep === 'review' && !acceptedEula && !noPaymentRequired
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<NewModal ref="modal" max-width="550px">
|
||||
<template #title>
|
||||
<div class="text-2xl font-semibold text-contrast">{{ formatMessage(messages.title) }}</div>
|
||||
</template>
|
||||
|
||||
<div class="flex w-[44rem] max-w-full flex-col gap-6">
|
||||
<template v-if="modalData">
|
||||
<p class="m-0 text-secondary leading-relaxed">
|
||||
<IntlFormatted
|
||||
:message-id="messages.description"
|
||||
:values="{ serverName: modalData.serverName }"
|
||||
>
|
||||
<template #server-name="{ children }">
|
||||
<span class="font-semibold text-contrast"><component :is="() => children" /></span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
|
||||
<div v-if="formattedPrice" class="flex flex-col gap-2.5">
|
||||
<span class="text-contrast font-semibold">{{ formatMessage(messages.planLabel) }}</span>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-5"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="truncate font-semibold text-contrast">{{ modalData.planName }}</div>
|
||||
<div
|
||||
v-if="
|
||||
modalData.ramGb != null ||
|
||||
modalData.storageGb != null ||
|
||||
modalData.sharedCpus != null
|
||||
"
|
||||
class="text-secondary flex gap-1.5 font-medium text-sm items-center"
|
||||
>
|
||||
<template v-if="modalData.ramGb != null">
|
||||
{{ formatMessage(messages.ramLabel, { ramGb: modalData.ramGb }) }}
|
||||
</template>
|
||||
<template v-if="modalData.storageGb != null">
|
||||
<div
|
||||
v-if="modalData.ramGb != null"
|
||||
class="h-1.5 w-1.5 bg-button-border rounded-full"
|
||||
></div>
|
||||
{{ formatMessage(messages.storageLabel, { storageGb: modalData.storageGb }) }}
|
||||
</template>
|
||||
<template v-if="modalData.sharedCpus != null">
|
||||
<div
|
||||
v-if="modalData.ramGb != null || modalData.storageGb != null"
|
||||
class="h-1.5 w-1.5 bg-button-border rounded-full"
|
||||
></div>
|
||||
{{ formatMessage(messages.cpusLabel, { sharedCpus: modalData.sharedCpus }) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 items-end">
|
||||
<div class="font-semibold text-contrast">
|
||||
{{ formattedPrice }}
|
||||
</div>
|
||||
<div v-if="intervalLabel" class="text-secondary">{{ intervalLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="formattedNextChargeDate" class="m-0 text-primary">
|
||||
<IntlFormatted
|
||||
:message-id="messages.nextCharge"
|
||||
:values="{ date: formattedNextChargeDate }"
|
||||
>
|
||||
<template #charge-date="{ children }">
|
||||
<span class="font-semibold text-contrast"><component :is="() => children" /></span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="m-0 text-secondary">{{ formatMessage(messages.failedLoad) }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-5" @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!canResubscribe" @click="handleResubscribe">
|
||||
<RotateCounterClockwiseIcon />
|
||||
{{ formatMessage(messages.resubscribeButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { RotateCounterClockwiseIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
|
||||
|
||||
import { useFormatDateTime, useFormatPrice } from '../../composables'
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import IntlFormatted from '../base/IntlFormatted.vue'
|
||||
import { ButtonStyled, NewModal } from '../index'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
type BillingInterval = Labrinth.Billing.Internal.PriceDuration
|
||||
|
||||
export type ResubscribeModalPayload = {
|
||||
subscriptionId: string
|
||||
wasSuspended: boolean
|
||||
serverName: string
|
||||
planName: string
|
||||
ramGb?: number
|
||||
storageGb?: number
|
||||
sharedCpus?: number
|
||||
priceCents?: number
|
||||
currencyCode?: string
|
||||
interval: BillingInterval
|
||||
nextChargeDate?: string | number | Date
|
||||
}
|
||||
|
||||
type ResubscribeModalState = {
|
||||
subscriptionId: string
|
||||
wasSuspended: boolean
|
||||
serverName: string
|
||||
planName: string
|
||||
ramGb?: number
|
||||
storageGb?: number
|
||||
sharedCpus?: number
|
||||
priceCents?: number
|
||||
currencyCode?: string
|
||||
interval: BillingInterval
|
||||
nextChargeDate?: string | number | Date
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancel'): void
|
||||
(e: 'resubscribe', payload: { subscriptionId: string; wasSuspended: boolean }): void
|
||||
}>()
|
||||
|
||||
const formatDate = useFormatDateTime({ dateStyle: 'long' })
|
||||
const formatPrice = useFormatPrice()
|
||||
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
|
||||
const modalData = ref<ResubscribeModalState | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'billing.resubscribe-modal.title', defaultMessage: 'Resubscribe to Server' },
|
||||
description: {
|
||||
id: 'billing.resubscribe-modal.description',
|
||||
defaultMessage:
|
||||
'You are about to resubscribe to <server-name>{serverName}</server-name>. Your subscription will be reactivated and your server will continue running without interruption.',
|
||||
},
|
||||
planLabel: { id: 'billing.resubscribe-modal.plan-label', defaultMessage: 'Plan' },
|
||||
ramLabel: { id: 'billing.resubscribe-modal.ram', defaultMessage: '{ramGb} GB RAM' },
|
||||
storageLabel: {
|
||||
id: 'billing.resubscribe-modal.storage',
|
||||
defaultMessage: '{storageGb} GB Storage',
|
||||
},
|
||||
cpusLabel: {
|
||||
id: 'billing.resubscribe-modal.cpus',
|
||||
defaultMessage: '{sharedCpus} Shared CPUs',
|
||||
},
|
||||
nextCharge: {
|
||||
id: 'billing.resubscribe-modal.next-charge',
|
||||
defaultMessage: 'Your next charge will be on <charge-date>{date}</charge-date>.',
|
||||
},
|
||||
failedLoad: {
|
||||
id: 'billing.resubscribe-modal.failed-load',
|
||||
defaultMessage: 'Failed to load subscription details.',
|
||||
},
|
||||
cancelButton: { id: 'billing.resubscribe-modal.cancel', defaultMessage: 'Cancel' },
|
||||
resubscribeButton: {
|
||||
id: 'billing.resubscribe-modal.resubscribe',
|
||||
defaultMessage: 'Resubscribe',
|
||||
},
|
||||
intervalMonthly: { id: 'billing.resubscribe-modal.interval.monthly', defaultMessage: '/month' },
|
||||
intervalQuarterly: {
|
||||
id: 'billing.resubscribe-modal.interval.quarterly',
|
||||
defaultMessage: '/quarter',
|
||||
},
|
||||
intervalYearly: { id: 'billing.resubscribe-modal.interval.yearly', defaultMessage: '/year' },
|
||||
intervalFiveDays: {
|
||||
id: 'billing.resubscribe-modal.interval.five-days',
|
||||
defaultMessage: '/5 days',
|
||||
},
|
||||
errorTitle: { id: 'billing.resubscribe-modal.error.title', defaultMessage: 'Error' },
|
||||
errorText: {
|
||||
id: 'billing.resubscribe-modal.error.text',
|
||||
defaultMessage: 'Cannot resubscribe, failed to load subscription details.',
|
||||
},
|
||||
})
|
||||
|
||||
const canResubscribe = computed(() => !!modalData.value?.subscriptionId)
|
||||
|
||||
const intervalLabel = computed(() => {
|
||||
switch (modalData.value?.interval) {
|
||||
case 'monthly':
|
||||
return formatMessage(messages.intervalMonthly)
|
||||
case 'quarterly':
|
||||
return formatMessage(messages.intervalQuarterly)
|
||||
case 'yearly':
|
||||
return formatMessage(messages.intervalYearly)
|
||||
case 'five-days':
|
||||
return formatMessage(messages.intervalFiveDays)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const formattedPrice = computed(() => {
|
||||
const { priceCents, currencyCode } = modalData.value ?? {}
|
||||
if (priceCents == null || currencyCode == null) return ''
|
||||
return formatPrice(priceCents, currencyCode)
|
||||
})
|
||||
|
||||
const normalizedNextChargeDate = computed(() => {
|
||||
if (!modalData.value?.nextChargeDate) return null
|
||||
const date = new Date(modalData.value.nextChargeDate)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null
|
||||
}
|
||||
return date
|
||||
})
|
||||
|
||||
const formattedNextChargeDate = computed(() =>
|
||||
normalizedNextChargeDate.value ? formatDate(normalizedNextChargeDate.value) : '',
|
||||
)
|
||||
|
||||
function show(payload: ResubscribeModalPayload) {
|
||||
if (!payload) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.errorTitle),
|
||||
text: formatMessage(messages.errorText),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
modalData.value = {
|
||||
subscriptionId: payload.subscriptionId,
|
||||
wasSuspended: payload.wasSuspended,
|
||||
serverName: payload.serverName.trim(),
|
||||
planName: payload.planName,
|
||||
ramGb: payload.ramGb,
|
||||
storageGb: payload.storageGb,
|
||||
sharedCpus: payload.sharedCpus,
|
||||
priceCents: payload.priceCents,
|
||||
currencyCode: payload.currencyCode,
|
||||
interval: payload.interval,
|
||||
nextChargeDate: payload.nextChargeDate,
|
||||
}
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
hide()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function handleResubscribe() {
|
||||
if (!canResubscribe.value || !modalData.value?.subscriptionId) return
|
||||
hide()
|
||||
|
||||
emit('resubscribe', {
|
||||
subscriptionId: modalData.value.subscriptionId,
|
||||
wasSuspended: modalData.value.wasSuspended,
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ChevronRightIcon, ExternalIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import NewModal from '../modal/NewModal.vue'
|
||||
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
|
||||
import PlanSelector from './ServersPurchase0Plan.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
availableProducts: Labrinth.Billing.Internal.Product[]
|
||||
currency: string
|
||||
loggedIn?: boolean
|
||||
}>(),
|
||||
{
|
||||
loggedIn: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'continue', payload: { interval: ServerBillingInterval; planId: string | null }): void
|
||||
}>()
|
||||
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
|
||||
const selectedPlan = ref<Labrinth.Billing.Internal.Product>()
|
||||
const selectedInterval = ref<ServerBillingInterval>('quarterly')
|
||||
const showSignInPrompt = ref(false)
|
||||
|
||||
const defaultPlan = computed<Labrinth.Billing.Internal.Product | undefined>(() => {
|
||||
return (
|
||||
props.availableProducts.find((p) => p?.metadata?.type === 'pyro' && p.metadata.ram === 6144) ??
|
||||
props.availableProducts.find((p) => p?.metadata?.type === 'pyro') ??
|
||||
props.availableProducts[0]
|
||||
)
|
||||
})
|
||||
|
||||
function emitSelection() {
|
||||
emit('continue', {
|
||||
interval: selectedInterval.value,
|
||||
planId: selectedPlan.value?.id ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
function continueWithSelection() {
|
||||
if (!props.loggedIn) {
|
||||
showSignInPrompt.value = true
|
||||
return
|
||||
}
|
||||
|
||||
emitSelection()
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function chooseCustom() {
|
||||
selectedPlan.value = undefined
|
||||
}
|
||||
|
||||
function closeSignInPrompt() {
|
||||
showSignInPrompt.value = false
|
||||
}
|
||||
|
||||
function continueToAuth() {
|
||||
emitSelection()
|
||||
closeSignInPrompt()
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleModalHide() {
|
||||
closeSignInPrompt()
|
||||
}
|
||||
|
||||
function show(initialInterval?: ServerBillingInterval, initialPlanId?: string | null) {
|
||||
closeSignInPrompt()
|
||||
selectedInterval.value = initialInterval ?? 'quarterly'
|
||||
if (initialPlanId === null) {
|
||||
selectedPlan.value = undefined
|
||||
} else if (initialPlanId) {
|
||||
selectedPlan.value = props.availableProducts.find((product) => product.id === initialPlanId)
|
||||
} else {
|
||||
selectedPlan.value = defaultPlan.value
|
||||
}
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal" :on-hide="handleModalHide" no-padding>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-1 font-bold text-secondary">
|
||||
<span class="text-contrast">Plan</span>
|
||||
<ChevronRightIcon class="h-5 w-5 text-secondary" stroke-width="3" />
|
||||
<span class=""> Region </span>
|
||||
<ChevronRightIcon class="h-5 w-5 text-secondary" stroke-width="3" />
|
||||
<span class=""> Payment method </span>
|
||||
<ChevronRightIcon class="h-5 w-5 text-secondary" stroke-width="3" />
|
||||
<span class=""> Review </span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="relative w-[56rem] max-w-full">
|
||||
<div class="w-full h-full p-6">
|
||||
<PlanSelector
|
||||
v-model:plan="selectedPlan"
|
||||
v-model:interval="selectedInterval"
|
||||
:available-products="availableProducts"
|
||||
:currency="currency"
|
||||
@choose-custom="chooseCustom"
|
||||
@proceed="continueWithSelection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-150 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showSignInPrompt"
|
||||
class="pointer-events-auto absolute inset-0 z-20 bg-black/60"
|
||||
@click="closeSignInPrompt"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="translate-y-4 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="translate-y-4 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showSignInPrompt"
|
||||
class="absolute inset-x-0 bottom-0 -m-px z-30 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-6 shadow-2xl"
|
||||
>
|
||||
<div class="absolute right-4 top-4">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button aria-label="Close sign in prompt" @click="closeSignInPrompt">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto flex max-w-xl flex-col items-center gap-4 text-center">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Sign in to continue your purchase</div>
|
||||
<div class="">You need a Modrinth account to add your billing details.</div>
|
||||
</div>
|
||||
<ButtonStyled color="brand" class="mt-2">
|
||||
<button @click="continueToAuth">
|
||||
Sign in or create an account
|
||||
<ExternalIcon class="size-4" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { computed, provide } from 'vue'
|
||||
import { RightArrowIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFormatPrice } from '../../composables'
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import OptionGroup from '../base/OptionGroup.vue'
|
||||
import ModalBasedServerPlan from './ModalBasedServerPlan.vue'
|
||||
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
|
||||
import ServersSpecs from './ServersSpecs.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatPrice = useFormatPrice()
|
||||
@@ -23,18 +25,10 @@ const availableBillingIntervals = ['monthly', 'quarterly']
|
||||
const selectedPlan = defineModel<Labrinth.Billing.Internal.Product>('plan')
|
||||
const selectedInterval = defineModel<ServerBillingInterval>('interval')
|
||||
const emit = defineEmits<{
|
||||
(e: 'choose-custom'): void
|
||||
(e: 'choose-custom' | 'proceed'): void
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'servers.purchase.step.plan.prompt',
|
||||
defaultMessage: 'Choose a plan',
|
||||
},
|
||||
subtitle: {
|
||||
id: 'servers.purchase.step.plan.subtitle',
|
||||
defaultMessage: 'Pick the amount of RAM and specs that fit your needs.',
|
||||
},
|
||||
selectPlan: {
|
||||
id: 'servers.purchase.step.plan.select',
|
||||
defaultMessage: 'Select Plan',
|
||||
@@ -43,9 +37,17 @@ const messages = defineMessages({
|
||||
id: 'servers.purchase.step.plan.get-started',
|
||||
defaultMessage: 'Get started',
|
||||
},
|
||||
billed: {
|
||||
id: 'servers.purchase.step.plan.billed',
|
||||
defaultMessage: 'billed {interval}',
|
||||
smallTitle: {
|
||||
id: 'servers.purchase.step.plan.small',
|
||||
defaultMessage: 'Small',
|
||||
},
|
||||
mediumTitle: {
|
||||
id: 'servers.purchase.step.plan.medium',
|
||||
defaultMessage: 'Medium',
|
||||
},
|
||||
largeTitle: {
|
||||
id: 'servers.purchase.step.plan.large',
|
||||
defaultMessage: 'Large',
|
||||
},
|
||||
smallDesc: {
|
||||
id: 'servers.purchase.step.plan.small.desc',
|
||||
@@ -67,6 +69,18 @@ const messages = defineMessages({
|
||||
id: 'servers.purchase.step.plan.most-popular',
|
||||
defaultMessage: 'Most Popular',
|
||||
},
|
||||
billingSubtitle: {
|
||||
id: 'servers.purchase.step.plan.billing-subtitle',
|
||||
defaultMessage: 'Available in North America, Europe, and Southeast Asia.',
|
||||
},
|
||||
customHeading: {
|
||||
id: 'servers.purchase.step.plan.custom.heading',
|
||||
defaultMessage: 'Know exactly what you need?',
|
||||
},
|
||||
yourCurrentPlan: {
|
||||
id: 'servers.purchase.step.plan.your-current-plan',
|
||||
defaultMessage: 'Your current plan',
|
||||
},
|
||||
})
|
||||
|
||||
const isSameAsExistingPlan = computed(() => {
|
||||
@@ -95,8 +109,12 @@ const plansByRam = computed(() => {
|
||||
return byName
|
||||
})
|
||||
|
||||
function handleCustomPlan() {
|
||||
emit('choose-custom')
|
||||
function planSpecs(plan: Labrinth.Billing.Internal.Product) {
|
||||
const m = plan.metadata
|
||||
if (m.type === 'pyro' || m.type === 'medal') {
|
||||
return { ram: m.ram, storage: m.storage, cpus: m.cpu }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function pricePerMonth(plan?: Labrinth.Billing.Internal.Product) {
|
||||
@@ -106,22 +124,6 @@ function pricePerMonth(plan?: Labrinth.Billing.Internal.Product) {
|
||||
return total / monthsInInterval[selectedInterval.value]
|
||||
}
|
||||
|
||||
const customPricePerGb = computed(() => {
|
||||
// Calculate lowest price per GB among products for current interval
|
||||
let min: number | undefined
|
||||
for (const p of props.availableProducts) {
|
||||
const perMonth = pricePerMonth(p)
|
||||
const metadata = p?.metadata
|
||||
if (!metadata || (metadata.type !== 'pyro' && metadata.type !== 'medal')) continue
|
||||
const ramGb = metadata.ram / 1024
|
||||
if (perMonth && ramGb > 0) {
|
||||
const perGb = perMonth / ramGb
|
||||
if (min === undefined || perGb < min) min = perGb
|
||||
}
|
||||
}
|
||||
return min
|
||||
})
|
||||
|
||||
const customStartingPrice = computed(() => {
|
||||
let min: number | undefined
|
||||
for (const p of props.availableProducts) {
|
||||
@@ -131,26 +133,47 @@ const customStartingPrice = computed(() => {
|
||||
return min
|
||||
})
|
||||
|
||||
provide('currency', props.currency)
|
||||
provide('selectedInterval', selectedInterval)
|
||||
const smallPrice = computed(() => pricePerMonth(plansByRam.value.small))
|
||||
const mediumPrice = computed(() => pricePerMonth(plansByRam.value.medium))
|
||||
const largePrice = computed(() => pricePerMonth(plansByRam.value.large))
|
||||
|
||||
const smallSpecs = computed(() =>
|
||||
plansByRam.value.small ? planSpecs(plansByRam.value.small) : null,
|
||||
)
|
||||
const mediumSpecs = computed(() =>
|
||||
plansByRam.value.medium ? planSpecs(plansByRam.value.medium) : null,
|
||||
)
|
||||
const largeSpecs = computed(() =>
|
||||
plansByRam.value.large ? planSpecs(plansByRam.value.large) : null,
|
||||
)
|
||||
|
||||
function selectPlan(plan: Labrinth.Billing.Internal.Product) {
|
||||
selectedPlan.value = plan
|
||||
emit('proceed')
|
||||
}
|
||||
|
||||
function selectCustom() {
|
||||
emit('choose-custom')
|
||||
emit('proceed')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 mb-5 !mt-0">
|
||||
<span></span>
|
||||
<div class="flex flex-col items-center gap-2 mb-5 !mt-0">
|
||||
<OptionGroup
|
||||
v-slot="{ option }"
|
||||
v-model="selectedInterval"
|
||||
class="!bg-button-bg !shadow-none"
|
||||
:options="availableBillingIntervals"
|
||||
>
|
||||
<template v-if="option === 'monthly'"> Pay monthly </template>
|
||||
<span v-else-if="option === 'quarterly'"> Pay quarterly </span>
|
||||
<span v-else-if="option === 'yearly'"> Pay yearly </span>
|
||||
<template v-if="option === 'monthly'">Monthly</template>
|
||||
<span v-else-if="option === 'quarterly'">
|
||||
Quarterly <span class="text-brand">(Save 16%)</span>
|
||||
</span>
|
||||
</OptionGroup>
|
||||
<span class="bg-transparent p-0 text-sm text-xs font-bold text-brand">
|
||||
{{ selectedInterval !== 'quarterly' ? 'Save' : 'Saving' }} 16% with quarterly billing!
|
||||
</span>
|
||||
<div class="text-sm text-secondary text-center">
|
||||
{{ formatMessage(messages.billingSubtitle) }}
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
@@ -164,68 +187,182 @@ provide('selectedInterval', selectedInterval)
|
||||
Your server is already on this plan, choose a different plan.
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 !gap-4">
|
||||
<ModalBasedServerPlan
|
||||
v-if="plansByRam.small"
|
||||
:plan="plansByRam.small"
|
||||
:title="{ id: 'servers.purchase.step.plan.small', defaultMessage: 'Small' }"
|
||||
:description="messages.smallDesc"
|
||||
:button-color="'blue'"
|
||||
:selected="selectedPlan?.id === plansByRam.small.id"
|
||||
@select="selectedPlan = $event"
|
||||
/>
|
||||
<ModalBasedServerPlan
|
||||
v-if="plansByRam.medium"
|
||||
:plan="plansByRam.medium"
|
||||
:title="{ id: 'servers.purchase.step.plan.medium', defaultMessage: 'Medium' }"
|
||||
:description="messages.mediumDesc"
|
||||
most-popular
|
||||
:button-color="'brand'"
|
||||
:selected="selectedPlan?.id === plansByRam.medium.id"
|
||||
@select="selectedPlan = $event"
|
||||
/>
|
||||
<ModalBasedServerPlan
|
||||
v-if="plansByRam.large"
|
||||
:plan="plansByRam.large"
|
||||
:title="{ id: 'servers.purchase.step.plan.large', defaultMessage: 'Large' }"
|
||||
:description="messages.largeDesc"
|
||||
:button-color="'purple'"
|
||||
:selected="selectedPlan?.id === plansByRam.large.id"
|
||||
@select="selectedPlan = $event"
|
||||
/>
|
||||
<div class="grid grid-cols-3 gap-4 items-start">
|
||||
<!-- Small -->
|
||||
<div
|
||||
v-if="customStartingPrice"
|
||||
class="rounded-2xl p-4 font-semibold transition-all duration-300 experimental-styles-within h-full border-2 border-solid cursor-pointer select-none"
|
||||
:class="!selectedPlan ? 'bg-brand-highlight border-brand' : 'bg-button-bg border-transparent'"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-pressed="!selectedPlan"
|
||||
@click="handleCustomPlan"
|
||||
@keydown.enter.prevent="handleCustomPlan"
|
||||
@keydown.space.prevent="handleCustomPlan"
|
||||
v-if="plansByRam.small && smallPrice"
|
||||
class="flex flex-col gap-4 rounded-2xl bg-surface-2 border-2 border-solid border-transparent p-5 h-full"
|
||||
>
|
||||
<div class="flex h-full flex-col justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-2xl font-semibold text-contrast">Custom</span>
|
||||
<div>
|
||||
<div class="text-3xl font-semibold text-contrast leading-none">
|
||||
{{ formatMessage(messages.smallTitle) }}
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<span class="text-2xl font-bold text-contrast">
|
||||
{{ formatPrice(smallPrice, currency, true) }}
|
||||
</span>
|
||||
<span class="text-sm">
|
||||
/ month<template v-if="selectedInterval !== 'monthly'"
|
||||
>, billed {{ selectedInterval }}</template
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-primary">
|
||||
{{ formatMessage(messages.smallDesc) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<ButtonStyled color="blue" class="w-full">
|
||||
<button
|
||||
class="w-full"
|
||||
:disabled="existingPlan?.id === plansByRam.small.id"
|
||||
@click="selectPlan(plansByRam.small!)"
|
||||
>
|
||||
{{
|
||||
existingPlan?.id === plansByRam.small.id
|
||||
? formatMessage(messages.yourCurrentPlan)
|
||||
: formatMessage(messages.selectPlan)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<ServersSpecs
|
||||
v-if="smallSpecs"
|
||||
:ram="smallSpecs.ram"
|
||||
:storage="smallSpecs.storage"
|
||||
:cpus="smallSpecs.cpus"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Medium (Most Popular) -->
|
||||
<div v-if="plansByRam.medium && mediumPrice" class="flex flex-col items-center relative h-full">
|
||||
<div
|
||||
class="z-10 -mb-3.5 rounded-full text-sm font-medium text-brand whitespace-nowrap absolute -top-3 right-4 bg-surface-3"
|
||||
>
|
||||
<div
|
||||
class="bg-brand-highlight border border-solid border-highlight-green px-2.5 py-0.5 rounded-full"
|
||||
>
|
||||
{{ formatMessage(messages.mostPopular) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full flex flex-col gap-4 rounded-2xl bg-brand-inverted border-brand-highlight border border-solid p-5 h-full"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'radial-gradient(86.12% 101.64% at 95.97% 94.07%, rgba(27, 217, 106, 0.23) 0%, rgba(14, 115, 56, 0.2) 100%)',
|
||||
}"
|
||||
>
|
||||
<div>
|
||||
<div class="text-3xl font-semibold text-contrast leading-none">
|
||||
{{ formatMessage(messages.mediumTitle) }}
|
||||
</div>
|
||||
<span class="m-0 text-lg font-bold text-contrast">
|
||||
{{ formatPrice(customStartingPrice, currency, true) }}
|
||||
<span class="text-sm font-semibold text-secondary">
|
||||
<div class="mt-1">
|
||||
<span class="text-2xl font-bold text-contrast">
|
||||
{{ formatPrice(mediumPrice, currency, true) }}
|
||||
</span>
|
||||
<span class="text-sm">
|
||||
/ month<template v-if="selectedInterval !== 'monthly'"
|
||||
>, billed {{ selectedInterval }}</template
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-sm">{{ formatMessage(messages.customDesc) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span v-if="customPricePerGb" class="text-sm text-secondary">
|
||||
From {{ formatPrice(customPricePerGb, currency, true) }} / GB
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-primary">
|
||||
{{ formatMessage(messages.mediumDesc) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<ButtonStyled color="brand" class="w-full">
|
||||
<button
|
||||
class="w-full"
|
||||
:disabled="existingPlan?.id === plansByRam.medium.id"
|
||||
@click="selectPlan(plansByRam.medium!)"
|
||||
>
|
||||
{{
|
||||
existingPlan?.id === plansByRam.medium.id
|
||||
? formatMessage(messages.yourCurrentPlan)
|
||||
: formatMessage(messages.selectPlan)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<ServersSpecs
|
||||
v-if="mediumSpecs"
|
||||
:ram="mediumSpecs.ram"
|
||||
:storage="mediumSpecs.storage"
|
||||
:cpus="mediumSpecs.cpus"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Large -->
|
||||
<div
|
||||
v-if="plansByRam.large && largePrice"
|
||||
class="flex flex-col gap-4 rounded-2xl bg-surface-2 border-2 border-solid border-transparent p-5 h-full"
|
||||
>
|
||||
<div>
|
||||
<div class="text-3xl font-semibold text-contrast leading-none">
|
||||
{{ formatMessage(messages.largeTitle) }}
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<span class="text-2xl font-bold text-contrast">
|
||||
{{ formatPrice(largePrice, currency, true) }}
|
||||
</span>
|
||||
<span class="text-sm">
|
||||
/ month<template v-if="selectedInterval !== 'monthly'"
|
||||
>, billed {{ selectedInterval }}</template
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-primary">
|
||||
{{ formatMessage(messages.largeDesc) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<ButtonStyled color="purple" class="w-full">
|
||||
<button
|
||||
class="w-full"
|
||||
:disabled="existingPlan?.id === plansByRam.large.id"
|
||||
@click="selectPlan(plansByRam.large!)"
|
||||
>
|
||||
{{
|
||||
existingPlan?.id === plansByRam.large.id
|
||||
? formatMessage(messages.yourCurrentPlan)
|
||||
: formatMessage(messages.selectPlan)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<ServersSpecs
|
||||
v-if="largeSpecs"
|
||||
:ram="largeSpecs.ram"
|
||||
:storage="largeSpecs.storage"
|
||||
:cpus="largeSpecs.cpus"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom plan banner -->
|
||||
<div
|
||||
v-if="customStartingPrice"
|
||||
class="mt-4 flex items-center justify-between gap-4 rounded-2xl bg-surface-2 border-2 border-solid border-transparent p-5"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.customHeading) }}
|
||||
</div>
|
||||
<div class="text-sm text-secondary">
|
||||
{{ formatMessage(messages.customDesc) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-2 shrink-0">
|
||||
<ButtonStyled>
|
||||
<button class="flex items-center gap-2" @click="selectCustom">
|
||||
{{ formatMessage(messages.getStarted) }} <RightArrowIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="text-sm text-secondary whitespace-nowrap">
|
||||
Starting at {{ formatPrice(customStartingPrice, currency, true) }}/mo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ const props = defineProps<{
|
||||
availableProducts: Labrinth.Billing.Internal.Product[]
|
||||
}>()
|
||||
|
||||
const loading = ref(true)
|
||||
const loading = ref(false)
|
||||
const checkingCustomStock = ref(false)
|
||||
const selectedPlan = defineModel<Labrinth.Billing.Internal.Product>('plan')
|
||||
const selectedRegion = defineModel<string>('region')
|
||||
@@ -214,12 +214,11 @@ async function updateStock() {
|
||||
onMounted(() => {
|
||||
// auto select region with lowest ping
|
||||
loading.value = true
|
||||
bestPing.value =
|
||||
props.pings.length > 0
|
||||
? props.pings.reduce((acc, cur) => {
|
||||
return acc.ping < cur.ping ? acc : cur
|
||||
})?.region
|
||||
: undefined
|
||||
bestPing.value = [...props.pings].sort((a, b) => {
|
||||
if (a.ping <= 0) return 1
|
||||
if (b.ping <= 0) return -1
|
||||
return a.ping - b.ping
|
||||
})[0]?.region
|
||||
selectedRegion.value = undefined
|
||||
selectedRam.value = minRam.value
|
||||
checkingCustomStock.value = true
|
||||
|
||||
@@ -179,7 +179,7 @@ function setInterval(newInterval: ServerBillingInterval) {
|
||||
|
||||
<template>
|
||||
<div class="grid sm:grid-cols-[3fr_2fr] gap-4">
|
||||
<div class="bg-table-alternateRow p-4 rounded-2xl">
|
||||
<div class="bg-surface-2 p-4 rounded-2xl">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<ModrinthServersIcon class="flex h-5 w-fit" />
|
||||
<TagItem>{{ planName }}</TagItem>
|
||||
@@ -194,9 +194,7 @@ function setInterval(newInterval: ServerBillingInterval) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-table-alternateRow p-4 rounded-2xl flex flex-col gap-2 items-center justify-center"
|
||||
>
|
||||
<div class="bg-surface-2 p-4 rounded-2xl flex flex-col gap-2 items-center justify-center">
|
||||
<img
|
||||
v-if="flag"
|
||||
class="aspect-[16/10] max-w-12 w-full object-cover rounded-md border-1 border-button-border border-solid"
|
||||
@@ -304,10 +302,7 @@ function setInterval(newInterval: ServerBillingInterval) {
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="p-4 rounded-2xl bg-table-alternateRow text-sm text-secondary leading-relaxed"
|
||||
>
|
||||
<div v-else class="p-4 rounded-2xl bg-surface-2 text-sm text-secondary leading-relaxed">
|
||||
No payment required. Your downgrade will apply at the end of the current billing period.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
:customer="customer"
|
||||
:payment-methods="paymentMethods"
|
||||
:currency="selectedCurrency"
|
||||
:return-url="`${props.siteUrl}/hosting/manage`"
|
||||
:return-url="checkoutReturnUrl"
|
||||
:pings="regionPings"
|
||||
:regions="regionsData"
|
||||
:refresh-payment-methods="fetchPaymentData"
|
||||
@@ -40,10 +40,14 @@ import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
stripePublishableKey: string
|
||||
siteUrl: string
|
||||
siteUrl?: string
|
||||
products: Labrinth.Billing.Internal.Product[]
|
||||
}>()
|
||||
|
||||
const checkoutReturnUrl = computed(() => {
|
||||
return props.siteUrl ? `${props.siteUrl}/hosting/manage` : undefined
|
||||
})
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { labrinth, archon } = injectModrinthClient()
|
||||
const debug = useDebugLogger('ServersUpgradeModalWrapper')
|
||||
@@ -185,11 +189,6 @@ function runPingTest(region: Archon.Servers.v1.Region, index = 1) {
|
||||
}
|
||||
|
||||
const subscription = ref<Labrinth.Billing.Internal.UserSubscription | null>(null)
|
||||
// Dry run state
|
||||
const dryRunResponse = ref<{
|
||||
requires_payment: boolean
|
||||
required_payment_is_proration: boolean
|
||||
} | null>(null)
|
||||
const pendingDowngradeBody = ref<Labrinth.Billing.Internal.EditSubscriptionRequest | null>(null)
|
||||
const currentPlanFromSubscription = computed<Labrinth.Billing.Internal.Product | undefined>(() => {
|
||||
return subscription.value
|
||||
@@ -246,20 +245,16 @@ async function initiatePayment(
|
||||
dry: true,
|
||||
})
|
||||
|
||||
if (dry && typeof dry === 'object' && 'payment_intent_id' in dry) {
|
||||
dryRunResponse.value = {
|
||||
requires_payment: !!dry.payment_intent_id,
|
||||
required_payment_is_proration: true,
|
||||
}
|
||||
pendingDowngradeBody.value = transformedBody
|
||||
if (dry.payment_intent_id) {
|
||||
return await finalizeImmediate(transformedBody)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
// Fallback if dry run not supported
|
||||
const requiresPayment =
|
||||
dry && typeof dry === 'object' && 'requires_payment' in dry && dry.requires_payment
|
||||
|
||||
if (requiresPayment) {
|
||||
// Upgrade: requires payment — finalize to create the payment intent
|
||||
return await finalizeImmediate(transformedBody)
|
||||
} else {
|
||||
// Downgrade or no payment change — defer until user confirms
|
||||
pendingDowngradeBody.value = transformedBody
|
||||
return null
|
||||
}
|
||||
} catch (e) {
|
||||
debug('Dry run failed, attempting immediate patch', e)
|
||||
@@ -291,8 +286,10 @@ async function finalizeImmediate(body: Labrinth.Billing.Internal.EditSubscriptio
|
||||
}
|
||||
|
||||
async function finalizeDowngrade() {
|
||||
if (!subscription.value || !pendingDowngradeBody.value) return
|
||||
try {
|
||||
if (!subscription.value || !pendingDowngradeBody.value)
|
||||
throw new Error('Missing subscription or pending downgrade body')
|
||||
|
||||
await finalizeImmediate(pendingDowngradeBody.value)
|
||||
addNotification({
|
||||
title: 'Subscription updated',
|
||||
@@ -307,7 +304,6 @@ async function finalizeDowngrade() {
|
||||
})
|
||||
throw e
|
||||
} finally {
|
||||
dryRunResponse.value = null
|
||||
pendingDowngradeBody.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export { default as AddPaymentMethodModal } from './AddPaymentMethodModal.vue'
|
||||
export { default as ModrinthServersPurchaseModal } from './ModrinthServersPurchaseModal.vue'
|
||||
export { default as PurchaseModal } from './PurchaseModal.vue'
|
||||
export { default as ResubscribeModal } from './ResubscribeModal.vue'
|
||||
export { default as ServersGuestPlanModal } from './ServersGuestPlanModal.vue'
|
||||
export { default as ServersSpecs } from './ServersSpecs.vue'
|
||||
export { default as ServersUpgradeModalWrapper } from './ServersUpgradeModalWrapper.vue'
|
||||
|
||||
Reference in New Issue
Block a user