Merge branch 'migrate-to-nuxt' of https://github.com/JhumanJ/OpnForm into migrate-to-nuxt

This commit is contained in:
Julien Nahum 2024-01-05 11:12:29 +01:00
commit 8b92f24094
45 changed files with 624 additions and 614 deletions

View File

@ -44,6 +44,17 @@ export default {
components: {}, components: {},
setup() { setup() {
useOpnSeoMeta({
title: 'OpnForm',
description: 'Create beautiful forms for free. Unlimited fields, unlimited submissions. It\'s free and it takes less than 1 minute to create your first form.',
ogImage: '/img/social-preview.jpg',
})
useHead({
titleTemplate: (titleChunk) => {
return titleChunk ? `${titleChunk} - OpnForm` : 'OpnForm';
}
})
const appStore = useAppStore() const appStore = useAppStore()
return { return {
@ -57,8 +68,6 @@ export default {
}, },
data: () => ({ data: () => ({
metaTitle: 'OpnForm',
metaDescription: 'Create beautiful forms for free. Unlimited fields, unlimited submissions. It\'s free and it takes less than 1 minute to create your first form.',
announcement: false, announcement: false,
alert: { alert: {
type: null, type: null,

View File

@ -203,7 +203,7 @@ export default {
return !this.appStore.navbarHidden return !this.appStore.navbarHidden
}, },
userOnboarded() { userOnboarded() {
return this.user && this.user.workspaces_count > 0 return this.user && this.user.has_forms === true
}, },
hasCrisp() { hasCrisp() {
return this.config.crispWebsiteId return this.config.crispWebsiteId

View File

@ -6,7 +6,7 @@
</p> </p>
</div> </div>
<div class="w-full sm:w-40 sm:ml-2 mt-2 sm:mt-0 shrink-0"> <div class="w-full sm:w-40 sm:ml-2 mt-2 sm:mt-0 shrink-0">
<v-button color="light-gray" class="w-full" @click="copyToClipboard(content)"> <v-button color="light-gray" class="w-full" @click="copyToClipboard">
<slot name="icon"> <slot name="icon">
<svg class="h-4 w-4 -mt-1 text-blue-600 inline mr-1" viewBox="0 0 20 20" fill="none" <svg class="h-4 w-4 -mt-1 text-blue-600 inline mr-1" viewBox="0 0 20 20" fill="none"
xmlns="http://www.w3.org/2000/svg"> xmlns="http://www.w3.org/2000/svg">
@ -21,10 +21,11 @@
</div> </div>
</template> </template>
<script> <script setup>
export default { import { defineProps } from 'vue'
name: 'CopyContent', const { copy } = useClipboard()
props: {
const props = defineProps({
content: { content: {
type: String, type: String,
required: true required: true
@ -32,36 +33,16 @@ export default {
isDraft: { isDraft: {
type: Boolean, type: Boolean,
default: false default: false
}, }
}, })
data() { const copyToClipboard = () => {
return {}
},
computed: {},
watch: {},
mounted() {
},
methods: {
copyToClipboard(str) {
if (process.server) return if (process.server) return
const el = document.createElement('textarea') copy(props.content)
el.value = str if(props.isDraft){
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
if(this.isDraft){
useAlert().warning('Copied! But other people won\'t be able to see the form since it\'s currently in draft mode') useAlert().warning('Copied! But other people won\'t be able to see the form since it\'s currently in draft mode')
} else { } else {
useAlert().success('Copied!') useAlert().success('Copied!')
} }
}
}
} }
</script> </script>

View File

@ -17,10 +17,11 @@
</div> </div>
</template> </template>
<script> <script setup>
export default { import { defineProps, computed } from 'vue'
name: 'FormUrlPrefill', const { copy } = useClipboard()
props: {
const props = defineProps({
form: { form: {
type: Object, type: Object,
required: true required: true
@ -33,56 +34,33 @@ export default {
type: String, type: String,
default: '' default: ''
} }
}, })
data () { const preFillUrl = computed(() => {
return {} const url = props.form.share_url
},
computed: {
preFillUrl () {
const url = this.form.share_url
const uriComponents = new URLSearchParams() const uriComponents = new URLSearchParams()
this.form.properties.filter((property) => { props.form.properties.filter((property) => {
return this.formData.hasOwnProperty(property.id) && this.formData[property.id] !== null return props.formData.hasOwnProperty(property.id) && props.formData[property.id] !== null
}).forEach((property) => { }).forEach((property) => {
if (Array.isArray(this.formData[property.id])) { if (Array.isArray(props.formData[property.id])) {
this.formData[property.id].forEach((value) => { props.formData[property.id].forEach((value) => {
uriComponents.append(property.id + '[]', value) uriComponents.append(property.id + '[]', value)
}) })
} else { } else {
uriComponents.append(property.id, this.formData[property.id]) uriComponents.append(property.id, props.formData[property.id])
} }
}) })
if(uriComponents.toString() !== ""){ if(uriComponents.toString() !== ""){
return (this.extraQueryParam) ? url + '?' + uriComponents + '&' + this.extraQueryParam : url + '?' + uriComponents return (props.extraQueryParam) ? url + '?' + uriComponents + '&' + props.extraQueryParam : url + '?' + uriComponents
}else{ }else{
return (this.extraQueryParam) ? url + '?' + this.extraQueryParam : url return (props.extraQueryParam) ? url + '?' + props.extraQueryParam : url
} }
} })
},
watch: {}, const copyToClipboard = () => {
mounted () {
},
methods: {
getPropertyUriComponent (property) {
const prefillValue = encodeURIComponent(this.formData[property.id])
return encodeURIComponent(property.id) + '=' + prefillValue
},
copyToClipboard () {
if (process.server) return if (process.server) return
const str = this.preFillUrl copy(preFillUrl.value)
const el = document.createElement('textarea') useAlert().success('Copied!')
el.value = str
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
}
} }
</script> </script>

View File

@ -1,5 +1,5 @@
<template> <template>
<modal :show="show" @close="$emit('close')"> <modal :show="show" @close="emit('close')">
<template #icon> <template #icon>
<svg class="w-10 h-10 text-blue" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg class="w-10 h-10 text-blue" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path <path
@ -57,11 +57,11 @@
</template> </template>
</v-button> </v-button>
<v-button v-if="template" color="red" class="mr-2" <v-button v-if="template" color="red" class="mr-2"
@click.prevent="alertConfirm('Do you really want to delete this template?', deleteFormTemplate)" @click.prevent="useAlert().confirm('Do you really want to delete this template?', deleteFormTemplate)"
> >
Delete Delete
</v-button> </v-button>
<v-button color="white" @click.prevent="$emit('close')"> <v-button color="white" @click.prevent="emit('close')">
Close Close
</v-button> </v-button>
</div> </div>
@ -71,38 +71,29 @@
</modal> </modal>
</template> </template>
<script> <script setup>
import { computed } from 'vue' import { ref, defineProps, defineEmits, computed } from 'vue'
import QuestionsEditor from './QuestionsEditor.vue' import QuestionsEditor from './QuestionsEditor.vue'
export default { const props = defineProps({
name: 'FormTemplateModal',
components: { QuestionsEditor },
props: {
show: { type: Boolean, required: true }, show: { type: Boolean, required: true },
form: { type: Object, required: true }, form: { type: Object, required: true },
template: { type: Object, required: false, default: () => {} } template: { type: Object, required: false, default: () => {} }
}, })
setup () {
const authStore = useAuthStore() const authStore = useAuthStore()
const templatesStore = useTemplatesStore() const templatesStore = useTemplatesStore()
return { const router = useRouter()
templatesStore, let user = computed(() => authStore.user)
user : computed(() => authStore.user), let templates = computed(() => [...templatesStore.content.values()])
templates : computed(() => templatesStore.content), let industries = computed(() => [...templatesStore.industries.values()])
industries : computed(() => templatesStore.industries), let types = computed(() => [...templatesStore.types.values()])
types : computed(() => templatesStore.types),
useAlert: useAlert()
}
},
data: () => ({ let templateForm = ref(null)
templateForm: null const emit = defineEmits(['close'])
}),
mounted () { onMounted(() => {
this.templateForm = useForm(this.template ?? { templateForm.value = useForm(props.template ?? {
publicly_listed: false, publicly_listed: false,
name: '', name: '',
slug: '', slug: '',
@ -114,75 +105,70 @@ export default {
related_templates: null, related_templates: null,
questions: [] questions: []
}) })
loadAllTemplates(this.templatesStore) loadAllTemplates(templatesStore)
}, })
computed: { let typesOptions = computed(() => {
typesOptions () { return Object.values(types.value).map((type) => {
return Object.values(this.types).map((type) => {
return { return {
name: type.name, name: type.name,
value: type.slug value: type.slug
} }
}) })
}, })
industriesOptions () { let industriesOptions = computed(() => {
return Object.values(this.industries).map((industry) => { return Object.values(industries.value).map((industry) => {
return { return {
name: industry.name, name: industry.name,
value: industry.slug value: industry.slug
} }
}) })
}, })
templatesOptions () { let templatesOptions = computed(() => {
return this.templates.map((template) => { return Object.values(templates.value).map((template) => {
return { return {
name: template.name, name: template.name,
value: template.slug value: template.slug
} }
}) })
} })
},
methods: { const onSubmit = () => {
onSubmit () { if (props.template) {
if (this.template) { updateFormTemplate()
this.updateFormTemplate()
} else { } else {
this.createFormTemplate() createFormTemplate()
} }
},
async createFormTemplate () {
this.templateForm.form = this.form
await this.templateForm.post('/api/templates').then((response) => {
if (response.data.message) {
this.useAlert.success(response.data.message)
} }
this.templatesStore.save(response.data.data) const createFormTemplate = async () => {
this.$emit('close') templateForm.value.form = props.form
}) await templateForm.value.post('/templates').then((data) => {
},
async updateFormTemplate () {
this.templateForm.form = this.form
await this.templateForm.put('/api/templates/' + this.template.id).then((response) => {
if (response.data.message) {
this.useAlert.success(response.data.message)
}
this.templatesStore.save(response.data.data)
this.$emit('close')
})
},
async deleteFormTemplate () {
if (!this.template) return
opnFetch('/templates/' + this.template.id, {method:'DELETE'}).then((data) => {
if (data.message) { if (data.message) {
this.useAlert.success(data.message) useAlert().success(data.message)
} }
this.$router.push({ name: 'templates' }) templatesStore.save(data.data)
this.templatesStore.remove(this.template) emit('close')
this.$emit('close')
}) })
} }
const updateFormTemplate = async () => {
templateForm.value.form = props.form
await templateForm.value.put('/templates/' + props.template.id).then((data) => {
if (data.message) {
useAlert().success(data.message)
} }
templatesStore.save(data.data)
emit('close')
})
}
const deleteFormTemplate = async () => {
if (!props.template) return
opnFetch('/templates/' + props.template.id, {method:'DELETE'}).then((data) => {
if (data.message) {
useAlert().success(data.message)
}
router.push({ name: 'templates' })
templatesStore.remove(props.template)
emit('close')
})
} }
</script> </script>

View File

@ -59,7 +59,7 @@
</div> </div>
</div> </div>
<collapse class="py-5 w-full border rounded-md px-4" :model-value="false"> <collapse class="py-5 w-full border rounded-md px-4" :model-value="true">
<template #title> <template #title>
<div class="flex"> <div class="flex">
<h3 class="font-semibold block text-lg"> <h3 class="font-semibold block text-lg">
@ -101,69 +101,59 @@
</div> </div>
</template> </template>
<script> <script setup>
import Collapse from '~/components/global/Collapse.vue' import { ref, defineProps, computed } from 'vue'
export default { const { copy } = useClipboard()
name: 'EmbedFormAsPopupModal', const crisp = useCrisp()
components: { Collapse }, const props = defineProps({
props: {
form: { type: Object, required: true } form: { type: Object, required: true }
}, })
data: () => ({ const embedScriptUrl = '/widgets/embed-min.js'
showEmbedFormAsPopupModal: false, let showEmbedFormAsPopupModal = ref(false)
embedScriptUrl: 'widgets/embed-min.js', let advancedOptions = ref({
advancedOptions: {
hide_title: false, hide_title: false,
emoji: '💬', emoji: '💬',
position: 'right', position: 'right',
bgcolor: '#3B82F6', bgcolor: '#3B82F6',
width: '500' width: '500'
} })
}),
computed: { let hideTitleHelp = computed(() => {
hideTitleHelp () { return props.form.hide_title ? 'This option is disabled because the form title is already hidden' : null
return this.form.hide_title ? 'This option is disabled because the form title is already hidden' : null })
}, let shareUrl = computed(() => {
shareUrl () { return (advancedOptions.value.hide_title) ? props.form.share_url + '?hide_title=true' : props.form.share_url
return (this.advancedOptions.hide_title) ? this.form.share_url + '?hide_title=true' : this.form.share_url })
}, let embedPopupCode = computed(() => {
embedPopupCode () {
const nfData = { const nfData = {
formurl: this.shareUrl, formurl: shareUrl.value,
emoji: this.advancedOptions.emoji, emoji: advancedOptions.value.emoji,
position: this.advancedOptions.position, position: advancedOptions.value.position,
bgcolor: this.advancedOptions.bgcolor, bgcolor: advancedOptions.value.bgcolor,
width: this.advancedOptions.width width: advancedOptions.value.width
} }
this.previewPopup(nfData) previewPopup(nfData)
return '<script async data-nf=\'' + JSON.stringify(nfData) + '\' src=\'' + this.asset(this.embedScriptUrl) + '\'></scrip' + 't>' return '<script async data-nf=\'' + JSON.stringify(nfData) + '\' src=\'' + embedScriptUrl + '\'></scrip' + 't>'
})
onMounted(() => {
advancedOptions.value.bgcolor = props.form.color
})
const onClose = () => {
removePreview()
crisp.showChat()
showEmbedFormAsPopupModal.value = false
} }
}, const copyToClipboard = () => {
mounted () {
this.advancedOptions.bgcolor = this.form.color
},
methods: {
onClose () {
this.removePreview()
this.$crisp.push(['do', 'chat:show'])
this.showEmbedFormAsPopupModal = false
},
copyToClipboard () {
if (process.server) return if (process.server) return
const str = this.embedPopupCode copy(embedPopupCode.value)
const el = document.createElement('textarea') useAlert().success('Copied!')
el.value = str }
document.body.appendChild(el) const removePreview = () => {
el.select()
document.execCommand('copy')
document.body.removeChild(el)
},
removePreview () {
if (process.server) return if (process.server) return
const oldP = document.head.querySelector('#nf-popup-preview') const oldP = document.head.querySelector('#nf-popup-preview')
if (oldP) { if (oldP) {
@ -173,27 +163,25 @@ export default {
if (oldM) { if (oldM) {
oldM.remove() oldM.remove()
} }
}, }
previewPopup (nfData) { const previewPopup = (nfData) => {
if (process.server) return if (process.server) return
if (!this.showEmbedFormAsPopupModal) { if (!showEmbedFormAsPopupModal.value) {
return return
} }
// Remove old preview, if there // Remove old preview, if there
this.removePreview() removePreview()
// Hide crisp // Hide crisp
this.$crisp.push(['do', 'chat:hide']) crisp.hideChat()
// Add new preview // Add new preview
const el = document.createElement('script') const el = document.createElement('script')
el.id = 'nf-popup-preview' el.id = 'nf-popup-preview'
el.async = true el.async = true
el.src = this.asset(this.embedScriptUrl) el.src = embedScriptUrl
el.setAttribute('data-nf', JSON.stringify(nfData)) el.setAttribute('data-nf', JSON.stringify(nfData))
document.head.appendChild(el) document.head.appendChild(el)
} }
}
}
</script> </script>

View File

@ -138,70 +138,51 @@
</div> </div>
</template> </template>
<script> <script setup>
import { computed } from 'vue' import { ref, defineProps, computed } from 'vue'
import Dropdown from '~/components/global/Dropdown.vue' import Dropdown from '~/components/global/Dropdown.vue'
import FormTemplateModal from '../../../open/forms/components/templates/FormTemplateModal.vue' import FormTemplateModal from '../../../open/forms/components/templates/FormTemplateModal.vue'
export default { const { copy } = useClipboard()
name: 'ExtraMenu', const router = useRouter()
components: { Dropdown, FormTemplateModal },
props: { const props = defineProps({
form: { type: Object, required: true }, form: { type: Object, required: true },
isMainPage: { type: Boolean, required: false, default: false } isMainPage: { type: Boolean, required: false, default: false }
}, })
setup () {
const authStore = useAuthStore() const authStore = useAuthStore()
const formsStore = useFormsStore() const formsStore = useFormsStore()
return { const formEndpoint = '/open/forms/{id}'
formsStore, let user = computed(() => authStore.user)
user: computed(() => authStore.user),
useAlert: useAlert() let loadingDuplicate = ref(false)
let loadingDelete = ref(false)
let showDeleteFormModal = ref(false)
let showFormTemplateModal = ref(false)
const copyLink = () => {
copy(props.form.share_url)
useAlert().success('Copied!')
} }
}, const duplicateForm = () => {
if (loadingDuplicate.value) return
data: () => ({ loadingDuplicate.value = true
loadingDuplicate: false, opnFetch(formEndpoint.replace('{id}', props.form.id) + '/duplicate',{method: 'POST'}).then((data) => {
loadingDelete: false, formsStore.save(data.new_form)
showDeleteFormModal: false, router.push({ name: 'forms-slug-show', params: { slug: data.new_form.slug } })
showFormTemplateModal: false useAlert().success('Form was successfully duplicated.')
}), loadingDuplicate.value = false
computed: {
formEndpoint: () => '/open/forms/{id}'
},
methods: {
copyLink () {
const el = document.createElement('textarea')
el.value = this.form.share_url
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
this.useAlert.success('Copied!')
},
duplicateForm () {
if (this.loadingDuplicate) return
this.loadingDuplicate = true
opnFetch(this.formEndpoint.replace('{id}', this.form.id) + '/duplicate',{method: 'POST'}).then((data) => {
this.formsStore.save(data.new_form)
this.$router.push({ name: 'forms-show', params: { slug: data.new_form.slug } })
this.useAlert.success('Form was successfully duplicated.')
this.loadingDuplicate = false
})
},
deleteForm () {
if (this.loadingDelete) return
this.loadingDelete = true
opnFetch(this.formEndpoint.replace('{id}', this.form.id),{method:'DELETE'}).then(() => {
this.formsStore.remove(this.form)
this.$router.push({ name: 'home' })
this.useAlert.success('Form was deleted.')
this.loadingDelete = false
}) })
} }
} const deleteForm = () => {
if (loadingDelete.value) return
loadingDelete.value = true
opnFetch(formEndpoint.replace('{id}', props.form.id),{method:'DELETE'}).then(() => {
formsStore.remove(props.form)
router.push({ name: 'home' })
useAlert().success('Form was deleted.')
loadingDelete.value = false
})
} }
</script> </script>

16
client/composables/useOpnSeoMeta.js vendored Normal file
View File

@ -0,0 +1,16 @@
export const useOpnSeoMeta = (meta) => {
return useSeoMeta({
...meta.title ? {
ogTitle: meta.title,
twitterTitle: meta.title,
} : {},
...meta.description ? {
ogDescription: meta.description,
twitterDescription: meta.description,
} : {},
...meta.ogImage ? {
twitterImage: meta.ogImage,
} : {},
...meta,
})
}

25
client/error.vue Normal file
View File

@ -0,0 +1,25 @@
<template>
<NuxtLayout>
<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="/img/icons/plant.png" class="w-56 mb-5">
<h1 class="mb-4 font-semibold text-3xl text-gray-900">
Page Not Found
</h1>
<div class="links">
<NuxtLink :to="{ name: 'index' }" class="hover:underline text-gray-700">
Go Home
</NuxtLink>
</div>
</div>
</div>
</NuxtLayout>
</template>
<script setup>
useOpnSeoMeta({
title: '404 - Page not found'
})
</script>

View File

@ -1,23 +0,0 @@
export default {
metaInfo () {
const title = this.metaTitle ?? 'OpnForm'
const description = this.metaDescription ?? "Create beautiful forms for free. Unlimited fields, unlimited submissions. It's free and it takes less than 1 minute to create your first form."
const image = this.metaImage ?? this.asset('img/social-preview.jpg')
const metaTemplate = this.metaTemplate ?? '%s · OpnForm'
return {
title: title,
titleTemplate: metaTemplate,
meta: [
...(this.metaTags ?? []),
{ vmid: 'og:title', property: 'og:title', content: title },
{ vmid: 'twitter:title', property: 'twitter:title', content: title },
{ vmid: 'description', name: 'description', content: description },
{ vmid: 'og:description', property: 'og:description', content: description },
{ vmid: 'twitter:description', property: 'twitter:description', content: description },
{ vmid: 'twitter:image', property: 'twitter:image', content: image },
{ vmid: 'og:image', property: 'og:image', content: image }
]
}
}
}

View File

@ -493,39 +493,21 @@
</div> </div>
</template> </template>
<script> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import SeoMeta from '../mixins/seo-meta.js'
export default {
layout: 'default',
mixins: [SeoMeta],
setup () {
const authStore = useAuthStore() const authStore = useAuthStore()
useOpnSeoMeta({
title: 'Free AI form builder',
description: 'Transform your ideas into fully functional forms with OpnForm AI Builder quick, accurate, and tailored to fit any requirement.'
})
defineRouteRules({ defineRouteRules({
prerender: true prerender: true
}) })
return { let authenticated = computed(() => authStore.check)
authenticated : computed(() => authStore.check),
}
},
data: () => ({
title: 'OpnForm',
metaTitle: 'AI form builder for free',
}),
mounted() {},
methods: {},
computed: {
configLinks: () => this.$config.links
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -24,10 +24,16 @@
<script> <script>
export default { export default {
middleware: 'guest', setup () {
definePageMeta({
middleware: "guest"
})
useOpnSeoMeta({
title: 'Reset Password'
})
},
data: () => ({ data: () => ({
metaTitle: 'Reset Password',
status: '', status: '',
form: useForm({ form: useForm({
email: '' email: ''

View File

@ -34,10 +34,16 @@
<script> <script>
export default { export default {
middleware: 'guest', setup () {
definePageMeta({
middleware: "guest"
})
useOpnSeoMeta({
title: 'Reset Password'
})
},
data: () => ({ data: () => ({
metaTitle: 'Reset Password',
status: '', status: '',
form: useForm({ form: useForm({
token: '', token: '',

View File

@ -1,24 +0,0 @@
<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="/img/icons/plant.png" class="w-56 mb-5">
<h1 class="mb-4 font-semibold text-3xl text-gray-900">
Page Not Found
</h1>
<div class="links">
<NuxtLink :to="{ name: 'index' }" class="hover:underline text-gray-700">
Go Home
</NuxtLink>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'NotFound'
}
</script>

View File

@ -22,7 +22,6 @@ import {hash} from "~/lib/utils.js";
export default { export default {
name: 'EditForm', name: 'EditForm',
components: { Breadcrumb, FormEditor }, components: { Breadcrumb, FormEditor },
middleware: 'auth',
beforeRouteLeave (to, from, next) { beforeRouteLeave (to, from, next) {
if (this.isDirty()) { if (this.isDirty()) {
@ -52,6 +51,13 @@ export default {
} }
}) })
useOpnSeoMeta({
title: 'Edit ' + ((form && form.value) ? form.value.title : 'Your Form')
})
definePageMeta({
middleware: "auth"
})
return { return {
formsStore, formsStore,
workingFormStore, workingFormStore,
@ -70,9 +76,6 @@ export default {
}, },
computed: { computed: {
metaTitle () {
return 'Edit ' + (this.form ? this.form.title : 'Your Form')
}
}, },
async beforeMount() { async beforeMount() {

View File

@ -113,32 +113,36 @@ onMounted(() => {
await loadForm(slug) await loadForm(slug)
// metaTitle () { useOpnSeoMeta({
// if (this.form && this.form.is_pro && this.form.seo_meta.page_title) { title: () => {
// return this.form.seo_meta.page_title if (form && form.value.is_pro && form.value.seo_meta.page_title) {
// } return form.value.seo_meta.page_title
// return this.form ? this.form.title : 'Create beautiful forms' }
// }, return form.value ? form.value.title : 'Create beautiful forms'
// metaTemplate () { },
// if (this.form && this.form.is_pro && this.form.seo_meta.page_title) { description () {
// // Disable template if custom SEO title if (form && form.value.is_pro && form.value.seo_meta.page_description) {
// return '%s' return form.value.seo_meta.page_description
// } }
// return null return (form && form.value.description) ? form.value.description.substring(0, 160) : null
// }, },
// metaDescription () { ogImage () {
// if (this.form && this.form.is_pro && this.form.seo_meta.page_description) { if (form && form.value.is_pro && form.value.seo_meta.page_thumbnail) {
// return this.form.seo_meta.page_description return form.value.seo_meta.page_thumbnail
// } }
// return (this.form && this.form.description) ? this.form.description.substring(0, 160) : null return (form && form.value.cover_picture) ? form.value.cover_picture : null
// }, },
// metaImage () { robots () {
// if (this.form && this.form.is_pro && this.form.seo_meta.page_thumbnail) { return (form && form.value.can_be_indexed) ? null : 'noindex, nofollow'
// return this.form.seo_meta.page_thumbnail }
// } })
// return (this.form && this.form.cover_picture) ? this.form.cover_picture : null useHead({
// }, titleTemplate: (titleChunk) => {
// metaTags () { if (form && form.value.is_pro && form.value.seo_meta.page_title) {
// return (this.form && this.form.can_be_indexed) ? [] : [{ name: 'robots', content: 'noindex' }] // Disable template if custom SEO title
// } return titleChunk
}
return titleChunk ? `${titleChunk} - OpnForm` : 'OpnForm';
}
})
</script> </script>

View File

@ -141,9 +141,14 @@ export default {
FormCleanings FormCleanings
}, },
middleware: 'auth',
setup () { setup () {
definePageMeta({
middleware: "auth"
})
useOpnSeoMeta({
title: 'Home'
})
const authStore = useAuthStore() const authStore = useAuthStore()
const formsStore = useFormsStore() const formsStore = useFormsStore()
const workingFormStore = useWorkingFormStore() const workingFormStore = useWorkingFormStore()
@ -167,7 +172,6 @@ export default {
data () { data () {
return { return {
metaTitle: 'Home',
tabsList: [ tabsList: [
{ {
name: 'Submissions', name: 'Submissions',

View File

@ -26,7 +26,6 @@ import EmbedCode from '../../../../components/pages/forms/show/EmbedCode.vue'
import FormQrCode from '../../../../components/pages/forms/show/FormQrCode.vue' import FormQrCode from '../../../../components/pages/forms/show/FormQrCode.vue'
import UrlFormPrefill from '../../../../components/pages/forms/show/UrlFormPrefill.vue' import UrlFormPrefill from '../../../../components/pages/forms/show/UrlFormPrefill.vue'
import RegenerateFormLink from '../../../../components/pages/forms/show/RegenerateFormLink.vue' import RegenerateFormLink from '../../../../components/pages/forms/show/RegenerateFormLink.vue'
import SeoMeta from '../../../../mixins/seo-meta.js'
import AdvancedFormUrlSettings from '../../../../components/open/forms/components/AdvancedFormUrlSettings.vue' import AdvancedFormUrlSettings from '../../../../components/open/forms/components/AdvancedFormUrlSettings.vue'
import EmbedFormAsPopupModal from '../../../../components/pages/forms/show/EmbedFormAsPopupModal.vue' import EmbedFormAsPopupModal from '../../../../components/pages/forms/show/EmbedFormAsPopupModal.vue'
@ -45,6 +44,15 @@ export default {
form: {type: Object, required: true}, form: {type: Object, required: true},
}, },
setup (props) {
definePageMeta({
middleware: "auth"
})
useOpnSeoMeta({
title: (props.form) ? 'Share Form - '+props.form.title : 'Share Form'
})
},
data: () => ({ data: () => ({
shareFormConfig: { shareFormConfig: {
hide_title: false, hide_title: false,
@ -53,9 +61,6 @@ export default {
}), }),
computed: { computed: {
metaTitle() {
return (this.form) ? 'Form Share - '+this.form.title : 'Form Share'
},
shareUrlForQueryParams () { shareUrlForQueryParams () {
let queryStr = '' let queryStr = ''
for (const [key, value] of Object.entries(this.shareFormConfig)) { for (const [key, value] of Object.entries(this.shareFormConfig)) {

View File

@ -9,7 +9,6 @@
<script> <script>
import FormStats from '../../../../components/open/forms/components/FormStats.vue' import FormStats from '../../../../components/open/forms/components/FormStats.vue'
import SeoMeta from '../../../../mixins/seo-meta.js'
export default { export default {
components: {FormStats}, components: {FormStats},
@ -18,10 +17,16 @@ export default {
form: {type: Object, required: true}, form: {type: Object, required: true},
}, },
setup (props) {
definePageMeta({
middleware: "auth"
})
useOpnSeoMeta({
title: (props.form) ? 'Form Analytics - '+props.form.title : 'Form Analytics'
})
},
computed: { computed: {
metaTitle() {
return (this.form ? ('Form Analytics - ' + this.form.title) : 'Form Analytics')
}
} }
} }
</script> </script>

View File

@ -6,14 +6,21 @@
<script> <script>
import FormSubmissions from '../../../../components/open/forms/components/FormSubmissions.vue' import FormSubmissions from '../../../../components/open/forms/components/FormSubmissions.vue'
import SeoMeta from '../../../../mixins/seo-meta.js'
export default { export default {
components: {FormSubmissions}, components: {FormSubmissions},
props: { props: {
form: {type: Object, required: true} form: {type: Object, required: true}
}, },
mixins: [SeoMeta],
setup (props) {
definePageMeta({
middleware: "auth"
})
useOpnSeoMeta({
title: (props.form) ? 'Form Submissions - '+props.form.title : 'Form Submissions'
})
},
data: () => ({}), data: () => ({}),
@ -21,9 +28,6 @@ export default {
}, },
computed: { computed: {
metaTitle() {
return (this.form) ? 'Form Submissions - ' + this.form.title : 'Form Submissions'
},
}, },
methods: {} methods: {}

View File

@ -48,7 +48,13 @@ const workspace = computed(() => workspacesStore.getCurrent)
const workspacesLoading = computed(() => workspacesStore.loading) const workspacesLoading = computed(() => workspacesStore.loading)
const form = storeToRefs(workingFormStore).content const form = storeToRefs(workingFormStore).content
// metaTitle: 'Create a new Form as Guest', useOpnSeoMeta({
title: 'Create a new Form for free',
})
definePageMeta({
middleware: "guest"
})
// Data // Data
const stateReady = ref(false) const stateReady = ref(false)
const loading = ref(false) const loading = ref(false)

View File

@ -33,7 +33,9 @@ definePageMeta({
middleware: "auth" middleware: "auth"
}) })
const metaTitle = 'Create a new Form' useOpnSeoMeta({
title: 'Create a new Form'
})
onBeforeRouteLeave((to, from, next) => { onBeforeRouteLeave((to, from, next) => {
if (isDirty()) { if (isDirty()) {

View File

@ -125,11 +125,10 @@ definePageMeta({
middleware: "auth" middleware: "auth"
}) })
// metaTitle: {type: String, default: 'Your Forms'}, useOpnSeoMeta({
// metaDescription: { title: 'Your Forms',
// type: String, description: 'All of your OpnForm are here. Create new forms, or update your existing forms.'
// default: 'All of your OpnForm are here. Create new forms, or update your existing one!' })
// }
const authStore = useAuthStore() const authStore = useAuthStore()
const formsStore = useFormsStore() const formsStore = useFormsStore()

View File

@ -191,12 +191,10 @@ import PricingTable from '../components/pages/pricing/PricingTable.vue'
import AiFeature from '~/components/pages/welcome/AiFeature.vue' import AiFeature from '~/components/pages/welcome/AiFeature.vue'
import Testimonials from '../components/pages/welcome/Testimonials.vue' import Testimonials from '../components/pages/welcome/Testimonials.vue'
import TemplatesSlider from '../components/pages/welcome/TemplatesSlider.vue' import TemplatesSlider from '../components/pages/welcome/TemplatesSlider.vue'
import SeoMeta from '../mixins/seo-meta.js'
import opnformConfig from "~/opnform.config.js"; import opnformConfig from "~/opnform.config.js";
export default { export default {
components: {Testimonials, Features, MoreFeatures, PricingTable, AiFeature, TemplatesSlider}, components: {Testimonials, Features, MoreFeatures, PricingTable, AiFeature, TemplatesSlider},
mixins: [SeoMeta],
layout: 'default', layout: 'default',
setup() { setup() {
@ -213,8 +211,6 @@ export default {
}, },
data: () => ({ data: () => ({
title: 'OpnForm',
metaTitle: 'Create beautiful & open-source forms for free'
}), }),
computed: { computed: {

View File

@ -52,29 +52,16 @@
</div> </div>
</template> </template>
<script> <script setup>
import LoginForm from "~/components/pages/auth/components/LoginForm.vue" import LoginForm from "~/components/pages/auth/components/LoginForm.vue"
export default {
components: {
LoginForm
},
setup() {
definePageMeta({ definePageMeta({
middleware: "guest" middleware: "guest"
}) })
defineRouteRules({ defineRouteRules({
prerender: true prerender: true
}) })
}, useOpnSeoMeta({
title: 'Login'
data: () => ({ })
metaTitle: 'Login',
}),
methods: {
}
}
</script> </script>

View File

@ -245,6 +245,11 @@ export default {
layout: 'default', layout: 'default',
setup () { setup () {
useOpnSeoMeta({
title: 'Pricing',
description: 'All of our core features are free, and there is no quantity limit. You can also created more advanced and customized forms with OpnForms Pro.'
})
definePageMeta({ definePageMeta({
middleware: [ middleware: [
function (to, from) { function (to, from) {
@ -264,15 +269,6 @@ export default {
} }
}, },
data: () => ({
metaTitle: 'Pricing',
metaDescription: 'All of our core features are free, and there is no quantity limit. You can also created more advanced and customized forms with OpnForms Pro.',
}),
mounted() {},
computed: {},
methods: { methods: {
contactUs() { contactUs() {
window.$crisp.push(['do', 'chat:show']) window.$crisp.push(['do', 'chat:show'])

View File

@ -13,12 +13,13 @@
</template> </template>
<script setup> <script setup>
// metaTitle: 'Privacy Policy',
import {useNotionPagesStore} from "~/stores/notion_pages.js"; import {useNotionPagesStore} from "~/stores/notion_pages.js";
import {computed} from "vue"; import {computed} from "vue";
useOpnSeoMeta({
title: 'Privacy Policy'
})
const notionPageStore = useNotionPagesStore() const notionPageStore = useNotionPagesStore()
await notionPageStore.load('9c97349ceda7455aab9b341d1ff70f79') await notionPageStore.load('9c97349ceda7455aab9b341d1ff70f79')

View File

@ -64,6 +64,10 @@ export default {
}, },
setup() { setup() {
useOpnSeoMeta({
title: 'Register'
})
definePageMeta({ definePageMeta({
middleware: "guest" middleware: "guest"
}) })
@ -72,10 +76,7 @@ export default {
}) })
}, },
middleware: 'guest',
data: () => ({ data: () => ({
metaTitle: 'Register'
}), }),
computed: {}, computed: {},

View File

@ -45,6 +45,10 @@
middleware: "auth" middleware: "auth"
}) })
useOpnSeoMeta({
title: 'Settings'
})
const authStore = useAuthStore() const authStore = useAuthStore()
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
const tabsList = computed(() => { const tabsList = computed(() => {
@ -84,4 +88,3 @@
return tabs return tabs
}) })
</script> </script>

View File

@ -20,9 +20,15 @@ import { useRouter } from 'vue-router';
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const metaTitle = 'Account'
let loading = false let loading = false
useOpnSeoMeta({
title: 'Account'
})
definePageMeta({
middleware: "auth"
})
const deleteAccount = () => { const deleteAccount = () => {
loading = true loading = true
opnFetch('/user', {method:'DELETE'}).then(async (data) => { opnFetch('/user', {method:'DELETE'}).then(async (data) => {

View File

@ -41,7 +41,10 @@ definePageMeta({
middleware: "admin" middleware: "admin"
}) })
const metaTitle = 'Admin' useOpnSeoMeta({
title: 'Admin'
})
const authStore = useAuthStore() const authStore = useAuthStore()
const workspacesStore = useWorkspacesStore() const workspacesStore = useWorkspacesStore()
const router = useRouter() const router = useRouter()

View File

@ -24,7 +24,13 @@ import { computed } from 'vue'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import AppSumoBilling from '../../components/vendor/appsumo/AppSumoBilling.vue' import AppSumoBilling from '../../components/vendor/appsumo/AppSumoBilling.vue'
const metaTitle = 'Billing' useOpnSeoMeta({
title: 'Billing'
})
definePageMeta({
middleware: "auth"
})
const authStore = useAuthStore() const authStore = useAuthStore()
let user = computed(() => authStore.user) let user = computed(() => authStore.user)
let billingLoading = false let billingLoading = false

View File

@ -25,7 +25,13 @@
</template> </template>
<script setup> <script setup>
const metaTitle = 'Password' useOpnSeoMeta({
title: 'Password'
})
definePageMeta({
middleware: "auth"
})
let form = useForm({ let form = useForm({
password: '', password: '',
password_confirmation: '' password_confirmation: ''

View File

@ -23,7 +23,14 @@
<script setup> <script setup>
const authStore = useAuthStore() const authStore = useAuthStore()
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
const metaTitle = 'Profile'
useOpnSeoMeta({
title: 'Profile'
})
definePageMeta({
middleware: "auth"
})
let form = useForm({ let form = useForm({
name: '', name: '',
email: '' email: ''

View File

@ -122,7 +122,14 @@ const crisp = useCrisp()
const workspacesStore = useWorkspacesStore() const workspacesStore = useWorkspacesStore()
const workspaces = computed(() => workspacesStore.getAll) const workspaces = computed(() => workspacesStore.getAll)
let loading = computed(() => workspacesStore.loading) let loading = computed(() => workspacesStore.loading)
const metaTitle = 'Workspaces'
useOpnSeoMeta({
title: 'Workspaces'
})
definePageMeta({
middleware: "auth"
})
let form = useForm({ let form = useForm({
name: '', name: '',
emoji: '' emoji: ''

View File

@ -3,15 +3,17 @@
<script> <script>
import { computed } from 'vue' import { computed } from 'vue'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import SeoMeta from '../../mixins/seo-meta.js'
export default { export default {
components: { }, components: { },
layout: 'default', layout: 'default',
middleware: 'auth', middleware: 'auth',
mixins: [SeoMeta],
setup () { setup () {
useOpnSeoMeta({
title: 'Error'
})
const authStore = useAuthStore() const authStore = useAuthStore()
return { return {
authenticated : computed(() => authStore.check), authenticated : computed(() => authStore.check),
@ -19,7 +21,6 @@ export default {
}, },
data: () => ({ data: () => ({
metaTitle: 'Error',
}), }),
mounted () { mounted () {

View File

@ -18,13 +18,16 @@
<script> <script>
import { computed } from 'vue' import { computed } from 'vue'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import SeoMeta from '../../mixins/seo-meta.js'
export default { export default {
layout: 'default', layout: 'default',
middleware: 'auth', middleware: 'auth',
setup () { setup () {
useOpnSeoMeta({
title: 'Subscription Success'
})
const authStore = useAuthStore() const authStore = useAuthStore()
return { return {
authStore, authStore,
@ -34,7 +37,6 @@ export default {
}, },
data: () => ({ data: () => ({
metaTitle: 'Subscription Success',
interval: null interval: null
}), }),

View File

@ -6,9 +6,9 @@
<v-button color="gray" size="small" @click.prevent="showFormTemplateModal=true"> <v-button color="gray" size="small" @click.prevent="showFormTemplateModal=true">
Edit Template Edit Template
</v-button> </v-button>
<!-- <form-template-modal v-if="form" :form="form" :template="template" :show="showFormTemplateModal"--> <form-template-modal v-if="form" :form="form" :template="template" :show="showFormTemplateModal"
<!-- @close="showFormTemplateModal=false"--> @close="showFormTemplateModal=false"
<!-- />--> />
</div> </div>
</template> </template>
<template #right> <template #right>
@ -199,12 +199,14 @@ import {computed} from 'vue'
import OpenCompleteForm from '../../components/open/forms/OpenCompleteForm.vue' import OpenCompleteForm from '../../components/open/forms/OpenCompleteForm.vue'
import Breadcrumb from '~/components/global/Breadcrumb.vue' import Breadcrumb from '~/components/global/Breadcrumb.vue'
import SingleTemplate from '../../components/pages/templates/SingleTemplate.vue' import SingleTemplate from '../../components/pages/templates/SingleTemplate.vue'
import {fetchTemplate} from "~/stores/templates.js"; import {fetchTemplate} from "~/stores/templates.js"
import FormTemplateModal from '~/components/open/forms/components/templates/FormTemplateModal.vue'
defineRouteRules({ defineRouteRules({
prerender: true prerender: true
}) })
const { copy } = useClipboard()
const authStore = useAuthStore() const authStore = useAuthStore()
const templatesStore = useTemplatesStore() const templatesStore = useTemplatesStore()
@ -255,34 +257,29 @@ const cleanQuotes = (str) => {
} }
const copyTemplateUrl = () => { const copyTemplateUrl = () => {
const str = template.value.share_url copy(template.value.share_url)
const el = document.createElement('textarea')
el.value = str
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
useAlert().success('Copied!') useAlert().success('Copied!')
} }
// metaTitle() { useOpnSeoMeta({
// return this.template ? this.template.name : 'Form Template' title: () => {
// }, if (!template || !template.value) return 'Form Template'
// metaDescription() { return template.value.name
// if (!this.template) return null },
// // take the first 140 characters of the description description () {
// return this.template.short_description?.substring(0, 140) + '... | Customize any template and create your own form in minutes.' if (!template || !template.value) return null
// }, // take the first 140 characters of the description
// metaImage() { return template.value.short_description?.substring(0, 140) + '... | Customize any template and create your own form in minutes.'
// if (!this.template) return null },
// return this.template.image_url ogImage () {
// }, if (!template || !template.value) return null
// metaTags() { return template.value.image_url
// if (!this.template) { },
// return []; robots () {
// } if (!template || !template.value) return null
// return this.template.publicly_listed ? [] : [{name: 'robots', content: 'noindex'}] return template.value.publicly_listed ? null : 'noindex'
// }, }
})
</script> </script>
<style lang='scss'> <style lang='scss'>

View File

@ -26,10 +26,10 @@ defineRouteRules({
prerender: true prerender: true
}) })
// props: { useOpnSeoMeta({
// metaTitle: { type: String, default: 'Templates' }, title: 'Form Templates',
// metaDescription: { type: String, default: 'Our collection of beautiful templates to create your own forms!' } description: 'Our collection of beautiful templates to create your own forms!'
// }, })
const templatesStore = useTemplatesStore() const templatesStore = useTemplatesStore()
loadAllTemplates(templatesStore) loadAllTemplates(templatesStore)

View File

@ -3,7 +3,7 @@
<breadcrumb :path="breadcrumbs"/> <breadcrumb :path="breadcrumbs"/>
<p v-if="industry === null || !industry" class="text-center my-4"> <p v-if="industry === null || !industry" class="text-center my-4">
We could not find this type. We could not find this industry.
</p> </p>
<template v-else> <template v-else>
<section class="py-12 sm:py-16 bg-gray-50 border-b border-gray-200"> <section class="py-12 sm:py-16 bg-gray-50 border-b border-gray-200">
@ -23,7 +23,7 @@
</section> </section>
<templates-list :templates="templates" :filter-industries="false" :show-types="false"> <templates-list :templates="templates" :filter-industries="false" :show-industries="false">
<template #before-lists> <template #before-lists>
<section class="py-12 bg-white border-t border-gray-200 sm:py-16"> <section class="py-12 bg-white border-t border-gray-200 sm:py-16">
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl"> <div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
@ -70,6 +70,29 @@ const breadcrumbs = computed(() => {
const industry = computed(() => templatesStore.industries.get(route.params.slug)) const industry = computed(() => templatesStore.industries.get(route.params.slug))
useOpnSeoMeta({
title: () => {
if (!industry.value) return 'Form Templates'
if (industry.value.meta_title.length > 60) {
return industry.value.meta_title
}
return industry.value.meta_title
},
description: () => industry.value ? industry.value.meta_description: 'Our collection of beautiful templates to create your own forms!'
})
useHead({
titleTemplate: (titleChunk) => {
// Disable title template for longer titles
if (industry.value
&& industry.value.meta_title.length < 60
&& !industry.value.meta_title.toLowerCase().includes('opnform')
) {
return titleChunk ? `${titleChunk} - OpnForm` : 'Form Templates - OpnForm'
}
return titleChunk ? titleChunk : 'Form Templates - OpnForm'
}
})
</script> </script>
<style lang='scss'> <style lang='scss'>

View File

@ -13,30 +13,28 @@
</div> </div>
</section> </section>
<!-- <templates-list :only-my="true" />--> <templates-list :templates="templates" :loading="loading" :show-types="false" :show-industries="false"/>
</div> </div>
</template> </template>
<script> <script setup>
import TemplatesList from '../../components/pages/templates/TemplatesList.vue' definePageMeta({
middleware: "auth"
})
export default { useOpnSeoMeta({
components: { TemplatesList }, title: 'My Templates',
middleware: 'auth', description: 'Our collection of beautiful templates to create your own forms!'
})
props: { let loading = ref(false)
metaTitle: { type: String, default: 'My Templates' }, let templates = ref([])
metaDescription: { type: String, default: 'Our collection of beautiful templates to create your own forms!' }
},
data () { onMounted(() => {
return {} loading.value = true
}, opnFetch('templates',{query: {onlymy: true}}).then((data) => {
loading.value = false
mounted () {}, templates.value = data
})
computed: {}, })
methods: {}
}
</script> </script>

View File

@ -71,6 +71,29 @@ const breadcrumbs = computed(() => {
const type = computed(() => templatesStore.types.get(route.params.slug)) const type = computed(() => templatesStore.types.get(route.params.slug))
useOpnSeoMeta({
title: () => {
if (!type.value) return 'Form Templates'
if (type.value.meta_title.length > 60) {
return type.value.meta_title
}
return type.value.meta_title
},
description: () => type.value ? type.value.meta_description: 'Our collection of beautiful templates to create your own forms!'
})
useHead({
titleTemplate: (titleChunk) => {
// Disable title template for longer titles
if (type.value
&& type.value.meta_title.length < 60
&& !type.value.meta_title.toLowerCase().includes('opnform')
) {
return titleChunk ? `${titleChunk} - OpnForm` : 'Form Templates - OpnForm'
}
return titleChunk ? titleChunk : 'Form Templates - OpnForm'
}
})
</script> </script>
<style lang='scss'> <style lang='scss'>

View File

@ -13,11 +13,13 @@
</template> </template>
<script setup> <script setup>
// metaTitle: 'Terms & Conditions',
import {useNotionPagesStore} from "~/stores/notion_pages.js"; import {useNotionPagesStore} from "~/stores/notion_pages.js";
import {computed} from "vue"; import {computed} from "vue";
useOpnSeoMeta({
title: 'Terms & Conditions'
})
const notionPageStore = useNotionPagesStore() const notionPageStore = useNotionPagesStore()
await notionPageStore.load('246420da2834480ca04047b0c5a00929') await notionPageStore.load('246420da2834480ca04047b0c5a00929')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

2
client/public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: