* feat: init selecting paper+purpur on purchase flow Signed-off-by: Evan Song <theevansong@gmail.com> * feat: properly implement Paper/Purpur in Platform Signed-off-by: Evan Song <theevansong@gmail.com> * chore: correct wording Signed-off-by: Evan Song <theevansong@gmail.com> * feat: redo platform modal Signed-off-by: Evan Song <theevansong@gmail.com> * Switch to HCaptcha for Auth-related captchas (#2945) * Switch to HCaptcha for Auth-related captchas * run fmt * fix hcaptcha not loading * fix: more robust loader dropdown logic Signed-off-by: Evan Song <theevansong@gmail.com> * fix: handle "not yet supported" install err Signed-off-by: Evan Song <theevansong@gmail.com> * chore: fix icon kerfuffles Signed-off-by: Evan Song <theevansong@gmail.com> * chore: improve vanilla install modal title Signed-off-by: Evan Song <theevansong@gmail.com> * fix: spacing Signed-off-by: Evan Song <theevansong@gmail.com> * chore: improve no loader state Signed-off-by: Evan Song <theevansong@gmail.com> * fix: type error Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust mod version modal title Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust modpack warning copy Signed-off-by: Evan Song <theevansong@gmail.com> * feat: vanilla empty state in content page Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust copy Signed-off-by: Evan Song <theevansong@gmail.com> * chore: update icon Signed-off-by: Evan Song <theevansong@gmail.com> * fix: loader type Signed-off-by: Evan Song <theevansong@gmail.com> * fix: loader type Signed-off-by: Evan Song <theevansong@gmail.com> * feat: always show dropdown if possible Signed-off-by: Evan Song <theevansong@gmail.com> * chore: improve spacing Signed-off-by: Evan Song <theevansong@gmail.com> * chore: appear disabled Signed-off-by: Evan Song <theevansong@gmail.com> * h Signed-off-by: Evan Song <theevansong@gmail.com> * chore: if reinstalling, show it on the modal title Signed-off-by: Evan Song <theevansong@gmail.com> * feat: put it in the dropdown, they said Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust style Signed-off-by: Evan Song <theevansong@gmail.com> * chore: sort paper-purpur versions desc Signed-off-by: Evan Song <theevansong@gmail.com> * fix: do not consider backup limit in reinstall prompt Signed-off-by: Evan Song <theevansong@gmail.com> * feat: backup locking, plugin support * fix: content type error Signed-off-by: Evan Song <theevansong@gmail.com> * fix: casing Signed-off-by: Evan Song <theevansong@gmail.com> * fix: plugins pt 2 * feat: backups, mrpack * fix: type errors come on Signed-off-by: Evan Song <theevansong@gmail.com> * fix: spacing Signed-off-by: Evan Song <theevansong@gmail.com> * fix: type maxing * chore: show copy button on allocation rows Signed-off-by: Evan Song <theevansong@gmail.com> * feat: suspend improvement --------- Signed-off-by: Evan Song <theevansong@gmail.com> Co-authored-by: Evan Song <theevansong@gmail.com> Co-authored-by: Geometrically <18202329+Geometrically@users.noreply.github.com> Co-authored-by: Jai A <jaiagr+gpg@pm.me> Co-authored-by: Evan Song <52982404+ferothefox@users.noreply.github.com>
961 lines
30 KiB
Vue
961 lines
30 KiB
Vue
<template>
|
|
<NewModal ref="purchaseModal">
|
|
<template #title>
|
|
<span class="text-contrast text-xl font-extrabold">
|
|
<template v-if="product.metadata.type === 'midas'">Subscribe to Modrinth Plus!</template>
|
|
<template v-else-if="product.metadata.type === 'pyro'"
|
|
>Subscribe to Modrinth Servers!</template
|
|
>
|
|
<template v-else>Purchase product</template>
|
|
</span>
|
|
</template>
|
|
<div class="flex items-center gap-1 pb-4">
|
|
<template v-if="product.metadata.type === 'pyro' && !projectId">
|
|
<span
|
|
:class="{
|
|
'text-secondary': purchaseModalStep !== 0,
|
|
'font-bold': purchaseModalStep === 0,
|
|
}"
|
|
>
|
|
Configure
|
|
<span class="hidden sm:inline">server</span>
|
|
</span>
|
|
<ChevronRightIcon class="h-5 w-5 text-secondary" />
|
|
</template>
|
|
<span
|
|
:class="{
|
|
'text-secondary':
|
|
purchaseModalStep !== (product.metadata.type === 'pyro' && !projectId ? 1 : 0),
|
|
'font-bold':
|
|
purchaseModalStep === (product.metadata.type === 'pyro' && !projectId ? 1 : 0),
|
|
}"
|
|
>
|
|
{{ product.metadata.type === 'pyro' ? 'Billing' : 'Plan' }}
|
|
<span class="hidden sm:inline">{{
|
|
product.metadata.type === 'pyro' ? 'interval' : 'selection'
|
|
}}</span>
|
|
</span>
|
|
<ChevronRightIcon class="h-5 w-5 text-secondary" />
|
|
<span
|
|
:class="{
|
|
'text-secondary':
|
|
purchaseModalStep !== (product.metadata.type === 'pyro' && !projectId ? 2 : 1),
|
|
'font-bold':
|
|
purchaseModalStep === (product.metadata.type === 'pyro' && !projectId ? 2 : 1),
|
|
}"
|
|
>
|
|
Payment
|
|
</span>
|
|
<ChevronRightIcon class="h-5 w-5 text-secondary" />
|
|
<span
|
|
:class="{
|
|
'text-secondary':
|
|
purchaseModalStep !== (product.metadata.type === 'pyro' && !projectId ? 3 : 2),
|
|
'font-bold':
|
|
purchaseModalStep === (product.metadata.type === 'pyro' && !projectId ? 3 : 2),
|
|
}"
|
|
>
|
|
Review
|
|
</span>
|
|
</div>
|
|
<div
|
|
v-if="product.metadata.type === 'pyro' && !projectId && purchaseModalStep === 0"
|
|
class="md:w-[600px] flex flex-col gap-4"
|
|
>
|
|
<div>
|
|
<p class="my-2 text-lg font-bold">Configure your server</p>
|
|
<div class="flex flex-col gap-4">
|
|
<input v-model="serverName" placeholder="Server name" class="input" maxlength="48" />
|
|
<!-- <DropdownSelect
|
|
v-model="serverLoader"
|
|
v-tooltip="'Select the mod loader for your server'"
|
|
name="server-loader"
|
|
:options="['Vanilla', 'Fabric', 'Forge']"
|
|
placeholder="Select mod loader..."
|
|
/> -->
|
|
<div class="grid lg:grid-cols-5 grid-cols-3 gap-4">
|
|
<button
|
|
v-for="loader in [
|
|
'Vanilla',
|
|
'Fabric',
|
|
'Forge',
|
|
'Quilt',
|
|
'NeoForge',
|
|
'Paper',
|
|
'Purpur',
|
|
]"
|
|
:key="loader"
|
|
class="!h-24 btn flex !flex-col !items-center !justify-between !pt-4 !pb-3 !w-full"
|
|
:style="{
|
|
filter: serverLoader === loader ? 'brightness(1.5)' : '',
|
|
}"
|
|
@click="serverLoader = loader"
|
|
>
|
|
<UiServersIconsLoaderIcon :loader="loader" class="!h-12 !w-12" />
|
|
<p class="text-lg font-bold m-0">{{ loader }}</p>
|
|
</button>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<InfoIcon class="hidden sm:block" />
|
|
<span class="text-sm text-secondary">
|
|
You can change these settings later in your server options.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="customServer">
|
|
<div class="flex gap-2 items-center">
|
|
<p class="my-2 text-lg font-bold">Configure your RAM</p>
|
|
<IssuesIcon
|
|
v-if="customServerConfig.ramInGb < 4"
|
|
v-tooltip="'This might not be enough resources for your Minecraft server.'"
|
|
class="h-6 w-6 text-orange"
|
|
/>
|
|
</div>
|
|
<div class="flex flex-col gap-4">
|
|
<div class="flex w-full gap-2 items-center">
|
|
<Slider
|
|
v-model="customServerConfig.ramInGb"
|
|
class="fix-slider"
|
|
:min="customMinRam"
|
|
:max="customMaxRam"
|
|
:step="2"
|
|
unit="GB"
|
|
/>
|
|
<div class="font-semibold text-nowrap"></div>
|
|
</div>
|
|
<div
|
|
v-if="customMatchingProduct && !customOutOfStock"
|
|
class="flex sm:flex-row flex-col gap-4 w-full"
|
|
>
|
|
<div class="flex flex-col w-full gap-2">
|
|
<div class="font-semibold">vCPUs</div>
|
|
<input v-model="mutatedProduct.metadata.cpu" disabled class="input" />
|
|
</div>
|
|
<div class="flex flex-col w-full gap-2">
|
|
<div class="font-semibold">Storage</div>
|
|
<input v-model="customServerConfig.storageGbFormatted" disabled class="input" />
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="flex justify-between rounded-2xl border-2 border-solid border-blue bg-bg-blue p-4 font-semibold text-contrast"
|
|
>
|
|
<div class="flex w-full justify-between gap-2">
|
|
<div class="flex flex-row gap-4">
|
|
<InfoIcon class="hidden flex-none h-8 w-8 text-blue sm:block" />
|
|
|
|
<div v-if="customOutOfStock && customMatchingProduct" class="flex flex-col gap-2">
|
|
<div class="font-semibold">This plan is currently out of stock</div>
|
|
<div class="font-normal">
|
|
We are currently
|
|
<a :href="outOfStockUrl" class="underline" target="_blank">out of capacity</a>
|
|
for your selected RAM amount. Please try again later, or try a different amount.
|
|
</div>
|
|
</div>
|
|
<div v-else class="flex flex-col gap-2">
|
|
<div class="font-semibold">We can't seem to find your selected plan</div>
|
|
<div class="font-normal">
|
|
We are currently unable to find a server for your selected RAM amount. Please
|
|
try again later, or try a different amount.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<InfoIcon class="hidden sm:block" />
|
|
<span class="text-sm text-secondary">
|
|
Storage and vCPUs are currently not configurable.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="purchaseModalStep === (mutatedProduct.metadata.type === 'pyro' && !projectId ? 1 : 0)"
|
|
class="md:w-[600px]"
|
|
>
|
|
<div>
|
|
<p class="my-2 text-lg font-bold">Choose billing interval</p>
|
|
<div class="flex flex-col gap-4">
|
|
<div
|
|
v-for="([interval, rawPrice], index) in Object.entries(price.prices.intervals)"
|
|
:key="index"
|
|
class="flex cursor-pointer items-center gap-2"
|
|
@click="selectedPlan = interval"
|
|
>
|
|
<RadioButtonChecked v-if="selectedPlan === interval" class="h-8 w-8 text-brand" />
|
|
<RadioButtonIcon v-else class="h-8 w-8 text-secondary" />
|
|
<span
|
|
class="text-lg capitalize"
|
|
:class="{ 'text-secondary': selectedPlan !== interval }"
|
|
>
|
|
{{ interval }}
|
|
</span>
|
|
<span
|
|
v-if="interval === 'yearly'"
|
|
class="rounded-full bg-brand px-2 py-1 font-bold text-brand-inverted"
|
|
>
|
|
SAVE {{ calculateSavings(price.prices.intervals.monthly, rawPrice) }}%
|
|
</span>
|
|
<span class="ml-auto text-lg" :class="{ 'text-secondary': selectedPlan !== interval }">
|
|
{{ formatPrice(locale, rawPrice, price.currency_code) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4 flex justify-between border-0 border-t border-solid border-code-bg pt-4">
|
|
<span class="text-xl text-secondary">Total</span>
|
|
<div class="flex items-baseline gap-2">
|
|
<span class="text-2xl font-extrabold text-primary">
|
|
{{ formatPrice(locale, price.prices.intervals[selectedPlan], price.currency_code) }}
|
|
</span>
|
|
<span class="text-lg text-secondary">/ {{ selectedPlan }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 pt-4">
|
|
<InfoIcon class="hidden sm:block" />
|
|
<span class="text-sm text-secondary">
|
|
Final price and currency will be based on your selected payment method.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<template
|
|
v-if="purchaseModalStep === (mutatedProduct.metadata.type === 'pyro' && !projectId ? 2 : 1)"
|
|
>
|
|
<div
|
|
v-show="loadingPaymentMethodModal !== 2"
|
|
class="flex min-h-[16rem] items-center justify-center md:w-[600px]"
|
|
>
|
|
<AnimatedLogo class="w-[80px]" />
|
|
</div>
|
|
<div v-show="loadingPaymentMethodModal === 2" class="min-h-[16rem] p-1 md:w-[600px]">
|
|
<div id="address-element"></div>
|
|
<div id="payment-element" class="mt-4"></div>
|
|
</div>
|
|
</template>
|
|
<div
|
|
v-if="purchaseModalStep === (mutatedProduct.metadata.type === 'pyro' && !projectId ? 3 : 2)"
|
|
class="md:w-[650px]"
|
|
>
|
|
<div v-if="mutatedProduct.metadata.type === 'pyro'" class="r-4 rounded-xl bg-bg p-4 mb-4">
|
|
<p class="my-2 text-lg font-bold text-primary">Server details</p>
|
|
<div class="flex items-center gap-4">
|
|
<img
|
|
v-if="projectImage"
|
|
:src="projectImage"
|
|
alt="Project image"
|
|
class="w-16 h-16 rounded"
|
|
/>
|
|
<div>
|
|
<p v-if="projectName" class="font-bold">{{ projectName }}</p>
|
|
<p>Server name: {{ serverName }}</p>
|
|
<p v-if="!projectId">Loader: {{ serverLoader }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="r-4 rounded-xl bg-bg p-4">
|
|
<p class="my-2 text-lg font-bold text-primary">Purchase details</p>
|
|
<div class="mb-2 flex justify-between">
|
|
<span class="text-secondary"
|
|
>{{ mutatedProduct.metadata.type === 'midas' ? 'Modrinth+' : 'Modrinth Servers' }}
|
|
{{ selectedPlan }}</span
|
|
>
|
|
<span class="text-secondary text-end">
|
|
{{ formatPrice(locale, price.prices.intervals[selectedPlan], price.currency_code) }} /
|
|
{{ selectedPlan }}
|
|
</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-secondary">Tax</span>
|
|
<span class="text-secondary text-end">{{
|
|
formatPrice(locale, tax, price.currency_code)
|
|
}}</span>
|
|
</div>
|
|
<div class="mt-4 flex justify-between border-0 border-t border-solid border-code-bg pt-4">
|
|
<span class="text-lg font-bold">Today's total</span>
|
|
<span class="text-lg font-extrabold text-primary text-end">
|
|
{{ formatPrice(locale, price.prices.intervals[selectedPlan], price.currency_code) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<p class="my-2 text-lg font-bold">Pay for it with</p>
|
|
<multiselect
|
|
v-model="selectedPaymentMethod"
|
|
placeholder="Payment method"
|
|
label="id"
|
|
track-by="id"
|
|
:options="selectablePaymentMethods"
|
|
:option-height="104"
|
|
:show-labels="false"
|
|
:searchable="false"
|
|
:close-on-select="true"
|
|
:allow-empty="false"
|
|
open-direction="top"
|
|
class="max-w-[20rem]"
|
|
@select="selectPaymentMethod"
|
|
>
|
|
<!-- eslint-disable-next-line vue/no-template-shadow -->
|
|
<template #singleLabel="props">
|
|
<div class="flex items-center gap-2">
|
|
<CardIcon v-if="props.option.type === 'card'" class="h-8 w-8" />
|
|
<CurrencyIcon v-else-if="props.option.type === 'cashapp'" class="h-8 w-8" />
|
|
<PayPalIcon v-else-if="props.option.type === 'paypal'" class="h-8 w-8" />
|
|
|
|
<span v-if="props.option.type === 'card'">
|
|
{{
|
|
formatMessage(messages.paymentMethodCardDisplay, {
|
|
card_brand:
|
|
formatMessage(paymentMethodTypes[props.option.card.brand]) ??
|
|
formatMessage(paymentMethodTypes.unknown),
|
|
last_four: props.option.card.last4,
|
|
})
|
|
}}
|
|
</span>
|
|
<template v-else>
|
|
{{
|
|
formatMessage(paymentMethodTypes[props.option.type]) ??
|
|
formatMessage(paymentMethodTypes.unknown)
|
|
}}
|
|
</template>
|
|
|
|
<span v-if="props.option.type === 'cashapp' && props.option.cashapp.cashtag">
|
|
({{ props.option.cashapp.cashtag }})
|
|
</span>
|
|
<span v-else-if="props.option.type === 'paypal' && props.option.paypal.payer_email">
|
|
({{ props.option.paypal.payer_email }})
|
|
</span>
|
|
</div>
|
|
</template>
|
|
<!-- eslint-disable-next-line vue/no-template-shadow -->
|
|
<template #option="props">
|
|
<div class="flex items-center gap-2">
|
|
<template v-if="props.option.id === 'new'">
|
|
<PlusIcon class="h-8 w-8" />
|
|
<span class="text-secondary">Add payment method</span>
|
|
</template>
|
|
<template v-else>
|
|
<CardIcon v-if="props.option.type === 'card'" class="h-8 w-8" />
|
|
<CurrencyIcon v-else-if="props.option.type === 'cashapp'" class="h-8 w-8" />
|
|
<PayPalIcon v-else-if="props.option.type === 'paypal'" class="h-8 w-8" />
|
|
|
|
<span v-if="props.option.type === 'card'">
|
|
{{
|
|
formatMessage(messages.paymentMethodCardDisplay, {
|
|
card_brand:
|
|
formatMessage(paymentMethodTypes[props.option.card.brand]) ??
|
|
formatMessage(paymentMethodTypes.unknown),
|
|
last_four: props.option.card.last4,
|
|
})
|
|
}}
|
|
</span>
|
|
<template v-else>
|
|
{{
|
|
formatMessage(paymentMethodTypes[props.option.type]) ??
|
|
formatMessage(paymentMethodTypes.unknown)
|
|
}}
|
|
</template>
|
|
|
|
<span v-if="props.option.type === 'cashapp'">
|
|
({{ props.option.cashapp.cashtag }})
|
|
</span>
|
|
<span v-else-if="props.option.type === 'paypal'">
|
|
({{ props.option.paypal.payer_email }})
|
|
</span>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</multiselect>
|
|
</div>
|
|
<p class="m-0 mt-9 text-sm text-secondary">
|
|
<strong>By clicking "Subscribe", you are purchasing a recurring subscription.</strong>
|
|
<br />
|
|
You'll be charged
|
|
{{ formatPrice(locale, price.prices.intervals[selectedPlan], price.currency_code) }} /
|
|
{{ selectedPlan }} plus applicable taxes starting today, until you cancel.
|
|
<br />
|
|
You can cancel anytime from your settings page.
|
|
</p>
|
|
<p v-if="mutatedProduct.metadata.type === 'pyro'" class="mb-2 mt-4 text-secondary">
|
|
<Checkbox v-model="eulaAccepted" :disabled="paymentLoading">
|
|
<label>
|
|
I acknowledge that I have read and agree to the
|
|
<a class="underline" target="_blank" href="https://aka.ms/MinecraftEULA">
|
|
Minecraft EULA
|
|
</a>
|
|
</label>
|
|
</Checkbox>
|
|
</p>
|
|
</div>
|
|
<div class="input-group push-right pt-4">
|
|
<template v-if="purchaseModalStep === 0">
|
|
<button class="btn" @click="$refs.purchaseModal.hide()">
|
|
<XIcon />
|
|
Cancel
|
|
</button>
|
|
<button
|
|
class="btn btn-primary"
|
|
:disabled="
|
|
paymentLoading ||
|
|
(mutatedProduct.metadata.type === 'pyro' && !projectId && !serverName) ||
|
|
customAllowedToContinue
|
|
"
|
|
@click="nextStep"
|
|
>
|
|
<RightArrowIcon />
|
|
{{ mutatedProduct.metadata.type === 'pyro' && !projectId ? 'Next' : 'Select' }}
|
|
</button>
|
|
</template>
|
|
<template
|
|
v-else-if="
|
|
purchaseModalStep === (mutatedProduct.metadata.type === 'pyro' && !projectId ? 1 : 0)
|
|
"
|
|
>
|
|
<button
|
|
class="btn"
|
|
@click="
|
|
purchaseModalStep =
|
|
mutatedProduct.metadata.type === 'pyro' && !projectId ? 0 : purchaseModalStep
|
|
"
|
|
>
|
|
Back
|
|
</button>
|
|
<button class="btn btn-primary" :disabled="paymentLoading" @click="beginPurchaseFlow(true)">
|
|
<RightArrowIcon />
|
|
Select
|
|
</button>
|
|
</template>
|
|
<template
|
|
v-else-if="
|
|
purchaseModalStep === (mutatedProduct.metadata.type === 'pyro' && !projectId ? 2 : 1)
|
|
"
|
|
>
|
|
<button
|
|
class="btn"
|
|
@click="
|
|
() => {
|
|
purchaseModalStep = mutatedProduct.metadata.type === 'pyro' && !projectId ? 1 : 0
|
|
loadingPaymentMethodModal = 0
|
|
paymentLoading = false
|
|
}
|
|
"
|
|
>
|
|
Back
|
|
</button>
|
|
<button class="btn btn-primary" :disabled="paymentLoading" @click="validatePayment">
|
|
<RightArrowIcon />
|
|
Continue
|
|
</button>
|
|
</template>
|
|
<template
|
|
v-else-if="
|
|
purchaseModalStep === (mutatedProduct.metadata.type === 'pyro' && !projectId ? 3 : 2)
|
|
"
|
|
>
|
|
<button class="btn" @click="$refs.purchaseModal.hide()">
|
|
<XIcon />
|
|
Cancel
|
|
</button>
|
|
<button
|
|
v-if="mutatedProduct.metadata.type === 'pyro'"
|
|
class="btn btn-primary"
|
|
:disabled="paymentLoading || !eulaAccepted"
|
|
@click="submitPayment"
|
|
>
|
|
<CheckCircleIcon /> Subscribe
|
|
</button>
|
|
<!-- Default Subscribe Button, so M+ still works -->
|
|
<button v-else class="btn btn-primary" :disabled="paymentLoading" @click="submitPayment">
|
|
<CheckCircleIcon /> Subscribe
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</NewModal>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, nextTick, reactive, watch } from 'vue'
|
|
import NewModal from '../modal/NewModal.vue'
|
|
import {
|
|
CardIcon,
|
|
CheckCircleIcon,
|
|
ChevronRightIcon,
|
|
CurrencyIcon,
|
|
InfoIcon,
|
|
IssuesIcon,
|
|
PayPalIcon,
|
|
PlusIcon,
|
|
RadioButtonChecked,
|
|
RadioButtonIcon,
|
|
RightArrowIcon,
|
|
XIcon,
|
|
} from '@modrinth/assets'
|
|
import AnimatedLogo from '../brand/AnimatedLogo.vue'
|
|
import { getCurrency, calculateSavings, formatPrice, createStripeElements } from '@modrinth/utils'
|
|
import { useVIntl, defineMessages } from '@vintl/vintl'
|
|
import { Multiselect } from 'vue-multiselect'
|
|
import Checkbox from '../base/Checkbox.vue'
|
|
import Slider from '../base/Slider.vue'
|
|
|
|
const { locale, formatMessage } = useVIntl()
|
|
|
|
const props = defineProps({
|
|
product: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
customer: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
paymentMethods: {
|
|
type: Array,
|
|
required: true,
|
|
},
|
|
country: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
returnUrl: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
publishableKey: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
fetchPaymentData: {
|
|
type: Function,
|
|
default: async () => {},
|
|
},
|
|
sendBillingRequest: {
|
|
type: Function,
|
|
required: true,
|
|
},
|
|
onError: {
|
|
type: Function,
|
|
required: true,
|
|
},
|
|
projectName: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
projectImage: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
projectId: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
versionId: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
serverName: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
customServer: {
|
|
type: Boolean,
|
|
required: false,
|
|
},
|
|
fetchCapacityStatuses: {
|
|
type: Function,
|
|
required: false,
|
|
default: null,
|
|
},
|
|
outOfStockUrl: {
|
|
type: String,
|
|
required: false,
|
|
default: '',
|
|
},
|
|
})
|
|
|
|
const messages = defineMessages({
|
|
paymentMethodCardDisplay: {
|
|
id: 'omorphia.component.purchase_modal.payment_method_card_display',
|
|
defaultMessage: '{card_brand} ending in {last_four}',
|
|
},
|
|
})
|
|
|
|
const paymentMethodTypes = defineMessages({
|
|
visa: {
|
|
id: 'omorphia.component.purchase_modal.payment_method_type.visa',
|
|
defaultMessage: 'Visa',
|
|
},
|
|
amex: {
|
|
id: 'omorphia.component.purchase_modal.payment_method_type.amex',
|
|
defaultMessage: 'American Express',
|
|
},
|
|
diners: {
|
|
id: 'omorphia.component.purchase_modal.payment_method_type.diners',
|
|
defaultMessage: 'Diners Club',
|
|
},
|
|
discover: {
|
|
id: 'omorphia.component.purchase_modal.payment_method_type.discover',
|
|
defaultMessage: 'Discover',
|
|
},
|
|
eftpos: {
|
|
id: 'omorphia.component.purchase_modal.payment_method_type.eftpos',
|
|
defaultMessage: 'EFTPOS',
|
|
},
|
|
jcb: { id: 'omorphia.component.purchase_modal.payment_method_type.jcb', defaultMessage: 'JCB' },
|
|
mastercard: {
|
|
id: 'omorphia.component.purchase_modal.payment_method_type.mastercard',
|
|
defaultMessage: 'MasterCard',
|
|
},
|
|
unionpay: {
|
|
id: 'omorphia.component.purchase_modal.payment_method_type.unionpay',
|
|
defaultMessage: 'UnionPay',
|
|
},
|
|
paypal: {
|
|
id: 'omorphia.component.purchase_modal.payment_method_type.paypal',
|
|
defaultMessage: 'PayPal',
|
|
},
|
|
cashapp: {
|
|
id: 'omorphia.component.purchase_modal.payment_method_type.cashapp',
|
|
defaultMessage: 'Cash App',
|
|
},
|
|
amazon_pay: {
|
|
id: 'omorphia.component.purchase_modal.payment_method_type.amazon_pay',
|
|
defaultMessage: 'Amazon Pay',
|
|
},
|
|
unknown: {
|
|
id: 'omorphia.component.purchase_modal.payment_method_type.unknown',
|
|
defaultMessage: 'Unknown payment method',
|
|
},
|
|
})
|
|
|
|
let stripe = null
|
|
let elements = null
|
|
|
|
const purchaseModal = ref()
|
|
const purchaseModalStep = ref(0)
|
|
const loadingPaymentMethodModal = ref(0)
|
|
const paymentLoading = ref(false)
|
|
|
|
const selectedPlan = ref('yearly')
|
|
const currency = computed(() => getCurrency(props.country))
|
|
const price = computed(() => {
|
|
return (
|
|
mutatedProduct.value?.prices?.find((x) => x.currency_code === currency.value) ??
|
|
mutatedProduct.value?.prices?.find((x) => x.currency_code === 'USD') ??
|
|
null
|
|
)
|
|
})
|
|
|
|
const clientSecret = ref()
|
|
const paymentIntentId = ref()
|
|
const confirmationToken = ref()
|
|
const tax = ref()
|
|
const total = ref()
|
|
|
|
const serverName = ref(props.serverName || '')
|
|
const serverLoader = ref('Vanilla')
|
|
const eulaAccepted = ref(false)
|
|
|
|
const mutatedProduct = ref({ ...props.product })
|
|
const customMinRam = ref(0)
|
|
const customMaxRam = ref(0)
|
|
const customMatchingProduct = ref()
|
|
const customOutOfStock = ref(false)
|
|
const customLoading = ref(true)
|
|
const customAllowedToContinue = computed(
|
|
() =>
|
|
props.customServer &&
|
|
(!customMatchingProduct.value || customLoading.value || customOutOfStock.value),
|
|
)
|
|
|
|
const customServerConfig = reactive({
|
|
ramInGb: 4,
|
|
storageGbFormatted: computed(() => `${mutatedProduct.value.metadata.storage / 1024} GB`),
|
|
ram: computed(() => customServerConfig.ramInGb * 1024),
|
|
})
|
|
|
|
const updateCustomServerProduct = () => {
|
|
customMatchingProduct.value = props.product.find(
|
|
(product) => product.metadata.ram === customServerConfig.ram,
|
|
)
|
|
|
|
if (customMatchingProduct.value) mutatedProduct.value = { ...customMatchingProduct.value }
|
|
}
|
|
|
|
let updateCustomServerStockTimeout = null
|
|
const updateCustomServerStock = async () => {
|
|
if (updateCustomServerStockTimeout) {
|
|
clearTimeout(updateCustomServerStockTimeout)
|
|
customLoading.value = true
|
|
}
|
|
|
|
updateCustomServerStockTimeout = setTimeout(async () => {
|
|
if (props.fetchCapacityStatuses) {
|
|
const capacityStatus = await props.fetchCapacityStatuses(mutatedProduct.value)
|
|
if (capacityStatus.custom?.available === 0) {
|
|
customOutOfStock.value = true
|
|
} else {
|
|
customOutOfStock.value = false
|
|
}
|
|
customLoading.value = false
|
|
} else {
|
|
console.error('No fetchCapacityStatuses function provided.')
|
|
customOutOfStock.value = true
|
|
}
|
|
}, 300)
|
|
}
|
|
|
|
if (props.customServer) {
|
|
const ramValues = props.product.map((product) => product.metadata.ram / 1024)
|
|
customMinRam.value = Math.min(...ramValues)
|
|
customMaxRam.value = Math.max(...ramValues)
|
|
|
|
const updateProductAndStock = () => {
|
|
updateCustomServerProduct()
|
|
updateCustomServerStock()
|
|
}
|
|
|
|
updateProductAndStock()
|
|
watch(() => customServerConfig.ram, updateProductAndStock)
|
|
}
|
|
|
|
const selectedPaymentMethod = ref()
|
|
const inputtedPaymentMethod = ref()
|
|
const selectablePaymentMethods = computed(() => {
|
|
const values = [
|
|
...(props.paymentMethods ?? []),
|
|
{
|
|
id: 'new',
|
|
},
|
|
]
|
|
|
|
if (inputtedPaymentMethod.value) {
|
|
values.unshift(inputtedPaymentMethod.value)
|
|
}
|
|
|
|
return values
|
|
})
|
|
|
|
const primaryPaymentMethodId = computed(() => {
|
|
if (
|
|
props.customer &&
|
|
props.customer.invoice_settings &&
|
|
props.customer.invoice_settings.default_payment_method
|
|
) {
|
|
return props.customer.invoice_settings.default_payment_method
|
|
} else if (props.paymentMethods && props.paymentMethods[0] && props.paymentMethods[0].id) {
|
|
return props.paymentMethods[0].id
|
|
} else {
|
|
return null
|
|
}
|
|
})
|
|
|
|
const metadata = computed(() => {
|
|
if (mutatedProduct.value.metadata.type === 'pyro') {
|
|
return {
|
|
type: 'pyro',
|
|
server_name: serverName.value,
|
|
source:
|
|
props.projectId && props.versionId
|
|
? {
|
|
project_id: props.projectId,
|
|
version_id: props.versionId,
|
|
}
|
|
: {
|
|
loader: serverLoader.value,
|
|
loader_version: '',
|
|
game_version: 'latest',
|
|
},
|
|
}
|
|
}
|
|
return null
|
|
})
|
|
|
|
function nextStep() {
|
|
if (
|
|
mutatedProduct.value.metadata.type === 'pyro' &&
|
|
!props.projectId &&
|
|
purchaseModalStep.value === 0
|
|
) {
|
|
purchaseModalStep.value = 1
|
|
} else {
|
|
beginPurchaseFlow(true)
|
|
}
|
|
}
|
|
|
|
async function beginPurchaseFlow(skip = false) {
|
|
if (!props.customer) {
|
|
paymentLoading.value = true
|
|
await props.fetchPaymentData()
|
|
paymentLoading.value = false
|
|
}
|
|
|
|
if (primaryPaymentMethodId.value && skip) {
|
|
paymentLoading.value = true
|
|
await refreshPayment(null, primaryPaymentMethodId.value)
|
|
paymentLoading.value = false
|
|
purchaseModalStep.value =
|
|
mutatedProduct.value.metadata.type === 'pyro' && !props.projectId ? 3 : 2
|
|
} else {
|
|
try {
|
|
loadingPaymentMethodModal.value = 0
|
|
purchaseModalStep.value =
|
|
mutatedProduct.value.metadata.type === 'pyro' && !props.projectId ? 2 : 1
|
|
|
|
await nextTick()
|
|
|
|
const {
|
|
elements: elementsVal,
|
|
addressElement,
|
|
paymentElement,
|
|
} = createStripeElements(stripe, props.paymentMethods, {
|
|
mode: 'payment',
|
|
amount: price.value.prices.intervals[selectedPlan.value],
|
|
currency: price.value.currency_code.toLowerCase(),
|
|
paymentMethodCreation: 'manual',
|
|
setupFutureUsage: 'off_session',
|
|
})
|
|
|
|
elements = elementsVal
|
|
paymentElement.on('ready', () => {
|
|
loadingPaymentMethodModal.value += 1
|
|
})
|
|
addressElement.on('ready', () => {
|
|
loadingPaymentMethodModal.value += 1
|
|
})
|
|
} catch (err) {
|
|
props.onError(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function createConfirmationToken() {
|
|
const { error, confirmationToken: confirmation } = await stripe.createConfirmationToken({
|
|
elements,
|
|
})
|
|
|
|
if (error) {
|
|
props.onError(error)
|
|
return
|
|
}
|
|
|
|
return confirmation.id
|
|
}
|
|
|
|
async function validatePayment() {
|
|
paymentLoading.value = true
|
|
const { error: submitError } = await elements.submit()
|
|
|
|
if (submitError) {
|
|
paymentLoading.value = false
|
|
props.onError(submitError)
|
|
return
|
|
}
|
|
|
|
await refreshPayment(await createConfirmationToken())
|
|
|
|
elements.update({ currency: price.value.currency_code.toLowerCase(), amount: total.value })
|
|
|
|
loadingPaymentMethodModal.value = 0
|
|
confirmationToken.value = await createConfirmationToken()
|
|
purchaseModalStep.value =
|
|
mutatedProduct.value.metadata.type === 'pyro' && !props.projectId ? 3 : 2
|
|
paymentLoading.value = false
|
|
}
|
|
|
|
async function selectPaymentMethod(paymentMethod) {
|
|
if (paymentMethod.id === 'new') {
|
|
await beginPurchaseFlow(false)
|
|
} else if (inputtedPaymentMethod.value && inputtedPaymentMethod.value.id === paymentMethod.id) {
|
|
paymentLoading.value = true
|
|
await refreshPayment(confirmationToken.value)
|
|
paymentLoading.value = false
|
|
} else {
|
|
paymentLoading.value = true
|
|
await refreshPayment(null, paymentMethod.id)
|
|
paymentLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function refreshPayment(confirmationId, paymentMethodId) {
|
|
try {
|
|
const base = confirmationId
|
|
? {
|
|
type: 'confirmation_token',
|
|
token: confirmationId,
|
|
}
|
|
: {
|
|
type: 'payment_method',
|
|
id: paymentMethodId,
|
|
}
|
|
|
|
const result = await props.sendBillingRequest({
|
|
charge: {
|
|
type: 'new',
|
|
product_id: mutatedProduct.value.id,
|
|
interval: selectedPlan.value,
|
|
},
|
|
existing_payment_intent: paymentIntentId.value,
|
|
metadata: metadata.value,
|
|
...base,
|
|
})
|
|
|
|
if (!paymentIntentId.value) {
|
|
paymentIntentId.value = result.payment_intent_id
|
|
clientSecret.value = result.client_secret
|
|
}
|
|
|
|
price.value = mutatedProduct.value.prices.find((x) => x.id === result.price_id)
|
|
currency.value = price.value.currency_code
|
|
tax.value = result.tax
|
|
total.value = result.total
|
|
|
|
if (confirmationId) {
|
|
confirmationToken.value = confirmationId
|
|
inputtedPaymentMethod.value = result.payment_method
|
|
}
|
|
|
|
selectedPaymentMethod.value = result.payment_method
|
|
} catch (err) {
|
|
props.onError(err)
|
|
}
|
|
}
|
|
|
|
async function submitPayment() {
|
|
paymentLoading.value = true
|
|
const { error } = await stripe.confirmPayment({
|
|
clientSecret: clientSecret.value,
|
|
confirmParams: {
|
|
confirmation_token: confirmationToken.value,
|
|
return_url: `${props.returnUrl}?priceId=${price.value.id}&plan=${selectedPlan.value}`,
|
|
},
|
|
})
|
|
|
|
if (error) {
|
|
props.onError(error)
|
|
}
|
|
paymentLoading.value = false
|
|
}
|
|
|
|
defineExpose({
|
|
show: () => {
|
|
// eslint-disable-next-line no-undef
|
|
stripe = Stripe(props.publishableKey)
|
|
|
|
selectedPlan.value = 'yearly'
|
|
serverName.value = props.serverName || ''
|
|
serverLoader.value = 'Vanilla'
|
|
|
|
purchaseModalStep.value = 0
|
|
loadingPaymentMethodModal.value = 0
|
|
paymentLoading.value = false
|
|
|
|
purchaseModal.value.show()
|
|
},
|
|
})
|
|
</script>
|
|
<style scoped lang="scss"></style>
|