Merge branch 'main' of https://github.com/JhumanJ/OpnForm
This commit is contained in:
commit
48af78e94c
|
@ -1,7 +1,7 @@
|
|||
# OpnForm
|
||||
|
||||
<p align="center">
|
||||
<img src="https://github.com/JhumanJ/OpnForm/blob/main/public/img/social-preview.png?raw=true">
|
||||
<img src="https://github.com/JhumanJ/OpnForm/blob/main/public/img/social-preview.jpg?raw=true">
|
||||
</p>
|
||||
|
||||
<a href="https://github.com/jhumanj/OpnForm/actions"><img src="https://github.com/jhumanj/laravel-vue-tailwind-spa/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Service\SeoMetaResolver;
|
||||
use Illuminate\Http\Request;
|
||||
class SpaController extends Controller
|
||||
{
|
||||
/**
|
||||
|
@ -9,8 +11,10 @@ class SpaController extends Controller
|
|||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function __invoke()
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
return view('spa');
|
||||
return view('spa',[
|
||||
'meta' => (new SeoMetaResolver($request))->getMetas(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Templates\CreateTemplateRequest;
|
||||
use App\Http\Resources\TemplateResource;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Template;
|
||||
|
||||
|
@ -11,8 +12,7 @@ class TemplateController extends Controller
|
|||
{
|
||||
public function index()
|
||||
{
|
||||
// TODO: create resource
|
||||
return Template::all();
|
||||
return TemplateResource::collection(Template::all());
|
||||
}
|
||||
|
||||
public function create(CreateTemplateRequest $request)
|
||||
|
|
|
@ -7,6 +7,40 @@ use Illuminate\Foundation\Http\FormRequest;
|
|||
|
||||
class CreateTemplateRequest extends FormRequest
|
||||
{
|
||||
const IGNORED_KEYS = [
|
||||
'id',
|
||||
'creator',
|
||||
'cleanings',
|
||||
'closes_at',
|
||||
'deleted_at',
|
||||
'updated_at',
|
||||
'form_pending_submission_key',
|
||||
'is_closed',
|
||||
'is_pro',
|
||||
'is_password_protected',
|
||||
'last_edited_human',
|
||||
'max_number_of_submissions_reached',
|
||||
'notifies',
|
||||
'notification_body',
|
||||
'notification_emails',
|
||||
'notification_sender',
|
||||
'notification_subject',
|
||||
'notifications_include_submission',
|
||||
'notifies_slack',
|
||||
'slack_webhook_url',
|
||||
'removed_properties',
|
||||
'creator_id',
|
||||
'extra',
|
||||
'workspace',
|
||||
'workspace_id',
|
||||
'submissions',
|
||||
'submissions_count',
|
||||
'views',
|
||||
'views_count',
|
||||
'visibility',
|
||||
'webhook_url',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
|
@ -27,10 +61,9 @@ class CreateTemplateRequest extends FormRequest
|
|||
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;
|
||||
if (in_array($key, self::IGNORED_KEYS)) {
|
||||
unset($structure[$key]);
|
||||
}
|
||||
}
|
||||
return new Template([
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Http\Requests\Templates\CreateTemplateRequest;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class TemplateResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
|
||||
*/
|
||||
public function toArray($request)
|
||||
{
|
||||
$data = parent::toArray($request);
|
||||
foreach (CreateTemplateRequest::IGNORED_KEYS as $key) {
|
||||
unset($data[$key]);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,178 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Models\Forms\Form;
|
||||
use App\Models\Template;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Route;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Generates meta per-route matching. This is useful because Google, Twitter and Facebook struggle to load meta tags
|
||||
* injected dynamically by JavaScript. This class allows us to inject meta tags in the HTML head tag.
|
||||
*
|
||||
* Here's how to use this class
|
||||
* - Add a pattern to URL_PATTERNS
|
||||
* - Then choose between a static meta or a dynamic meta:
|
||||
* - If the content is dynamic (ex: needs to retrieve data from the database), then add a method to this class for
|
||||
* the corresponding pattern. The method should be named "getMyPatternName" (where pattern name is
|
||||
* my_pattern_name) and it should return an array of meta tags.
|
||||
* - If the content is static, then add meta tags to the PATTERN_STATIC_META array.
|
||||
*/
|
||||
class SeoMetaResolver
|
||||
{
|
||||
private array $patternData = [];
|
||||
|
||||
const URL_PATTERNS = [
|
||||
'welcome' => '/',
|
||||
'form_show' => '/forms/{slug}',
|
||||
'login' => '/login',
|
||||
'register' => '/register',
|
||||
'reset_password' => '/password/reset',
|
||||
'privacy_policy' => '/privacy-policy',
|
||||
'terms_conditions' => '/terms-conditions',
|
||||
'integrations' => '/integrations',
|
||||
'templates' => '/templates',
|
||||
'template_show' => '/templates/{slug}',
|
||||
];
|
||||
|
||||
/**
|
||||
* Metas for simple route (without needing to access DB)
|
||||
*/
|
||||
const PATTERN_STATIC_META = [
|
||||
'login' => [
|
||||
'title' => 'Login',
|
||||
],
|
||||
'register' => [
|
||||
'title' => 'Create your account',
|
||||
],
|
||||
'reset_password' => [
|
||||
'title' => 'Reset your password',
|
||||
],
|
||||
'privacy_policy' => [
|
||||
'title' => 'Our Privacy Policy',
|
||||
],
|
||||
'terms_conditions' => [
|
||||
'title' => 'Our Terms & Conditions',
|
||||
],
|
||||
'integrations' => [
|
||||
'title' => 'Our Integrations',
|
||||
],
|
||||
'templates' => [
|
||||
'title' => 'Templates',
|
||||
'description' => 'Free templates to quickly create beautiful forms for free!'
|
||||
],
|
||||
];
|
||||
|
||||
const META_CACHE_DURATION = 60 * 60 * 12; // 12 hours
|
||||
|
||||
const META_CACHE_KEY_PREFIX = 'seo_meta_';
|
||||
|
||||
public function __construct(private Request $request)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the right metas for a given route, caches meta for 1 hour.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getMetas(): array
|
||||
{
|
||||
$cacheKey = self::META_CACHE_KEY_PREFIX . urlencode($this->request->path());
|
||||
|
||||
return Cache::remember($cacheKey, now()->addSeconds(self::META_CACHE_DURATION), function () {
|
||||
$pattern = $this->resolvePattern();
|
||||
|
||||
if ($this->hasPatternMetaGetter($pattern)) {
|
||||
// Custom function for pattern
|
||||
try {
|
||||
return array_merge($this->getDefaultMeta(), $this->{'get' . Str::studly($pattern) . 'Meta'}());
|
||||
} catch (\Exception $e) {
|
||||
return $this->getDefaultMeta();
|
||||
}
|
||||
} elseif (in_array($pattern, array_keys(self::PATTERN_STATIC_META))) {
|
||||
// Simple meta for pattern
|
||||
$meta = self::PATTERN_STATIC_META[$pattern];
|
||||
if (isset($meta['title'])) {
|
||||
$meta['title'] .= $this->titleSuffix();
|
||||
}
|
||||
if (isset($meta['image'])) {
|
||||
$meta['image'] = asset($meta['image']);
|
||||
}
|
||||
|
||||
return array_merge($this->getDefaultMeta(), $meta);
|
||||
}
|
||||
|
||||
return $this->getDefaultMeta();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates the Laravel router to match route with Metas
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function resolvePattern()
|
||||
{
|
||||
foreach (self::URL_PATTERNS as $patternName => $patternData) {
|
||||
$path = rtrim($this->request->getPathInfo(), '/') ?: '/';
|
||||
|
||||
$route = (new Route('GET', $patternData, fn() => ''))->bind($this->request);
|
||||
if (preg_match($route->getCompiled()->getRegex(), rawurldecode($path))) {
|
||||
$this->patternData = $route->parameters();
|
||||
|
||||
return $patternName;
|
||||
}
|
||||
}
|
||||
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a get mutator exists for a pattern.
|
||||
*
|
||||
* @param string $key
|
||||
* @return bool
|
||||
*/
|
||||
private function hasPatternMetaGetter($key)
|
||||
{
|
||||
return method_exists($this, 'get' . Str::studly($key) . 'Meta');
|
||||
}
|
||||
|
||||
private function titleSuffix()
|
||||
{
|
||||
return ' · ' . config('app.name');
|
||||
}
|
||||
|
||||
private function getDefaultMeta(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'Create beautiful forms for free' . $this->titleSuffix(),
|
||||
'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.",
|
||||
'image' => asset('/img/social-preview.jpg'),
|
||||
];
|
||||
}
|
||||
|
||||
private function getFormShowMeta(): array
|
||||
{
|
||||
$form = Form::whereSlug($this->patternData['slug'])->firstOrFail();
|
||||
|
||||
return [
|
||||
'title' => $form->title . $this->titleSuffix(),
|
||||
];
|
||||
}
|
||||
|
||||
private function getTemplateShowMeta(): array
|
||||
{
|
||||
$template = Template::whereSlug($this->patternData['slug'])->firstOrFail();
|
||||
|
||||
return [
|
||||
'title' => $template->name . $this->titleSuffix(),
|
||||
'description' => Str::of($template->description)->limit(160) ,
|
||||
'image' => $template->image_url
|
||||
];
|
||||
}
|
||||
}
|
|
@ -42,6 +42,7 @@
|
|||
"@babel/eslint-parser": "^7.15.0",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/preset-env": "^7.15.0",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"autoprefixer": "^10.4.12",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^7.32.0",
|
||||
|
@ -2048,6 +2049,15 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/aspect-ratio": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/aspect-ratio/-/aspect-ratio-0.4.2.tgz",
|
||||
"integrity": "sha512-8QPrypskfBa7QIMuKHg2TA7BqES6vhBrDLOv8Unb6FcFyd3TjKbc6lcmb9UPQHxfl24sXoJ41ux/H7qQQvfaSQ==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@trysound/sax": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||
|
@ -18695,6 +18705,13 @@
|
|||
"integrity": "sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow==",
|
||||
"dev": true
|
||||
},
|
||||
"@tailwindcss/aspect-ratio": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/aspect-ratio/-/aspect-ratio-0.4.2.tgz",
|
||||
"integrity": "sha512-8QPrypskfBa7QIMuKHg2TA7BqES6vhBrDLOv8Unb6FcFyd3TjKbc6lcmb9UPQHxfl24sXoJ41ux/H7qQQvfaSQ==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@trysound/sax": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||
|
|
|
@ -51,6 +51,7 @@
|
|||
"autoprefixer": "^10.4.12",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^7.32.0",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"eslint-config-standard": "^16.0.3",
|
||||
"eslint-plugin-import": "^2.24.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
Before Width: | Height: | Size: 185 KiB |
|
@ -94,10 +94,10 @@ export default {
|
|||
{ vmid: 'description', name: 'description', content: description },
|
||||
{ vmid: 'og:title', property: 'og:title', content: appName },
|
||||
{ vmid: 'og:description', property: 'og:description', content: description },
|
||||
{ vmid: 'og:image', property: 'og:image', content: '/img/social-preview.png' },
|
||||
{ vmid: 'og:image', property: 'og:image', content: this.asset('img/social-preview.jpg') },
|
||||
{ vmid: 'twitter:title', property: 'twitter:title', content: appName },
|
||||
{ vmid: 'twitter:description', property: 'twitter:description', content: description },
|
||||
{ vmid: 'twitter:image', property: 'twitter:image', content: '/img/social-preview.png' },
|
||||
{ vmid: 'twitter:image', property: 'twitter:image', content: this.asset('img/social-preview.jpg') },
|
||||
{ vmid: 'twitter:card', property: 'twitter:card', content: 'summary_large_image' }
|
||||
]
|
||||
}
|
||||
|
|
|
@ -3,10 +3,8 @@
|
|||
<!-- Login modal -->
|
||||
<modal :show="showLoginModal" @close="showLoginModal=false" max-width="lg">
|
||||
<template #icon>
|
||||
<svg class="w-8 h-8" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 8V16M8 12H16M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</template>
|
||||
<template #title>
|
||||
|
@ -21,10 +19,8 @@
|
|||
<!-- Register modal -->
|
||||
<modal :show="showRegisterModal" @close="$emit('close')" max-width="lg">
|
||||
<template #icon>
|
||||
<svg class="w-8 h-8" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 8V16M8 12H16M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</template>
|
||||
<template #title>
|
||||
|
|
|
@ -21,18 +21,21 @@
|
|||
</div>
|
||||
<div v-html="template.description"></div>
|
||||
<div class="mt-5 text-center">
|
||||
<v-button class="mt-4 sm:mt-0" :to="{path:'/forms/create?template='+template.slug}">
|
||||
<v-button v-if="authenticated" class="mt-4 sm:mt-0" :to="{path:'/forms/create?template='+template.slug}">
|
||||
Use this template
|
||||
</v-button>
|
||||
<v-button v-else class="mt-4 sm:mt-0" :to="{path:'/forms/create/guest?template='+template.slug}">
|
||||
Use this template
|
||||
</v-button>
|
||||
</div>
|
||||
|
||||
<h3 class="text-center text-gray-500 mt-6 mb-2">Template Preview</h3>
|
||||
<h3 class="text-center text-gray-500 mt-8 mb-2">Template Preview</h3>
|
||||
<open-complete-form ref="open-complete-form" :form="form" :creating="true"
|
||||
class="mb-4 p-4 bg-gray-50 rounded-lg overflow-hidden"/>
|
||||
|
||||
<div v-if="template.questions.length > 0" id="questions">
|
||||
<h3 class="text-xl font-semibold mb-3">Frequently asked questions</h3>
|
||||
<div class="mt-5 pt-2">
|
||||
<h3 class="text-xl font-semibold mt-8">Frequently asked questions</h3>
|
||||
<div class="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>
|
||||
|
@ -70,11 +73,6 @@ export default {
|
|||
next()
|
||||
},
|
||||
|
||||
props: {
|
||||
metaTitle: {type: String, default: 'Templates'},
|
||||
metaDescription: {type: String, default: 'Public templates for create form quickly!'}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
|
@ -85,6 +83,9 @@ export default {
|
|||
methods: {},
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
authenticated: 'auth/check'
|
||||
}),
|
||||
...mapState({
|
||||
templatesLoading: state => state['open/templates'].loading
|
||||
}),
|
||||
|
@ -99,7 +100,10 @@ export default {
|
|||
},
|
||||
form() {
|
||||
return new Form(this.template.structure)
|
||||
}
|
||||
},
|
||||
metaTitle () {
|
||||
return this.template ? this.template.name : 'Template'
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -21,11 +21,23 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
|
||||
<title>{{ config('app.name') }}</title>
|
||||
|
||||
<link rel="stylesheet" href="{{ mix('dist/css/app.css') }}">
|
||||
<link rel="icon" href="{{asset('/img/logo.svg')}}">
|
||||
|
||||
@if($meta)
|
||||
<title>{{$meta['title']}}</title>
|
||||
<meta name='description' content='{{$meta['description']}}'>
|
||||
|
||||
<meta name='og:title' content='{{$meta['title']}}'>
|
||||
<meta name='og:description' content='{{$meta['description']}}'>
|
||||
<meta name='og:image' content='{{$meta['image']}}'>
|
||||
<meta name='og:site_name' content='OpenForm'>
|
||||
|
||||
<meta name="twitter:title" content="{{$meta['title']}}">
|
||||
<meta name="twitter:description" content="{{$meta['description']}}">
|
||||
<meta name="twitter:image" content="{{$meta['image']}}">
|
||||
@endif
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
@ -70,5 +70,8 @@ module.exports = {
|
|||
spacing: 'margin, padding'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/aspect-ratio'),
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue