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"
|
||||
/>
|
||||
|
||||
<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 }}
|
||||
</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -69,10 +69,11 @@
|
|||
<checkbox-input
|
||||
:form="form"
|
||||
name="agree_terms"
|
||||
class="mb-3"
|
||||
class="my-3"
|
||||
:required="true"
|
||||
>
|
||||
<template #label>
|
||||
<label for="agree_terms">
|
||||
I agree with the
|
||||
<NuxtLink
|
||||
:to="{ name: 'terms-conditions' }"
|
||||
|
|
@ -90,6 +91,7 @@
|
|||
Privacy policy
|
||||
</NuxtLink>
|
||||
of the website and I accept them.
|
||||
</label>
|
||||
</template>
|
||||
</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"
|
||||
class="mr-1"
|
||||
:arrow="true"
|
||||
@click.prevent="openBilling"
|
||||
:to="{ name: 'redirect-billing-portal' }"
|
||||
target="_blank"
|
||||
>
|
||||
View Billing
|
||||
</v-button>
|
||||
|
|
@ -222,15 +223,6 @@ export default {
|
|||
|
||||
computed: {},
|
||||
|
||||
methods: {
|
||||
openBilling() {
|
||||
this.billingLoading = true
|
||||
opnFetch("/subscription/billing-portal").then((data) => {
|
||||
this.billingLoading = false
|
||||
const url = data.portal_url
|
||||
window.location = url
|
||||
})
|
||||
},
|
||||
},
|
||||
methods: {},
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -92,9 +92,9 @@
|
|||
<v-button
|
||||
v-else
|
||||
: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"
|
||||
@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
|
||||
</v-button>
|
||||
|
|
@ -301,7 +301,9 @@
|
|||
size="md"
|
||||
class="w-auto flex-grow"
|
||||
:loading="form.busy || loading"
|
||||
@click="saveDetails"
|
||||
:disabled="form.busy || loading"
|
||||
:to="checkoutUrl"
|
||||
target="_blank"
|
||||
>
|
||||
<template v-if="isSubscribed">
|
||||
Upgrade
|
||||
|
|
@ -329,6 +331,7 @@
|
|||
<script setup>
|
||||
import SlidingTransition from '~/components/global/transitions/SlidingTransition.vue'
|
||||
import { fetchAllWorkspaces } from '~/stores/workspaces.js'
|
||||
import { useCheckoutUrl } from '@/composables/useCheckoutUrl'
|
||||
|
||||
const router = useRouter()
|
||||
const subscriptionModalStore = useSubscriptionModalStore()
|
||||
|
|
@ -354,17 +357,31 @@ const user = computed(() => authStore.user)
|
|||
const isSubscribed = computed(() => workspacesStore.isSubscribed)
|
||||
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
|
||||
watch(() => subscriptionModalStore.show, () => {
|
||||
currentStep.value = 1
|
||||
if (subscriptionModalStore.show && subscriptionModalStore.plan) {
|
||||
if (user.value.is_subscribed) {
|
||||
return
|
||||
|
||||
// Update user data when modal opens
|
||||
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(() => {
|
||||
if (user && user.value) {
|
||||
form.name = user.value.name
|
||||
form.email = user.value.email
|
||||
}
|
||||
updateUser()
|
||||
})
|
||||
|
||||
// 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) => {
|
||||
if (!authenticated.value) {
|
||||
subscriptionModalStore.closeModal()
|
||||
|
|
@ -443,56 +477,4 @@ const onSelectPlan = (planName) => {
|
|||
const goBackToStep1 = () => {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
icon="i-heroicons-credit-card"
|
||||
:loading="billingLoading"
|
||||
@click="openBillingDashboard"
|
||||
:to="{ name: 'redirect-billing-portal' }"
|
||||
target="_blank"
|
||||
>
|
||||
<span v-if="canCancel">Manage Subscription & 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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue