Custom SEO (#154)
* Enable Pro plan - WIP * no pricing page if have no paid plans * Set pricing ids in env * views & submissions FREE for all * extra param for env * form password FREE for all * Custom Code is PRO feature * Replace codeinput prism with codemirror * Better form Cleaning message * Added risky user email spam protection * fix form cleaning * Custom SEO * fix custom seo formcleaner * remvoe fix condition
This commit is contained in:
parent
fb79a5bf3e
commit
01a01a8c72
|
@ -121,6 +121,9 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest
|
||||||
// Security & Privacy
|
// Security & Privacy
|
||||||
'can_be_indexed' => 'boolean',
|
'can_be_indexed' => 'boolean',
|
||||||
'password' => 'sometimes|nullable',
|
'password' => 'sometimes|nullable',
|
||||||
|
|
||||||
|
// Custom SEO
|
||||||
|
'seo_meta' => 'nullable|array'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -48,7 +48,8 @@ class FormResource extends JsonResource
|
||||||
'slack_webhook_url' => $this->slack_webhook_url,
|
'slack_webhook_url' => $this->slack_webhook_url,
|
||||||
'discord_webhook_url' => $this->discord_webhook_url,
|
'discord_webhook_url' => $this->discord_webhook_url,
|
||||||
'removed_properties' => $this->removed_properties,
|
'removed_properties' => $this->removed_properties,
|
||||||
'last_edited_human' => $this->updated_at?->diffForHumans()
|
'last_edited_human' => $this->updated_at?->diffForHumans(),
|
||||||
|
'seo_meta' => $this->seo_meta
|
||||||
] : [];
|
] : [];
|
||||||
|
|
||||||
$baseData = $this->getFilteredFormData(parent::toArray($request), $this->userIsFormOwner());
|
$baseData = $this->getFilteredFormData(parent::toArray($request), $this->userIsFormOwner());
|
||||||
|
|
|
@ -83,7 +83,10 @@ class Form extends Model
|
||||||
|
|
||||||
// Security & Privacy
|
// Security & Privacy
|
||||||
'can_be_indexed',
|
'can_be_indexed',
|
||||||
'password'
|
'password',
|
||||||
|
|
||||||
|
// Custom SEO
|
||||||
|
'seo_meta'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
@ -91,7 +94,8 @@ class Form extends Model
|
||||||
'database_fields_update' => 'array',
|
'database_fields_update' => 'array',
|
||||||
'closes_at' => 'datetime',
|
'closes_at' => 'datetime',
|
||||||
'tags' => 'array',
|
'tags' => 'array',
|
||||||
'removed_properties' => 'array'
|
'removed_properties' => 'array',
|
||||||
|
'seo_meta' => 'object'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $appends = [
|
protected $appends = [
|
||||||
|
|
|
@ -23,6 +23,9 @@ class FormCleaner
|
||||||
|
|
||||||
private array $data;
|
private array $data;
|
||||||
|
|
||||||
|
// For remove keys those have empty value
|
||||||
|
private array $customKeys = ['seo_meta'];
|
||||||
|
|
||||||
private array $formDefaults = [
|
private array $formDefaults = [
|
||||||
'notifies' => false,
|
'notifies' => false,
|
||||||
'no_branding' => false,
|
'no_branding' => false,
|
||||||
|
@ -32,6 +35,7 @@ class FormCleaner
|
||||||
'discord_webhook_url' => null,
|
'discord_webhook_url' => null,
|
||||||
'editable_submissions' => false,
|
'editable_submissions' => false,
|
||||||
'custom_code' => null,
|
'custom_code' => null,
|
||||||
|
'seo_meta' => []
|
||||||
];
|
];
|
||||||
|
|
||||||
private array $fieldDefaults = [
|
private array $fieldDefaults = [
|
||||||
|
@ -49,6 +53,7 @@ class FormCleaner
|
||||||
'discord_webhook_url' => "Discord webhook disabled.",
|
'discord_webhook_url' => "Discord webhook disabled.",
|
||||||
'editable_submissions' => 'Users will not be able to edit their submissions.',
|
'editable_submissions' => 'Users will not be able to edit their submissions.',
|
||||||
'custom_code' => 'Custom code was disabled',
|
'custom_code' => 'Custom code was disabled',
|
||||||
|
'seo_meta' => 'Custom code was disabled',
|
||||||
|
|
||||||
// For fields
|
// For fields
|
||||||
'file_upload' => "Link field is not a file upload.",
|
'file_upload' => "Link field is not a file upload.",
|
||||||
|
@ -202,6 +207,9 @@ class FormCleaner
|
||||||
// Get value from form
|
// Get value from form
|
||||||
$formVal = Arr::get($data, $key);
|
$formVal = Arr::get($data, $key);
|
||||||
|
|
||||||
|
// Transform customkeys values
|
||||||
|
$formVal = $this->cleanCustomKeys($key, $formVal);
|
||||||
|
|
||||||
// Transform boolean values
|
// Transform boolean values
|
||||||
$formVal = (($formVal === 0 || $formVal === "0") ? false : $formVal);
|
$formVal = (($formVal === 0 || $formVal === "0") ? false : $formVal);
|
||||||
$formVal = (($formVal === 1 || $formVal === "1") ? true : $formVal);
|
$formVal = (($formVal === 1 || $formVal === "1") ? true : $formVal);
|
||||||
|
@ -242,4 +250,20 @@ class FormCleaner
|
||||||
}*/
|
}*/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove keys those have empty value
|
||||||
|
private function cleanCustomKeys($key, $formVal)
|
||||||
|
{
|
||||||
|
if (in_array($key, $this->customKeys) && $formVal !== null) {
|
||||||
|
$newVal = [];
|
||||||
|
foreach ($formVal as $k => $val) {
|
||||||
|
if ($val) {
|
||||||
|
$newVal[$k] = $val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $newVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formVal;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -160,15 +160,25 @@ class SeoMetaResolver
|
||||||
{
|
{
|
||||||
$form = Form::whereSlug($this->patternData['slug'])->firstOrFail();
|
$form = Form::whereSlug($this->patternData['slug'])->firstOrFail();
|
||||||
|
|
||||||
$meta = [
|
$meta = [];
|
||||||
'title' => $form->title . $this->titleSuffix(),
|
if ($form->is_pro && $form->seo_meta->page_title) {
|
||||||
];
|
$meta['title'] = $form->seo_meta->page_title;
|
||||||
if($form->description){
|
} else {
|
||||||
|
$meta['title'] = $form->title . $this->titleSuffix();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($form->is_pro && $form->seo_meta->page_description) {
|
||||||
|
$meta['description'] = $form->seo_meta->page_description;
|
||||||
|
} else if ($form->description) {
|
||||||
$meta['description'] = Str::of($form->description)->limit(160);
|
$meta['description'] = Str::of($form->description)->limit(160);
|
||||||
}
|
}
|
||||||
if($form->cover_picture){
|
|
||||||
|
if ($form->is_pro && $form->seo_meta->page_thumbnail) {
|
||||||
|
$meta['image'] = $form->seo_meta->page_thumbnail;
|
||||||
|
} else if ($form->cover_picture) {
|
||||||
$meta['image'] = $form->cover_picture;
|
$meta['image'] = $form->cover_picture;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $meta;
|
return $meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -85,7 +85,8 @@ class FormFactory extends Factory
|
||||||
'tags' => [],
|
'tags' => [],
|
||||||
'slack_webhook_url' => null,
|
'slack_webhook_url' => null,
|
||||||
'editable_submissions_button_text' => 'Edit submission',
|
'editable_submissions_button_text' => 'Edit submission',
|
||||||
'confetti_on_submission' => false
|
'confetti_on_submission' => false,
|
||||||
|
'seo_meta' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::table('forms', function (Blueprint $table) {
|
||||||
|
$table->json('seo_meta')->default('{}');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::table('forms', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('seo_meta');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
|
@ -339,7 +339,7 @@ export default {
|
||||||
|
|
||||||
const formData = clonedeep(this.dataForm ? this.dataForm.data() : {})
|
const formData = clonedeep(this.dataForm ? this.dataForm.data() : {})
|
||||||
let urlPrefill = null
|
let urlPrefill = null
|
||||||
if (this.isPublicFormPage && this.form.is_pro) {
|
if (this.isPublicFormPage) {
|
||||||
urlPrefill = new URLSearchParams(window.location.search)
|
urlPrefill = new URLSearchParams(window.location.search)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -37,6 +37,7 @@
|
||||||
<form-about-submission/>
|
<form-about-submission/>
|
||||||
<form-notifications/>
|
<form-notifications/>
|
||||||
<form-security-privacy/>
|
<form-security-privacy/>
|
||||||
|
<form-custom-seo />
|
||||||
<form-custom-code/>
|
<form-custom-code/>
|
||||||
<form-integrations/>
|
<form-integrations/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -66,6 +67,7 @@ import FormNotifications from './form-components/FormNotifications.vue'
|
||||||
import FormIntegrations from './form-components/FormIntegrations.vue'
|
import FormIntegrations from './form-components/FormIntegrations.vue'
|
||||||
import FormEditorPreview from './form-components/FormEditorPreview.vue'
|
import FormEditorPreview from './form-components/FormEditorPreview.vue'
|
||||||
import FormSecurityPrivacy from './form-components/FormSecurityPrivacy.vue'
|
import FormSecurityPrivacy from './form-components/FormSecurityPrivacy.vue'
|
||||||
|
import FormCustomSeo from './form-components/FormCustomSeo.vue'
|
||||||
import saveUpdateAlert from '../../../../mixins/forms/saveUpdateAlert.js'
|
import saveUpdateAlert from '../../../../mixins/forms/saveUpdateAlert.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -80,7 +82,8 @@ export default {
|
||||||
FormStructure,
|
FormStructure,
|
||||||
FormInformation,
|
FormInformation,
|
||||||
FormErrorModal,
|
FormErrorModal,
|
||||||
FormSecurityPrivacy
|
FormSecurityPrivacy,
|
||||||
|
FormCustomSeo
|
||||||
},
|
},
|
||||||
mixins: [saveUpdateAlert],
|
mixins: [saveUpdateAlert],
|
||||||
props: {
|
props: {
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
<template>
|
||||||
|
<collapse class="p-5 w-full border-b" :default-value="false">
|
||||||
|
<template #title>
|
||||||
|
<h3 id="v-step-2" class="font-semibold text-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||||
|
class="h-5 w-5 inline text-gray-500 mr-2 -mt-1"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
Link Settings - SEO
|
||||||
|
<pro-tag />
|
||||||
|
</h3>
|
||||||
|
</template>
|
||||||
|
<p class="mt-4 text-gray-500 text-sm">
|
||||||
|
Customize the image and text that appear when you share your form on other sites (Open Graph).
|
||||||
|
</p>
|
||||||
|
<text-input v-model="form.seo_meta.page_title" name="page_title" class="mt-4"
|
||||||
|
label="Page Title" help="Under or approximately 60 characters"
|
||||||
|
/>
|
||||||
|
<text-area-input v-model="form.seo_meta.page_description" name="page_description" class="mt-4"
|
||||||
|
label="Page Description" help="Between 150 and 160 characters"
|
||||||
|
/>
|
||||||
|
<image-input v-model="form.seo_meta.page_thumbnail" name="page_thumbnail" class="mt-4"
|
||||||
|
label="Page Thumbnail Image" help="Also know as og:image - 1200 X 630"
|
||||||
|
/>
|
||||||
|
</collapse>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Collapse from '../../../../common/Collapse.vue'
|
||||||
|
import ProTag from '../../../../common/ProTag.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { Collapse, ProTag },
|
||||||
|
props: {},
|
||||||
|
data () {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
form: {
|
||||||
|
get () {
|
||||||
|
return this.$store.state['open/working_form'].content
|
||||||
|
},
|
||||||
|
/* We add a setter */
|
||||||
|
set (value) {
|
||||||
|
this.$store.commit('open/working_form/set', value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {},
|
||||||
|
mounted () {
|
||||||
|
['page_title', 'page_description', 'page_thumbnail'].forEach((keyname) => {
|
||||||
|
if (this.form.seo_meta[keyname] === undefined) {
|
||||||
|
this.form.seo_meta[keyname] = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
methods: {}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -45,7 +45,10 @@ export default {
|
||||||
confetti_on_submission: false,
|
confetti_on_submission: false,
|
||||||
|
|
||||||
// Security & Privacy
|
// Security & Privacy
|
||||||
can_be_indexed: true
|
can_be_indexed: true,
|
||||||
|
|
||||||
|
// Custom SEO
|
||||||
|
seo_meta: {}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,10 +3,11 @@ export default {
|
||||||
const title = this.metaTitle ?? 'OpnForm'
|
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 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 image = this.metaImage ?? this.asset('img/social-preview.jpg')
|
||||||
|
const metaTemplate = this.metaTemplate ?? '%s · OpnForm'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: title,
|
title: title,
|
||||||
titleTemplate: '%s · OpnForm',
|
titleTemplate: metaTemplate,
|
||||||
meta: [
|
meta: [
|
||||||
...(this.metaTags ?? []),
|
...(this.metaTags ?? []),
|
||||||
{ vmid: 'og:title', property: 'og:title', content: title },
|
{ vmid: 'og:title', property: 'og:title', content: title },
|
||||||
|
|
|
@ -181,12 +181,28 @@ export default {
|
||||||
return window.location !== window.parent.location || window.frameElement
|
return window.location !== window.parent.location || window.frameElement
|
||||||
},
|
},
|
||||||
metaTitle () {
|
metaTitle () {
|
||||||
|
if(this.form && this.form.is_pro && this.form.seo_meta.page_title) {
|
||||||
|
return this.form.seo_meta.page_title
|
||||||
|
}
|
||||||
return this.form ? this.form.title : 'Create beautiful forms'
|
return this.form ? this.form.title : 'Create beautiful forms'
|
||||||
},
|
},
|
||||||
|
metaTemplate () {
|
||||||
|
if (this.form && this.form.is_pro && this.form.seo_meta.page_title) {
|
||||||
|
// Disable template if custom SEO title
|
||||||
|
return '%s'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
metaDescription () {
|
metaDescription () {
|
||||||
|
if (this.form && this.form.is_pro && this.form.seo_meta.page_description) {
|
||||||
|
return this.form.seo_meta.page_description
|
||||||
|
}
|
||||||
return (this.form && this.form.description) ? this.form.description.substring(0, 160) : null
|
return (this.form && this.form.description) ? this.form.description.substring(0, 160) : null
|
||||||
},
|
},
|
||||||
metaImage () {
|
metaImage () {
|
||||||
|
if (this.form && this.form.is_pro && this.form.seo_meta.page_thumbnail) {
|
||||||
|
return this.form.seo_meta.page_thumbnail
|
||||||
|
}
|
||||||
return (this.form && this.form.cover_picture) ? this.form.cover_picture : null
|
return (this.form && this.form.cover_picture) ? this.form.cover_picture : null
|
||||||
},
|
},
|
||||||
metaTags () {
|
metaTags () {
|
||||||
|
|
Loading…
Reference in New Issue