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:
107
resources/js/components/pages/forms/show/FormCleanings.vue
Normal file
107
resources/js/components/pages/forms/show/FormCleanings.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<v-transition>
|
||||
<div v-if="hasCleanings && !hideWarning" class="border border-gray-300 dark:border-gray-600 rounded-md bg-white p-2"
|
||||
:class="{'hover:bg-yellow-50 dark:hover:bg-yellow-900':!collapseOpened}"
|
||||
>
|
||||
<collapse v-model="collapseOpened">
|
||||
<template #title>
|
||||
<p class="text-yellow-500 dark:text-yellow-400 font-semibold text-sm p-1 pr-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6 inline"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
Some features that are included in our {{ form.is_pro ? 'Enterprise' : 'Pro' }} plan are disabled when
|
||||
publicly sharing this form<span v-if="specifyFormOwner"> (only owners of this form can see this)</span>.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="border-t mt-1 p-4 pb-2 -mx-2">
|
||||
<p class="text-gray-500 text-sm" v-html="cleaningContent" />
|
||||
<p class="text-gray-500 text-sm mb-4 font-semibold">
|
||||
<router-link :to="{name:'pricing'}">
|
||||
{{ form.is_pro ? 'Upgrade your OpnForms plan today' : 'Start your free OpnForms trial' }}
|
||||
</router-link>
|
||||
to unlock all of our features and build powerful forms.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-end w-full">
|
||||
<div class="flex-grow flex pr-2">
|
||||
<v-button v-track.upgrade_from_form_cleanings_click size="small" class="inline-block" :to="{name:'pricing'}">
|
||||
{{ form.is_pro ? 'Upgrade plan' : 'Start free trial' }}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-4 h-4 inline -mt-[2px]"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</v-button>
|
||||
<v-button color="white" size="small" class="ml-2" @click.prevent="openCrisp">
|
||||
Contact us
|
||||
</v-button>
|
||||
</div>
|
||||
<v-button v-if="hideable" color="white" size="small" class="mt-2" @click.prevent="hideWarning=true">
|
||||
Hide warning
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</collapse>
|
||||
</div>
|
||||
</v-transition>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
import Collapse from '../../../common/Collapse.vue'
|
||||
import VButton from '../../../common/Button.vue'
|
||||
import VTransition from '../../../common/transitions/VTransition.vue'
|
||||
|
||||
export default {
|
||||
name: 'FormCleanings',
|
||||
components: { VTransition, VButton, Collapse },
|
||||
props: {
|
||||
form: { type: Object, required: true },
|
||||
specifyFormOwner: { type: Boolean, default: false },
|
||||
hideable: { type: Boolean, default: false }
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
collapseOpened: false,
|
||||
hideWarning: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasCleanings () {
|
||||
return this.form.cleanings && Object.keys(this.form.cleanings).length > 0
|
||||
},
|
||||
cleanings () {
|
||||
return this.form.cleanings
|
||||
},
|
||||
cleaningContent () {
|
||||
let message = ''
|
||||
Object.keys(this.cleanings).forEach((key) => {
|
||||
let fieldName = key.charAt(0).toUpperCase() + key.slice(1)
|
||||
if (fieldName !== 'Form') {
|
||||
fieldName = '"' + fieldName + '" field'
|
||||
}
|
||||
let fieldInfo = '<span class="font-semibold">' + fieldName + '</span><br/><ul class=\'list-disc list-inside\'>'
|
||||
this.cleanings[key].forEach((msg) => {
|
||||
fieldInfo = fieldInfo + '<li>' + msg + '</li>'
|
||||
})
|
||||
if (fieldInfo) {
|
||||
message = message + fieldInfo + '<ul/><br/>'
|
||||
}
|
||||
})
|
||||
return message
|
||||
}
|
||||
},
|
||||
watch: {},
|
||||
mounted () {
|
||||
},
|
||||
methods: {
|
||||
openCrisp () {
|
||||
this.$crisp.push(['do', 'chat:show'])
|
||||
this.$crisp.push(['do', 'chat:open'])
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -13,7 +13,6 @@
|
||||
/>
|
||||
</svg>
|
||||
Url form pre-fill
|
||||
<pro-tag class="ml-2"/>
|
||||
</v-button>
|
||||
|
||||
<modal :show="showUrlFormPrefillModal" @close="showUrlFormPrefillModal=false">
|
||||
@@ -26,7 +25,6 @@
|
||||
</template>
|
||||
<template #title>
|
||||
<span>Url Form Prefill</span>
|
||||
<pro-tag class="ml-4 pb-3" />
|
||||
</template>
|
||||
|
||||
<div class="p-4">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
132
resources/js/components/pages/pricing/PricingTable.vue
Normal file
132
resources/js/components/pages/pricing/PricingTable.vue
Normal 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>
|
||||
Reference in New Issue
Block a user