diff --git a/app/Http/Requests/UserFormRequest.php b/app/Http/Requests/UserFormRequest.php index f822269f5..a0d777c01 100644 --- a/app/Http/Requests/UserFormRequest.php +++ b/app/Http/Requests/UserFormRequest.php @@ -121,6 +121,9 @@ public function rules() // Security & Privacy 'can_be_indexed' => 'boolean', 'password' => 'sometimes|nullable', + + // Custom SEO + 'seo_meta' => 'nullable|array' ]; } diff --git a/app/Http/Resources/FormResource.php b/app/Http/Resources/FormResource.php index 3ffb362ca..49857ad75 100644 --- a/app/Http/Resources/FormResource.php +++ b/app/Http/Resources/FormResource.php @@ -48,7 +48,8 @@ public function toArray($request) 'slack_webhook_url' => $this->slack_webhook_url, 'discord_webhook_url' => $this->discord_webhook_url, '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()); diff --git a/app/Models/Forms/Form.php b/app/Models/Forms/Form.php index a45752f6d..9f061f444 100644 --- a/app/Models/Forms/Form.php +++ b/app/Models/Forms/Form.php @@ -83,7 +83,10 @@ class Form extends Model // Security & Privacy 'can_be_indexed', - 'password' + 'password', + + // Custom SEO + 'seo_meta' ]; protected $casts = [ @@ -91,7 +94,8 @@ class Form extends Model 'database_fields_update' => 'array', 'closes_at' => 'datetime', 'tags' => 'array', - 'removed_properties' => 'array' + 'removed_properties' => 'array', + 'seo_meta' => 'object' ]; protected $appends = [ diff --git a/app/Service/Forms/FormCleaner.php b/app/Service/Forms/FormCleaner.php index 03e14175a..060305ddb 100644 --- a/app/Service/Forms/FormCleaner.php +++ b/app/Service/Forms/FormCleaner.php @@ -23,6 +23,9 @@ class FormCleaner private array $data; + // For remove keys those have empty value + private array $customKeys = ['seo_meta']; + private array $formDefaults = [ 'notifies' => false, 'no_branding' => false, @@ -32,6 +35,7 @@ class FormCleaner 'discord_webhook_url' => null, 'editable_submissions' => false, 'custom_code' => null, + 'seo_meta' => [] ]; private array $fieldDefaults = [ @@ -49,6 +53,7 @@ class FormCleaner 'discord_webhook_url' => "Discord webhook disabled.", 'editable_submissions' => 'Users will not be able to edit their submissions.', 'custom_code' => 'Custom code was disabled', + 'seo_meta' => 'Custom code was disabled', // For fields 'file_upload' => "Link field is not a file upload.", @@ -202,6 +207,9 @@ private function clean(array &$data, array $defaults, $simulation = false): void // Get value from form $formVal = Arr::get($data, $key); + // Transform customkeys values + $formVal = $this->cleanCustomKeys($key, $formVal); + // Transform boolean values $formVal = (($formVal === 0 || $formVal === "0") ? false : $formVal); $formVal = (($formVal === 1 || $formVal === "1") ? true : $formVal); @@ -242,4 +250,20 @@ private function cleanField(array &$data, array $defaults, $simulation = false): }*/ } + // 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; + } + } diff --git a/app/Service/SeoMetaResolver.php b/app/Service/SeoMetaResolver.php index 5232562ee..c5ec12e58 100644 --- a/app/Service/SeoMetaResolver.php +++ b/app/Service/SeoMetaResolver.php @@ -160,15 +160,25 @@ private function getFormShowMeta(): array { $form = Form::whereSlug($this->patternData['slug'])->firstOrFail(); - $meta = [ - 'title' => $form->title . $this->titleSuffix(), - ]; - if($form->description){ + $meta = []; + if ($form->is_pro && $form->seo_meta->page_title) { + $meta['title'] = $form->seo_meta->page_title; + } 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); } - 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; } + return $meta; } diff --git a/database/factories/FormFactory.php b/database/factories/FormFactory.php index 18a900b2b..c7a831e7f 100644 --- a/database/factories/FormFactory.php +++ b/database/factories/FormFactory.php @@ -85,7 +85,8 @@ public function definition() 'tags' => [], 'slack_webhook_url' => null, 'editable_submissions_button_text' => 'Edit submission', - 'confetti_on_submission' => false + 'confetti_on_submission' => false, + 'seo_meta' => [], ]; } diff --git a/database/migrations/2023_07_20_073728_add_seo_meta_to_forms.php b/database/migrations/2023_07_20_073728_add_seo_meta_to_forms.php new file mode 100644 index 000000000..e0a67d76b --- /dev/null +++ b/database/migrations/2023_07_20_073728_add_seo_meta_to_forms.php @@ -0,0 +1,32 @@ +json('seo_meta')->default('{}'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('forms', function (Blueprint $table) { + $table->dropColumn('seo_meta'); + }); + } +}; diff --git a/resources/js/components/open/forms/OpenForm.vue b/resources/js/components/open/forms/OpenForm.vue index 11b13d556..2f492d7b7 100644 --- a/resources/js/components/open/forms/OpenForm.vue +++ b/resources/js/components/open/forms/OpenForm.vue @@ -339,7 +339,7 @@ export default { const formData = clonedeep(this.dataForm ? this.dataForm.data() : {}) let urlPrefill = null - if (this.isPublicFormPage && this.form.is_pro) { + if (this.isPublicFormPage) { urlPrefill = new URLSearchParams(window.location.search) } diff --git a/resources/js/components/open/forms/components/FormEditor.vue b/resources/js/components/open/forms/components/FormEditor.vue index 3a8f5b289..513c09688 100644 --- a/resources/js/components/open/forms/components/FormEditor.vue +++ b/resources/js/components/open/forms/components/FormEditor.vue @@ -37,6 +37,7 @@ + @@ -66,6 +67,7 @@ import FormNotifications from './form-components/FormNotifications.vue' import FormIntegrations from './form-components/FormIntegrations.vue' import FormEditorPreview from './form-components/FormEditorPreview.vue' import FormSecurityPrivacy from './form-components/FormSecurityPrivacy.vue' +import FormCustomSeo from './form-components/FormCustomSeo.vue' import saveUpdateAlert from '../../../../mixins/forms/saveUpdateAlert.js' export default { @@ -80,7 +82,8 @@ export default { FormStructure, FormInformation, FormErrorModal, - FormSecurityPrivacy + FormSecurityPrivacy, + FormCustomSeo }, mixins: [saveUpdateAlert], props: { diff --git a/resources/js/components/open/forms/components/form-components/FormCustomSeo.vue b/resources/js/components/open/forms/components/form-components/FormCustomSeo.vue new file mode 100644 index 000000000..fcec4eae6 --- /dev/null +++ b/resources/js/components/open/forms/components/form-components/FormCustomSeo.vue @@ -0,0 +1,63 @@ + + + diff --git a/resources/js/mixins/form_editor/initForm.js b/resources/js/mixins/form_editor/initForm.js index 47861286f..cc38ba84d 100644 --- a/resources/js/mixins/form_editor/initForm.js +++ b/resources/js/mixins/form_editor/initForm.js @@ -45,7 +45,10 @@ export default { confetti_on_submission: false, // Security & Privacy - can_be_indexed: true + can_be_indexed: true, + + // Custom SEO + seo_meta: {} }) }, } diff --git a/resources/js/mixins/seo-meta.js b/resources/js/mixins/seo-meta.js index 602059613..2d4044722 100644 --- a/resources/js/mixins/seo-meta.js +++ b/resources/js/mixins/seo-meta.js @@ -3,10 +3,11 @@ export default { 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: '%s · OpnForm', + titleTemplate: metaTemplate, meta: [ ...(this.metaTags ?? []), { vmid: 'og:title', property: 'og:title', content: title }, diff --git a/resources/js/pages/forms/show-public.vue b/resources/js/pages/forms/show-public.vue index 39b34c3a3..176c5c4f2 100644 --- a/resources/js/pages/forms/show-public.vue +++ b/resources/js/pages/forms/show-public.vue @@ -181,12 +181,28 @@ export default { return window.location !== window.parent.location || window.frameElement }, 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' }, + 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 () { + 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 }, 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 }, metaTags () {