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:
@@ -8,9 +8,7 @@
|
||||
<span
|
||||
class="ml-2 text-md hidden sm:inline text-black dark:text-white"
|
||||
>
|
||||
{{ appName }}</span><span
|
||||
class="bg-gray-100 text-gray-600 font-semibold inline-block ml-1 px-2 rounded-full text-black text-xs tracking-wider"
|
||||
>BETA</span>
|
||||
{{ appName }}</span>
|
||||
</router-link>
|
||||
<workspace-dropdown class="ml-6"/>
|
||||
</div>
|
||||
@@ -23,6 +21,11 @@
|
||||
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8">
|
||||
Templates
|
||||
</router-link>
|
||||
<router-link :to="{name:'pricing'}" v-if="paidPlansEnabled && (user===null || (user && workspace && !workspace.is_pro)) && $route.name !== 'pricing'"
|
||||
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8">
|
||||
<span v-if="user">Upgrade</span>
|
||||
<span v-else>Pricing</span>
|
||||
</router-link>
|
||||
<a href="#" class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1"
|
||||
@click.prevent="$crisp.push(['do', 'helpdesk:search'])" v-if="hasCrisp"
|
||||
>
|
||||
@@ -158,6 +161,12 @@ export default {
|
||||
}
|
||||
return null
|
||||
},
|
||||
workspace () {
|
||||
return this.$store.getters['open/workspaces/getCurrent']()
|
||||
},
|
||||
paidPlansEnabled() {
|
||||
return window.config.paid_plans_enabled
|
||||
},
|
||||
showAuth() {
|
||||
return this.$route.name && !this.$route.name.startsWith('forms.show_public')
|
||||
},
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
<slot name="title" />
|
||||
</div>
|
||||
<div class="text-gray-400 hover:text-gray-600 absolute -right-2 -top-1 cursor-pointer p-2" @click="trigger">
|
||||
<svg class="h-3 w-3 transition transform duration-500" :class="{'rotate-180':showContent}" viewBox="0 0 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1L7 7L13 1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 transition transform duration-500"
|
||||
:class="{'rotate-180':showContent}" viewBox="0 0 20 20" fill="currentColor"
|
||||
>
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -20,21 +25,23 @@
|
||||
|
||||
<script>
|
||||
import VTransition from './transitions/VTransition.vue'
|
||||
|
||||
export default {
|
||||
name: 'Collapse',
|
||||
components: { VTransition },
|
||||
props: {
|
||||
defaultValue: { type: Boolean, default: false }
|
||||
defaultValue: { type: Boolean, default: false },
|
||||
value: { type: Boolean, default: null }
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
showContent: this.defaultValue
|
||||
showContent: this.value ?? this.defaultValue
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
trigger () {
|
||||
this.showContent = !this.showContent
|
||||
this.$emit('click', this.showContent)
|
||||
this.$emit('input', this.showContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</p>
|
||||
|
||||
<p class="my-4 text-center">
|
||||
Feel free to <a href="mailto:contact@opnform.com">contact us</a> if you have any feature request.
|
||||
Feel free to <a href="#" @click.prevent="openChat">contact us</a> if you have any feature request.
|
||||
</p>
|
||||
<div class="mb-4 text-center">
|
||||
<v-button color="gray" shade="light" @click="showPremiumModal=false">
|
||||
@@ -66,13 +66,20 @@ export default {
|
||||
currentWorkSpace: 'open/workspaces/getCurrent',
|
||||
}),
|
||||
shouldDisplayProTag() {
|
||||
return false; //!this.user.is_subscribed && !(this.currentWorkSpace.is_pro || this.currentWorkSpace.is_enterprise);
|
||||
if(!window.config.paid_plans_enabled) return false
|
||||
if (!this.user) return true
|
||||
return !(this.currentWorkSpace().is_pro || this.currentWorkSpace().is_enterprise)
|
||||
},
|
||||
},
|
||||
|
||||
mounted () {
|
||||
},
|
||||
|
||||
methods: {}
|
||||
methods: {
|
||||
openChat () {
|
||||
window.$crisp.push(['do', 'chat:show'])
|
||||
window.$crisp.push(['do', 'chat:open'])
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
<span v-if="required" class="text-red-500 required-dot">*</span>
|
||||
</label>
|
||||
|
||||
<prism-editor :id="id?id:name" v-model="compVal" :disabled="disabled"
|
||||
class="code-editor"
|
||||
:class="[theme.CodeInput.input,{ '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }]"
|
||||
<div :class="[theme.CodeInput.input,{ '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }]">
|
||||
<codemirror :id="id?id:name" v-model="compVal" :disabled="disabled"
|
||||
:options="cmOptions"
|
||||
:style="inputStyle" :name="name"
|
||||
:placeholder="placeholder"
|
||||
:highlight="highlighter" @change="onChange"
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<small v-if="help" :class="theme.CodeInput.help">
|
||||
<slot name="help"><span class="field-help" v-html="help" /></slot>
|
||||
@@ -23,31 +23,32 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// import Prism Editor
|
||||
import { PrismEditor } from 'vue-prism-editor'
|
||||
import 'vue-prism-editor/dist/prismeditor.min.css' // import the styles somewhere
|
||||
// import highlighting library (you can use any library you want just return html string)
|
||||
import { codemirror } from 'vue-codemirror'
|
||||
import 'codemirror/lib/codemirror.css'
|
||||
|
||||
import 'codemirror/mode/htmlmixed/htmlmixed.js'
|
||||
|
||||
import { highlight, languages } from 'prismjs/components/prism-core'
|
||||
import 'prismjs/components/prism-clike'
|
||||
import 'prismjs/components/prism-markup'
|
||||
import 'prismjs/themes/prism-tomorrow.css' // import syntax highlighting styles
|
||||
import inputMixin from '~/mixins/forms/input.js'
|
||||
|
||||
export default {
|
||||
name: 'CodeInput',
|
||||
|
||||
components: { PrismEditor },
|
||||
components: { codemirror },
|
||||
mixins: [inputMixin],
|
||||
|
||||
methods: {
|
||||
onChange (event) {
|
||||
const file = event.target.files[0]
|
||||
this.$set(this.form, this.name, file)
|
||||
},
|
||||
highlighter (code) {
|
||||
return highlight(code, languages.markup) // languages.<insert language> to return html with markup
|
||||
data () {
|
||||
return {
|
||||
cmOptions: {
|
||||
// codemirror options
|
||||
tabSize: 4,
|
||||
mode: 'text/html',
|
||||
theme: 'default',
|
||||
lineNumbers: true,
|
||||
line: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
</small>
|
||||
<div class="flex">
|
||||
<v-switch :id="id?id:name" v-model="compVal" class="inline-block mr-2" :disabled="disabled" :name="name" @input="$emit('input',$event)" />
|
||||
<span>{{ label }} <span v-if="required" class="text-red-500 required-dot">*</span></span>
|
||||
<slot name="label">
|
||||
<span>{{ label }} <span v-if="required" class="text-red-500 required-dot">*</span></span>
|
||||
</slot>
|
||||
</div>
|
||||
<small v-if="help && helpPosition=='below_input'" :class="theme.default.help">
|
||||
<slot name="help"><span class="field-help" v-html="help" /></slot>
|
||||
|
||||
@@ -49,23 +49,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="getFormCleaningsMsg"
|
||||
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 border-yellow-500"
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<p class="mb-0 py-2 px-4 text-yellow-600">
|
||||
You're seeing this because you are an owner of this form. <br>
|
||||
All your Pro features are de-activated when sharing this form: <br>
|
||||
|
||||
<span v-html="getFormCleaningsMsg" />
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<v-button color="yellow" shade="light" @click="form.cleanings=false">
|
||||
Close
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
<form-cleanings v-if="!adminPreview" :hideable="true" class="mb-4 mx-2" :form="form" :specify-form-owner="true" />
|
||||
|
||||
<transition
|
||||
v-if="!form.is_password_protected && (!isPublicFormPage || (!form.is_closed && !form.max_number_of_submissions_reached && form.visibility!='closed'))"
|
||||
@@ -131,9 +115,10 @@ import { themes } from '~/config/form-themes.js'
|
||||
import VButton from '../../common/Button.vue'
|
||||
import VTransition from '../../common/transitions/VTransition.vue'
|
||||
import FormPendingSubmissionKey from '../../../mixins/forms/form-pending-submission-key.js'
|
||||
import FormCleanings from '../../pages/forms/show/FormCleanings.vue'
|
||||
|
||||
export default {
|
||||
components: { VTransition, VButton, OpenFormButton, OpenForm },
|
||||
components: { VTransition, VButton, OpenFormButton, OpenForm, FormCleanings },
|
||||
|
||||
props: {
|
||||
form: { type: Object, required: true },
|
||||
@@ -166,22 +151,6 @@ export default {
|
||||
theme () {
|
||||
return this.themes[this.themes.hasOwnProperty(this.form.theme) ? this.form.theme : 'default']
|
||||
},
|
||||
getFormCleaningsMsg () {
|
||||
if (this.form.cleanings && Object.keys(this.form.cleanings).length > 0) {
|
||||
let message = ''
|
||||
Object.keys(this.form.cleanings).forEach((key) => {
|
||||
const fieldName = key.charAt(0).toUpperCase() + key.slice(1)
|
||||
let fieldInfo = '<br/>' + fieldName + "<br/><ul class='list-disc list-inside'>"
|
||||
this.form.cleanings[key].forEach((msg) => {
|
||||
fieldInfo = fieldInfo + '<li>' + msg + '</li>'
|
||||
})
|
||||
message = message + fieldInfo + '<ul/>'
|
||||
})
|
||||
|
||||
return message
|
||||
}
|
||||
return false
|
||||
},
|
||||
isPublicFormPage () {
|
||||
return this.$route.name === 'forms.show_public'
|
||||
},
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<form v-else-if="dataForm" @submit.prevent="">
|
||||
<transition name="fade" mode="out-in" appear>
|
||||
<template v-for="group, groupIndex in fieldGroups">
|
||||
<div v-if="currentFieldGroupIndex===groupIndex"
|
||||
:key="groupIndex"
|
||||
<div v-if="currentFieldGroupIndex===groupIndex"
|
||||
:key="groupIndex"
|
||||
class="form-group flex flex-wrap w-full">
|
||||
|
||||
<draggable v-model="currentFields"
|
||||
|
||||
@@ -52,11 +52,6 @@
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Field options -->
|
||||
|
||||
<!-- <div class="flex-grow" v-if="['files'].includes(field.type) || field.type.startsWith('nf-')">-->
|
||||
<!-- <pro-tag/>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<template v-if="removing == field.id">
|
||||
<div class="flex text-sm items-center">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="border border-nt-blue-light bg-blue-50 dark:bg-notion-dark-light rounded-md p-4 mb-5 w-full mx-auto mt-4 select-all">
|
||||
<div v-if="!form.is_pro" class="relative">
|
||||
<div v-if="false" class="relative">
|
||||
<div class="absolute inset-0 z-10">
|
||||
<div class="p-5 max-w-md mx-auto mt-5">
|
||||
<p class="text-center">
|
||||
@@ -102,7 +102,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
getChartData () {
|
||||
if (!this.form || !this.form.is_pro) { return null }
|
||||
if (!this.form) { return null }
|
||||
this.isLoading = true
|
||||
axios.get('/api/open/workspaces/' + this.form.workspace_id + '/form-stats/' + this.form.id).then((response) => {
|
||||
const statsData = response.data
|
||||
|
||||
@@ -21,8 +21,13 @@
|
||||
/>
|
||||
|
||||
<toggle-switch-input name="editable_submissions" :form="form" class="mt-4"
|
||||
label="Allow users to edit their submission"
|
||||
/>
|
||||
help="Gives user a unique url to update their submission"
|
||||
>
|
||||
<template #label>
|
||||
Editable submissions
|
||||
<pro-tag class="ml-1" />
|
||||
</template>
|
||||
</toggle-switch-input>
|
||||
<text-input v-if="form.editable_submissions" name="editable_submissions_button_text"
|
||||
:form="form"
|
||||
label="Text of editable submissions button"
|
||||
@@ -111,7 +116,6 @@
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<pro-tag class="float-right"/>
|
||||
<toggle-switch-input name="re_fillable" :form="form" class="mt-4"
|
||||
label="Allow users to fill the form again"
|
||||
/>
|
||||
|
||||
@@ -12,10 +12,7 @@
|
||||
</h3>
|
||||
</template>
|
||||
<p class="mt-4">
|
||||
The code will be injected in the <span class="font-semibold">head</span> section of your form page. <a href="#" class="text-gray-500"
|
||||
@click.prevent="$crisp.push(['do', 'helpdesk:article:open', ['en', 'how-to-inject-custom-code-in-my-form-1amadj3']])"
|
||||
>Click
|
||||
here to get an example CSS code.</a>
|
||||
The code will be injected in the <span class="font-semibold">head</span> section of your form page.
|
||||
</p>
|
||||
<code-input name="custom_code" class="mt-4"
|
||||
:form="form" help="Custom code cannot be previewed in our editor. Please test your code using
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
</svg>
|
||||
|
||||
Customization
|
||||
<pro-tag />
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
@@ -62,9 +61,12 @@
|
||||
<toggle-switch-input name="hide_title" :form="form" class="mt-4"
|
||||
label="Hide Title"
|
||||
/>
|
||||
<toggle-switch-input name="no_branding" :form="form" class="mt-4"
|
||||
label="Remove OpnForm Branding"
|
||||
/>
|
||||
<toggle-switch-input name="no_branding" :form="form" class="mt-4">
|
||||
<template #label>
|
||||
Remove OpnForm Branding
|
||||
<pro-tag class="ml-1" />
|
||||
</template>
|
||||
</toggle-switch-input>
|
||||
<toggle-switch-input name="uppercase_labels" :form="form" class="mt-4"
|
||||
label="Uppercase Input Labels"
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
label="Protect your form with a Captcha"
|
||||
help="If enabled we will make sure respondant is a human"
|
||||
/>
|
||||
<pro-tag class="float-right" />
|
||||
<text-input name="password" :form="form" class="mt-4"
|
||||
label="Form Password" help="Leave empty to disable password"
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<div v-if="logic" :key="resetKey" class="-mx-4 sm:-mx-6 p-5 border-b">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
Logic
|
||||
<pro-tag/>
|
||||
</h3>
|
||||
<p class="text-gray-400 text-xs mb-5">
|
||||
Add some logic to this block. Start by adding some conditions, and then add some actions.
|
||||
|
||||
@@ -99,7 +99,6 @@
|
||||
<div v-if="field.type === 'number'" class="-mx-4 sm:-mx-6 p-5 border-b">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
Number Options
|
||||
<pro-tag/>
|
||||
</h3>
|
||||
<v-checkbox v-model="field.is_rating" class="mt-4"
|
||||
:name="field.id+'_is_rating'" @input="initRating"
|
||||
@@ -136,7 +135,6 @@
|
||||
<div v-if="field.type === 'date'" class="-mx-4 sm:-mx-6 p-5 border-b">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
Date Options
|
||||
<pro-tag/>
|
||||
</h3>
|
||||
<v-checkbox v-model="field.date_range" class="mt-4"
|
||||
:name="field.id+'_date_range'"
|
||||
@@ -191,7 +189,6 @@
|
||||
<div v-if="['select','multi_select'].includes(field.type)" class="-mx-4 sm:-mx-6 p-5 border-b">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
Select Options
|
||||
<pro-tag/>
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-5 text-xs">
|
||||
Advanced options for your select/multiselect fields.
|
||||
@@ -218,7 +215,6 @@
|
||||
<div v-if="displayBasedOnAdvanced" class="-mx-4 sm:-mx-6 p-5 border-b">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
Customization
|
||||
<pro-tag/>
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-400 mb-5 text-xs">
|
||||
@@ -322,7 +318,6 @@
|
||||
<div v-if="field.type === 'text'" class="-mx-4 sm:-mx-6 p-5 border-b">
|
||||
<h3 class="font-semibold block text-lg">
|
||||
Advanced Options
|
||||
<pro-tag/>
|
||||
</h3>
|
||||
|
||||
<v-checkbox v-model="field.generates_uuid"
|
||||
|
||||
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