B8f7a improve templates pages for seo (#5)
* Templates * access templates without login also * Set required on UI * Improve templates pages for SEO * test case for Templates * Refactor SitemapController * Cosmetic changes to templates Co-authored-by: Julien Nahum <jhumanj@MacBook-Pro-de-Julien.local>
This commit is contained in:
parent
9f9da5aed8
commit
36bc081f8f
|
@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Spatie\Sitemap\Sitemap;
|
use Spatie\Sitemap\Sitemap;
|
||||||
use Spatie\Sitemap\Tags\Url;
|
use Spatie\Sitemap\Tags\Url;
|
||||||
|
use App\Models\Template;
|
||||||
|
|
||||||
class SitemapController extends Controller
|
class SitemapController extends Controller
|
||||||
{
|
{
|
||||||
|
@ -20,6 +21,7 @@ class SitemapController extends Controller
|
||||||
['/login', 0.4],
|
['/login', 0.4],
|
||||||
['/register', 0.4],
|
['/register', 0.4],
|
||||||
['/password/reset', 0.3],
|
['/password/reset', 0.3],
|
||||||
|
['/templates', 0.9],
|
||||||
];
|
];
|
||||||
|
|
||||||
public function getSitemap(Request $request)
|
public function getSitemap(Request $request)
|
||||||
|
@ -28,6 +30,7 @@ class SitemapController extends Controller
|
||||||
foreach ($this->urls as $url) {
|
foreach ($this->urls as $url) {
|
||||||
$sitemap->add($this->createUrl($url[0], $url[1]));
|
$sitemap->add($this->createUrl($url[0], $url[1]));
|
||||||
}
|
}
|
||||||
|
$this->addTemplatesUrls($sitemap);
|
||||||
|
|
||||||
return $sitemap->toResponse($request);
|
return $sitemap->toResponse($request);
|
||||||
}
|
}
|
||||||
|
@ -36,4 +39,13 @@ class SitemapController extends Controller
|
||||||
{
|
{
|
||||||
return Url::create($url)->setPriority($priority)->setChangeFrequency($frequency);
|
return Url::create($url)->setPriority($priority)->setChangeFrequency($frequency);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function addTemplatesUrls(Sitemap $sitemap)
|
||||||
|
{
|
||||||
|
Template::chunk(100, function ($templates) use ($sitemap) {
|
||||||
|
foreach ($templates as $template) {
|
||||||
|
$sitemap->add($this->createUrl('/templates/' . $template->slug, 0.7));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Templates\CreateTemplateRequest;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use App\Models\Template;
|
||||||
|
|
||||||
|
class TemplateController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
// TODO: create resource
|
||||||
|
return Template::all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(CreateTemplateRequest $request)
|
||||||
|
{
|
||||||
|
$this->middleware('admin');
|
||||||
|
|
||||||
|
// Create template
|
||||||
|
$template = $request->getTemplate();
|
||||||
|
$template->save();
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'message' => 'Template created.',
|
||||||
|
'template_id' => $template->id
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Templates;
|
||||||
|
|
||||||
|
use App\Models\Template;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class CreateTemplateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'form' => 'required|array',
|
||||||
|
'name' => 'required|string|max:60',
|
||||||
|
'slug' => 'required|string|unique:templates',
|
||||||
|
'description' => 'required|string|max:2000',
|
||||||
|
'image_url' => 'required|string',
|
||||||
|
'questions' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTemplate() : Template
|
||||||
|
{
|
||||||
|
$structure = $this->form;
|
||||||
|
$ignoreKeys = ['id','creator','creator_id','created_at','updated_at','extra','workspace','submissions','submissions_count','views','views_count'];
|
||||||
|
foreach($structure as $key=>$val){
|
||||||
|
if(in_array($key, $ignoreKeys)){
|
||||||
|
$structure[$key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Template([
|
||||||
|
'name' => $this->name,
|
||||||
|
'slug' => $this->slug,
|
||||||
|
'description' => $this->description,
|
||||||
|
'image_url' => $this->image_url,
|
||||||
|
'structure' => $structure,
|
||||||
|
'questions' => $this->questions ?? []
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Stevebauman\Purify\Facades\Purify;
|
||||||
|
|
||||||
|
class Template extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'slug',
|
||||||
|
'description',
|
||||||
|
'image_url',
|
||||||
|
'structure',
|
||||||
|
'questions',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'structure' => 'array',
|
||||||
|
'questions' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function setDescriptionAttribute($value)
|
||||||
|
{
|
||||||
|
// Strip out unwanted html
|
||||||
|
$this->attributes['description'] = Purify::clean($value);
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,8 +18,6 @@ class User extends Authenticatable implements JWTSubject //, MustVerifyEmail
|
||||||
{
|
{
|
||||||
use Notifiable, HasFactory, Billable;
|
use Notifiable, HasFactory, Billable;
|
||||||
|
|
||||||
const ADMINS = ['julien@notionforms.io'];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
*
|
*
|
||||||
|
@ -98,7 +96,7 @@ class User extends Authenticatable implements JWTSubject //, MustVerifyEmail
|
||||||
|
|
||||||
public function getAdminAttribute()
|
public function getAdminAttribute()
|
||||||
{
|
{
|
||||||
return in_array($this->email, self::ADMINS);
|
return in_array($this->email, config('services.admin_emails'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -50,5 +50,7 @@ return [
|
||||||
|
|
||||||
'google_analytics_code' => env('GOOGLE_ANALYTICS_CODE'),
|
'google_analytics_code' => env('GOOGLE_ANALYTICS_CODE'),
|
||||||
'amplitude_code' => env('AMPLITUDE_CODE'),
|
'amplitude_code' => env('AMPLITUDE_CODE'),
|
||||||
'crisp_website_id' => env('CRISP_WEBSITE_ID')
|
'crisp_website_id' => env('CRISP_WEBSITE_ID'),
|
||||||
|
|
||||||
|
'admin_emails' => explode(",", env('ADMIN_EMAILS') ?? '')
|
||||||
];
|
];
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?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::create('templates', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('slug');
|
||||||
|
$table->text('description');
|
||||||
|
$table->string('image_url');
|
||||||
|
$table->jsonb('structure')->default('{}');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('templates');
|
||||||
|
}
|
||||||
|
};
|
|
@ -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('templates', function (Blueprint $table) {
|
||||||
|
$table->jsonb('questions')->default('{}');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::table('templates', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('questions');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
|
@ -224,7 +224,7 @@ export default {
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
if (this.$route.name === 'forms.create') { // Set Default fields
|
if (this.$route.name === 'forms.create') { // Set Default fields
|
||||||
this.formFields = this.getDefaultFields()
|
this.formFields = (this.form.properties.length > 0) ? clonedeep(this.form.properties): this.getDefaultFields()
|
||||||
} else {
|
} else {
|
||||||
this.formFields = clonedeep(this.form.properties).map((field) => {
|
this.formFields = clonedeep(this.form.properties).map((field) => {
|
||||||
// Add more field properties
|
// Add more field properties
|
||||||
|
|
|
@ -53,7 +53,6 @@ export default {
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
form () {
|
form () {
|
||||||
debugger
|
|
||||||
if(!this.form){
|
if(!this.form){
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,13 @@
|
||||||
<!-- Pricing-->
|
<!-- Pricing-->
|
||||||
<!-- </router-link>-->
|
<!-- </router-link>-->
|
||||||
<!-- </li>-->
|
<!-- </li>-->
|
||||||
|
<li>
|
||||||
|
<router-link :to="{name:'templates'}"
|
||||||
|
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
|
||||||
|
>
|
||||||
|
Templates
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a target="_blank" :href="helpUrl"
|
<a target="_blank" :href="helpUrl"
|
||||||
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
|
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
<template>
|
||||||
|
<modal :show="show" @close="$emit('close')">
|
||||||
|
<form @submit.prevent="createTemplate" @keydown="templateForm.onKeydown($event)">
|
||||||
|
<div class="-m-6">
|
||||||
|
<div class="p-6">
|
||||||
|
<h2 class="text-nt-blue text-3xl font-bold mb-6">
|
||||||
|
Create template
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
New template will be create from your form <span class="font-semibold">{{form.title}}</span>.
|
||||||
|
Template will be public for all to create form quickly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="border-t py-4 px-6">
|
||||||
|
<text-input name="name" :form="templateForm" class="mt-4" label="Title" :required="true" />
|
||||||
|
<text-input name="slug" :form="templateForm" class="mt-4" label="Slug" :required="true" />
|
||||||
|
<rich-text-area-input name="description" :form="templateForm" class="mt-4" label="Description" :required="true" />
|
||||||
|
<text-input name="image_url" :form="templateForm" class="mt-4" label="Image" :required="true" />
|
||||||
|
<questions-editor name="questions" :form="templateForm" class="mt-4" label="Frequently asked questions" />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end mt-4 pb-5 px-6">
|
||||||
|
<v-button class="mr-2" :loading="templateForm.busy">Create</v-button>
|
||||||
|
<v-button color="gray" shade="light" @click.prevent="$emit('close')">Close</v-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Form from 'vform'
|
||||||
|
import QuestionsEditor from '../../templates/QuestionsEditor';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CreateTemplateModal',
|
||||||
|
components: { QuestionsEditor },
|
||||||
|
props: {
|
||||||
|
show: { type: Boolean, required: true },
|
||||||
|
form: { type: Object, required: true }
|
||||||
|
},
|
||||||
|
|
||||||
|
data: () => ({
|
||||||
|
templateForm: new Form({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
image_url: '',
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
computed: {},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
async createTemplate() {
|
||||||
|
this.templateForm.form = this.form
|
||||||
|
await this.templateForm.post('/api/templates').then((response) => {
|
||||||
|
this.alertSuccess('Template was successfully created.')
|
||||||
|
this.$emit('close')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
|
@ -0,0 +1,80 @@
|
||||||
|
<template>
|
||||||
|
<div :class="wrapperClass">
|
||||||
|
<label v-if="label" :for="id?id:name"
|
||||||
|
:class="[theme.default.label,{'uppercase text-xs':uppercaseLabels, 'text-sm':!uppercaseLabels}]"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
<span v-if="required" class="text-red-500 required-dot">*</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<loader v-if="loading" key="loader" class="h-6 w-6 text-nt-blue mx-auto" />
|
||||||
|
<div v-else class="my-3">
|
||||||
|
<div v-for="(questionForm, quesKey) in allQuestions" :key="quesKey" class="bg-gray-100 p-2 mb-4">
|
||||||
|
<v-button color="red" size="small" nativeType="button" class="text-right mb-2" @click.prevent="onRemove(quesKey)">
|
||||||
|
<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>
|
||||||
|
</v-button>
|
||||||
|
<text-input name="question" :form="questionForm" placeholder="Question title" />
|
||||||
|
<rich-text-area-input name="answer" :form="questionForm" class="mt-4" placeholder="Question response" />
|
||||||
|
</div>
|
||||||
|
<v-button v-if="addNew" color="green" size="small" nativeType="button" class="mt-2 flex" @click.prevent="onAdd">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Add New
|
||||||
|
</v-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<small v-if="help" :class="theme.SelectInput.help">
|
||||||
|
<slot name="help">{{ help }}</slot>
|
||||||
|
</small>
|
||||||
|
<has-error v-if="hasValidation" :form="form" :field="name" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import inputMixin from '~/mixins/forms/input'
|
||||||
|
import Form from 'vform'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'QuestionsEditor',
|
||||||
|
mixins: [inputMixin],
|
||||||
|
|
||||||
|
props: {
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
addNew: { type: Boolean, default: true }
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
allQuestions: [new Form({
|
||||||
|
question: '',
|
||||||
|
answer: '',
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
allQuestions: {
|
||||||
|
deep: true,
|
||||||
|
handler () {
|
||||||
|
this.compVal = this.allQuestions.map((ques) => {
|
||||||
|
return ques.data()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: { },
|
||||||
|
methods: {
|
||||||
|
onAdd() {
|
||||||
|
this.allQuestions.push(new Form({
|
||||||
|
question: '',
|
||||||
|
answer: '',
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
onRemove(key){
|
||||||
|
this.allQuestions.splice(key, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
|
@ -51,12 +51,21 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import store from '~/store'
|
||||||
import Form from 'vform'
|
import Form from 'vform'
|
||||||
import {mapState, mapActions} from 'vuex'
|
import {mapState, mapActions} from 'vuex'
|
||||||
import saveUpdateAlert from '../../mixins/forms/saveUpdateAlert'
|
import saveUpdateAlert from '../../mixins/forms/saveUpdateAlert'
|
||||||
import clonedeep from 'clone-deep'
|
import clonedeep from 'clone-deep'
|
||||||
|
|
||||||
const FormEditor = () => import('../../components/open/forms/components/FormEditor')
|
const FormEditor = () => import('../../components/open/forms/components/FormEditor')
|
||||||
|
|
||||||
|
const loadTemplates = function () {
|
||||||
|
store.commit('open/templates/startLoading')
|
||||||
|
store.dispatch('open/templates/loadIfEmpty').then(() => {
|
||||||
|
store.commit('open/templates/stopLoading')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'CreateForm',
|
name: 'CreateForm',
|
||||||
components: {
|
components: {
|
||||||
|
@ -69,6 +78,11 @@ export default {
|
||||||
|
|
||||||
mixins: [saveUpdateAlert],
|
mixins: [saveUpdateAlert],
|
||||||
|
|
||||||
|
beforeRouteEnter (to, from, next) {
|
||||||
|
loadTemplates()
|
||||||
|
next()
|
||||||
|
},
|
||||||
|
|
||||||
middleware: 'auth',
|
middleware: 'auth',
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
|
@ -126,7 +140,16 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
|
if(this.$route.query.template !== undefined && this.$route.query.template){
|
||||||
|
let template = this.$store.getters['open/templates/getBySlug'](this.$route.query.template)
|
||||||
|
if(template && template.structure){
|
||||||
|
this.form = new Form(template.structure)
|
||||||
|
}else{
|
||||||
this.initForm()
|
this.initForm()
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
this.initForm()
|
||||||
|
}
|
||||||
this.closeAlert()
|
this.closeAlert()
|
||||||
this.loadWorkspaces()
|
this.loadWorkspaces()
|
||||||
|
|
||||||
|
|
|
@ -164,6 +164,20 @@
|
||||||
<loader v-else class="h-6 w-6 text-nt-blue mx-auto" />
|
<loader v-else class="h-6 w-6 text-nt-blue mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w-full sm:w-1/2 px-2 flex mb-5" v-if="user.admin">
|
||||||
|
<div 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="showCreateTemplateModal=true"
|
||||||
|
>
|
||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M17 14v6m-3-3h6M6 10h2a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v2a2 2 0 002 2zm10 0h2a2 2 0 002-2V6a2 2 0 00-2-2h-2a2 2 0 00-2 2v2a2 2 0 002 2zM6 20h2a2 2 0 002-2v-2a2 2 0 00-2-2H6a2 2 0 00-2 2v2a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-semibold group relative-hover:text-blue-500">
|
||||||
|
Create template
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Form Submissions -->
|
<!-- Form Submissions -->
|
||||||
|
@ -260,6 +274,8 @@
|
||||||
</div>
|
</div>
|
||||||
</modal>
|
</modal>
|
||||||
|
|
||||||
|
<create-template-modal :form="form" :show="showCreateTemplateModal" @close="showCreateTemplateModal=false" />
|
||||||
|
|
||||||
<url-form-prefill-modal :form="form" :show="showUrlFormPrefillModal" @close="showUrlFormPrefillModal=false" />
|
<url-form-prefill-modal :form="form" :show="showUrlFormPrefillModal" @close="showUrlFormPrefillModal=false" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="loading" class="text-center w-full p-5">
|
<div v-else-if="loading" class="text-center w-full p-5">
|
||||||
|
@ -282,6 +298,7 @@ import Breadcrumb from '../../components/common/Breadcrumb'
|
||||||
import { mapGetters, mapState } from 'vuex'
|
import { mapGetters, mapState } from 'vuex'
|
||||||
import ProTag from '../../components/common/ProTag'
|
import ProTag from '../../components/common/ProTag'
|
||||||
import UrlFormPrefillModal from '../../components/pages/forms/UrlFormPrefillModal'
|
import UrlFormPrefillModal from '../../components/pages/forms/UrlFormPrefillModal'
|
||||||
|
import CreateTemplateModal from '../../components/pages/forms/CreateTemplateModal'
|
||||||
import FormStats from '../../components/open/forms/components/FormStats'
|
import FormStats from '../../components/open/forms/components/FormStats'
|
||||||
import FormSubmissions from '../../components/open/forms/components/FormSubmissions'
|
import FormSubmissions from '../../components/open/forms/components/FormSubmissions'
|
||||||
|
|
||||||
|
@ -294,7 +311,7 @@ const loadForms = function () {
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'EditForm',
|
name: 'EditForm',
|
||||||
components: { UrlFormPrefillModal, ProTag, Breadcrumb, ShareFormUrl, EmbedFormCode, FormStats, FormSubmissions },
|
components: { UrlFormPrefillModal, CreateTemplateModal, ProTag, Breadcrumb, ShareFormUrl, EmbedFormCode, FormStats, FormSubmissions },
|
||||||
|
|
||||||
beforeRouteEnter (to, from, next) {
|
beforeRouteEnter (to, from, next) {
|
||||||
loadForms()
|
loadForms()
|
||||||
|
@ -315,7 +332,8 @@ export default {
|
||||||
showNotionEmbedModal: false,
|
showNotionEmbedModal: false,
|
||||||
showShareEmbedFormModal: false,
|
showShareEmbedFormModal: false,
|
||||||
showUrlFormPrefillModal: false,
|
showUrlFormPrefillModal: false,
|
||||||
showGenerateFormLinkModal: false
|
showGenerateFormLinkModal: false,
|
||||||
|
showCreateTemplateModal: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,100 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col min-h-full mt-6">
|
||||||
|
<div class="w-full flex-grow md:w-4/5 lg:w-2/3 md:mx-auto md:max-w-4xl px-4">
|
||||||
|
|
||||||
|
<breadcrumb :path="breadcrumbs" />
|
||||||
|
<div v-if="templatesLoading" class="text-center">
|
||||||
|
<loader class="h-6 w-6 text-nt-blue mx-auto" />
|
||||||
|
</div>
|
||||||
|
<p v-else-if="template === null || !template">
|
||||||
|
Template does not exist.
|
||||||
|
</p>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex flex-wrap items-center mt-6 mb-4">
|
||||||
|
<h2 class="text-nt-blue text-3xl font-bold flex-grow">
|
||||||
|
{{ template.name }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="mb-10">
|
||||||
|
<img :src="template.image_url" alt="" class="w-full shadow-xl rounded-lg my-5"/>
|
||||||
|
<div v-html="template.description"></div>
|
||||||
|
<div class="mt-5 text-center">
|
||||||
|
<fancy-link class="mt-4 sm:mt-0" :to="{path:'/forms/create?template='+template.slug}" color="nt-blue">
|
||||||
|
Use this template
|
||||||
|
</fancy-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-center text-gray-500">Template Preview</h3>
|
||||||
|
<open-complete-form ref="open-complete-form" :form="form" :creating="true" class="my-5 p-4 bg-gray-50 rounded-lg"/>
|
||||||
|
|
||||||
|
<h3 class="text-xl font-semibold mb-3">Frequently asked questions</h3>
|
||||||
|
<div v-if="template.questions.length > 0" class="mt-5 pt-2">
|
||||||
|
<div v-for="(ques,ques_key) in template.questions" :key="ques_key" class="my-3 border rounded-lg">
|
||||||
|
<h5 class="border-b p-2">{{ ques.question }}</h5>
|
||||||
|
<div class="p-2" v-html="ques.answer"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<open-form-footer class="mt-8 border-t" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import store from '~/store'
|
||||||
|
import Form from 'vform'
|
||||||
|
import { mapGetters, mapState } from 'vuex'
|
||||||
|
import Fuse from 'fuse.js'
|
||||||
|
import OpenFormFooter from '../../components/pages/OpenFormFooter'
|
||||||
|
import OpenCompleteForm from '../../components/open/forms/OpenCompleteForm'
|
||||||
|
import Breadcrumb from "../../components/common/Breadcrumb";
|
||||||
|
|
||||||
|
const loadTemplates = function () {
|
||||||
|
store.commit('open/templates/startLoading')
|
||||||
|
store.dispatch('open/templates/loadIfEmpty').then(() => {
|
||||||
|
store.commit('open/templates/stopLoading')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {Breadcrumb, OpenFormFooter, OpenCompleteForm },
|
||||||
|
|
||||||
|
beforeRouteEnter (to, from, next) {
|
||||||
|
loadTemplates()
|
||||||
|
next()
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
metaTitle: { type: String, default: 'Templates' },
|
||||||
|
metaDescription: { type: String, default: 'Public templates for create form quickly!' }
|
||||||
|
},
|
||||||
|
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted () {},
|
||||||
|
|
||||||
|
methods: {},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
templatesLoading: state => state['open/templates'].loading
|
||||||
|
}),
|
||||||
|
breadcrumbs () {
|
||||||
|
if (!this.template) {
|
||||||
|
return [{ route: { name: 'templates' }, label: 'Templates' }]
|
||||||
|
}
|
||||||
|
return [{ route: { name: 'templates' }, label: 'Templates' }, { label: this.template.name }]
|
||||||
|
},
|
||||||
|
template () {
|
||||||
|
return this.$store.getters['open/templates/getBySlug'](this.$route.params.slug)
|
||||||
|
},
|
||||||
|
form (){
|
||||||
|
return new Form(this.template.structure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,91 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col min-h-full mt-6">
|
||||||
|
<div class="w-full flex-grow md:w-4/5 lg:w-2/3 md:mx-auto md:max-w-4xl 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">
|
||||||
|
Templates
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div v-if="templatesLoading" class="text-center">
|
||||||
|
<loader class="h-6 w-6 text-nt-blue mx-auto"/>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="templates.length === 0">
|
||||||
|
No any templates found.
|
||||||
|
</p>
|
||||||
|
<div v-else class="mb-4">
|
||||||
|
<div v-if="templates && templates.length"
|
||||||
|
class="grid max-w-3xl grid-cols-1 mx-auto text-center sm:text-left sm:grid-cols-2 gap-y-8 gap-x-8 lg:gap-x-20">
|
||||||
|
<div class="relative group" v-for="(template, index) in templates" :key="template.id">
|
||||||
|
<div class="overflow-hidden rounded-lg aspect-w-16 aspect-h-9">
|
||||||
|
<img class="object-cover w-full h-full transition-all duration-300 transform group-hover:scale-125"
|
||||||
|
:src="template.image_url" alt=""/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 mb-2 text-sm font-normal text-gray-600 font-pj">
|
||||||
|
{{ formatCreatedDate(template.created_at) }}</p>
|
||||||
|
<p class="text-xl font-bold text-gray-900 font-pj">{{ template.name }}</p>
|
||||||
|
<router-link :to="{params:{slug:template.slug},name:'templates.show'}" title="">
|
||||||
|
<span class="absolute inset-0" aria-hidden="true"></span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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 OpenFormFooter from '../../components/pages/OpenFormFooter'
|
||||||
|
|
||||||
|
const loadTemplates = function () {
|
||||||
|
store.commit('open/templates/startLoading')
|
||||||
|
store.dispatch('open/templates/load').then(() => {
|
||||||
|
store.commit('open/templates/stopLoading')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {OpenFormFooter},
|
||||||
|
|
||||||
|
beforeRouteEnter(to, from, next) {
|
||||||
|
loadTemplates()
|
||||||
|
next()
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
metaTitle: {type: String, default: 'Templates'},
|
||||||
|
metaDescription: {type: String, default: 'Public templates for create form quickly!'}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
formatCreatedDate(createdDate) {
|
||||||
|
const date = new Date(createdDate)
|
||||||
|
const dateTimeFormat = new Intl.DateTimeFormat('en', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
return dateTimeFormat.format(date)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
templates: state => state['open/templates'].content,
|
||||||
|
templatesLoading: state => state['open/templates'].loading
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -52,5 +52,9 @@ export default [
|
||||||
{ path: '/integrations', name: 'integrations', component: page('integrations.vue') },
|
{ path: '/integrations', name: 'integrations', component: page('integrations.vue') },
|
||||||
{ path: '/forms/:slug', name: 'forms.show_public', component: page('forms/show-public.vue') },
|
{ path: '/forms/:slug', name: 'forms.show_public', component: page('forms/show-public.vue') },
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
{ path: '/templates', name: 'templates', component: page('templates/templates.vue') },
|
||||||
|
{ path: '/templates/:slug', name: 'templates.show', component: page('templates/show.vue') },
|
||||||
|
|
||||||
{ path: '*', component: page('errors/404.vue') }
|
{ path: '*', component: page('errors/404.vue') }
|
||||||
]
|
]
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
import Vue from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
// state
|
||||||
|
export const state = {
|
||||||
|
content: [],
|
||||||
|
loading: false
|
||||||
|
}
|
||||||
|
|
||||||
|
// getters
|
||||||
|
export const getters = {
|
||||||
|
getById: (state) => (id) => {
|
||||||
|
if (state.content.length === 0) return null
|
||||||
|
return state.content.find(item => item.id === id)
|
||||||
|
},
|
||||||
|
getBySlug: (state) => (slug) => {
|
||||||
|
if (state.content.length === 0) return null
|
||||||
|
return state.content.find(item => item.slug === slug)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// mutations
|
||||||
|
export const mutations = {
|
||||||
|
set (state, items) {
|
||||||
|
state.content = items
|
||||||
|
},
|
||||||
|
addOrUpdate (state, item) {
|
||||||
|
state.content = state.content.filter((val) => val.id !== item.id)
|
||||||
|
state.content.push(item)
|
||||||
|
},
|
||||||
|
startLoading () {
|
||||||
|
state.loading = true
|
||||||
|
},
|
||||||
|
stopLoading () {
|
||||||
|
state.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// actions
|
||||||
|
export const actions = {
|
||||||
|
load (context) {
|
||||||
|
context.commit('set', [])
|
||||||
|
context.commit('startLoading')
|
||||||
|
return axios.get('/api/templates').then((response) => {
|
||||||
|
context.commit('set', response.data)
|
||||||
|
context.commit('stopLoading')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
loadIfEmpty ({ context, dispatch, state }) {
|
||||||
|
if (state.content.length === 0) {
|
||||||
|
return dispatch('load')
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
}
|
|
@ -15,6 +15,7 @@ use App\Http\Controllers\Forms\PublicFormController;
|
||||||
use App\Http\Controllers\Forms\FormSubmissionController;
|
use App\Http\Controllers\Forms\FormSubmissionController;
|
||||||
use App\Http\Controllers\Forms\FormController;
|
use App\Http\Controllers\Forms\FormController;
|
||||||
use App\Http\Controllers\WorkspaceController;
|
use App\Http\Controllers\WorkspaceController;
|
||||||
|
use App\Http\Controllers\TemplateController;
|
||||||
use App\Http\Controllers\Forms\Integration\FormZapierWebhookController;
|
use App\Http\Controllers\Forms\Integration\FormZapierWebhookController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
@ -150,3 +151,7 @@ Route::prefix('forms')->name('forms.')->group(function () {
|
||||||
Route::prefix('content')->name('content.')->group(function () {
|
Route::prefix('content')->name('content.')->group(function () {
|
||||||
Route::get('changelog/entries', [\App\Http\Controllers\Content\ChangelogController::class, 'index'])->name('changelog.entries');
|
Route::get('changelog/entries', [\App\Http\Controllers\Content\ChangelogController::class, 'index'])->name('changelog.entries');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
Route::get('templates', [TemplateController::class, 'index'])->name('templates.show');
|
||||||
|
Route::post('templates', [TemplateController::class, 'create'])->name('templates.create');
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
it('can create template', function () {
|
||||||
|
$user = $this->actingAsUser();
|
||||||
|
|
||||||
|
// Create Form
|
||||||
|
$workspace = $this->createUserWorkspace($user);
|
||||||
|
$form = $this->makeForm($user, $workspace);
|
||||||
|
|
||||||
|
// Create Template
|
||||||
|
$templateData = [
|
||||||
|
'name' => 'Demo Template',
|
||||||
|
'slug' => 'demo_template',
|
||||||
|
'description' => 'Some description here...',
|
||||||
|
'image_url' => 'https://d3ietpyl4f2d18.cloudfront.net/6c35a864-ee3a-4039-80a4-040b6c20ac60/img/pages/welcome/product_cover.jpg',
|
||||||
|
'form' => $form->getAttributes(),
|
||||||
|
'questions' => [['question'=>'Question 1','answer'=>'Answer 1 will be here...']]
|
||||||
|
];
|
||||||
|
$this->postJson(route('templates.create', $templateData))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertJson([
|
||||||
|
'type' => 'success',
|
||||||
|
'message' => 'Template created.'
|
||||||
|
]);
|
||||||
|
});
|
Loading…
Reference in New Issue