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:
Chirag Chhatrala 2025-03-05 16:46:33 +05:30 committed by GitHub
parent b633f97ce1
commit a59b46665c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 193 additions and 221 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

42
client/composables/useCheckoutUrl.js vendored Normal file
View File

@ -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
}
})
}

View File

@ -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>

View File

@ -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>

View File

@ -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>