Initial commit
This commit is contained in:
103
resources/js/pages/auth/login.vue
Normal file
103
resources/js/pages/auth/login.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex mt-6 mb-10">
|
||||
<div class="w-full md:w-2/3 md:mx-auto md:max-w-md px-4">
|
||||
<h1 class="my-6">
|
||||
{{ $t('login') }}
|
||||
</h1>
|
||||
<form @submit.prevent="login" @keydown="form.onKeydown($event)">
|
||||
<!-- Email -->
|
||||
<text-input name="email" :form="form" :label="$t('email')" :required="true" />
|
||||
|
||||
<!-- Password -->
|
||||
<text-input class="mt-8" native-type="password"
|
||||
name="password" :form="form" :label="$t('password')" :required="true"
|
||||
/>
|
||||
|
||||
<!-- Remember Me -->
|
||||
<div class="relative flex items-center mt-8 mb-6">
|
||||
<v-checkbox v-model="remember" class="w-full md:w-1/2" name="remember">
|
||||
{{ $t('remember_me') }}
|
||||
</v-checkbox>
|
||||
|
||||
<div class="w-full md:w-1/2 text-right">
|
||||
<router-link :to="{ name: 'password.request' }"
|
||||
class="text-xs hover:underline text-gray-500 sm:text-sm hover:text-gray-700"
|
||||
>
|
||||
{{ $t('forgot_password') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<v-button class="w-full" dusk="btn_login" :loading="form.busy">
|
||||
{{ $t('login') }}
|
||||
</v-button>
|
||||
|
||||
<p class="text-center text-gray-500 mt-4">
|
||||
No Account? <router-link :to="{name:'register'}">
|
||||
Register
|
||||
</router-link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Form from 'vform'
|
||||
import Cookies from 'js-cookie'
|
||||
import OpenFormFooter from '../../components/pages/OpenFormFooter'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
OpenFormFooter
|
||||
},
|
||||
|
||||
middleware: 'guest',
|
||||
|
||||
metaInfo () {
|
||||
return { title: this.$t('login') }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
form: new Form({
|
||||
email: '',
|
||||
password: ''
|
||||
}),
|
||||
remember: false
|
||||
}),
|
||||
|
||||
methods: {
|
||||
async login () {
|
||||
// Submit the form.
|
||||
const { data } = await this.form.post('/api/login')
|
||||
|
||||
// Save the token.
|
||||
this.$store.dispatch('auth/saveToken', {
|
||||
token: data.token,
|
||||
remember: this.remember
|
||||
})
|
||||
|
||||
// Fetch the user.
|
||||
await this.$store.dispatch('auth/fetchUser')
|
||||
|
||||
// Redirect home.
|
||||
this.redirect()
|
||||
},
|
||||
|
||||
redirect () {
|
||||
const intendedUrl = Cookies.get('intended_url')
|
||||
|
||||
if (intendedUrl) {
|
||||
Cookies.remove('intended_url')
|
||||
this.$router.push({ path: intendedUrl })
|
||||
} else {
|
||||
this.$router.push({ name: 'home' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
55
resources/js/pages/auth/password/email.vue
Normal file
55
resources/js/pages/auth/password/email.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex mt-6 mb-10">
|
||||
<div class="w-full md:w-2/3 md:mx-auto md:max-w-md px-4">
|
||||
<h1 class="my-6">
|
||||
{{ $t('reset_password') }}
|
||||
</h1>
|
||||
<form @submit.prevent="send" @keydown="form.onKeydown($event)">
|
||||
<alert-success :form="form" :message="status" class="mb-4" />
|
||||
|
||||
<!-- Email -->
|
||||
<text-input name="email" :form="form" :label="$t('email')" :required="true" />
|
||||
|
||||
<!-- Submit Button -->
|
||||
<v-button class="w-full" :loading="form.busy">
|
||||
{{ $t('send_password_reset_link') }}
|
||||
</v-button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Form from 'vform'
|
||||
import OpenFormFooter from '../../../components/pages/OpenFormFooter'
|
||||
|
||||
export default {
|
||||
middleware: 'guest',
|
||||
components: {
|
||||
OpenFormFooter
|
||||
},
|
||||
metaInfo () {
|
||||
return { title: this.$t('reset_password') }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
status: '',
|
||||
form: new Form({
|
||||
email: ''
|
||||
})
|
||||
}),
|
||||
|
||||
methods: {
|
||||
async send () {
|
||||
const { data } = await this.form.post('/api/password/email')
|
||||
|
||||
this.status = data.status
|
||||
|
||||
this.form.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
73
resources/js/pages/auth/password/reset.vue
Normal file
73
resources/js/pages/auth/password/reset.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex mt-6 mb-10">
|
||||
<div class="w-full md:w-2/3 md:mx-auto md:max-w-md px-4">
|
||||
<h1 class="my-6">
|
||||
{{ $t('reset_password') }}
|
||||
</h1>
|
||||
<form @submit.prevent="reset" @keydown="form.onKeydown($event)">
|
||||
<alert-success class="mb-4" :form="form" :message="status" />
|
||||
|
||||
<!-- Email -->
|
||||
<text-input name="email" :form="form" :label="$t('email')" :required="true" />
|
||||
|
||||
<!-- Password -->
|
||||
<text-input class="mt-8" native-type="password"
|
||||
name="password" :form="form" :label="$t('password')" :required="true"
|
||||
/>
|
||||
|
||||
<!-- Password Confirmation-->
|
||||
<text-input class="mt-8" native-type="password"
|
||||
name="password_confirmation" :form="form" :label="$t('confirm_password')" :required="true"
|
||||
/>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<v-button class="w-full" :loading="form.busy">
|
||||
{{ $t('reset_password') }}
|
||||
</v-button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Form from 'vform'
|
||||
import OpenFormFooter from '../../../components/pages/OpenFormFooter'
|
||||
|
||||
export default {
|
||||
middleware: 'guest',
|
||||
components: {
|
||||
OpenFormFooter
|
||||
},
|
||||
metaInfo () {
|
||||
return { title: this.$t('reset_password') }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
status: '',
|
||||
form: new Form({
|
||||
token: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirmation: ''
|
||||
})
|
||||
}),
|
||||
|
||||
created () {
|
||||
this.form.email = this.$route.query.email
|
||||
this.form.token = this.$route.params.token
|
||||
},
|
||||
|
||||
methods: {
|
||||
async reset () {
|
||||
const { data } = await this.form.post('/api/password/reset')
|
||||
|
||||
this.status = data.status
|
||||
|
||||
this.form.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
129
resources/js/pages/auth/register.vue
Normal file
129
resources/js/pages/auth/register.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex mt-6 mb-10">
|
||||
<div class="w-full md:w-2/3 md:mx-auto md:max-w-md px-4">
|
||||
<template v-if="mustVerifyEmail">
|
||||
<h1 class="my-6">
|
||||
{{ $t('register') }}
|
||||
</h1>
|
||||
<div class="text-green-500">
|
||||
{{ $t('verify_email_address') }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h1 class="my-6">
|
||||
{{ $t('register') }}
|
||||
</h1>
|
||||
<form @submit.prevent="register" @keydown="form.onKeydown($event)">
|
||||
<!-- Name -->
|
||||
<text-input name="name" :form="form" :label="$t('name')" :required="true" />
|
||||
|
||||
<!-- Email -->
|
||||
<text-input name="email" :form="form" :label="$t('email')" :required="true" />
|
||||
|
||||
<select-input name="hear_about_us" :options="hearAboutUsOptions" :form="form" label="How did you hear about us?" :required="true" />
|
||||
|
||||
<!-- Password -->
|
||||
<text-input native-type="password"
|
||||
name="password" :form="form" :label="$t('password')" :required="true"
|
||||
/>
|
||||
|
||||
<!-- Password Confirmation-->
|
||||
<text-input class="mb-4" native-type="password"
|
||||
name="password_confirmation" :form="form" :label="$t('confirm_password')" :required="true"
|
||||
/>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<v-button class="w-full" :loading="form.busy">
|
||||
{{ $t('register') }}
|
||||
</v-button>
|
||||
|
||||
<!-- GitHub Register Button -->
|
||||
<login-with-github />
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Form from 'vform'
|
||||
import LoginWithGithub from '~/components/LoginWithGithub'
|
||||
import SelectInput from '../../components/forms/SelectInput'
|
||||
import OpenFormFooter from '../../components/pages/OpenFormFooter'
|
||||
import { initCrisp } from '../../middleware/check-auth'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SelectInput,
|
||||
LoginWithGithub,
|
||||
OpenFormFooter
|
||||
},
|
||||
|
||||
middleware: 'guest',
|
||||
|
||||
metaInfo () {
|
||||
return { title: this.$t('register') }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
form: new Form({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirmation: ''
|
||||
}),
|
||||
mustVerifyEmail: false
|
||||
}),
|
||||
|
||||
computed: {
|
||||
hearAboutUsOptions () {
|
||||
const options = [
|
||||
{ name: 'Facebook', value: 'facebook' },
|
||||
{ name: 'Twitter', value: 'twitter' },
|
||||
{ name: 'Reddit', value: 'reddit' },
|
||||
{ name: 'Github', value: 'github' },
|
||||
{ name: 'Search Engine (Google, DuckDuckGo...)', value: 'search_engine' },
|
||||
{ name: 'Friend or Colleague', value: 'friend_colleague' },
|
||||
{ name: 'Blog/Article', value: 'blog_article' }
|
||||
].map((value) => ({ value, sort: Math.random() }))
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
.map(({ value }) => value)
|
||||
options.push({ name: 'Other', value: 'other' })
|
||||
return options
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async register () {
|
||||
// Register the user.
|
||||
const { data } = await this.form.post('/api/register')
|
||||
|
||||
// Must verify email fist.
|
||||
if (data.status) {
|
||||
this.mustVerifyEmail = true
|
||||
} else {
|
||||
// Log in the user.
|
||||
const { data: { token } } = await this.form.post('/api/login')
|
||||
|
||||
// Save the token.
|
||||
this.$store.dispatch('auth/saveToken', { token })
|
||||
|
||||
// Update the user.
|
||||
await this.$store.dispatch('auth/updateUser', { user: data })
|
||||
|
||||
// Track event
|
||||
this.$logEvent('register', { source: this.form.hear_about_us })
|
||||
initCrisp(data).then(() => {
|
||||
this.$getCrisp().push(['set', 'session:event', [[['register', {}, 'blue']]]])
|
||||
})
|
||||
|
||||
// Redirect home.
|
||||
this.$router.push({ name: 'forms.create' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
59
resources/js/pages/auth/verification/resend.vue
Normal file
59
resources/js/pages/auth/verification/resend.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="row">
|
||||
<div class="col-lg-8 m-auto px-4">
|
||||
<h1 class="my-6">
|
||||
{{ $t('verify_email') }}
|
||||
</h1>
|
||||
<form @submit.prevent="send" @keydown="form.onKeydown($event)">
|
||||
<alert-success :form="form" :message="status" />
|
||||
|
||||
<!-- Email -->
|
||||
<text-input name="email" :form="form" :label="$t('email')" :required="true" />
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="form-group row">
|
||||
<div class="col-md-9 ml-md-auto">
|
||||
<v-button :loading="form.busy">
|
||||
{{ $t('send_verification_link') }}
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Form from 'vform'
|
||||
|
||||
export default {
|
||||
middleware: 'guest',
|
||||
|
||||
metaInfo () {
|
||||
return { title: this.$t('verify_email') }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
status: '',
|
||||
form: new Form({
|
||||
email: ''
|
||||
})
|
||||
}),
|
||||
|
||||
created () {
|
||||
if (this.$route.query.email) {
|
||||
this.form.email = this.$route.query.email
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async send () {
|
||||
const { data } = await this.form.post('/api/email/resend')
|
||||
|
||||
this.status = data.status
|
||||
|
||||
this.form.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
60
resources/js/pages/auth/verification/verify.vue
Normal file
60
resources/js/pages/auth/verification/verify.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="row">
|
||||
<div class="col-lg-8 m-auto px-4">
|
||||
<h1 class="my-6">
|
||||
{{ $t('verify_email') }}
|
||||
</h1>
|
||||
<template v-if="success">
|
||||
<div class="alert alert-success" role="alert">
|
||||
{{ success }}
|
||||
</div>
|
||||
|
||||
<router-link :to="{ name: 'login' }" class="btn btn-primary">
|
||||
{{ $t('login') }}
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{{ error || $t('failed_to_verify_email') }}
|
||||
</div>
|
||||
|
||||
<router-link :to="{ name: 'verification.resend' }" class="small float-right">
|
||||
{{ $t('resend_verification_link') }}
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
|
||||
const qs = (params) => Object.keys(params).map(key => `${key}=${params[key]}`).join('&')
|
||||
|
||||
export default {
|
||||
async beforeRouteEnter (to, from, next) {
|
||||
try {
|
||||
const { data } = await axios.post(`/api/email/verify/${to.params.id}?${qs(to.query)}`)
|
||||
|
||||
next(vm => {
|
||||
vm.success = data.status
|
||||
})
|
||||
} catch (e) {
|
||||
next(vm => {
|
||||
vm.error = e.response.data.status
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
middleware: 'guest',
|
||||
|
||||
metaInfo () {
|
||||
return { title: this.$t('verify_email') }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
error: '',
|
||||
success: ''
|
||||
})
|
||||
}
|
||||
</script>
|
||||
36
resources/js/pages/community/students-academics-ngos.vue
Normal file
36
resources/js/pages/community/students-academics-ngos.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mt-6 flex flex-col">
|
||||
<div class="w-full md:max-w-3xl md:mx-auto px-4 md:pt-16 pb-10">
|
||||
<h1 class="sm:text-5xl mb-4">
|
||||
OpnForm Discount for Students, Academics and NGOs
|
||||
</h1>
|
||||
<notion-page page-id="c65a499d39834e0b8978556a8d7af867" />
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NotionPage from '../../components/open/NotionPage'
|
||||
import OpenFormFooter from '../../components/pages/OpenFormFooter'
|
||||
|
||||
export default {
|
||||
components: { OpenFormFooter, NotionPage },
|
||||
layout: 'default',
|
||||
|
||||
props: {
|
||||
metaTitle: { type: String, default: 'OpnForm Discount for Students, Academics and NGOs' },
|
||||
metaDescription: { type: String, default: 'If you are a student, an academic of if you work for a NGO we are happy to offer you a 40% discount on your OpnForm Pro subscription.' }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
}),
|
||||
|
||||
computed: {},
|
||||
|
||||
mounted () {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
23
resources/js/pages/errors/404.vue
Normal file
23
resources/js/pages/errors/404.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="flex mt-6">
|
||||
<div class="w-full md:w-2/3 md:mx-auto md:max-w-md">
|
||||
<img alt="Nice plant as we have nothing else to show!" :src="asset('img/icons/plant.png')" class="w-56 mb-5">
|
||||
|
||||
<h1 class="mb-4 font-semibold text-3xl text-gray-900">
|
||||
{{ $t('page_not_found') }}
|
||||
</h1>
|
||||
|
||||
<div class="links">
|
||||
<router-link :to="{ name: 'welcome' }" class="hover:underline text-gray-700">
|
||||
{{ $t('go_home') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'NotFound'
|
||||
}
|
||||
</script>
|
||||
255
resources/js/pages/forms/create.vue
Normal file
255
resources/js/pages/forms/create.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap flex-col">
|
||||
<!-- Step 1: Select Database -->
|
||||
<div ref="progress" class="w-full px-4 " :class="{
|
||||
'md:mx-auto md:max-w-5xl':currentStep===0}"
|
||||
>
|
||||
<div class="flex items-center justify-between pb-2">
|
||||
<v-button v-if="currentStep>0" color="gray" shade="light" class="hidden md:block mx-4 flex-shrink-0"
|
||||
@click="goBack"
|
||||
>
|
||||
Previous
|
||||
</v-button>
|
||||
<v-button v-if="currentStep>0" :loading="loading || createFormLoading" color="nt-blue"
|
||||
class="v-last-step hidden md:block mx-4 flex-shrink-0"
|
||||
@click="nextStep"
|
||||
>
|
||||
{{ currentStep !== 1 ? 'Continue' : 'Create Form' }}
|
||||
</v-button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<transition v-if="stateReady" name="fade" mode="out-in">
|
||||
<!-- Step1: Form Customization -->
|
||||
<div v-if="currentStep===1" key="2">
|
||||
<form-editor v-if="!workspacesLoading" ref="editor"
|
||||
class="w-full flex border-t flex-grow"
|
||||
:style="{
|
||||
'max-height': editorMaxHeight + 'px'
|
||||
}" :error="error"
|
||||
:validation-error-response="validationErrorResponse"
|
||||
@mounted="onResize"
|
||||
/>
|
||||
<div v-else class="text-center mt-4 py-6">
|
||||
<loader class="h-6 w-6 text-nt-blue mx-auto"/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div v-if="currentStep===1" class="md:hidden pt-4 mb-16 px-6 border-t flex justify-between">
|
||||
<v-button color="gray" shade="light" class="mt-2" @click="previousStep">
|
||||
Previous
|
||||
</v-button>
|
||||
<v-button v-track.create_form_click :loading="createFormLoading" color="nt-blue" class="mt-2 px-5 v-last-step"
|
||||
@click="nextStep"
|
||||
>
|
||||
Create Form
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Form from 'vform'
|
||||
import {mapState, mapActions} from 'vuex'
|
||||
import saveUpdateAlert from '../../mixins/forms/saveUpdateAlert'
|
||||
import clonedeep from 'clone-deep'
|
||||
|
||||
const FormEditor = () => import('../../components/open/forms/components/FormEditor')
|
||||
export default {
|
||||
name: 'CreateForm',
|
||||
components: {
|
||||
FormEditor,
|
||||
},
|
||||
|
||||
metaInfo() {
|
||||
return {title: 'Create a new Form'}
|
||||
},
|
||||
|
||||
mixins: [saveUpdateAlert],
|
||||
|
||||
middleware: 'auth',
|
||||
|
||||
data() {
|
||||
return {
|
||||
stateReady: false,
|
||||
validationErrorResponse: null,
|
||||
loading: false,
|
||||
createFormLoading: false,
|
||||
error: '',
|
||||
createdFormId: null,
|
||||
currentStep: 1,
|
||||
|
||||
editorMaxHeight: 500
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState({
|
||||
workspaces: state => state['open/workspaces'].content,
|
||||
workspacesLoading: state => state['open/workspaces'].loading,
|
||||
user: state => state.auth.user
|
||||
}),
|
||||
form: {
|
||||
get() {
|
||||
return this.$store.state['open/working_form'].content
|
||||
},
|
||||
/* We add a setter */
|
||||
set(value) {
|
||||
this.$store.commit('open/working_form/set', value)
|
||||
}
|
||||
},
|
||||
workspace() {
|
||||
return this.$store.getters['open/workspaces/getCurrent']()
|
||||
},
|
||||
createdForm() {
|
||||
return this.$store.getters['open/forms/getById'](this.createdFormId)
|
||||
},
|
||||
fromOnboarding() {
|
||||
return this.$route.params.from_onboarding
|
||||
},
|
||||
fbGroupLink() {
|
||||
return window.config.links.facebook_group
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
workspace() {
|
||||
if (this.workspace) {
|
||||
this.form.workspace_id = this.workspace.id
|
||||
}
|
||||
},
|
||||
user() {
|
||||
this.stateReady = true
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.initForm()
|
||||
this.closeAlert()
|
||||
this.loadWorkspaces()
|
||||
|
||||
this.stateReady = this.user !== null
|
||||
},
|
||||
|
||||
created() {
|
||||
window.addEventListener('resize', this.onResize)
|
||||
},
|
||||
destroyed() {
|
||||
window.removeEventListener('resize', this.onResize)
|
||||
},
|
||||
|
||||
methods: {
|
||||
...mapActions({
|
||||
loadWorkspaces: 'open/workspaces/loadIfEmpty'
|
||||
}),
|
||||
initForm() {
|
||||
this.form = new Form({
|
||||
title: 'My Form',
|
||||
description: null,
|
||||
workspace_id: this.workspace?.id,
|
||||
properties: [],
|
||||
|
||||
notifies: false,
|
||||
send_submission_confirmation: false,
|
||||
webhook_url: null,
|
||||
|
||||
// Customization
|
||||
theme: 'default',
|
||||
width: 'centered',
|
||||
dark_mode: 'auto',
|
||||
color: '#3B82F6',
|
||||
hide_title: false,
|
||||
no_branding: false,
|
||||
uppercase_labels: true,
|
||||
transparent_background: false,
|
||||
closes_at: null,
|
||||
closed_text: 'This form has now been closed by its owner and does not accept submissions anymore.',
|
||||
|
||||
// Submission
|
||||
submit_button_text: 'Submit',
|
||||
re_fillable: false,
|
||||
re_fill_button_text: 'Fill Again',
|
||||
submitted_text: 'Amazing, we saved your answers. Thank you for your time and have a great day!',
|
||||
notification_sender: 'OpnForm',
|
||||
notification_subject: 'We saved your answers',
|
||||
notification_body: 'Hello there 👋 <br>This is a confirmation that your submission was successfully saved.',
|
||||
notifications_include_submission: true,
|
||||
use_captcha: false,
|
||||
is_rating: false,
|
||||
rating_max_value: 5,
|
||||
max_submissions_count: null,
|
||||
max_submissions_reached_text: 'This form has now reached the maximum number of allowed submissions and is now closed.',
|
||||
|
||||
// Security & Privacy
|
||||
can_be_indexed: true
|
||||
})
|
||||
},
|
||||
nextStep() {
|
||||
this.error = ''
|
||||
if (this.currentStep === 0) {
|
||||
this.form.workspace = clonedeep(this.workspace)
|
||||
// Init editor max height
|
||||
this.currentStep++
|
||||
this.$nextTick(() => {
|
||||
this.editorMaxHeight = window.innerHeight - (this.$refs.progress.offsetTop + this.$refs.progress.offsetHeight)
|
||||
})
|
||||
return
|
||||
} else if (this.currentStep === 1) {
|
||||
return this.submit()
|
||||
}
|
||||
this.currentStep++
|
||||
},
|
||||
submit() {
|
||||
if (this.loading) return
|
||||
this.form.workspace_id = this.workspace.id
|
||||
this.validationErrorResponse = null
|
||||
|
||||
this.createFormLoading = true
|
||||
this.form.post('/api/open/forms').then((response) => {
|
||||
this.$store.commit('open/forms/addOrUpdate', response.data.form)
|
||||
this.createdFormId = response.data.form.id
|
||||
|
||||
this.$logEvent('form_created', {form_id: response.data.form.id, form_slug: response.data.form.slug})
|
||||
this.$getCrisp().push(['set', 'session:event', [[['form_created', {
|
||||
form_id: response.data.form.id,
|
||||
form_slug: response.data.form.slug
|
||||
}, 'blue']]]])
|
||||
this.displayFormModificationAlert(response.data)
|
||||
this.$router.push({
|
||||
name: 'forms.show',
|
||||
params: {
|
||||
slug: this.createdForm.slug,
|
||||
new_form: response.data.users_first_form
|
||||
}
|
||||
})
|
||||
}).catch((error) => {
|
||||
if (error.response && error.response.status === 422) {
|
||||
this.validationErrorResponse = error.response.data
|
||||
this.$refs.editor.showValidationErrors()
|
||||
}
|
||||
}).finally(() => {
|
||||
this.createFormLoading = false
|
||||
})
|
||||
},
|
||||
previousStep() {
|
||||
if (this.currentStep > 0) {
|
||||
this.currentStep--
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Compute max height of editor
|
||||
*/
|
||||
onResize() {
|
||||
if (this.$refs.editor) {
|
||||
this.editorMaxHeight = window.innerHeight - this.$refs.editor.$el.offsetTop
|
||||
}
|
||||
},
|
||||
goBack() {
|
||||
return this.$router.back();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
161
resources/js/pages/forms/edit.vue
Normal file
161
resources/js/pages/forms/edit.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="w-full flex flex-col">
|
||||
<div class="flex justify-center md:justify-between pb-2 px-2">
|
||||
<div class="hidden md:block" />
|
||||
<breadcrumb class="hidden md:flex sm:px-6 mx-auto max-w-lg" :path="breadcrumbs" />
|
||||
<v-button v-if="!loading && updatedForm"
|
||||
v-track.save_form_click
|
||||
lass="hidden md:block"
|
||||
:loading="updateFormLoading" @click="saveForm"
|
||||
>
|
||||
Save changes
|
||||
</v-button>
|
||||
</div>
|
||||
<form-editor v-if="pageLoaded" ref="editor"
|
||||
:style="{
|
||||
'max-height': editorMaxHeight + 'px'
|
||||
}"
|
||||
:validation-error-response="validationErrorResponse"
|
||||
@mounted="onResize"
|
||||
/>
|
||||
<div v-else-if="!loading && error" class="mt-4 rounded-lg max-w-xl mx-auto p-6 bg-red-100 text-red-500">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-else class="text-center mt-4 py-6">
|
||||
<loader class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import store from '~/store'
|
||||
import Breadcrumb from '../../components/common/Breadcrumb'
|
||||
|
||||
import Form from 'vform'
|
||||
|
||||
import saveUpdateAlert from '../../mixins/forms/saveUpdateAlert'
|
||||
import { mapState } from 'vuex'
|
||||
const FormEditor = () => import('../../components/open/forms/components/FormEditor')
|
||||
|
||||
const loadForms = function () {
|
||||
store.commit('open/forms/startLoading')
|
||||
store.dispatch('open/workspaces/loadIfEmpty').then(() => {
|
||||
store.dispatch('open/forms/load', store.state['open/workspaces'].currentId)
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'EditForm',
|
||||
components: { Breadcrumb, FormEditor },
|
||||
mixins: [saveUpdateAlert],
|
||||
|
||||
beforeRouteEnter (to, from, next) {
|
||||
if (!store.getters['open/forms/getBySlug'](to.params.slug)) {
|
||||
loadForms()
|
||||
}
|
||||
next()
|
||||
},
|
||||
middleware: 'auth',
|
||||
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
updateFormLoading: false,
|
||||
error: null,
|
||||
validationErrorResponse: null,
|
||||
|
||||
editorMaxHeight: 500
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState({
|
||||
formsLoading: state => state['open/forms'].loading
|
||||
}),
|
||||
updatedForm: {
|
||||
get () {
|
||||
return this.$store.state['open/working_form'].content
|
||||
},
|
||||
/* We add a setter */
|
||||
set (value) {
|
||||
this.$store.commit('open/working_form/set', value)
|
||||
}
|
||||
},
|
||||
form () {
|
||||
return this.$store.getters['open/forms/getBySlug'](this.$route.params.slug)
|
||||
},
|
||||
breadcrumbs () {
|
||||
if (!this.form) {
|
||||
return [{ route: { name: 'home' }, label: 'Your Forms' }]
|
||||
}
|
||||
return [
|
||||
{ route: { name: 'home' }, label: 'Your Forms' },
|
||||
{ label: this.form ? this.form.title : 'Your Form', route: { name: 'forms.show', params: { slug: this.form.slug } } },
|
||||
{ label: 'Edit' }
|
||||
]
|
||||
},
|
||||
formEndpoint: () => '/api/open/forms/{id}/',
|
||||
pageLoaded () {
|
||||
return !this.loading && this.updatedForm !== null
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
form () {
|
||||
this.updatedForm = new Form(this.form)
|
||||
}
|
||||
},
|
||||
|
||||
created () {
|
||||
window.addEventListener('resize', this.onResize)
|
||||
},
|
||||
destroyed () {
|
||||
window.removeEventListener('resize', this.onResize)
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.closeAlert()
|
||||
if (!this.form) {
|
||||
loadForms()
|
||||
} else {
|
||||
this.updatedForm = new Form(this.form)
|
||||
}
|
||||
},
|
||||
|
||||
metaInfo () {
|
||||
return { title: 'Edit ' + (this.form ? this.form.title : 'Your Form') }
|
||||
},
|
||||
|
||||
methods: {
|
||||
saveForm () {
|
||||
if (this.updateFormLoading) return
|
||||
|
||||
this.updateFormLoading = true
|
||||
this.validationErrorResponse = null
|
||||
this.updatedForm.put(this.formEndpoint.replace('{id}', this.form.id)).then((response) => {
|
||||
const data = response.data
|
||||
this.$store.commit('open/forms/addOrUpdate', data.form)
|
||||
this.$router.push({ name: 'forms.show', params: { slug: this.form.slug } })
|
||||
this.$logEvent('form_saved', { form_id: this.form.id, form_slug: this.form.slug })
|
||||
this.displayFormModificationAlert(data)
|
||||
}).catch((error) => {
|
||||
if (error.response.status === 422) {
|
||||
this.validationErrorResponse = error.response.data
|
||||
this.$refs.editor.showValidationErrors()
|
||||
}
|
||||
}).finally(() => {
|
||||
this.updateFormLoading = false
|
||||
})
|
||||
},
|
||||
/**
|
||||
* Compute max height of editor
|
||||
*/
|
||||
onResize () {
|
||||
if (this.$refs.editor) {
|
||||
this.editorMaxHeight = Math.max(500, window.innerHeight - this.$refs.editor.$el.offsetTop)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
180
resources/js/pages/forms/show-public.vue
Normal file
180
resources/js/pages/forms/show-public.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div v-if="form && !isIframe && (form.logo_picture || form.cover_picture)">
|
||||
<div v-if="form.cover_picture">
|
||||
<div id="cover-picture" class="max-h-56 w-full overflow-hidden flex items-center justify-center">
|
||||
<img alt="Form Cover Picture" :src="form.cover_picture" class="w-full">
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="form.logo_picture" class="w-full p-5 relative mx-auto"
|
||||
:class="{'pt-20':!form.cover_picture, 'md:w-3/5 lg:w-1/2 md:max-w-2xl': form.width === 'centered', 'max-w-7xl': (form.width === 'full' && !isIframe) }"
|
||||
>
|
||||
<img alt="Logo Picture" :src="form.logo_picture"
|
||||
:class="{'top-5':!form.cover_picture, '-top-10':form.cover_picture}"
|
||||
class="w-20 h-20 absolute left-5 transition-all"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full mx-auto px-4"
|
||||
:class="{'mt-6':!isIframe, 'md:w-3/5 lg:w-1/2 md:max-w-2xl': form && (form.width === 'centered'), 'max-w-7xl': (form && form.width === 'full' && !isIframe)}"
|
||||
>
|
||||
<div v-if="!formLoading && !form">
|
||||
<h1 class="mt-6" v-text="'Whoops'" />
|
||||
<p class="mt-6">
|
||||
Unfortunately we could not find this form. It may have been deleted by it's author.
|
||||
</p>
|
||||
<p class="mb-10 mt-4">
|
||||
<router-link :to="{name:'welcome'}">
|
||||
Create your form for free with OpnForm
|
||||
</router-link>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="formLoading">
|
||||
<p class="text-center mt-6 p-4">
|
||||
<loader class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
</p>
|
||||
</div>
|
||||
<open-complete-form v-else ref="open-complete-form" :form="form" class="mb-10" @password-entered="passwordEntered" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import store from '~/store'
|
||||
import { mapState } from 'vuex'
|
||||
import OpenCompleteForm from '../../components/open/forms/OpenCompleteForm'
|
||||
import Cookies from 'js-cookie'
|
||||
import sha256 from 'js-sha256'
|
||||
|
||||
const isFrame = window.location !== window.parent.location || window.frameElement
|
||||
|
||||
function handleDarkMode (form) {
|
||||
// Dark mode
|
||||
const body = document.body
|
||||
if (form.dark_mode === 'dark') {
|
||||
body.classList.add('dark')
|
||||
} else if (form.dark_mode === 'light') {
|
||||
body.classList.remove('dark')
|
||||
} else if (form.dark_mode === 'auto' && isFrame) {
|
||||
// Remove dark mode if embed in a notion basic site
|
||||
let parentUrl
|
||||
try {
|
||||
parentUrl = window.location.ancestorOrigins[0]
|
||||
} catch (e) {
|
||||
parentUrl = (window.location !== window.parent.location)
|
||||
? document.referrer
|
||||
: document.location.href
|
||||
}
|
||||
if (parentUrl.includes('.notion.site')) {
|
||||
body.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleTransparentMode (form) {
|
||||
const isFrame = window.location !== window.parent.location || window.frameElement
|
||||
if (!isFrame || !form.transparent_background) return
|
||||
|
||||
const app = document.getElementById('app')
|
||||
app.classList.remove('bg-white')
|
||||
app.classList.remove('dark:bg-notion-dark')
|
||||
app.classList.add('bg-transparent')
|
||||
}
|
||||
|
||||
function loadForm (slug) {
|
||||
if (store.state['open/forms'].loading) return
|
||||
store.commit('open/forms/startLoading')
|
||||
return axios.get('/api/forms/' + slug).then((response) => {
|
||||
const form = response.data
|
||||
store.commit('open/forms/set', [response.data])
|
||||
|
||||
// Custom code injection
|
||||
if (form.custom_code) {
|
||||
const scriptEl = document.createRange().createContextualFragment(form.custom_code)
|
||||
document.head.append(scriptEl)
|
||||
}
|
||||
|
||||
handleDarkMode(form)
|
||||
handleTransparentMode(form)
|
||||
|
||||
store.commit('open/forms/stopLoading')
|
||||
}).catch(() => {
|
||||
store.commit('open/forms/stopLoading')
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
components: { OpenCompleteForm },
|
||||
|
||||
beforeRouteEnter (to, from, next) {
|
||||
if (window.$crisp) {
|
||||
window.$crisp.push(['do', 'chat:hide'])
|
||||
}
|
||||
next()
|
||||
},
|
||||
|
||||
beforeRouteLeave (to, from, next) {
|
||||
if (window.$crisp) {
|
||||
window.$crisp.push(['do', 'chat:show'])
|
||||
}
|
||||
next()
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
submitted: false
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
loadForm(this.formSlug).then(() => {
|
||||
if (this.isIframe) return
|
||||
// Auto focus on first input
|
||||
const visibleElements = []
|
||||
document.querySelectorAll('input,button').forEach(ele => {
|
||||
if (ele.offsetWidth !== 0 || ele.offsetHeight !== 0) {
|
||||
visibleElements.push(ele)
|
||||
}
|
||||
})
|
||||
if (visibleElements.length > 0) {
|
||||
visibleElements[0].focus()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
passwordEntered (password) {
|
||||
Cookies.set('password-' + this.form.slug, sha256(password), { expires: 7 })
|
||||
loadForm(this.formSlug).then(() => {
|
||||
if (this.form.is_password_protected) {
|
||||
this.$refs['open-complete-form'].addPasswordError('Invalid password.')
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState({
|
||||
forms: state => state['open/forms'].content,
|
||||
formLoading: state => state['open/forms'].loading
|
||||
}),
|
||||
formSlug () {
|
||||
return this.$route.params.slug
|
||||
},
|
||||
form () {
|
||||
return this.$store.getters['open/forms/getBySlug'](this.formSlug)
|
||||
},
|
||||
isIframe () {
|
||||
return window.location !== window.parent.location || window.frameElement
|
||||
},
|
||||
metaTitle () {
|
||||
return this.form ? this.form.title : 'Create beautiful forms'
|
||||
},
|
||||
metaTags () {
|
||||
return (this.form && this.form.can_be_indexed) ? [] : [{ name: 'robots', content: 'noindex' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
462
resources/js/pages/forms/show.vue
Normal file
462
resources/js/pages/forms/show.vue
Normal file
@@ -0,0 +1,462 @@
|
||||
<template>
|
||||
<div class="flex mt-6">
|
||||
<div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl px-4">
|
||||
<breadcrumb class="sm:px-6" :path="breadcrumbs" />
|
||||
<div v-if="form" class="sm:px-6">
|
||||
<h2 class="text-nt-blue text-3xl font-bold z-10 mt-6 mb-3">
|
||||
{{ form.title }}
|
||||
</h2>
|
||||
|
||||
<p class="mb-3">
|
||||
<span v-if="form.views_count">This form has been seen
|
||||
<span class="font-semibold">{{ form.views_count }}</span> time{{ form.views_count > 0 ? 's' : '' }}
|
||||
and it has received
|
||||
<span class="font-semibold">{{ form.submissions_count }}</span> submission{{ form.submissions_count > 0 ? 's' : '' }}.</span>
|
||||
</p>
|
||||
|
||||
<p v-if="form.closes_at" class="text-yellow-500">
|
||||
<span v-if="form.is_closed"> This form stopped accepting submissions on the {{ displayClosesDate }} </span>
|
||||
<span v-else> This form will stop accepting submissions on the {{ displayClosesDate }} </span>
|
||||
</p>
|
||||
|
||||
<p v-if="form.max_submissions_count > 0" class="text-yellow-500">
|
||||
<span v-if="form.max_number_of_submissions_reached"> The form is now closed because it reached its limit of {{ form.max_submissions_count }} submissions. </span>
|
||||
<span v-else> This form will stop accepting submissions after {{ form.max_submissions_count }} submissions. </span>
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<share-form-url :form="form" :link="true" />
|
||||
</div>
|
||||
|
||||
<!-- Open Form -->
|
||||
<div class="flex flex-wrap -mx-2">
|
||||
<!-- Edit Form -->
|
||||
<div class="w-full sm:w-1/2 px-2 flex">
|
||||
<div v-track.edit_form_click="{form_id:form.id, form_slug:form.slug}"
|
||||
class="group relative transition-all mt-4 flex items-center p-3 px-6 w-full rounded-md bg-gray-50 dark:bg-gray-700 hover:bg-blue-50 dark:hover:bg-blue-900 cursor-pointer hover:text-blue-500"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-4 "
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-semibold group relative-hover:text-blue-500">
|
||||
Edit form
|
||||
</span>
|
||||
<router-link :to="{name:'forms.edit',params:{slug:form.slug}}" class="absolute inset-0" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Open Form -->
|
||||
<div class="w-full sm:w-1/2 px-2 flex">
|
||||
<div
|
||||
v-track.open_form_click="{form_id:form.id, form_slug:form.slug}" class="group relative transition-all mt-4 flex items-center p-3 px-6 w-full rounded-md bg-gray-50 dark:bg-gray-700
|
||||
hover:bg-blue-50 dark:hover:bg-blue-500 cursor-pointer hover:text-blue-500 dark:hover:text-white"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-4 "
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-semibold group relative-hover:text-blue-500">
|
||||
Open form
|
||||
</span>
|
||||
<a target="_blank" :href="form.share_url" class="absolute inset-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share/Embed form table -->
|
||||
<div class="w-full sm:w-1/2 px-2 flex">
|
||||
<div
|
||||
v-track.share_embed_form_click="{form_id:form.id, form_slug:form.slug}"
|
||||
class="group relative transition-all mt-4 flex items-center p-3 px-6 w-full rounded-md bg-gray-50 dark:bg-gray-700 hover:bg-blue-50 dark:hover:bg-blue-900 cursor-pointer hover:text-blue-500"
|
||||
@click.prevent="showShareEmbedFormModal=true"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-4 "
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-semibold group relative-hover:text-blue-500">
|
||||
Share/Embed form
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Regenerate form link -->
|
||||
<div class="w-full sm:w-1/2 px-2 flex">
|
||||
<div v-track.regenerate_form_link_click="{form_id:form.id, form_slug:form.slug}"
|
||||
class="group relative transition-all mt-4 flex items-center p-3 px-6 w-full rounded-md bg-gray-50 dark:bg-gray-700 hover:bg-blue-50 dark:hover:bg-blue-900 cursor-pointer hover:text-blue-500"
|
||||
@click="showGenerateFormLinkModal=true"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-4 "
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-semibold group relative-hover:text-blue-500">
|
||||
Regenerate form link
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-1/2 px-2 flex">
|
||||
<div v-track.url_form_prefill_click="{form_id:form.id, form_slug:form.slug}"
|
||||
class="group relative transition-all mt-4 flex items-center p-3 px-6 w-full rounded-md bg-gray-50 dark:bg-gray-700 hover:bg-blue-50 dark:hover:bg-blue-900 cursor-pointer hover:text-blue-500"
|
||||
@click="showUrlFormPrefillModal=true"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-4" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M17 16v2a2 2 0 01-2 2H5a2 2 0 01-2-2v-7a2 2 0 012-2h2m3-4H9a2 2 0 00-2 2v7a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-1m-1 4l-3 3m0 0l-3-3m3 3V3"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-semibold group relative-hover:text-blue-500">
|
||||
Url form pre-fill <pro-tag class="ml-2" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-1/2 px-2 flex">
|
||||
<div v-track.duplicate_form_click="{form_id:form.id, form_slug:form.slug}"
|
||||
class="group relative transition-all mt-4 flex items-center p-3 px-6 w-full rounded-md bg-gray-50 dark:bg-gray-700 hover:bg-blue-50 dark:hover:bg-blue-900 cursor-pointer hover:text-blue-500"
|
||||
@click="duplicateForm"
|
||||
>
|
||||
<template v-if="!loadingDuplicate">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-4 "
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-semibold group relative-hover:text-blue-500">
|
||||
Duplicate form
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<loader class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-1/2 px-2 flex mb-5">
|
||||
<div v-track.delete_form_click="{form_id:form.id, form_slug:form.slug}"
|
||||
class="group relative transition-all mt-4 flex items-center p-3 px-6 w-full rounded-md bg-gray-50 dark:bg-gray-700 hover:bg-red-50 dark:hover:bg-red-900 cursor-pointer hover:text-red-500"
|
||||
@click="alertConfirm('Do you really want to delete this form?',deleteForm)"
|
||||
>
|
||||
<template v-if="!loadingDelete">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-4"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-semibold group relative-hover:text-red-500">
|
||||
Delete form
|
||||
</span>
|
||||
</template>
|
||||
<loader v-else class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Submissions -->
|
||||
<div class="pt-5 mt-5 border-t" id="table-page" v-if="form">
|
||||
<form-submissions />
|
||||
</div>
|
||||
|
||||
<!-- Form Analytics -->
|
||||
<div class="pt-5 mt-5 border-t">
|
||||
<h3 class="font-semibold">
|
||||
Form Analytics (last 30 days)
|
||||
</h3>
|
||||
<form-stats :form="form" />
|
||||
</div>
|
||||
|
||||
<!-- Share/Embed form modal -->
|
||||
<modal :show="showShareEmbedFormModal" @close="showShareEmbedFormModal=false">
|
||||
<div class="px-4">
|
||||
<h2 class="text-nt-blue text-3xl font-bold mb-6">
|
||||
Share/Embed your form
|
||||
</h2>
|
||||
|
||||
<!-- Link -->
|
||||
<h3 class="font-bold text-xl border-t pt-4">
|
||||
Share
|
||||
</h3>
|
||||
<p>Share your form using the link below:</p>
|
||||
<share-form-url :form="form" />
|
||||
|
||||
<!-- Embed -->
|
||||
<h3 class="font-bold text-xl border-t pt-4">
|
||||
Embed
|
||||
</h3>
|
||||
<p>
|
||||
Embed your form on your website by copying the html code below.
|
||||
</p>
|
||||
<embed-form-code :form="form" />
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<v-button color="gray" shade="light" @click="showShareEmbedFormModal=false">Close</v-button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</modal>
|
||||
|
||||
<!-- Regenerate form link modal -->
|
||||
<modal :show="showGenerateFormLinkModal" @close="showGenerateFormLinkModal=false">
|
||||
<div class="-m-6">
|
||||
<div class="p-6">
|
||||
<h2 class="text-nt-blue text-3xl font-bold mb-6">
|
||||
Generate new form link
|
||||
</h2>
|
||||
<p>
|
||||
You can choose between two different URL formats for your form. <span class="font-semibold">Be careful, changing your form URL
|
||||
is not a reversible operation</span>. Make sure to udpate your form URL everywhere where it's used.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-t py-4 mt-4 px-6">
|
||||
<h3 class="text-xl text-gray-700 font-semibold">
|
||||
Human Readable URL
|
||||
</h3>
|
||||
<p>If your users are going to see this url, you might want to make nice and readable. Example:</p>
|
||||
<p class="text-gray-600 p-4 bg-gray-100 rounded-md mt-4">
|
||||
https://opnform.com/forms/contact
|
||||
</p>
|
||||
<div class="text-center mt-4">
|
||||
<v-button :loading="loadingNewLink" @click="regenerateLink('slug')">
|
||||
Generate a Human Readable URL
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t pt-4 mt-4 px-6 pb-10">
|
||||
<h3 class="text-xl text-gray-700 font-semibold">
|
||||
Random ID URL
|
||||
</h3>
|
||||
<p>
|
||||
If your user are not going to see your form url (if it's embedded), and if you prefer to have a random
|
||||
non-guessable URL. Example:
|
||||
</p>
|
||||
<p class="text-gray-600 p-4 bg-gray-100 rounded-md mt-4">
|
||||
https://opnform.com/forms/b4417f9c-34ae-4421-8006-832ee47786e7
|
||||
</p>
|
||||
<div class="text-center mt-4">
|
||||
<v-button :loading="loadingNewLink" @click="regenerateLink('uuid')">
|
||||
Generate a Random ID URL
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4 pb-5 px-6">
|
||||
<v-button color="gray" shade="light" @click="showGenerateFormLinkModal=false">Close</v-button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</modal>
|
||||
|
||||
<url-form-prefill-modal :form="form" :show="showUrlFormPrefillModal" @close="showUrlFormPrefillModal=false" />
|
||||
</div>
|
||||
<div v-else-if="loading" class="text-center w-full p-5">
|
||||
<loader class="h-6 w-6 mx-auto" />
|
||||
</div>
|
||||
<div v-else>
|
||||
Form not found.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import store from '~/store'
|
||||
import Form from 'vform'
|
||||
import ShareFormUrl from '../../components/open/forms/components/ShareFormUrl'
|
||||
import EmbedFormCode from '../../components/open/forms/components/EmbedFormCode'
|
||||
import Breadcrumb from '../../components/common/Breadcrumb'
|
||||
import { mapGetters, mapState } from 'vuex'
|
||||
import ProTag from '../../components/common/ProTag'
|
||||
import UrlFormPrefillModal from '../../components/pages/forms/UrlFormPrefillModal'
|
||||
import FormStats from '../../components/open/forms/components/FormStats'
|
||||
import FormSubmissions from '../../components/open/forms/components/FormSubmissions'
|
||||
|
||||
const loadForms = function () {
|
||||
store.commit('open/forms/startLoading')
|
||||
store.dispatch('open/workspaces/loadIfEmpty').then(() => {
|
||||
store.dispatch('open/forms/load', store.state['open/workspaces'].currentId)
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'EditForm',
|
||||
components: { UrlFormPrefillModal, ProTag, Breadcrumb, ShareFormUrl, EmbedFormCode, FormStats, FormSubmissions },
|
||||
|
||||
beforeRouteEnter (to, from, next) {
|
||||
loadForms()
|
||||
next()
|
||||
},
|
||||
|
||||
beforeRouteLeave (to, from, next) {
|
||||
this.workingForm = null
|
||||
next()
|
||||
},
|
||||
middleware: 'auth',
|
||||
|
||||
data () {
|
||||
return {
|
||||
loadingDuplicate: false,
|
||||
loadingDelete: false,
|
||||
loadingNewLink: false,
|
||||
showNotionEmbedModal: false,
|
||||
showShareEmbedFormModal: false,
|
||||
showUrlFormPrefillModal: false,
|
||||
showGenerateFormLinkModal: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
user: 'auth/user'
|
||||
}),
|
||||
...mapState({
|
||||
formsLoading: state => state['open/forms'].loading,
|
||||
workspacesLoading: state => state['open/workspaces'].loading
|
||||
}),
|
||||
workingForm: {
|
||||
get () {
|
||||
return this.$store.state['open/working_form'].content
|
||||
},
|
||||
set (value) {
|
||||
this.$store.commit('open/working_form/set', value)
|
||||
}
|
||||
},
|
||||
workspace () {
|
||||
if (!this.form) return null
|
||||
return this.$store.getters['open/workspaces/getById'](this.form.workspace_id)
|
||||
},
|
||||
form () {
|
||||
return this.$store.getters['open/forms/getBySlug'](this.$route.params.slug)
|
||||
},
|
||||
formEndpoint: () => '/api/open/forms/{id}',
|
||||
breadcrumbs () {
|
||||
if (!this.form) {
|
||||
return [{ route: { name: 'home' }, label: 'Your Forms' }]
|
||||
}
|
||||
return [{ route: { name: 'home' }, label: 'Your Forms' }, { label: this.form.title }]
|
||||
},
|
||||
loading () {
|
||||
return this.formsLoading || this.workspacesLoading
|
||||
},
|
||||
displayClosesDate(){
|
||||
if(this.form.closes_at){
|
||||
let dateObj = new Date(this.form.closes_at)
|
||||
return dateObj.getFullYear() + "-" +
|
||||
String(dateObj.getMonth() + 1).padStart(2, '0') + "-" +
|
||||
String(dateObj.getDate()).padStart(2, '0') + " " +
|
||||
String(dateObj.getHours()).padStart(2, '0') + ":" +
|
||||
String(dateObj.getMinutes()).padStart(2, '0')
|
||||
}
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
form () {
|
||||
this.workingForm = new Form(this.form)
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.updatedForm = new Form(this.form)
|
||||
|
||||
if (this.$route.params.hasOwnProperty('new_form') && this.$route.params.new_form) {
|
||||
// if (!this.user.is_subscribed && !this.user.has_customer_id) {
|
||||
// // Crisp offer
|
||||
// this.$getCrisp().push(['set', 'session:event', [[['first_form_created', { form_id: this.form.id, form_slug: this.form.slug }, 'blue']]]])
|
||||
//
|
||||
// setTimeout(
|
||||
// function () {
|
||||
// window.$crisp.push(['do', 'chat:show'])
|
||||
// window.$crisp.push(['do', 'chat:open'])
|
||||
// window.$crisp.push([
|
||||
// 'do',
|
||||
// 'message:show',
|
||||
// ['text',
|
||||
// 'Hey there! I\m Julien the founder of NotionForms. Congrats on setting up your first OpnForm 🎉']
|
||||
// ])
|
||||
// setTimeout(
|
||||
// function () {
|
||||
// window.$crisp.push(['do', 'chat:show'])
|
||||
// window.$crisp.push(['do', 'chat:open'])
|
||||
// window.$crisp.push([
|
||||
// 'do',
|
||||
// 'message:show',
|
||||
// ['text',
|
||||
// 'A small gift to congratulate you? 🎁 I\'d be happy to offer you a 40% discount on your first month of a Pro subscription. Let me know if you\'re interested!']
|
||||
// ])
|
||||
// setTimeout(
|
||||
// function () {
|
||||
// window.$crisp.push(['do', 'chat:show'])
|
||||
// window.$crisp.push(['do', 'chat:open'])
|
||||
// window.$crisp.push([
|
||||
// 'do',
|
||||
// 'message:show',
|
||||
// ['text',
|
||||
// 'Just use the code "FIRSTFORM40" in the next 24 hours to get the discount! 🎉']
|
||||
// ])
|
||||
// }, 20000)
|
||||
// }, 4000)
|
||||
// }, 4000)
|
||||
// }
|
||||
}
|
||||
},
|
||||
|
||||
metaInfo () {
|
||||
return { title: this.$t('home') }
|
||||
},
|
||||
|
||||
methods: {
|
||||
openCrisp () {
|
||||
window.$crisp.push(['do', 'chat:show'])
|
||||
window.$crisp.push(['do', 'chat:open'])
|
||||
},
|
||||
duplicateForm () {
|
||||
if (this.loadingDuplicate) return
|
||||
this.loadingDuplicate = true
|
||||
axios.post(this.formEndpoint.replace('{id}', this.form.id) + '/duplicate').then((response) => {
|
||||
this.$store.commit('open/forms/addOrUpdate', response.data.new_form)
|
||||
this.$router.push({ name: 'forms.show', params: { slug: response.data.new_form.slug } })
|
||||
this.alertSuccess('Form was successfully duplicated.')
|
||||
this.loadingDuplicate = false
|
||||
})
|
||||
},
|
||||
regenerateLink (option) {
|
||||
if (this.loadingNewLink) return
|
||||
this.loadingNewLink = true
|
||||
axios.put(this.formEndpoint.replace('{id}', this.form.id) + '/regenerate-link/' + option).then((response) => {
|
||||
this.$store.commit('open/forms/addOrUpdate', response.data.form)
|
||||
this.$router.push({ name: 'forms.show', params: { slug: response.data.form.slug } })
|
||||
this.alertSuccess(response.data.message)
|
||||
this.loadingNewLink = false
|
||||
}).finally(() => {
|
||||
this.showGenerateFormLinkModal = false
|
||||
})
|
||||
},
|
||||
deleteForm () {
|
||||
if (this.loadingDelete) return
|
||||
this.loadingDelete = true
|
||||
axios.delete(this.formEndpoint.replace('{id}', this.form.id)).then(() => {
|
||||
this.$store.commit('open/forms/remove', this.form)
|
||||
this.$router.push({ name: 'home' })
|
||||
this.alertSuccess('Form was deleted.')
|
||||
this.loadingDelete = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
177
resources/js/pages/home.vue
Normal file
177
resources/js/pages/home.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full mt-6">
|
||||
<div class="w-full flex-grow md:w-3/5 lg:w-1/2 md:mx-auto md:max-w-2xl px-4">
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center mt-6 mb-4">
|
||||
<h2 class="text-nt-blue text-3xl font-bold flex-grow">
|
||||
Your Forms
|
||||
</h2>
|
||||
<fancy-link v-track.create_form_click class="mt-4 sm:mt-0" :to="{name:'forms.create'}" color="nt-blue" @click="showCreateFormModal=true">
|
||||
Create a new form
|
||||
</fancy-link>
|
||||
</div>
|
||||
<div v-if="formsLoading" class="text-center">
|
||||
<loader class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
</div>
|
||||
<p v-else-if="enrichedForms.length === 0 && !isFilteringForms">
|
||||
You don't have any form yet.
|
||||
</p>
|
||||
<div v-else class="mb-10">
|
||||
<text-input v-if="forms.length > 5" class="mb-6" :form="searchForm" name="search" label="Search a form"
|
||||
placeholder="Name of form to search"
|
||||
/>
|
||||
<div v-if="allTags.length > 0" class="mb-6">
|
||||
<div v-for="tag in allTags" :key="tag"
|
||||
:class="['text-white p-2 text-xs inline rounded-lg font-semibold cursor-pointer mr-2',{'bg-gray-500 dark:bg-gray-400':selectedTags.includes(tag), 'bg-gray-300 dark:bg-gray-700':!selectedTags.includes(tag)}]"
|
||||
title="Click for filter by tag(s)"
|
||||
@click="onTagClick(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="enrichedForms && enrichedForms.length" class="border border border-gray-300 dark:bg-notion-dark-light rounded-md w-full">
|
||||
<div v-for="(form, index) in enrichedForms" :key="form.id"
|
||||
class="p-4 w-full mx-auto border-gray-300 hover:bg-blue-100 dark:hover:bg-blue-900 transition-colors cursor-pointer relative" :class="{'border-t':index!==0}"
|
||||
>
|
||||
<div class="items-center space-x-4 truncate">
|
||||
<p class="truncate float-left">
|
||||
{{ form.title }} <span v-if="form.submissions_count" class="text-gray-400 ml-1">- {{
|
||||
form.submissions_count
|
||||
}} Submission{{ form.submissions_count > 0 ? 's' : '' }}</span>
|
||||
</p>
|
||||
<div v-if="form.tags && form.tags.length > 0" class="float-right hidden sm:block">
|
||||
<template v-for="(tag,i) in form.tags">
|
||||
<div v-if="i<1" :key="tag"
|
||||
class="bg-gray-300 dark:bg-gray-700 text-white px-2 py-1 mr-2 text-xs inline rounded-lg font-semibold"
|
||||
>
|
||||
{{ tag }}
|
||||
</div>
|
||||
<div v-if="i==1" :key="tag"
|
||||
class="bg-gray-300 dark:bg-gray-700 text-white px-2 py-1 mr-2 text-xs inline rounded-lg font-semibold"
|
||||
>
|
||||
{{ form.tags.length-1 }} more
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<router-link class="absolute inset-0"
|
||||
:to="{params:{slug:form.slug},name:'forms.show'}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-400 dark:text-gray-600 mt-2 px-4">
|
||||
You have {{ forms.length }} forms<template v-if="isFilteringForms">
|
||||
({{ enrichedForms.length }} matching search criteria)
|
||||
</template>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer class="mt-8 border-t" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import store from '~/store'
|
||||
import { mapGetters, mapState } from 'vuex'
|
||||
import Fuse from 'fuse.js'
|
||||
import Form from 'vform'
|
||||
import TextInput from '../components/forms/TextInput'
|
||||
import OpenFormFooter from '../components/pages/OpenFormFooter'
|
||||
|
||||
const loadForms = function () {
|
||||
store.commit('open/forms/startLoading')
|
||||
store.dispatch('open/workspaces/loadIfEmpty').then(() => {
|
||||
store.dispatch('open/forms/load', store.state['open/workspaces'].currentId)
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
components: { OpenFormFooter, TextInput },
|
||||
|
||||
beforeRouteEnter (to, from, next) {
|
||||
loadForms()
|
||||
next()
|
||||
},
|
||||
middleware: 'auth',
|
||||
|
||||
props: {
|
||||
metaTitle: { type: String, default: 'Your Forms' },
|
||||
metaDescription: { type: String, default: 'All of your OpnForm are here. Create new forms, or update your existing one!' }
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
showEditFormModal: false,
|
||||
selectedForm: null,
|
||||
searchForm: new Form({
|
||||
search: ''
|
||||
}),
|
||||
selectedTags: []
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {},
|
||||
|
||||
methods: {
|
||||
editForm (form) {
|
||||
this.selectedForm = form
|
||||
this.showEditFormModal = true
|
||||
},
|
||||
onTagClick (tag) {
|
||||
const idx = this.selectedTags.indexOf(tag)
|
||||
if (idx === -1) {
|
||||
this.selectedTags.push(tag)
|
||||
} else {
|
||||
this.selectedTags.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
user: 'auth/user'
|
||||
}),
|
||||
...mapState({
|
||||
forms: state => state['open/forms'].content,
|
||||
formsLoading: state => state['open/forms'].loading
|
||||
}),
|
||||
isFilteringForms () {
|
||||
return (this.searchForm.search !== '' && this.searchForm.search !== null) || this.selectedTags.length > 0
|
||||
},
|
||||
enrichedForms () {
|
||||
let enrichedForms = this.forms.map((form) => {
|
||||
form.workspace = this.$store.getters['open/workspaces/getById'](form.workspace_id)
|
||||
return form
|
||||
})
|
||||
|
||||
// Filter by Selected Tags
|
||||
if (this.selectedTags.length > 0) {
|
||||
enrichedForms = enrichedForms.filter((item) => {
|
||||
return (item.tags && item.tags.length > 0) ? this.selectedTags.every(r => item.tags.includes(r)) : false
|
||||
})
|
||||
}
|
||||
|
||||
if (!this.isFilteringForms || this.searchForm.search === '' || this.searchForm.search === null) {
|
||||
return enrichedForms
|
||||
}
|
||||
|
||||
// Fuze search
|
||||
const fuzeOptions = {
|
||||
keys: [
|
||||
'title',
|
||||
'slug',
|
||||
'tags'
|
||||
]
|
||||
}
|
||||
const fuse = new Fuse(enrichedForms, fuzeOptions)
|
||||
return fuse.search(this.searchForm.search).map((res) => {
|
||||
return res.item
|
||||
})
|
||||
},
|
||||
allTags () {
|
||||
return this.$store.getters['open/forms/getAllTags']
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
44
resources/js/pages/integrations.vue
Normal file
44
resources/js/pages/integrations.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mt-6 flex flex-col">
|
||||
<div class="w-full md:max-w-3xl md:mx-auto px-4 md:pt-16">
|
||||
<h1 class="sm:text-5xl mb-4">
|
||||
Integrations
|
||||
</h1>
|
||||
<notion-page class="mb-8 integration-page" page-id="492c2bbb31404481b9faaaf407e59640" />
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NotionPage from '../components/open/NotionPage'
|
||||
import OpenFormFooter from '../components/pages/OpenFormFooter'
|
||||
|
||||
export default {
|
||||
components: { OpenFormFooter, NotionPage },
|
||||
layout: 'default',
|
||||
|
||||
props: {
|
||||
metaTitle: { type: String, default: 'Integrations' },
|
||||
metaDescription: { type: String, default: 'You can connect your OpnForms to other services via our two integrations: Zapier and Webhooks. Use our integrations to automate your various workflows.' }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
}),
|
||||
|
||||
computed: {},
|
||||
|
||||
mounted () {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.integration-page {
|
||||
.notion-asset-wrapper {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
35
resources/js/pages/legal-help/privacy-policy.vue
Normal file
35
resources/js/pages/legal-help/privacy-policy.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mt-6 flex flex-col">
|
||||
<div class="w-full md:max-w-3xl md:mx-auto px-4 md:pt-16">
|
||||
<h1 class="sm:text-5xl">
|
||||
Privacy Policy
|
||||
</h1>
|
||||
<notion-page page-id="9c97349ceda7455aab9b341d1ff70f79" />
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NotionPage from '../../components/open/NotionPage'
|
||||
import OpenFormFooter from '../../components/pages/OpenFormFooter'
|
||||
|
||||
export default {
|
||||
components: { OpenFormFooter, NotionPage },
|
||||
layout: 'default',
|
||||
|
||||
metaInfo () {
|
||||
return { title: 'Privacy Policy' }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
}),
|
||||
|
||||
computed: {},
|
||||
|
||||
mounted () {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
35
resources/js/pages/legal-help/terms-conditions.vue
Normal file
35
resources/js/pages/legal-help/terms-conditions.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mt-6 flex flex-col">
|
||||
<div class="w-full md:max-w-3xl md:mx-auto px-4 md:pt-16">
|
||||
<h1 class="sm:text-5xl">
|
||||
Terms & Conditions
|
||||
</h1>
|
||||
<notion-page page-id="246420da2834480ca04047b0c5a00929" />
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NotionPage from '../../components/open/NotionPage'
|
||||
import OpenFormFooter from '../../components/pages/OpenFormFooter'
|
||||
|
||||
export default {
|
||||
components: { OpenFormFooter, NotionPage },
|
||||
layout: 'default',
|
||||
|
||||
metaInfo () {
|
||||
return { title: 'Terms & Conditions' }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
}),
|
||||
|
||||
computed: {},
|
||||
|
||||
mounted () {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
54
resources/js/pages/settings/account.vue
Normal file
54
resources/js/pages/settings/account.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<card title="Account" class="bg-gray-50 dark:bg-notion-dark-light">
|
||||
<h3 class="text-lg font-semibold mb-4">
|
||||
Your Account
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-800 dark:text-gray-200">
|
||||
You can delete your account. All your data will be removed. <span class="font-semibold">This cannot be undone.</span>
|
||||
</p>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<v-button :loading="loading" class="mt-4" color="red" @click="alertConfirm('Do you really want to delete your account?',deleteAccount)">
|
||||
Delete my account
|
||||
</v-button>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Form from 'vform'
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
scrollToTop: false,
|
||||
|
||||
metaInfo () {
|
||||
return { title: 'Account' }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
form: new Form({
|
||||
identifier: ''
|
||||
}),
|
||||
loading: false
|
||||
}),
|
||||
|
||||
methods: {
|
||||
async deleteAccount () {
|
||||
this.loading = true
|
||||
axios.delete('/api/user').then(async (response) => {
|
||||
this.loading = false
|
||||
this.alertSuccess(response.data.message)
|
||||
// Log out the user.
|
||||
await this.$store.dispatch('auth/logout')
|
||||
|
||||
// Redirect to login.
|
||||
this.$router.push({ name: 'login' })
|
||||
}).catch((error) => {
|
||||
this.alertError(error.response.data.message)
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
84
resources/js/pages/settings/admin.vue
Normal file
84
resources/js/pages/settings/admin.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<card title="Admin" class="bg-gray-50 dark:bg-notion-dark-light">
|
||||
<h3 class="text-lg font-semibold mb-4">
|
||||
Tools
|
||||
</h3>
|
||||
<div class="flex flex-wrap mb-10">
|
||||
<a href="/stats">
|
||||
<v-button class="mx-1" color="gray" shade="lighter">
|
||||
Stats
|
||||
</v-button>
|
||||
</a>
|
||||
<a href="/horizon">
|
||||
<v-button class="mx-1" color="gray" shade="lighter">
|
||||
Horizon
|
||||
</v-button>
|
||||
</a>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-4">
|
||||
Impersonate User
|
||||
</h3>
|
||||
<form @submit.prevent="impersonate" @keydown="form.onKeydown($event)">
|
||||
<!-- Password -->
|
||||
<text-input name="identifier" :form="form" label="Identifier"
|
||||
:required="true" help="User Id, User Email or Form Slug"
|
||||
/>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<v-button :loading="loading" type="success" color="nt-blue" class="mt-4 w-full">
|
||||
Impersonate User
|
||||
</v-button>
|
||||
</form>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Form from 'vform'
|
||||
import axios from 'axios'
|
||||
import FancyLink from '../../components/common/FancyLink'
|
||||
|
||||
export default {
|
||||
components: { FancyLink },
|
||||
middleware: 'admin',
|
||||
scrollToTop: false,
|
||||
|
||||
metaInfo () {
|
||||
return { title: 'Admin' }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
form: new Form({
|
||||
identifier: ''
|
||||
}),
|
||||
loading: false
|
||||
}),
|
||||
|
||||
methods: {
|
||||
async impersonate () {
|
||||
this.loading = true
|
||||
this.$store.commit('auth/startImpersonating')
|
||||
axios.get('/api/admin/impersonate/' + encodeURI(this.form.identifier)).then(async (response) => {
|
||||
this.loading = false
|
||||
|
||||
// Save the token.
|
||||
this.$store.dispatch('auth/saveToken', {
|
||||
token: response.data.token,
|
||||
remember: false
|
||||
})
|
||||
|
||||
// Fetch the user.
|
||||
await this.$store.dispatch('auth/fetchUser')
|
||||
|
||||
// Redirect to the dashboard.
|
||||
this.$store.commit('open/workspaces/set', [])
|
||||
this.$router.push({ name: 'home' })
|
||||
}).catch((error) => {
|
||||
this.alertError(error.response.data.message)
|
||||
this.loading = false
|
||||
})
|
||||
|
||||
// this.form.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
43
resources/js/pages/settings/billing.vue
Normal file
43
resources/js/pages/settings/billing.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<card title="Billing" class="bg-gray-50 dark:bg-notion-dark-light">
|
||||
<v-button color="gray" shade="light" :loading="billingLoading" @click.prevent="openBillingDashboard">
|
||||
Manage Subscription
|
||||
</v-button>
|
||||
<v-button color="red" class="mt-3" @click.prevent="cancelSubscription">
|
||||
Cancel Subscription
|
||||
</v-button>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import VButton from '../../components/common/Button'
|
||||
|
||||
export default {
|
||||
components: { VButton },
|
||||
scrollToTop: false,
|
||||
|
||||
metaInfo () {
|
||||
return { title: 'Billing' }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
billingLoading: false
|
||||
}),
|
||||
|
||||
methods: {
|
||||
cancelSubscription () {
|
||||
// this.alertError('Sorry to see you leave 😢')
|
||||
},
|
||||
openBillingDashboard () {
|
||||
this.billingLoading = true
|
||||
axios.get('/api/subscription/billing-portal').then((response) => {
|
||||
const url = response.data.portal_url
|
||||
window.location = url
|
||||
}).finally(() => {
|
||||
this.billingLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
123
resources/js/pages/settings/index.vue
Normal file
123
resources/js/pages/settings/index.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap mt-6 md:max-w-3xl w-full md:mx-auto">
|
||||
<div class="w-full md:w-1/3 md:pr-4">
|
||||
<card :padding="false" class="bg-gray-50 dark:bg-notion-dark-light">
|
||||
<ul>
|
||||
<li v-for="tab in tabs" :key="tab.route">
|
||||
<router-link :to="{ name: tab.route }"
|
||||
class="px-6 py-4 flex items-center text-gray-600 dark:text-gray-400 dark:hover:text-gray-300 hover:text-gray-900 hover:bg-gray-50 dark:hover:bg-gray-900 rounded"
|
||||
active-class="text-nt-blue bg-indigo-50 dark:bg-gray-800 hover:bg-blue-50"
|
||||
>
|
||||
<template v-if="tab.route == 'settings.profile'">
|
||||
<svg class="w-6 h-6 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="tab.route == 'settings.account'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7a4 4 0 11-8 0 4 4 0 018 0zM9 14a6 6 0 00-6 6v1h12v-1a6 6 0 00-6-6zM21 12h-6" />
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="tab.route == 'settings.workspaces'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="tab.route == 'settings.billing'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-2" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="tab.route == 'settings.password'">
|
||||
<svg class="w-6 h-6 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<span class="ml-2">
|
||||
{{ tab.name }}
|
||||
</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li v-if="user.admin">
|
||||
<router-link :to="{ name: 'settings.admin' }"
|
||||
class="px-6 py-4 flex items-center text-gray-600 dark:text-gray-400 dark:hover:text-gray-300 hover:text-gray-900 hover:bg-gray-50 dark:hover:bg-gray-900 rounded"
|
||||
active-class="text-nt-blue bg-indigo-50 dark:bg-gray-800 hover:bg-blue-50"
|
||||
>
|
||||
<svg class="w-6 h-6 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="ml-2">
|
||||
Admin
|
||||
</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</card>
|
||||
</div>
|
||||
|
||||
<div class="w-full md:w-2/3">
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view />
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
export default {
|
||||
middleware: 'auth',
|
||||
|
||||
computed: {
|
||||
tabs () {
|
||||
const tabs = [
|
||||
{
|
||||
name: 'Workspaces',
|
||||
route: 'settings.workspaces'
|
||||
},
|
||||
{
|
||||
name: this.$t('profile'),
|
||||
route: 'settings.profile'
|
||||
},
|
||||
{
|
||||
name: this.$t('password'),
|
||||
route: 'settings.password'
|
||||
},
|
||||
{
|
||||
name: 'Delete Account',
|
||||
route: 'settings.account'
|
||||
}
|
||||
]
|
||||
|
||||
if (this.user.is_subscribed) {
|
||||
tabs.splice(1, 0, {
|
||||
name: 'Billing',
|
||||
route: 'settings.billing'
|
||||
})
|
||||
}
|
||||
|
||||
return tabs
|
||||
},
|
||||
...mapGetters({
|
||||
user: 'auth/user'
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
49
resources/js/pages/settings/password.vue
Normal file
49
resources/js/pages/settings/password.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<card :title="$t('your_password')" class="bg-gray-50 dark:bg-notion-dark-light">
|
||||
<form @submit.prevent="update" @keydown="form.onKeydown($event)">
|
||||
<alert-success class="mb-5" :form="form" :message="$t('password_updated')" />
|
||||
|
||||
<!-- Password -->
|
||||
<text-input class="mt-8" native-type="password"
|
||||
name="password" :form="form" :label="$t('password')" :required="true"
|
||||
/>
|
||||
|
||||
<!-- Password Confirmation-->
|
||||
<text-input class="mt-8" native-type="password"
|
||||
name="password_confirmation" :form="form" :label="$t('confirm_password')" :required="true"
|
||||
/>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<v-button :loading="form.busy" type="success" color="nt-blue" class="mt-4 w-full">
|
||||
{{ $t('update') }}
|
||||
</v-button>
|
||||
</form>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Form from 'vform'
|
||||
|
||||
export default {
|
||||
scrollToTop: false,
|
||||
|
||||
metaInfo () {
|
||||
return { title: this.$t('settings') }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
form: new Form({
|
||||
password: '',
|
||||
password_confirmation: ''
|
||||
})
|
||||
}),
|
||||
|
||||
methods: {
|
||||
async update () {
|
||||
await this.form.patch('/api/settings/password')
|
||||
|
||||
this.form.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
57
resources/js/pages/settings/profile.vue
Normal file
57
resources/js/pages/settings/profile.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<card title="Profile" class="bg-gray-50 dark:bg-notion-dark-light">
|
||||
<form @submit.prevent="update" @keydown="form.onKeydown($event)">
|
||||
<alert-success class="mb-5" :form="form" :message="$t('info_updated')" />
|
||||
|
||||
<!-- Name -->
|
||||
<text-input name="name" :form="form" :label="$t('name')" :required="true" />
|
||||
|
||||
<!-- Email -->
|
||||
<text-input name="email" :form="form" :label="$t('email')" :required="true" />
|
||||
|
||||
<!-- Submit Button -->
|
||||
<v-button :loading="form.busy" type="success" color="nt-blue" class="mt-4 w-full">
|
||||
{{ $t('update') }}
|
||||
</v-button>
|
||||
</form>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Form from 'vform'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
export default {
|
||||
scrollToTop: false,
|
||||
|
||||
metaInfo () {
|
||||
return { title: this.$t('settings') }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
form: new Form({
|
||||
name: '',
|
||||
email: ''
|
||||
})
|
||||
}),
|
||||
|
||||
computed: mapGetters({
|
||||
user: 'auth/user'
|
||||
}),
|
||||
|
||||
created () {
|
||||
// Fill the form with user data.
|
||||
this.form.keys().forEach(key => {
|
||||
this.form[key] = this.user[key]
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
async update () {
|
||||
const { data } = await this.form.patch('/api/settings/profile')
|
||||
|
||||
this.$store.dispatch('auth/updateUser', { user: data })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
126
resources/js/pages/settings/workspace.vue
Normal file
126
resources/js/pages/settings/workspace.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<card title="Workspaces" class="bg-gray-50 dark:bg-notion-dark-light">
|
||||
<div v-if="loading" class="w-full text-blue-500 text-center">
|
||||
<loader class="h-10 w-10 p-5" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="workspace in workspaces" :key="workspace.id"
|
||||
class="border border-nt-blue-light shadow rounded-md p-4 mb-5 max-w-sm w-full flex group mx-auto bg-white dark:bg-notion-dark items-center"
|
||||
>
|
||||
<div class="flex space-x-4 flex-grow cursor-pointer" role="button" @click.prevent="switchWorkspace(workspace)">
|
||||
<img v-if="isUrl(workspace.icon)" :src="workspace.icon" :alt="workspace.name + ' icon'"
|
||||
class="rounded-full h-12 w-12"
|
||||
>
|
||||
<div v-else class="rounded-full bg-nt-blue-lighter h-12 w-12 text-2xl pt-2 text-center overflow-hidden"
|
||||
v-text="workspace.icon"
|
||||
/>
|
||||
<div class="flex-1 flex items-center space-y-4 py-1">
|
||||
<p class="font-bold truncate" v-text="workspace.name" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="workspaces.length > 1" class="block md:hidden group-hover:block text-red-500 p-2 rounded hover:bg-red-50" role="button" @click="deleteWorkspace(workspace)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-sm w-full mx-auto mt-4">
|
||||
<v-button :loading="loading" class="w-full" @click="workspaceModal=true">
|
||||
Create a new workspace
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workspace modal -->
|
||||
<modal :show="workspaceModal" @close="workspaceModal=false">
|
||||
<div class="px-4">
|
||||
<form @submit.prevent="createWorkspace" @keydown="form.onKeydown($event)">
|
||||
<h2 class="text-2xl font-bold z-10 truncate mb-5 text-nt-blue">
|
||||
Create Workspace
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<text-input name="name" class="mt-4" :form="form" :required="true"
|
||||
label="Workspace Name"
|
||||
/>
|
||||
<text-input name="emoji" class="mt-4" :form="form" :required="false"
|
||||
label="Emoji"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<v-button :loading="form.busy" class="mr-1">Save</v-button>
|
||||
<v-button color="gray" shade="light" @click.prevent="workspaceModal=false">Close</v-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</modal>
|
||||
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Form from 'vform'
|
||||
import { mapActions, mapState } from 'vuex'
|
||||
|
||||
export default {
|
||||
|
||||
components: { },
|
||||
scrollToTop: false,
|
||||
|
||||
metaInfo () {
|
||||
return { title: 'Workspaces' }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
form: new Form({
|
||||
name: '',
|
||||
emoji: ''
|
||||
}),
|
||||
workspaceModal: false
|
||||
}),
|
||||
|
||||
mounted () {
|
||||
this.loadWorkspaces()
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState({
|
||||
workspaces: state => state['open/workspaces'].content,
|
||||
loading: state => state['open/workspaces'].loading
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
...mapActions({
|
||||
loadWorkspaces: 'open/workspaces/loadIfEmpty'
|
||||
}),
|
||||
switchWorkspace (workspace) {
|
||||
this.$store.commit('open/workspaces/setCurrentId', workspace.id)
|
||||
this.$router.push({ name: 'home' })
|
||||
this.$store.dispatch('open/forms/load', workspace.id)
|
||||
},
|
||||
deleteWorkspace (workspace) {
|
||||
this.alertConfirm('Do you really want to delete this workspace? All forms created in this workspace will be removed.', () => {
|
||||
this.$store.dispatch('open/workspaces/delete', workspace.id).then(() => {
|
||||
this.alertSuccess('Workspace successfully removed.')
|
||||
})
|
||||
})
|
||||
},
|
||||
isUrl (str) {
|
||||
const pattern = new RegExp('^(https?:\\/\\/)?' + // protocol
|
||||
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name
|
||||
'((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address
|
||||
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path
|
||||
'(\\?[;&a-z\\d%_.~+=-]*)?' + // query string
|
||||
'(\\#[-a-z\\d_]*)?$', 'i') // fragment locator
|
||||
return !!pattern.test(str)
|
||||
},
|
||||
async createWorkspace () {
|
||||
const { data } = await this.form.post('/api/open/workspaces/create')
|
||||
this.$store.dispatch('open/workspaces/load')
|
||||
this.workspaceModal = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
28
resources/js/pages/subscriptions/error.vue
Normal file
28
resources/js/pages/subscriptions/error.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template />
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
export default {
|
||||
components: { },
|
||||
layout: 'default',
|
||||
middleware: 'auth',
|
||||
|
||||
metaInfo () {
|
||||
return { title: 'Error' }
|
||||
},
|
||||
|
||||
data: () => ({}),
|
||||
|
||||
mounted () {
|
||||
this.$router.push({ name: 'pricing' })
|
||||
this.alertError('Unfortunately we could not confirm your subscription. Please try again and contact us if the issue persists.')
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
authenticated: 'auth/check'
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
74
resources/js/pages/subscriptions/success.vue
Normal file
74
resources/js/pages/subscriptions/success.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="flex flex-col min-h-screen">
|
||||
<div class="w-full md:max-w-3xl md:mx-auto px-4 mb-10 md:pb-20 md:pt-16 text-center flex-grow">
|
||||
<h1 class="text-4xl font-semibold">
|
||||
Thank you!
|
||||
</h1>
|
||||
<h4 class="text-xl mt-6">
|
||||
We're checking the status of your subscription please wait a moment...
|
||||
</h4>
|
||||
<div class="text-center">
|
||||
<loader class="h-6 w-6 text-nt-blue mx-auto mt-20" />
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
import OpenFormFooter from '../../components/pages/OpenFormFooter'
|
||||
|
||||
export default {
|
||||
components: { OpenFormFooter },
|
||||
layout: 'default',
|
||||
middleware: 'auth',
|
||||
|
||||
metaInfo () {
|
||||
return { title: 'Subscription Success' }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
interval: null
|
||||
}),
|
||||
|
||||
mounted () {
|
||||
this.redirectIfSubscribed()
|
||||
this.interval = setInterval(() => this.checkSubscription(), 5000)
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
clearInterval(this.interval)
|
||||
},
|
||||
|
||||
methods: {
|
||||
async checkSubscription () {
|
||||
// Fetch the user.
|
||||
await this.$store.dispatch('auth/fetchUser')
|
||||
this.redirectIfSubscribed()
|
||||
},
|
||||
redirectIfSubscribed () {
|
||||
if (this.user.is_subscribed) {
|
||||
this.$logEvent('subscribed', { plan: this.user.has_enterprise_subscription ? 'enterprise' : 'pro' })
|
||||
this.$getCrisp().push(['set', 'session:event', [[['subscribed', { plan: this.user.has_enterprise_subscription ? 'enterprise' : 'pro' }, 'blue']]]])
|
||||
this.$router.push({ name: 'home' })
|
||||
|
||||
if (this.user.has_enterprise_subscription) {
|
||||
this.alertSuccess('Awesome! Your subscription to OpnForm is now confirmed! You now have access to all Enterprise ' +
|
||||
'features. No need to invite your teammates, just ask them to create a OpnForm account and to connect the same Notion workspace. Feel free to contact us if you have any question 🙌')
|
||||
} else {
|
||||
this.alertSuccess('Awesome! Your subscription to OpnForm is now confirmed! You now have access to all Pro ' +
|
||||
'features. Feel free to contact us if you have any question 🙌')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
authenticated: 'auth/check',
|
||||
user: 'auth/user'
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
130
resources/js/pages/welcome.vue
Normal file
130
resources/js/pages/welcome.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mt-6 flex flex-col">
|
||||
<div class="w-full md:mx-auto flex flex-wrap pb-10 md:pb-20 md:pt-10 relative max-w-5xl">
|
||||
<div
|
||||
class="flex flex-col justify-center w-full lg:w-1/2 text-center lg:text-left lg:pr-10 p-6 lg:p-10 relative z-10"
|
||||
>
|
||||
<h1 dusk="title" class="text-4xl lg:text-5xl">
|
||||
The Open-Source <br>
|
||||
<span
|
||||
class="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-blue-600"
|
||||
>Form Builder</span><br>
|
||||
</h1>
|
||||
<h3 class="mt-6 text-xl text-gray-500 dark:text-gray-400 max-w-xl mx-auto">
|
||||
Create beautiful forms and share them anywhere. It takes seconds, you don't need to know how to code
|
||||
and <span
|
||||
class="font-semibold"
|
||||
>it's free</span>.
|
||||
</h3>
|
||||
<p class="mt-6">
|
||||
<router-link :to="{ name: 'register' }">
|
||||
<v-button v-track.welcome_create_form_click>
|
||||
Create an OpnForm for free
|
||||
</v-button>
|
||||
</router-link>
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 relative px-6 sm:px-10 lg:px-0 z-10 lg:pr-10 flex items-center justify-center">
|
||||
<img loading="lazy" class="w-full shadow-xl rounded-lg block max-w-2xl lg:max-w-5xl"
|
||||
:src="asset('img/pages/welcome/product_cover.jpg')" alt="cover-product">
|
||||
</div>
|
||||
<div class="z-0 absolute bottom-0 inset-x-0 z-0 w-full opacity-30 dark:opacity-70">
|
||||
<img class="mx-auto" :src="asset('img/pages/welcome/homebackdrop.png')" alt="Backdrop decoration image">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-notion-dark-light pb-8">
|
||||
<div class="w-full md:max-w-4xl md:mx-auto px-4 flex flex-wrap">
|
||||
<features class="pt-16 pb-8" />
|
||||
<p class="text-center w-full mb-8 font-semibold">
|
||||
And much more!
|
||||
</p>
|
||||
<p class="mt-4 text-center w-full">
|
||||
<router-link :to="{ name: 'register' }">
|
||||
<v-button v-track.welcome_create_form_click>
|
||||
Create an OpnForm for free
|
||||
</v-button>
|
||||
</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full dark:bg-notion-dark-light">
|
||||
<div class="w-full bg-blue-500 p-10 relative">
|
||||
<div class="md:max-w-3xl md:mx-auto flex flex-wrap relative z-10">
|
||||
<div class="flex items-center">
|
||||
<div class="text-2xl font-bold">
|
||||
<h3 class="text-white">
|
||||
The contact form below is an <span class="text-gray-900">OpnForm</span>.
|
||||
It can be created for free and it uses open-source code!
|
||||
</h3>
|
||||
</div>
|
||||
<img class="w-32 hidden md:block md:ml-6" loading="lazy" :src="asset('img/icons/sparks.png')" alt="contact-form">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full dark:bg-notion-dark">
|
||||
<div class="md:max-w-3xl md:mx-auto px-4 flex flex-wrap pt-6">
|
||||
<iframe class="w-full" height="450px" src="https://opnform.com/forms/my-form" />
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer class="dark:border-t border-t" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
import Features from '~/components/pages/welcome/Features'
|
||||
import OpenFormFooter from '../components/pages/OpenFormFooter'
|
||||
import Testimonials from '../components/pages/welcome/Testimonials'
|
||||
|
||||
export default {
|
||||
components: { Testimonials, OpenFormFooter, Features },
|
||||
|
||||
layout: 'default',
|
||||
|
||||
metaInfo () {
|
||||
return { title: 'Create beautiful & open-source forms for free' }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
title: window.config.appName
|
||||
}),
|
||||
|
||||
mounted () {},
|
||||
|
||||
methods: {
|
||||
// openCrisp () {
|
||||
// window.$crisp.push(['do', 'chat:show'])
|
||||
// window.$crisp.push(['do', 'chat:open'])
|
||||
// window.$crisp.push(['do', 'message:send', ['text', "Hi! I'd like to learn more about OpnForm"]])
|
||||
// }
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
authenticated: 'auth/check'
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.customer-logo-container {
|
||||
max-width: 130px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@screen md {
|
||||
#macbook-video {
|
||||
position: absolute;
|
||||
max-width: 84.8% !important;
|
||||
right: 0px;
|
||||
top: 6.8%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user