Billing flow redirect (#713)
* Billing flow redirect * fix checkout url * Refactor checkout and subscription flow - Improve checkout URL generation with ref unwrapping - Enhance user data handling in subscription modal - Remove deprecated CheckoutDetailsModal component - Update form field and register form styling - Add more robust user data update mechanism * Refactor checkout and subscription flow - Improve checkout URL generation with ref unwrapping - Enhance user data handling in subscription modal - Remove deprecated CheckoutDetailsModal component - Update form field and register form styling - Add more robust user data update mechanism * Fix accessibility and checkout URL generation - Add proper label for terms and conditions checkbox in RegisterForm - Refactor checkout URL generation in SubscriptionModal using computed refs - Improve form input handling and reactivity --------- Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
parent
b633f97ce1
commit
a59b46665c
|
|
@ -24,7 +24,10 @@
|
||||||
:type="field.type"
|
:type="field.type"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p class="text-sm text-gray-500" v-if="blocksTypes[field.type]">
|
<p
|
||||||
|
v-if="blocksTypes[field.type]"
|
||||||
|
class="text-sm text-gray-500"
|
||||||
|
>
|
||||||
{{ blocksTypes[field.type].title }}
|
{{ blocksTypes[field.type].title }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,10 +69,11 @@
|
||||||
<checkbox-input
|
<checkbox-input
|
||||||
:form="form"
|
:form="form"
|
||||||
name="agree_terms"
|
name="agree_terms"
|
||||||
class="mb-3"
|
class="my-3"
|
||||||
:required="true"
|
:required="true"
|
||||||
>
|
>
|
||||||
<template #label>
|
<template #label>
|
||||||
|
<label for="agree_terms">
|
||||||
I agree with the
|
I agree with the
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="{ name: 'terms-conditions' }"
|
:to="{ name: 'terms-conditions' }"
|
||||||
|
|
@ -90,6 +91,7 @@
|
||||||
Privacy policy
|
Privacy policy
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
of the website and I accept them.
|
of the website and I accept them.
|
||||||
|
</label>
|
||||||
</template>
|
</template>
|
||||||
</checkbox-input>
|
</checkbox-input>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
<template>
|
|
||||||
<modal
|
|
||||||
:show="show"
|
|
||||||
max-width="lg"
|
|
||||||
@close="close"
|
|
||||||
>
|
|
||||||
<text-input
|
|
||||||
ref="companyName"
|
|
||||||
label="Company Name"
|
|
||||||
name="name"
|
|
||||||
:required="true"
|
|
||||||
:form="form"
|
|
||||||
help="Name that will appear on invoices"
|
|
||||||
/>
|
|
||||||
<text-input
|
|
||||||
label="Email"
|
|
||||||
name="email"
|
|
||||||
native-type="email"
|
|
||||||
:required="true"
|
|
||||||
:form="form"
|
|
||||||
help="Where invoices will be sent"
|
|
||||||
/>
|
|
||||||
<v-button
|
|
||||||
:loading="form.busy || loading"
|
|
||||||
:disabled="form.busy || loading ? true : null"
|
|
||||||
class="mt-6 block mx-auto"
|
|
||||||
:arrow="true"
|
|
||||||
@click="saveDetails"
|
|
||||||
>
|
|
||||||
Go to checkout
|
|
||||||
</v-button>
|
|
||||||
</modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { computed } from "vue"
|
|
||||||
import TextInput from "../../forms/TextInput.vue"
|
|
||||||
import VButton from "~/components/global/VButton.vue"
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: { VButton, TextInput },
|
|
||||||
props: {
|
|
||||||
show: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
plan: {
|
|
||||||
type: String,
|
|
||||||
default: "pro",
|
|
||||||
},
|
|
||||||
yearly: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ['close'],
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
return {
|
|
||||||
user: computed(() => authStore.user),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data: () => ({
|
|
||||||
form: useForm({
|
|
||||||
name: "",
|
|
||||||
email: "",
|
|
||||||
}),
|
|
||||||
loading: false,
|
|
||||||
}),
|
|
||||||
|
|
||||||
computed: {},
|
|
||||||
|
|
||||||
watch: {
|
|
||||||
user() {
|
|
||||||
this.updateUser()
|
|
||||||
},
|
|
||||||
show() {
|
|
||||||
// Wait for modal to open and focus on first field
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.$refs.companyName) {
|
|
||||||
this.$refs.companyName.$el.querySelector("input").focus()
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
|
|
||||||
this.loading = false
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
this.updateUser()
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
updateUser() {
|
|
||||||
if (this.user) {
|
|
||||||
this.form.name = this.user.name
|
|
||||||
this.form.email = this.user.email
|
|
||||||
}
|
|
||||||
},
|
|
||||||
saveDetails() {
|
|
||||||
if (this.form.busy) return
|
|
||||||
this.form.put("subscription/update-customer-details").then(() => {
|
|
||||||
this.loading = true
|
|
||||||
opnFetch(
|
|
||||||
"/subscription/new/" +
|
|
||||||
this.plan +
|
|
||||||
"/" +
|
|
||||||
(!this.yearly ? "monthly" : "yearly") +
|
|
||||||
"/checkout/with-trial",
|
|
||||||
)
|
|
||||||
.then((data) => {
|
|
||||||
window.location = data.checkout_url
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
useAlert().error(error.data.message)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.loading = false
|
|
||||||
this.close()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
this.$emit("close")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
@ -141,7 +141,8 @@
|
||||||
v-else-if="authenticated && user.is_subscribed"
|
v-else-if="authenticated && user.is_subscribed"
|
||||||
class="mr-1"
|
class="mr-1"
|
||||||
:arrow="true"
|
:arrow="true"
|
||||||
@click.prevent="openBilling"
|
:to="{ name: 'redirect-billing-portal' }"
|
||||||
|
target="_blank"
|
||||||
>
|
>
|
||||||
View Billing
|
View Billing
|
||||||
</v-button>
|
</v-button>
|
||||||
|
|
@ -222,15 +223,6 @@ export default {
|
||||||
|
|
||||||
computed: {},
|
computed: {},
|
||||||
|
|
||||||
methods: {
|
methods: {},
|
||||||
openBilling() {
|
|
||||||
this.billingLoading = true
|
|
||||||
opnFetch("/subscription/billing-portal").then((data) => {
|
|
||||||
this.billingLoading = false
|
|
||||||
const url = data.portal_url
|
|
||||||
window.location = url
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -92,9 +92,9 @@
|
||||||
<v-button
|
<v-button
|
||||||
v-else
|
v-else
|
||||||
:loading="billingLoading"
|
:loading="billingLoading"
|
||||||
class="relative border border-white border-opacity-20 h-10 inline-flex px-4 items-center rounded-lg text-sm font-semibold w-full justify-center mt-4"
|
:to="{ name: 'redirect-billing-portal' }"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@click="openBillingDashboard"
|
class="relative border border-white border-opacity-20 h-10 inline-flex px-4 items-center rounded-lg text-sm font-semibold w-full justify-center mt-4"
|
||||||
>
|
>
|
||||||
Manage Plan
|
Manage Plan
|
||||||
</v-button>
|
</v-button>
|
||||||
|
|
@ -301,7 +301,9 @@
|
||||||
size="md"
|
size="md"
|
||||||
class="w-auto flex-grow"
|
class="w-auto flex-grow"
|
||||||
:loading="form.busy || loading"
|
:loading="form.busy || loading"
|
||||||
@click="saveDetails"
|
:disabled="form.busy || loading"
|
||||||
|
:to="checkoutUrl"
|
||||||
|
target="_blank"
|
||||||
>
|
>
|
||||||
<template v-if="isSubscribed">
|
<template v-if="isSubscribed">
|
||||||
Upgrade
|
Upgrade
|
||||||
|
|
@ -329,6 +331,7 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import SlidingTransition from '~/components/global/transitions/SlidingTransition.vue'
|
import SlidingTransition from '~/components/global/transitions/SlidingTransition.vue'
|
||||||
import { fetchAllWorkspaces } from '~/stores/workspaces.js'
|
import { fetchAllWorkspaces } from '~/stores/workspaces.js'
|
||||||
|
import { useCheckoutUrl } from '@/composables/useCheckoutUrl'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const subscriptionModalStore = useSubscriptionModalStore()
|
const subscriptionModalStore = useSubscriptionModalStore()
|
||||||
|
|
@ -354,17 +357,31 @@ const user = computed(() => authStore.user)
|
||||||
const isSubscribed = computed(() => workspacesStore.isSubscribed)
|
const isSubscribed = computed(() => workspacesStore.isSubscribed)
|
||||||
const currency = 'usd'
|
const currency = 'usd'
|
||||||
|
|
||||||
|
const checkoutUrl = useCheckoutUrl(
|
||||||
|
computed(() => form.name),
|
||||||
|
computed(() => form.email),
|
||||||
|
currentPlan,
|
||||||
|
isYearly,
|
||||||
|
currency
|
||||||
|
)
|
||||||
|
|
||||||
// When opening modal with a plan already (and user not subscribed yet) - skip first step
|
// When opening modal with a plan already (and user not subscribed yet) - skip first step
|
||||||
watch(() => subscriptionModalStore.show, () => {
|
watch(() => subscriptionModalStore.show, () => {
|
||||||
currentStep.value = 1
|
currentStep.value = 1
|
||||||
if (subscriptionModalStore.show && subscriptionModalStore.plan) {
|
|
||||||
if (user.value.is_subscribed) {
|
// Update user data when modal opens
|
||||||
return
|
if (subscriptionModalStore.show) {
|
||||||
|
updateUser()
|
||||||
|
|
||||||
|
if (subscriptionModalStore.plan) {
|
||||||
|
if (user.value.is_subscribed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isYearly.value = subscriptionModalStore.yearly
|
||||||
|
shouldShowUpsell.value = !isYearly.value
|
||||||
|
currentStep.value = 2
|
||||||
|
currentPlan.value = subscriptionModalStore.plan
|
||||||
}
|
}
|
||||||
isYearly.value = subscriptionModalStore.yearly
|
|
||||||
shouldShowUpsell.value = !isYearly.value
|
|
||||||
currentStep.value = 2
|
|
||||||
currentPlan.value = subscriptionModalStore.plan
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -421,12 +438,29 @@ watch(broadcastData, () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (user && user.value) {
|
updateUser()
|
||||||
form.name = user.value.name
|
|
||||||
form.email = user.value.email
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Update form with user data - sets company name to user name by default
|
||||||
|
const updateUser = () => {
|
||||||
|
if (user.value) {
|
||||||
|
// Set company name to user name by default
|
||||||
|
if (user.value.name && !form.name) {
|
||||||
|
form.name = user.value.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set email if available
|
||||||
|
if (user.value.email && !form.email) {
|
||||||
|
form.email = user.value.email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for user changes
|
||||||
|
watch(user, () => {
|
||||||
|
updateUser()
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
const onSelectPlan = (planName) => {
|
const onSelectPlan = (planName) => {
|
||||||
if (!authenticated.value) {
|
if (!authenticated.value) {
|
||||||
subscriptionModalStore.closeModal()
|
subscriptionModalStore.closeModal()
|
||||||
|
|
@ -443,56 +477,4 @@ const onSelectPlan = (planName) => {
|
||||||
const goBackToStep1 = () => {
|
const goBackToStep1 = () => {
|
||||||
currentStep.value = 1
|
currentStep.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveDetails = () => {
|
|
||||||
if (form.busy)
|
|
||||||
return
|
|
||||||
form.put('subscription/update-customer-details').then(() => {
|
|
||||||
loading.value = true
|
|
||||||
|
|
||||||
// Get param trial_duration from url
|
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
|
||||||
const trialDuration = urlParams.get('trial_duration') || null
|
|
||||||
if (trialDuration) {
|
|
||||||
useAmplitude().logEvent('extended_trial_used', {
|
|
||||||
duration: trialDuration
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
trial_duration: trialDuration,
|
|
||||||
currency: currency
|
|
||||||
}
|
|
||||||
opnFetch(
|
|
||||||
`/subscription/new/${
|
|
||||||
currentPlan.value
|
|
||||||
}/${
|
|
||||||
!isYearly.value ? 'monthly' : 'yearly'
|
|
||||||
}/checkout/with-trial?${
|
|
||||||
new URLSearchParams(params).toString()}`
|
|
||||||
)
|
|
||||||
.then((data) => {
|
|
||||||
window.open(data.checkout_url, '_blank')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
useAlert().error(error.data.message)
|
|
||||||
loading.value = false
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const openBillingDashboard = () => {
|
|
||||||
billingLoading.value = true
|
|
||||||
opnFetch('/subscription/billing-portal').then((data) => {
|
|
||||||
const url = data.portal_url
|
|
||||||
window.location = url
|
|
||||||
}).catch((error) => {
|
|
||||||
useAlert().error(error.data.message)
|
|
||||||
}).finally(() => {
|
|
||||||
billingLoading.value = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { computed, unref } from 'vue'
|
||||||
|
|
||||||
|
export const useCheckoutUrl = (name, email, plan, yearly, currency) => {
|
||||||
|
return computed(() => {
|
||||||
|
// Unwrap refs if they are passed
|
||||||
|
const nameValue = unref(name)
|
||||||
|
const emailValue = unref(email)
|
||||||
|
const planValue = unref(plan)
|
||||||
|
const yearlyValue = unref(yearly)
|
||||||
|
const currencyValue = unref(currency)
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
plan: planValue,
|
||||||
|
yearly: yearlyValue.toString(),
|
||||||
|
currency: currencyValue,
|
||||||
|
name: nameValue,
|
||||||
|
email: emailValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get trial duration if exists - only in client side
|
||||||
|
if (import.meta.client) {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
|
const trialDuration = urlParams.get('trial_duration')
|
||||||
|
if (trialDuration) {
|
||||||
|
params.trial_duration = trialDuration
|
||||||
|
// Keep the amplitude event
|
||||||
|
useAmplitude().logEvent('extended_trial_used', { duration: trialDuration })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out empty params
|
||||||
|
const filteredParams = Object.fromEntries(
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
Object.entries(params).filter(([_, value]) => value !== null && value !== undefined && value !== '')
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'redirect-checkout',
|
||||||
|
query: filteredParams
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center justify-center min-h-screen gap-4">
|
||||||
|
<Loader class="w-8 h-8 text-blue-500" />
|
||||||
|
<p class="text-gray-500">
|
||||||
|
Redirecting to billing portal...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth'
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const { portal_url } = await opnFetch('/subscription/billing-portal')
|
||||||
|
if (!portal_url) {
|
||||||
|
throw new Error('No portal URL returned')
|
||||||
|
}
|
||||||
|
window.location.href = portal_url
|
||||||
|
} catch (error) {
|
||||||
|
useAlert().error('Unable to access billing portal. Please try again or contact support.')
|
||||||
|
setTimeout(() => {
|
||||||
|
navigateTo({ name: 'settings-billing' })
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center justify-center min-h-screen gap-4">
|
||||||
|
<Loader class="w-8 h-8 text-blue-500" />
|
||||||
|
<p class="text-gray-500">
|
||||||
|
Preparing your checkout...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth'
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const { plan, yearly, trial_duration, currency, name, email } = route.query
|
||||||
|
|
||||||
|
if (!plan) {
|
||||||
|
useAlert().error('Missing plan information')
|
||||||
|
navigateTo({ name: 'pricing' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update customer details if provided
|
||||||
|
if (name && email) {
|
||||||
|
try {
|
||||||
|
await opnFetch('subscription/update-customer-details', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { name, email }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
useAlert().error('Failed to update customer details, but proceeding with checkout')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get checkout URL
|
||||||
|
const params = { trial_duration, currency }
|
||||||
|
const { checkout_url } = await opnFetch(
|
||||||
|
`/subscription/new/${plan}/${yearly === 'true' ? 'yearly' : 'monthly'}/checkout/with-trial?${new URLSearchParams(params)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!checkout_url) {
|
||||||
|
throw new Error('No checkout URL returned')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log trial usage if applicable
|
||||||
|
if (trial_duration) {
|
||||||
|
useAmplitude().logEvent('extended_trial_used', { duration: trial_duration })
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = checkout_url
|
||||||
|
} catch (error) {
|
||||||
|
useAlert().error('Unable to start checkout process. Please try again or contact support.')
|
||||||
|
setTimeout(() => {
|
||||||
|
navigateTo({ name: 'pricing' })
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
@ -19,7 +19,8 @@
|
||||||
color="gray"
|
color="gray"
|
||||||
icon="i-heroicons-credit-card"
|
icon="i-heroicons-credit-card"
|
||||||
:loading="billingLoading"
|
:loading="billingLoading"
|
||||||
@click="openBillingDashboard"
|
:to="{ name: 'redirect-billing-portal' }"
|
||||||
|
target="_blank"
|
||||||
>
|
>
|
||||||
<span v-if="canCancel">Manage Subscription & Invoices</span>
|
<span v-if="canCancel">Manage Subscription & Invoices</span>
|
||||||
<span else>Billing & Invoices</span>
|
<span else>Billing & Invoices</span>
|
||||||
|
|
@ -117,15 +118,4 @@ const cancelSubscription = () => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const openBillingDashboard = () => {
|
|
||||||
billingLoading.value = true
|
|
||||||
opnFetch('/subscription/billing-portal').then((data) => {
|
|
||||||
const url = data.portal_url
|
|
||||||
window.location = url
|
|
||||||
}).catch((error) => {
|
|
||||||
useAlert().error(error.data.message)
|
|
||||||
}).finally(() => {
|
|
||||||
billingLoading.value = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue