Enable pricing (#151)

* Enable Pro plan - WIP

* no pricing page if have no paid plans

* Set pricing ids in env

* views & submissions FREE for all

* extra param for env

* form password FREE for all

* Custom Code is PRO feature

* Replace codeinput prism with codemirror

* Better form Cleaning message

* Added risky user email spam protection

* fix form cleaning

* Pricing page new UI

* form cleaner

* Polish changes

* Fixed tests

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
formsdev
2023-08-30 13:28:29 +05:30
committed by GitHub
parent 29b153bd76
commit fb79a5bf3e
48 changed files with 1011 additions and 269 deletions

View File

@@ -0,0 +1,94 @@
<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" class="mt-6 block mx-auto"
@click="saveDetails" :arrow="true"
>
Go to checkout
</v-button>
</modal>
</template>
<script>
import { mapGetters } from 'vuex'
import TextInput from '../../forms/TextInput.vue'
import Form from 'vform'
import VButton from '../../common/Button.vue'
import axios from 'axios'
export default {
components: { VButton, TextInput },
props: {
show: {
type: Boolean,
default: false
},
plan: {
type: String,
default: 'pro'
},
yearly: {
type: Boolean,
default: true
}
},
data: () => ({
form: new Form({
name: '',
email: ''
}),
loading: false
}),
watch: {
user () {
this.form.email = this.user.email
},
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 () {
if (this.user) {
this.form.name = this.user.name
this.form.email = this.user.email
}
},
methods: {
saveDetails () {
if (this.form.busy) return
this.form.put('api/subscription/update-customer-details').then(() => {
this.loading = true
axios.get('/api/subscription/new/' + this.plan + '/' + (!this.yearly ? 'monthly' : 'yearly') + '/checkout/with-trial').then((response) => {
window.location = response.data.checkout_url
}).catch((error) => {
this.alertError(error.response.data.message)
}).finally(() => {
this.loading = false
this.close()
})
})
},
close () {
this.$emit('close')
}
},
computed: {
...mapGetters({
user: 'auth/user'
})
}
}
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div class="border border-gray-300 rounded-xl flex p-1 relative">
<button class="font-semibold block flex-grow cursor-pointer">
<div class="p-2 px-3 rounded-lg transition-colors" :class="{'bg-blue-500 text-white':!value}"
@click="set(false)"
>
Monthly
</div>
</button>
<button class="font-semibold block flex-grow cursor-pointer" @click="set(true)">
<div class="p-2 px-4 rounded-lg transition-colors" :class="{'bg-blue-500 text-white':value}">
Yearly
</div>
</button>
<div class="absolute hidden sm:block text-gray-500 text-xs mt-12">
Save 20% with annual plans
</div>
</div>
</template>
<script>
export default {
components: {},
props: {
value: {
type: Boolean,
default: false
}
},
data: () => ({}),
computed: {},
methods: {
set (value) {
this.$emit('input', value)
}
}
}
</script>

View File

@@ -0,0 +1,132 @@
<template>
<div class="w-full">
<section class="relative">
<div class="absolute inset-0 grid" aria-hidden="true">
<div class="bg-gray-100"></div>
<div class="bg-white"></div>
</div>
<div class="px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="max-w-5xl mx-auto bg-white shadow-xl rounded-3xl ring-1 ring-gray-200 lg:flex isolate">
<div class="p-8 sm:p-8 lg:flex-auto">
<h3 class="text-3xl font-semibold tracking-tight text-gray-950">
Pro Plan
</h3>
<p class="mt-2 text-base font-medium leading-7 text-gray-600">
OpnForm Pro offers empowering features tailored to the advanced needs of teams and creators. Enjoy our free 3-day trial!
</p>
<div class="flex items-center mt-6 gap-x-4">
<h4 class="flex-none text-sm font-semibold leading-6 tracking-widest text-gray-400 uppercase">
What's included
</h4>
<div class="flex-auto h-px bg-gray-200"></div>
</div>
<ul role="list" class="grid grid-cols-1 gap-4 mt-4 text-sm font-medium leading-6 text-gray-900 sm:grid-cols-2 sm:gap-x-6 sm:gap-y-2">
<li v-for="(title, i) in pricingInfo" :key="i" class="flex gap-x-3">
<svg aria-hidden="true" class="w-5 h-5 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 6L9 17L4 12" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
{{ title }}
</li>
</ul>
</div>
<div class="p-2 -mt-2 lg:mt-0 lg:w-full lg:max-w-md lg:flex-shrink-0">
<div class="py-10 text-center rounded-2xl bg-gray-50 ring-1 ring-inset ring-gray-900/5 lg:flex lg:flex-col lg:justify-center lg:py-12">
<div class="max-w-xs px-8 mx-auto space-y-6">
<div class="flex items-center justify-center mb-10">
<monthly-yearly-selector v-model="isYearly" />
</div><!-- lg+ -->
<p class="flex flex-col items-center">
<span class="text-6xl font-semibold tracking-tight text-gray-950">
<template v-if="isYearly">$16</template>
<template v-else>$19</template>
</span>
<span class="text-sm font-medium leading-6 text-gray-600">
per month
</span>
</p>
<div class="flex justify-center">
<v-button v-if="!authenticated" class="mr-1" :to="{ name: 'register' }" :arrow="true">
Start free trial
</v-button>
<v-button v-else-if="authenticated && user.is_subscribed" class="mr-1" @click.prevent="openBilling" :arrow="true">
View Billing
</v-button>
<v-button v-else class="mr-1" @click.prevent="openCustomerCheckout('default')" :arrow="true">
Start free trial
</v-button>
</div>
<p class="text-xs font-medium leading-5 text-gray-600">
Invoices and receipts available for easy company reimbursement.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<checkout-details-modal :show="showDetailsModal" :yearly="isYearly" :plan="selectedPlan"
@close="showDetailsModal=false"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import axios from 'axios'
import MonthlyYearlySelector from './MonthlyYearlySelector.vue'
import CheckoutDetailsModal from './CheckoutDetailsModal.vue'
export default {
components: {
MonthlyYearlySelector,
CheckoutDetailsModal,
},
props: {},
data: () => ({
isYearly: true,
selectedPlan: 'pro',
showDetailsModal: false,
pricingInfo: [
'Form confirmation emails',
'Slack notifications',
'Discord notifications',
'Editable submissions',
'Custom domain (soon)',
'Custom code',
'Larger file uploads (50mb)',
'Remove OpnForm branding',
'Priority support'
]
}),
methods: {
openCustomerCheckout (plan) {
this.selectedPlan = plan
this.showDetailsModal = true
},
openBilling () {
this.billingLoading = true
axios.get('/api/subscription/billing-portal').then((response) => {
this.billingLoading = false
const url = response.data.portal_url
window.location = url
})
}
},
computed: {
...mapGetters({
authenticated: 'auth/check',
user: 'auth/user'
})
}
}
</script>