Merge branch 'main' into vue-3

This commit is contained in:
Forms Dev 2023-11-28 16:24:36 +05:30
commit 54918354ea
81 changed files with 2327 additions and 1494 deletions

View File

@ -53,9 +53,6 @@ MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
JWT_TTL=1440
JWT_SECRET=
STRIPE_KEY=
STRIPE_SECRET=
MUX_WORKSPACE_ID=
MUX_API_TOKEN=

View File

@ -12,7 +12,7 @@ jobs:
services:
postgres:
# Docker Hub image
image: postgres
image: postgres:14
# Provide the password for postgres
env:
POSTGRES_PASSWORD: postgres
@ -27,6 +27,24 @@ jobs:
ports:
# Maps tcp port 5432 on service container to the host
- 5432:5432
mysql:
# Docker Hub image
image: mysql:8
# Provide the password for mysql
env:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: test
MYSQL_USER: test
MYSQL_PASSWORD: test
# Set health checks to wait until mysql has started
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
ports:
# Maps tcp port 3306 on service container to the host
- 3306:3306
concurrency:
group: 'run-tests'
@ -35,8 +53,22 @@ jobs:
fail-fast: true
matrix:
php: [ 8.2 ]
connection: [ pgsql, mysql ]
include:
- connection: pgsql
host: localhost
port: 5432
user: postgres
password: postgres
database: postgres
- connection: mysql
host: '127.0.0.1'
port: 3306
user: root
password: test
database: test
name: Run Feature & Unit tests (PHP ${{ matrix.php }})
name: Run Feature & Unit tests (PHP ${{ matrix.php }} - ${{ matrix.connection }})
steps:
- name: Checkout code
@ -80,6 +112,13 @@ jobs:
- name: Run tests (Unit and Feature)
run: ./vendor/bin/pest -p
env:
DB_CONNECTION: ${{ matrix.connection }}
DB_HOST: ${{ matrix.host }}
DB_PORT: ${{ matrix.port }}
DB_DATABASE: ${{ matrix.database }}
DB_USERNAME: ${{ matrix.user }}
DB_PASSWORD: ${{ matrix.password }}
- name: "Archive log results"
if: always()

View File

@ -0,0 +1,117 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\User;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
class AppSumoAuthController extends Controller
{
use AuthenticatesUsers;
public function handleCallback(Request $request)
{
$this->validate($request, [
'code' => 'required',
]);
$accessToken = $this->retrieveAccessToken($request->code);
$license = $this->fetchOrCreateLicense($accessToken);
// If user connected, attach license
if (Auth::check()) return $this->attachLicense($license);
// otherwise start login flow by passing the encrypted license key id
if (is_null($license->user_id)) {
return redirect(url('/register?appsumo_license='.encrypt($license->id)));
}
return redirect(url('/register?appsumo_error=1'));
}
private function retrieveAccessToken(string $requestCode): string
{
return Http::withHeaders([
'Content-type' => 'application/json'
])->post('https://appsumo.com/openid/token/', [
'grant_type' => 'authorization_code',
'code' => $requestCode,
'redirect_uri' => route('appsumo.callback'),
'client_id' => config('services.appsumo.client_id'),
'client_secret' => config('services.appsumo.client_secret'),
])->throw()->json('access_token');
}
private function fetchOrCreateLicense(string $accessToken): License
{
// Fetch license from API
$licenseKey = Http::get('https://appsumo.com/openid/license_key/?access_token=' . $accessToken)
->throw()
->json('license_key');
// Fetch or create license model
$license = License::where('license_provider','appsumo')->where('license_key',$licenseKey)->first();
if (!$license) {
$licenseData = Http::withHeaders([
'X-AppSumo-Licensing-Key' => config('services.appsumo.api_key'),
])->get('https://api.licensing.appsumo.com/v2/licenses/'.$licenseKey)->json();
// Create new license
$license = License::create([
'license_key' => $licenseKey,
'license_provider' => 'appsumo',
'status' => $licenseData['status'] === 'active' ? License::STATUS_ACTIVE : License::STATUS_INACTIVE,
'meta' => $licenseData,
]);
}
return $license;
}
private function attachLicense(License $license) {
if (!Auth::check()) {
throw new AuthenticationException('User not authenticated');
}
// Attach license if not already attached
if (is_null($license->user_id)) {
$license->user_id = Auth::id();
$license->save();
return redirect(url('/home?appsumo_connect=1'));
}
// Licensed already attached
return redirect(url('/home?appsumo_error=1'));
}
/**
* @param User $user
* @param string|null $licenseHash
* @return string|null
*
* Returns null if no license found
* Returns true if license was found and attached
* Returns false if there was an error (license not found or already attached)
*/
public static function registerWithLicense(User $user, ?string $licenseHash): ?bool
{
if (!$licenseHash) {
return null;
}
$licenseId = decrypt($licenseHash);
$license = License::find($licenseId);
if ($license && is_null($license->user_id)) {
$license->user_id = $user->id;
$license->save();
return true;
}
return false;
}
}

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Models\Workspace;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
@ -15,6 +16,8 @@ class RegisterController extends Controller
{
use RegistersUsers;
private ?bool $appsumoLicense = null;
/**
* Create a new controller instance.
*
@ -38,7 +41,11 @@ class RegisterController extends Controller
return response()->json(['status' => trans('verification.sent')]);
}
return response()->json($user);
return response()->json(array_merge(
(new UserResource($user))->toArray($request),
[
'appsumo_license' => $this->appsumoLicense,
]));
}
/**
@ -54,8 +61,9 @@ class RegisterController extends Controller
'email' => 'required|email:filter|max:255|unique:users|indisposable',
'password' => 'required|min:6|confirmed',
'hear_about_us' => 'required|string',
'agree_terms' => ['required',Rule::in([true])]
],[
'agree_terms' => ['required', Rule::in([true])],
'appsumo_license' => ['nullable'],
], [
'agree_terms' => 'Please agree with the terms and conditions.'
]);
}
@ -87,6 +95,8 @@ class RegisterController extends Controller
]
], false);
$this->appsumoLicense = AppSumoAuthController::registerWithLicense($user, $data['appsumo_license'] ?? null);
return $user;
}
}

View File

@ -21,7 +21,7 @@ class SitemapController extends Controller
['/login', 0.4],
['/register', 0.4],
['/password/reset', 0.3],
['/templates', 0.9],
['/form-templates', 0.9],
];
public function getSitemap(Request $request)
@ -31,6 +31,8 @@ class SitemapController extends Controller
$sitemap->add($this->createUrl($url[0], $url[1]));
}
$this->addTemplatesUrls($sitemap);
$this->addTemplatesTypesUrls($sitemap);
$this->addTemplatesIndustriesUrls($sitemap);
return $sitemap->toResponse($request);
}
@ -48,4 +50,20 @@ class SitemapController extends Controller
}
});
}
private function addTemplatesTypesUrls(Sitemap $sitemap)
{
$types = json_decode(file_get_contents(resource_path('data/forms/templates/types.json')), true);
foreach ($types as $type) {
$sitemap->add($this->createUrl('/form-templates/types/' . $type['slug'], 0.7));
}
}
private function addTemplatesIndustriesUrls(Sitemap $sitemap)
{
$industries = json_decode(file_get_contents(resource_path('data/forms/templates/industries.json')), true);
foreach ($industries as $industry) {
$sitemap->add($this->createUrl('/form-templates/industries/' . $industry['slug'], 0.7));
}
}
}

View File

@ -13,17 +13,26 @@ class TemplateController extends Controller
{
public function index(Request $request)
{
$limit = null;
if ($request->offsetExists('limit') && $request->get('limit') > 0) {
$limit = (int) $request->get('limit');
}
$limit = (int)$request->get('limit', 0);
$onlyMy = (bool)$request->get('onlymy', false);
$templates = Template::where('publicly_listed', true)
->when(Auth::check(), function ($query) {
$query->orWhere('creator_id', Auth::id());
$templates = Template::when(Auth::check(), function ($query) use ($onlyMy) {
if ($onlyMy) {
$query->where('creator_id', Auth::id());
} else {
$query->where(function ($query) {
$query->where('publicly_listed', true)
->orWhere('creator_id', Auth::id());
});
}
})
->when(!Auth::check(), function ($query) {
$query->where('publicly_listed', true);
})
->when($limit > 0, function ($query) use ($limit) {
$query->limit($limit);
})
->orderByDesc('created_at')
->limit($limit)
->get();
return FormTemplateResource::collection($templates);

View File

@ -0,0 +1,107 @@
<?php
namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
use App\Models\License;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\Request;
use Illuminate\Validation\UnauthorizedException;
class AppSumoController extends Controller
{
public function handle(Request $request)
{
$this->validateSignature($request);
if ($request->test) {
Log::info('[APPSUMO] test request received', $request->toArray());
return $this->success([
'message' => 'Webhook received.',
'event' => $request->event,
'success' => true,
]);
}
Log::info('[APPSUMO] request received', $request->toArray());
// Call the right function depending on the event using match()
match ($request->event) {
'activate' => $this->handleActivateEvent($request),
'upgrade', 'downgrade' => $this->handleChangeEvent($request),
'deactivate' => $this->handleDeactivateEvent($request),
default => null,
};
return $this->success([
'message' => 'Webhook received.',
'event' => $request->event,
'success' => true,
]);
}
private function handleActivateEvent($request)
{
$this->createLicense($request->json()->all());
}
private function handleChangeEvent($request)
{
$license = $this->deactivateLicense($request->prev_license_key);
$this->createLicense(array_merge($request->json()->all(), [
'user_id' => $license->user_id,
]));
}
private function handleDeactivateEvent($request)
{
$this->deactivateLicense($request->license_key);
}
private function createLicense(array $licenseData): License
{
$license = License::firstOrNew([
'license_key' => $licenseData['license_key'],
'license_provider' => 'appsumo',
'status' => License::STATUS_ACTIVE,
]);
$license->meta = $licenseData;
$license->user_id = $licenseData['user_id'] ?? null;
$license->save();
Log::info('[APPSUMO] creating new license',
[
'license_key' => $license->license_key,
'license_id' => $license->id,
]);
return $license;
}
private function deactivateLicense(string $licenseKey): License
{
$license = License::where([
'license_key' => $licenseKey,
'license_provider' => 'appsumo',
])->firstOrFail();
$license->update([
'status' => License::STATUS_INACTIVE,
]);
Log::info('[APPSUMO] De-activating license', [
'license_key' => $licenseKey,
'license_id' => $license->id,
]);
return $license;
}
private function validateSignature(Request $request)
{
$signature = $request->header('x-appsumo-signature');
$payload = $request->getContent();
if ($signature === hash_hmac('sha256', $payload, config('services.appsumo.api_key'))) {
throw new UnauthorizedException('Invalid signature.');
}
}
}

View File

@ -80,6 +80,6 @@ class Kernel extends HttpKernel
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'pro-form' => \App\Http\Middleware\Form\ProForm::class,
'password-protected-form' => \App\Http\Middleware\Form\PasswordProtectedForm::class,
'protected-form' => \App\Http\Middleware\Form\ProtectedForm::class,
];
}

View File

@ -7,7 +7,7 @@ use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class PasswordProtectedForm
class ProtectedForm
{
const PASSWORD_HEADER_NAME = 'form-password';
@ -20,24 +20,32 @@ class PasswordProtectedForm
*/
public function handle(Request $request, Closure $next)
{
if ($request->route('slug')) {
if (!$request->route('slug')) {
return $next($request);
}
$form = Form::where('slug',$request->route('slug'))->firstOrFail();
$request->merge([
'form' => $form,
]);
$userIsFormOwner = Auth::check() && Auth::user()->workspaces()->find($form->workspace_id) !== null;
if (!$userIsFormOwner && $form->has_password) {
if($this->hasCorrectPassword($request, $form)){
$userIsFormOwner = Auth::check() && Auth::user()->ownsForm($form);
if (!$userIsFormOwner && $this->isProtected($request, $form)) {
return response([
'status' => 'Unauthorized',
'message' => 'Form is protected.',
], 403);
}
return $next($request);
}
return response([
'status' => 'Unauthorized',
'message' => 'Form is password protected.',
], 403);
public static function isProtected(Request $request, Form $form)
{
if (!$form->has_password) {
return false;
}
}
return $next($request);
return !self::hasCorrectPassword($request, $form);
}
public static function hasCorrectPassword(Request $request, Form $form)

View File

@ -12,12 +12,10 @@ use Illuminate\Validation\Rule;
use Illuminate\Http\Request;
use App\Rules\ValidHCaptcha;
use App\Rules\ValidPhoneInputRule;
use App\Rules\ValidUrl;
class AnswerFormRequest extends FormRequest
{
const MAX_FILE_SIZE_FREE = 5000000; // 5 MB
const MAX_FILE_SIZE_PRO = 50000000; // 50 MB
public Form $form;
protected array $requestRules = [];
@ -26,12 +24,7 @@ class AnswerFormRequest extends FormRequest
public function __construct(Request $request)
{
$this->form = $request->form;
$this->maxFileSize = self::MAX_FILE_SIZE_FREE;
$workspace = $this->form->workspace;
if ($workspace && $workspace->is_pro) {
$this->maxFileSize = self::MAX_FILE_SIZE_PRO;
}
$this->maxFileSize = $this->form->workspace->max_file_size;
}
/**
@ -171,7 +164,7 @@ class AnswerFormRequest extends FormRequest
$this->requestRules[$property['id'].'.*'] = [new StorageFile($this->maxFileSize, [], $this->form)];
return ['array'];
}
return ['url'];
return [new ValidUrl];
case 'files':
$allowedFileTypes = [];
if(!empty($property['allowed_file_types'])){

View File

@ -16,15 +16,20 @@ class UploadAssetRequest extends FormRequest
*/
public function rules()
{
return [
'url' => ['required',new StorageFile(self::FORM_ASSET_MAX_SIZE, [
$fileTypes = [
'png',
'jpeg',
'jpg',
'bmp',
'gif',
'svg'
])],
];
if ($this->offsetExists('type') && $this->get('type') === 'files') {
$fileTypes = [];
}
return [
'url' => ['required', new StorageFile(self::FORM_ASSET_MAX_SIZE, $fileTypes)],
];
}
}

View File

@ -2,7 +2,7 @@
namespace App\Http\Resources;
use App\Http\Middleware\Form\PasswordProtectedForm;
use App\Http\Middleware\Form\ProtectedForm;
use App\Http\Resources\UserResource;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Auth;
@ -20,8 +20,8 @@ class FormResource extends JsonResource
*/
public function toArray($request)
{
if(!$this->userIsFormOwner() && $this->doesMissPassword($request)){
return $this->getPasswordProtectedForm();
if(!$this->userIsFormOwner() && ProtectedForm::isProtected($request, $this->resource)){
return $this->getProtectedForm();
}
$ownerData = $this->userIsFormOwner() ? [
@ -29,6 +29,7 @@ class FormResource extends JsonResource
'views_count' => $this->views_count,
'submissions_count' => $this->submissions_count,
'notifies' => $this->notifies,
'notifies_webhook' => $this->notifies_webhook,
'notifies_slack' => $this->notifies_slack,
'notifies_discord' => $this->notifies_discord,
'send_submission_confirmation' => $this->send_submission_confirmation,
@ -95,14 +96,7 @@ class FormResource extends JsonResource
return $this;
}
private function doesMissPassword(Request $request)
{
if (!$this->has_password) return false;
return !PasswordProtectedForm::hasCorrectPassword($request, $this->resource);
}
private function getPasswordProtectedForm()
private function getProtectedForm()
{
return [
'id' => $this->id,
@ -130,8 +124,7 @@ class FormResource extends JsonResource
private function userIsFormOwner() {
return $this->extra?->userIsOwner ??
(
Auth::check()
&& Auth::user()->workspaces()->find($this->workspace_id) !== null
Auth::check() && Auth::user()->ownsForm($this->resource)
);
}

View File

@ -46,7 +46,9 @@ class FormSubmissionResource extends JsonResource
});
foreach ($fileFields as $field) {
if (isset($data[$field['id']]) && !empty($data[$field['id']])) {
$data[$field['id']] = collect($data[$field['id']])->map(function ($file) {
$data[$field['id']] = collect($data[$field['id']])->filter(function ($file) {
return $file !== null && $file;
})->map(function ($file) {
return [
'file_url' => route('open.forms.submissions.file', [$this->form_id, $file]),
'file_name' => $file,

View File

@ -21,6 +21,7 @@ class UserResource extends JsonResource
'template_editor' => $this->template_editor,
'has_customer_id' => $this->has_customer_id,
'has_forms' => $this->has_forms,
'active_license' => $this->licenses()->active()->first(),
] : [];
return array_merge(parent::toArray($request), $personalData);

View File

@ -3,11 +3,9 @@
namespace App\Jobs\Form;
use App\Console\Commands\GenerateTemplate;
use App\Http\Requests\AiGenerateFormRequest;
use App\Models\Forms\AI\AiFormCompletion;
use App\Service\OpenAi\GptCompleter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
@ -40,6 +38,7 @@ class GenerateAiForm implements ShouldQueue
]);
$completer = (new GptCompleter(config('services.openai.api_key')))
->useStreaming()
->setSystemMessage('You are a robot helping to generate forms.');
try {
@ -53,29 +52,11 @@ class GenerateAiForm implements ShouldQueue
'result' => $this->cleanOutput($completer->getArray())
]);
} catch (\Exception $e) {
$this->completion->update([
'status' => AiFormCompletion::STATUS_FAILED,
'result' => ['error' => $e->getMessage()]
]);
$this->onError($e);
}
}
public function generateForm(AiGenerateFormRequest $request)
{
$completer = (new GptCompleter(config('services.openai.api_key')))
->setSystemMessage('You are a robot helping to generate forms.');
$completer->completeChat([
["role" => "user", "content" => Str::of(GenerateTemplate::FORM_STRUCTURE_PROMPT)
->replace('[REPLACE]', $request->form_prompt)->toString()]
], 3000);
return $this->success([
'message' => 'Form successfully generated!',
'form' => $this->cleanOutput($completer->getArray())
]);
}
private function cleanOutput($formData)
{
// Add property uuids
@ -85,4 +66,19 @@ class GenerateAiForm implements ShouldQueue
return $formData;
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception): void
{
$this->onError($exception);
}
private function onError(\Throwable $e) {
$this->completion->update([
'status' => AiFormCompletion::STATUS_FAILED,
'result' => ['error' => $e->getMessage()]
]);
}
}

View File

@ -4,9 +4,11 @@ namespace App\Jobs\Form;
use App\Events\Forms\FormSubmitted;
use App\Http\Controllers\Forms\PublicFormController;
use App\Http\Controllers\Forms\FormController;
use App\Http\Requests\AnswerFormRequest;
use App\Models\Forms\Form;
use App\Models\Forms\FormSubmission;
use App\Service\Forms\FormLogicPropertyResolver;
use App\Service\Storage\StorageFileNameParser;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Filesystem\Filesystem;
@ -162,6 +164,14 @@ class StoreFormSubmissionJob implements ShouldQueue
return null;
}
if(filter_var($value, FILTER_VALIDATE_URL) !== FALSE && str_contains($value, parse_url(config('app.url'))['host'])) { // In case of prefill we have full url so convert to s3
$fileName = basename($value);
$path = FormController::ASSETS_UPLOAD_PATH . '/' . $fileName;
$newPath = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $this->form->id);
Storage::move($path, $newPath.'/'.$fileName);
return $fileName;
}
if($this->isSkipForUpload($value)) {
return $value;
}
@ -215,8 +225,10 @@ class StoreFormSubmissionJob implements ShouldQueue
collect($this->form->properties)->filter(function ($property) {
return isset($property['hidden'])
&& isset($property['prefill'])
&& $property['hidden']
&& !is_null($property['prefill']);
&& FormLogicPropertyResolver::isHidden($property, $this->submissionData)
&& !is_null($property['prefill'])
&& !in_array($property['type'], ['files'])
&& !($property['type'] == 'url' && isset($property['file_upload']) && $property['file_upload']);
})->each(function (array $property) use (&$formData) {
if ($property['type'] === 'date' && isset($property['prefill_today']) && $property['prefill_today']) {
$formData[$property['id']] = now()->format((isset($property['with_time']) && $property['with_time']) ? 'Y-m-d H:i' : 'Y-m-d');

View File

@ -10,6 +10,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
use Illuminate\Support\Arr;
use Vinkla\Hashids\Facades\Hashids;
class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue
{
@ -44,7 +45,7 @@ class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue
'fields' => $formatter->getFieldsWithValue(),
'form' => $form,
'noBranding' => $form->no_branding,
'submission_id' => $this->event->data['submission_id'] ?? null
'submission_id' => (isset($this->event->data['submission_id']) && $this->event->data['submission_id']) ? Hashids::encode($this->event->data['submission_id']) : null
]);
}

View File

@ -279,6 +279,11 @@ class Form extends Model
}
public function getNotifiesWebhookAttribute()
{
return !empty($this->webhook_url);
}
public function getNotifiesDiscordAttribute()
{
return !empty($this->discord_webhook_url);

45
app/Models/License.php Normal file
View File

@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class License extends Model
{
const STATUS_ACTIVE = 'active';
const STATUS_INACTIVE = 'inactive';
use HasFactory;
protected $fillable = [
'license_key',
'user_id',
'license_provider',
'status',
'meta'
];
protected $casts = [
'meta' => 'array',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function scopeActive($query)
{
return $query->where('status', self::STATUS_ACTIVE);
}
public function getMaxFileSizeAttribute()
{
return [
1 => 25000000, // 25 MB,
2 => 50000000, // 50 MB,
3 => 75000000, // 75 MB,
][$this->meta['tier']];
}
}

View File

@ -13,6 +13,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Cashier\Billable;
use Tymon\JWTAuth\Contracts\JWTSubject;
class User extends Authenticatable implements JWTSubject
{
use Notifiable, HasFactory, Billable;
@ -60,6 +61,11 @@ class User extends Authenticatable implements JWTSubject
protected $withCount = ['workspaces'];
public function ownsForm(Form $form)
{
return $this->workspaces()->find($form->workspace_id) !== null;
}
/**
* Get the profile photo URL attribute.
*
@ -80,7 +86,9 @@ class User extends Authenticatable implements JWTSubject
public function getIsSubscribedAttribute()
{
return $this->subscribed() || in_array($this->email, config('opnform.extra_pro_users_emails'));
return $this->subscribed()
|| in_array($this->email, config('opnform.extra_pro_users_emails'))
|| !is_null($this->activeLicense());
}
public function getHasCustomerIdAttribute()
@ -138,7 +146,7 @@ class User extends Authenticatable implements JWTSubject
public function forms()
{
return $this->hasMany(Form::class,'creator_id');
return $this->hasMany(Form::class, 'creator_id');
}
public function formTemplates()
@ -146,6 +154,16 @@ class User extends Authenticatable implements JWTSubject
return $this->hasMany(Template::class, 'creator_id');
}
public function licenses()
{
return $this->hasMany(License::class);
}
public function activeLicense(): ?License
{
return $this->licenses()->active()->first();
}
/**
* =================================
* Oauth Related
@ -187,10 +205,10 @@ class User extends Authenticatable implements JWTSubject
})->first()?->onTrial();
}
public static function boot ()
public static function boot()
{
parent::boot();
static::deleting(function(User $user) {
static::deleting(function (User $user) {
// Remove user's workspace if he's the only one with this workspace
foreach ($user->workspaces as $workspace) {
if ($workspace->users()->count() == 1) {
@ -202,8 +220,8 @@ class User extends Authenticatable implements JWTSubject
public function scopeWithActiveSubscription($query)
{
return $query->whereHas('subscriptions', function($query) {
$query->where(function($q){
return $query->whereHas('subscriptions', function ($query) {
$query->where(function ($q) {
$q->where('stripe_status', 'trialing')
->orWhere('stripe_status', 'active');
});

View File

@ -2,8 +2,8 @@
namespace App\Models;
use App\Http\Requests\AnswerFormRequest;
use App\Models\Forms\Form;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -11,6 +11,9 @@ class Workspace extends Model
{
use HasFactory;
const MAX_FILE_SIZE_FREE = 5000000; // 5 MB
const MAX_FILE_SIZE_PRO = 50000000; // 50 MB
protected $fillable = [
'name',
'icon',
@ -37,6 +40,26 @@ class Workspace extends Model
return false;
}
public function getMaxFileSizeAttribute()
{
if(is_null(config('cashier.key'))){
return self::MAX_FILE_SIZE_PRO;
}
// Return max file size depending on subscription
foreach ($this->owners as $owner) {
if ($owner->is_subscribed) {
if ($license = $owner->activeLicense()) {
// In case of special License
return $license->max_file_size;
}
}
return self::MAX_FILE_SIZE_PRO;
}
return self::MAX_FILE_SIZE_FREE;
}
public function getIsEnterpriseAttribute()
{
if(is_null(config('cashier.key'))){

View File

@ -40,6 +40,11 @@ class StorageFile implements Rule
*/
public function passes($attribute, $value): bool
{
// If full path then no need to validate
if (filter_var($value, FILTER_VALIDATE_URL) !== FALSE) {
return true;
}
// This is use when updating a record, and file uploads aren't changed.
if($this->form){
$newPath = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $this->form->id);

33
app/Rules/ValidUrl.php Normal file
View File

@ -0,0 +1,33 @@
<?php
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
class ValidUrl implements Rule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param mixed $value
* @return bool
*/
public function passes($attribute, $value)
{
// Define the regular expression to match the desired URL patterns
$pattern = '/^(https?:\/\/)?(www\.)?[\w.-]+\.\w+(:\d+)?(\/[^\s]*)?$/';
return preg_match($pattern, $value);
}
/**
* Get the validation error message.
*
* @return string
*/
public function message()
{
return 'The :attribute format is invalid.';
}
}

View File

@ -36,6 +36,8 @@ class SeoMetaResolver
'integrations' => '/integrations',
'templates' => '/form-templates',
'templates_show' => '/form-templates/{slug}',
'templates_types_show' => '/form-templates/types/{slug}',
'templates_industries_show' => '/form-templates/industries/{slug}',
];
/**
@ -192,4 +194,26 @@ class SeoMetaResolver
'image' => $template->image_url
];
}
private function getTemplatesTypesShowMeta(): array
{
$types = json_decode(file_get_contents(resource_path('data/forms/templates/types.json')), true);
$type = $types[array_search($this->patternData['slug'], array_column($types, 'slug'))];
return [
'title' => $type['meta_title'],
'description' => Str::of($type['meta_description'])->limit(140),
];
}
private function getTemplatesIndustriesShowMeta(): array
{
$industries = json_decode(file_get_contents(resource_path('data/forms/templates/industries.json')), true);
$industry = $industries[array_search($this->patternData['slug'], array_column($industries, 'slug'))];
return [
'title' => $industry['meta_title'],
'description' => Str::of($industry['meta_description'])->limit(140),
];
}
}

View File

@ -63,6 +63,7 @@ return [
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', true),
],
],

View File

@ -45,7 +45,7 @@ return [
],
'notion' => [
'worker' => env('NOTION_WORKER','https://notion-forms-worker.notionforms.workers.dev/v1')
'worker' => env('NOTION_WORKER', 'https://notion-forms-worker.notionforms.workers.dev/v1')
],
'openai' => [
@ -57,6 +57,12 @@ return [
'secret_key' => env('UNSPLASH_SECRET_KEY'),
],
'appsumo' => [
'client_id' => env('APPSUMO_CLIENT_ID'),
'client_secret' => env('APPSUMO_CLIENT_SECRET'),
'api_key' => env('APPSUMO_API_KEY'),
],
'google_analytics_code' => env('GOOGLE_ANALYTICS_CODE'),
'amplitude_code' => env('AMPLITUDE_CODE'),
'crisp_website_id' => env('CRISP_WEBSITE_ID'),

View File

@ -1,7 +1,9 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class CreateFormsTable extends Migration
@ -13,7 +15,9 @@ class CreateFormsTable extends Migration
*/
public function up()
{
Schema::create('forms', function (Blueprint $table) {
$driver = DB::getDriverName();
Schema::create('forms', function (Blueprint $table) use ($driver) {
$table->id();
$table->foreignIdFor(\App\Models\Workspace::class,'workspace_id');
$table->string('title');
@ -22,14 +26,14 @@ class CreateFormsTable extends Migration
$table->timestamps();
$table->boolean('notifies')->default(false);
$table->text('description')->nullable();
$table->text('submit_button_text')->default('Submit');
$table->text('submit_button_text')->default(new Expression("('Submit')"));
$table->boolean('re_fillable')->default(false);
$table->text('re_fill_button_text')->default('Fill Again');
$table->text('re_fill_button_text')->default(new Expression("('Fill Again')"));
$table->string('color')->default('#3B82F6');
$table->boolean('uppercase_labels')->default(true);
$table->boolean('no_branding')->default(false);
$table->boolean('hide_title')->default(false);
$table->text('submitted_text')->default('Amazing, we saved your answers. Thank you for your time and have a great day!');
$table->text('submitted_text')->default(new Expression("('Amazing, we saved your answers. Thank you for your time and have a great day!')"));
$table->string('dark_mode')->default('auto');
$table->string('webhook_url')->nullable();
$table->boolean('send_submission_confirmation')->default(false);
@ -45,13 +49,17 @@ class CreateFormsTable extends Migration
$table->timestamp('closes_at')->nullable();
$table->text('closed_text')->nullable();
$table->string('notification_subject')->default("We saved your answers");
$table->text('notification_body')->default('<p>Hello there 👋 <br>This is a confirmation that your submission was successfully saved.</p>');
$table->text('notification_body')->default(new Expression("('<p>Hello there 👋 <br>This is a confirmation that your submission was successfully saved.</p>')"));
$table->boolean('notifications_include_submission')->default(true);
$table->boolean('use_captcha')->default(false);
$table->boolean('can_be_indexed')->default(true);
$table->string('password')->nullable()->default(null);
$table->string('notification_sender')->default("OpenForm");
if ($driver === 'mysql') {
$table->jsonb('tags')->default(new Expression('(JSON_ARRAY())'));
} else {
$table->jsonb('tags')->default('[]');
}
});
}

View File

@ -1,7 +1,9 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class CreateFormSubmissionsTable extends Migration
@ -13,10 +15,16 @@ class CreateFormSubmissionsTable extends Migration
*/
public function up()
{
Schema::create('form_submissions', function (Blueprint $table) {
$driver = DB::getDriverName();
Schema::create('form_submissions', function (Blueprint $table) use ($driver) {
$table->id();
$table->foreignIdFor(\App\Models\Forms\Form::class,'form_id');
$table->jsonb('data')->default('{}');
if ($driver === 'mysql') {
$table->jsonb('data')->default(new Expression("(JSON_OBJECT())"));
} else {
$table->jsonb('data')->default("{}");
}
$table->timestamps();
});
}

View File

@ -1,7 +1,9 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
@ -13,10 +15,16 @@ return new class extends Migration
*/
public function up()
{
Schema::create('form_statistics', function (Blueprint $table) {
$driver = DB::getDriverName();
Schema::create('form_statistics', function (Blueprint $table) use ($driver) {
$table->id();
$table->foreignIdFor(\App\Models\Forms\Form::class,'form_id');
$table->jsonb('data')->default('{}');
if ($driver === 'mysql') {
$table->jsonb('data')->default(new Expression("(JSON_OBJECT())"));
} else {
$table->jsonb('data')->default("{}");
}
$table->date('date');
});
}

View File

@ -1,7 +1,9 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
@ -13,8 +15,14 @@ return new class extends Migration
*/
public function up()
{
Schema::table('forms', function (Blueprint $table) {
$table->jsonb('removed_properties')->default('[]');
$driver = DB::getDriverName();
Schema::table('forms', function (Blueprint $table) use ($driver) {
if ($driver === 'mysql') {
$table->jsonb('removed_properties')->default(new Expression("(JSON_ARRAY())"));
} else {
$table->jsonb('removed_properties')->default("[]");
}
});
}

View File

@ -1,7 +1,9 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
@ -13,14 +15,20 @@ return new class extends Migration
*/
public function up()
{
Schema::create('templates', function (Blueprint $table) {
$driver = DB::getDriverName();
Schema::create('templates', function (Blueprint $table) use ($driver) {
$table->id();
$table->timestamps();
$table->string('name');
$table->string('slug');
$table->text('description');
$table->string('image_url');
if ($driver === 'mysql') {
$table->jsonb('structure')->default(new Expression("(JSON_OBJECT())"));
} else {
$table->jsonb('structure')->default('{}');
}
});
}

View File

@ -1,7 +1,9 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
@ -13,8 +15,14 @@ return new class extends Migration
*/
public function up()
{
Schema::table('templates', function (Blueprint $table) {
$table->jsonb('questions')->default('{}');
$driver = DB::getDriverName();
Schema::table('templates', function (Blueprint $table) use ($driver) {
if ($driver === 'mysql') {
$table->jsonb('questions')->default(new Expression("(JSON_ARRAY())"));
} else {
$table->jsonb('questions')->default('[]');
}
});
}

View File

@ -1,6 +1,7 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@ -14,7 +15,7 @@ return new class extends Migration
public function up()
{
Schema::table('forms', function (Blueprint $table) {
$table->text('editable_submissions_button_text')->default('Edit submission');
$table->text('editable_submissions_button_text')->default(new Expression("('Edit submission')"));
});
}

View File

@ -1,7 +1,9 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
@ -13,8 +15,14 @@ return new class extends Migration
*/
public function up()
{
Schema::table('forms', function (Blueprint $table) {
$table->json('seo_meta')->default('{}');
$driver = DB::getDriverName();
Schema::table('forms', function (Blueprint $table) use ($driver) {
if ($driver === 'mysql') {
$table->json('seo_meta')->default(new Expression("(JSON_OBJECT())"));
} else {
$table->json('seo_meta')->default("{}");
}
});
}

View File

@ -1,7 +1,9 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
@ -13,8 +15,14 @@ return new class extends Migration
*/
public function up()
{
Schema::table('forms', function (Blueprint $table) {
$driver = DB::getDriverName();
Schema::table('forms', function (Blueprint $table) use ($driver) {
if ($driver === 'mysql') {
$table->json('notification_settings')->default(new Expression("(JSON_OBJECT())"))->nullable(true);
} else {
$table->json('notification_settings')->default('{}')->nullable(true);
}
});
}

View File

@ -1,7 +1,9 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
@ -13,12 +15,31 @@ return new class extends Migration
*/
public function up()
{
Schema::table('templates', function (Blueprint $table) {
$driver = DB::getDriverName();
Schema::table('templates', function (Blueprint $table) use ($driver) {
$table->boolean('publicly_listed')->default(false);
if ($driver === 'mysql') {
$table->jsonb('industries')->default(new Expression("(JSON_ARRAY())"));
} else {
$table->jsonb('industries')->default('[]');
}
if ($driver === 'mysql') {
$table->jsonb('types')->default(new Expression("(JSON_ARRAY())"));
} else {
$table->jsonb('types')->default('[]');
}
$table->string('short_description')->nullable();
if ($driver === 'mysql') {
$table->jsonb('related_templates')->default(new Expression("(JSON_ARRAY())"));
} else {
$table->jsonb('related_templates')->default('[]');
}
$table->string('image_url',500)->nullable()->change();
});
}

View File

@ -0,0 +1,39 @@
<?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('licenses', function (Blueprint $table) {
$table->id();
$table->string('license_key');
$table->unsignedBigInteger('user_id')->nullable();
$table->string('license_provider');
$table->string('status');
$table->json('meta');
$table->timestamps();
$table->index(['license_key', 'license_provider']);
$table->index(['user_id', 'license_provider']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('licenses');
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -1,27 +1,30 @@
<template>
<button v-if="!to" :type="nativeType" :disabled="loading" :class="btnClasses"
<a v-if="href" :class="btnClasses" :href="href" :target="target">
<slot />
</a>
<button v-else-if="!to" :type="nativeType" :disabled="loading" :class="btnClasses"
@click="onClick($event)"
>
<template v-if="!loading">
<span class="no-underline mx-auto">
<slot/>
<slot />
</span>
<svg v-if="arrow" class="ml-2 w-3 h-3 inline" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 11L11 1M11 1H1M11 1V11" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"/>
stroke-linejoin="round"
/>
</svg>
</template>
<loader v-else class="h-6 w-6 mx-auto" :class="`text-${colorShades['text']}`"/>
<loader v-else class="h-6 w-6 mx-auto" :class="`text-${colorShades['text']}`" />
</button>
<router-link v-else :class="btnClasses" :to="to" :target="target"
>
<router-link v-else :class="btnClasses" :to="to" :target="target">
<span class="no-underline mx-auto">
<slot/>
<slot />
</span>
<svg v-if="arrow" class="ml-2 w-3 h-3 inline" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 11L11 1M11 1H1M11 1V11" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"/>
stroke-linejoin="round"
/>
</svg>
</router-link>
</template>
@ -61,14 +64,19 @@ export default {
default: null
},
href: {
type: String,
default: null
},
target: {
type: String,
default: '_self'
},
}
},
computed: {
btnClasses() {
btnClasses () {
const sizes = this.sizes
const colorShades = this.colorShades
return `v-btn ${sizes['p-y']} ${sizes['p-x']}
@ -76,14 +84,14 @@ export default {
${colorShades?.text} transition ease-in duration-200 text-center text-${sizes?.font} font-medium focus:outline-none focus:ring-2
focus:ring-offset-2 rounded-lg flex items-center hover:no-underline`
},
colorShades() {
colorShades () {
if (this.color === 'blue') {
return {
main: 'bg-blue-600',
hover: 'hover:bg-blue-700',
ring: 'focus:ring-blue-500',
'ring-offset': 'focus:ring-offset-blue-200',
text: 'text-white',
text: 'text-white'
}
} else if (this.color === 'outline-blue') {
return {
@ -91,7 +99,15 @@ export default {
hover: 'hover:bg-blue-600',
ring: 'focus:ring-blue-500',
'ring-offset': 'focus:ring-offset-blue-200',
text: 'text-blue-600 hover:text-white',
text: 'text-blue-600 hover:text-white'
}
} else if (this.color === 'outline-gray') {
return {
main: 'bg-transparent border border-gray-300',
hover: 'hover:bg-gray-500',
ring: 'focus:ring-gray-500',
'ring-offset': 'focus:ring-offset-gray-200',
text: 'text-gray-500 hover:text-white'
}
} else if (this.color === 'red') {
return {
@ -99,7 +115,7 @@ export default {
hover: 'hover:bg-red-700',
ring: 'focus:ring-red-500',
'ring-offset': 'focus:ring-offset-red-200',
text: 'text-white',
text: 'text-white'
}
} else if (this.color === 'gray') {
return {
@ -107,7 +123,7 @@ export default {
hover: 'hover:bg-gray-700',
ring: 'focus:ring-gray-500',
'ring-offset': 'focus:ring-offset-gray-200',
text: 'text-white',
text: 'text-white'
}
} else if (this.color === 'light-gray') {
return {
@ -115,7 +131,7 @@ export default {
hover: 'hover:bg-gray-100',
ring: 'focus:ring-gray-500',
'ring-offset': 'focus:ring-offset-gray-300',
text: 'text-gray-700',
text: 'text-gray-700'
}
} else if (this.color === 'green') {
return {
@ -123,7 +139,7 @@ export default {
hover: 'hover:bg-green-700',
ring: 'focus:ring-green-500',
'ring-offset': 'focus:ring-offset-green-200',
text: 'text-white',
text: 'text-white'
}
} else if (this.color === 'yellow') {
return {
@ -131,7 +147,7 @@ export default {
hover: 'hover:bg-yellow-700',
ring: 'focus:ring-yellow-500',
'ring-offset': 'focus:ring-offset-yellow-200',
text: 'text-white',
text: 'text-white'
}
} else if (this.color === 'white') {
return {
@ -139,12 +155,12 @@ export default {
hover: 'hover:bg-gray-200',
ring: 'focus:ring-white-500',
'ring-offset': 'focus:ring-offset-white-200',
text: 'text-gray-700',
text: 'text-gray-700'
}
}
console.error('Unknown color')
},
sizes() {
sizes () {
if (this.size === 'small') {
return {
font: 'sm',
@ -161,8 +177,8 @@ export default {
},
methods: {
onClick(event) {
this.$emit('click',event)
onClick (event) {
this.$emit('click', event)
}
}
}

View File

@ -1,180 +1,116 @@
<template>
<input-wrapper v-bind="$props">
<input-wrapper
v-bind="$props"
>
<template #label>
<slot name="label" />
</template>
<span class="inline-block w-full rounded-md shadow-sm">
<button type="button" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" role="button"
class="cursor-pointer relative flex"
:class="[theme.default.input,{'!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled}]"
:style="inputStyle" @click.self="showUploadModal=true"
<div class="flex w-full items-center justify-center transition-colors duration-40"
:class="[{'!cursor-not-allowed':disabled, 'cursor-pointer':!disabled,
[theme.fileInput.inputHover.light + ' dark:'+theme.fileInput.inputHover.dark]: uploadDragoverEvent,
['hover:'+theme.fileInput.inputHover.light +' dark:hover:'+theme.fileInput.inputHover.dark]: !loading}, theme.fileInput.input]"
@dragover.prevent="uploadDragoverEvent=true"
@dragleave.prevent="uploadDragoverEvent=false"
@drop.prevent="onUploadDropEvent"
@click="openFileUpload"
>
<div v-if="currentUrl==null" class="h-6 text-gray-600 dark:text-gray-400 flex-grow truncate"
@click.prevent="showUploadModal=true"
>
Upload {{ multiple ? 'file(s)' : 'a file' }} <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline"
fill="none" viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
<template v-else>
<div class="flex-grow h-6 text-gray-600 dark:text-gray-400 truncate" @click.prevent="showUploadModal=true">
<div>
<p v-if="files.length==1"><svg xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 inline mr-2 -mt-1" fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>{{ files[0].file.name }}</p>
<p v-else><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline mr-2 -mt-1" fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>{{ files.length }} file(s)</p>
</div>
</div>
<div v-if="files.length>0">
<a href="#" class="hover:text-nt-blue" role="button" @click.prevent="clearAll">
<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>
</a>
</div>
</template>
</button>
</span>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
<!-- Modal -->
<modal :portal-order="2" :show="showUploadModal" @close="showUploadModal=false">
<h2 class="text-lg font-semibold">
Upload {{ multiple ? 'file(s)' : 'a file' }}
</h2>
<div class="max-w-3xl mx-auto lg:max-w-none">
<div class="sm:mt-5 sm:grid sm:grid-cols-1 sm:gap-4 sm:items-start sm:pt-5">
<div class="mt-2 sm:mt-0 sm:col-span-2 mb-5">
<div
v-cloak
class="w-full flex justify-center items-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md h-128"
@dragover.prevent="onUploadDragoverEvent($event)"
@drop.prevent="onUploadDropEvent($event)"
v-if="loading"
class="text-gray-600 dark:text-gray-400"
>
<div v-if="loading" class="text-gray-600 dark:text-gray-400">
<loader class="h-6 w-6 mx-auto m-10" />
<p class="text-center mt-6">
<loader class="mx-auto h-6 w-6" />
<p class="mt-2 text-center text-sm text-gray-500">
Uploading your file...
</p>
</div>
<template v-else>
<div
class="absolute rounded-full bg-gray-100 h-20 w-20 z-10 transition-opacity duration-500 ease-in-out"
:class="{
'opacity-100': uploadDragoverTracking,
'opacity-0': !uploadDragoverTracking
}"
/>
<div class="relative z-20 text-center">
<input ref="actual-input" class="hidden" :multiple="multiple" type="file" :name="name"
<div class="text-center">
<input
ref="actual-input"
class="hidden"
:multiple="multiple"
type="file"
:name="name"
:accept="acceptExtensions"
@change="manualFileUpload"
>
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-24 w-24 text-gray-200" fill="none"
viewBox="0 0 24 24" stroke="currentColor"
<div v-if="files.length" class="flex flex-wrap items-center justify-center gap-4">
<uploaded-file
v-for="file in files"
:key="file.url"
:file="file"
:theme="theme"
@remove="clearFile(file)"
/>
</div>
<template v-else>
<div class="text-gray-500 w-full flex justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
</svg>
<p class="mt-5 text-sm text-gray-600">
<button
type="button"
class="font-semibold text-nt-blue hover:text-nt-blue-dark focus:outline-none focus:underline transition duration-150 ease-in-out"
@click="openFileUpload"
>
Upload {{ multiple ? 'file(s)' : 'a file' }}
</button>
or drag and drop
</div>
<p class="mt-2 text-sm text-gray-500 font-semibold select-none">
Click to choose {{ multiple ? 'file(s)' : 'a file' }} or drag here
</p>
<p class="mt-1 text-xs text-gray-500">
Up to {{ mbLimit }}mb
<p class="mt-1 text-xs text-gray-400 dark:text-gray-600 select-none">
Size limit: {{ mbLimit }}MB per file
</p>
</template>
</div>
</template>
</div>
<div v-if="files.length" class="mt-4">
<div class="border rounded-md">
<div v-for="file,index in files" class="flex p-2" :class="{'border-t':index!==0}">
<p class="flex-grow truncate text-gray-500">
{{ file.file.name }}
</p>
<div>
<a href="#" class="text-gray-400 dark:text-gray-600 hover:text-nt-blue flex" role="button"
@click.prevent="clearFile(index)"
>
<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>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</modal>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</input-wrapper>
</template>
<script>
import axios from 'axios'
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
import Modal from '../Modal.vue'
import inputMixin from '~/mixins/forms/input.js'
import UploadedFile from './components/UploadedFile.vue'
export default {
name: 'FileInput',
components: { InputWrapper, Modal },
components: { InputWrapper, UploadedFile },
mixins: [],
props: {
...inputProps,
multiple: { type: Boolean, default: true },
mbLimit: { type: Number, default: 5 },
accept: { type: String, default: '' }
accept: { type: String, default: '' },
moveToFormAssets: { type: Boolean, default: false }
},
setup (props, context) {
const {
compVal,
inputStyle,
hasValidation,
hasError
} = useFormInput(props, context)
return {
compVal,
inputStyle,
hasValidation,
hasError
}
},
mixins: [inputMixin],
data: () => ({
showUploadModal: false,
files: [],
uploadDragoverTracking: false,
uploadDragoverEvent: false,
loading: false
}),
@ -184,39 +120,44 @@ export default {
return this.form[this.name]
},
acceptExtensions () {
if (this.accept) {
return this.accept.split(',').map((i) => {
return '.' + i.trim()
}).join(',')
if (!this.accept) {
return null
}
return ''
return this.accept
.split(',')
.map((i) => {
return '.' + i.trim()
})
.join(',')
}
},
watch: {
showUploadModal: {
handler (val) {
if(this.disabled){
this.showUploadModal = false
}
}
},
files: {
deep: true,
handler (files) {
this.compVal = files.map(file => file.url)
this.compVal = files.map((file) => file.url)
}
}
},
async created () {
if(this.compVal && this.compVal.length > 0) {
let tmpFiles = []
if (typeof this.compVal === 'string' || this.compVal instanceof String) {
await this.getFileFromUrl(this.compVal).then((fileObj) => {
this.files = [{
file: fileObj,
url: this.compVal,
src: this.getFileSrc(fileObj)
}]
})
} else if (this.compVal && this.compVal.length > 0) {
const tmpFiles = []
for (let i = 0; i < this.compVal.length; i++) {
await this.getFileFromUrl(this.compVal[i]).then((fileObj) => {
tmpFiles.push({
file: fileObj,
url: this.compVal[i]
url: this.compVal[i],
src: this.getFileSrc(fileObj)
})
})
}
@ -231,25 +172,19 @@ export default {
clearFile (index) {
this.files.splice(index, 1)
},
onUploadDragoverEvent (e) {
this.uploadDragoverEvent = true
this.uploadDragoverTracking = true
},
onUploadDropEvent (e) {
this.uploadDragoverEvent = false
this.uploadDragoverTracking = false
this.droppedFiles(e)
this.droppedFiles(e.dataTransfer.files)
},
droppedFiles (e) {
const droppedFiles = e.dataTransfer.files
if (!droppedFiles) return
droppedFiles (droppedFiles) {
if (!droppedFiles || this.disabled) return
for (let i = 0; i < droppedFiles.length; i++) {
this.uploadFileToServer(droppedFiles.item(i))
}
},
openFileUpload () {
if (this.disabled) return
this.$refs['actual-input'].click()
},
manualFileUpload (e) {
@ -259,30 +194,55 @@ export default {
}
},
uploadFileToServer (file) {
if (this.disabled) return
this.loading = true
this.storeFile(file).then(response => {
this.storeFile(file)
.then((response) => {
if (!this.multiple) {
this.files = []
}
if (this.moveToFormAssets) {
// Move file to permanent storage for form assets
axios.post('/api/open/forms/assets/upload', {
type: 'files',
url: file.name.split('.').slice(0, -1).join('.') + '_' + response.uuid + '.' + response.extension
}).then(moveFileResponse => {
this.files.push({
file: file,
url: file.name.split('.').slice(0, -1).join('.') + '_' + response.uuid + '.' + response.extension
url: moveFileResponse.data.url,
src: this.getFileSrc(file)
})
this.showUploadModal = false
this.loading = false
}).catch((error) => {
this.loading = false
})
} else {
this.files.push({
file: file,
url: file.name.split('.').slice(0, -1).join('.') + '_' + response.uuid + '.' + response.extension,
src: this.getFileSrc(file)
})
this.loading = false
}
})
.catch((error) => {
this.clearAll()
this.showUploadModal = false
this.loading = false
})
},
async getFileFromUrl(url, defaultType='image/jpeg'){
async getFileFromUrl (url, defaultType = 'image/jpeg') {
const response = await fetch(url)
const data = await response.blob()
const name = url.replace(/^.*(\\|\/|\:)/, '')
return new File([data], name, {
type: data.type || defaultType,
type: data.type || defaultType
})
},
getFileSrc (file) {
if (file.type && file.type.split('/')[0] === 'image') {
return URL.createObjectURL(file)
}
return null
}
}
}

View File

@ -89,9 +89,9 @@
class="font-semibold text-nt-blue hover:text-nt-blue-dark focus:outline-none focus:underline transition duration-150 ease-in-out"
@click="openFileUpload"
>
Upload your image
Upload your image,
</button>
or drag and drop
use drag and drop or paste it
</p>
<p class="mt-1 text-xs text-gray-500">
.jpg, .jpeg, .png, .bmp, .gif, .svg up to 5mb
@ -151,6 +151,17 @@ export default {
}
},
watch: {
showUploadModal: {
handler (val) {
document.removeEventListener('paste', this.onUploadPasteEvent)
if(this.showUploadModal){
document.addEventListener("paste", this.onUploadPasteEvent)
}
}
}
},
methods: {
clearUrl () {
this.form[this.name] = null
@ -162,11 +173,15 @@ export default {
onUploadDropEvent (e) {
this.uploadDragoverEvent = false
this.uploadDragoverTracking = false
this.droppedFiles(e)
this.droppedFiles(e.dataTransfer.files)
},
droppedFiles (e) {
const droppedFiles = e.dataTransfer.files
onUploadPasteEvent (e) {
if(!this.showUploadModal) return
this.uploadDragoverEvent = false
this.uploadDragoverTracking = false
this.droppedFiles(e.clipboardData.files)
},
droppedFiles (droppedFiles) {
if (!droppedFiles) return
this.file = droppedFiles[0]

View File

@ -0,0 +1,102 @@
<template>
<div :class="wrapperClass" :style="inputStyle">
<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>
<small v-if="help && helpPosition=='above_input'" :class="theme.default.help" class="flex mb-1">
<slot name="help"><span class="field-help" v-html="help" /></slot>
</small>
<div class="rectangle-outer grid grid-cols-5 gap-2">
<div v-for="i in scaleList" :key="i"
:class="[{'font-semibold':compVal===i},theme.ScaleInput.button, compVal!==i ? unselectedButtonClass: '']"
:style="btnStyle(i===compVal)"
role="button" @click="setScale(i)"
>
{{ i }}
</div>
</div>
<small v-if="help && helpPosition=='below_input'" :class="theme.default.help">
<slot name="help"><span class="field-help" v-html="help" /></slot>
</small>
<has-error v-if="hasValidation" :form="form" :field="name" />
</div>
</template>
<script>
import inputMixin from '~/mixins/forms/input.js'
export default {
name: 'ScaleInput',
mixins: [inputMixin],
props: {
minScale: { type: Number, default: 1 },
maxScale: { type: Number, default: 5 },
stepScale: { type: Number, default: 1 }
},
data () {
return {}
},
computed: {
scaleList () {
let list = []
for (let i = this.minScale; i <= this.maxScale; i += this.stepScale) {
list.push(i)
}
return list
},
unselectedButtonClass () {
return this.theme.ScaleInput.unselectedButton
},
textColor () {
const color = (this.color.charAt(0) === '#') ? this.color.substring(1, 7) : this.color
const r = parseInt(color.substring(0, 2), 16) // hexToR
const g = parseInt(color.substring(2, 4), 16) // hexToG
const b = parseInt(color.substring(4, 6), 16) // hexToB
const uicolors = [r / 255, g / 255, b / 255]
const c = uicolors.map((col) => {
if (col <= 0.03928) {
return col / 12.92
}
return Math.pow((col + 0.055) / 1.055, 2.4)
})
const L = (0.2126 * c[0]) + (0.7152 * c[1]) + (0.0722 * c[2])
return (L > 0.55) ? '#000000' : '#FFFFFF'
}
},
mounted () {
if (this.compVal && typeof this.compVal === 'string'){
this.compVal = parseInt(this.compVal)
}
},
methods: {
btnStyle (isSelected) {
if (!isSelected) return {}
return {
color: this.textColor,
backgroundColor: this.color
}
},
setScale (val) {
if (this.disabled) {
return
}
if (this.compVal === val) {
this.compVal = null
} else {
this.compVal = val
}
}
}
}
</script>

View File

@ -0,0 +1,63 @@
<template>
<div
:class="[theme.fileInput.uploadedFile, 'overflow-hidden']"
:title="file.file.name"
>
<div v-if="file.src && !isImageHide" class="h-20 overflow-hidden flex">
<img class="block object-cover object-center w-full" :src="file.src" @error="isImageHide=true">
</div>
<div v-else class="h-20 flex items-center justify-center">
<svg class="w-10 h-10 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="0.8" stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
</div>
<div class="flex gap-2 items-center border-t py-1 px-2">
<p class="flex-grow text-left truncate text-gray-500 text-xs">
{{ file.file.name }}
</p>
<a
href="javascript:void(0);"
class="flex text-gray-400 rounded hover:bg-neutral-50 hover:text-red-500 dark:text-gray-600 p-1"
role="button"
title="Remove"
@click.stop="$emit('remove')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
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>
</a>
</div>
</div>
</template>
<script>
export default {
name: 'UploadedFile',
props: {
file: { default: null },
theme: { type: Object }
},
data: () => ({
isImageHide: false
}),
computed: {}
}
</script>

View File

@ -15,6 +15,7 @@ import ImageInput from './ImageInput.vue'
import RatingInput from './RatingInput.vue'
import FlatSelectInput from './FlatSelectInput.vue'
import ToggleSwitchInput from './ToggleSwitchInput.vue'
import ScaleInput from './ScaleInput.vue'
export function registerComponents (app) {
[
@ -32,7 +33,8 @@ export function registerComponents (app) {
ImageInput,
RatingInput,
FlatSelectInput,
ToggleSwitchInput
ToggleSwitchInput,
ScaleInput
].forEach(Component => {
Component.name ? app.component(Component.name, Component) : app.component(Component.name, Component)
})

View File

@ -0,0 +1,46 @@
<template>
<collapse class="p-4 w-full border-b" v-model="show">
<template #title>
<div class="flex items-center pr-8">
<div class="mr-3" :class="{'text-blue-600':show, 'text-gray-500':!show}">
<slot name="icon" />
</div>
<h3 id="v-step-2" class="font-semibold flex-grow">
{{ name }}
</h3>
<pro-tag v-if="hasProTag" />
</div>
</template>
<slot />
</collapse>
</template>
<script>
import Collapse from '../../common/Collapse.vue'
import ProTag from '../../common/ProTag.vue'
export default {
name: 'EditorOptionsPanel',
components: { Collapse, ProTag },
props: {
name: {
type: String,
required: true
},
hasProTag: {
type: Boolean,
default: false
},
alreadyOpened: {
type: Boolean,
default: false
}
},
data () {
return {
show: this.alreadyOpened
}
}
}
</script>

View File

@ -150,6 +150,9 @@ export default {
if (field.type === 'number' && field.is_rating && field.rating_max_value) {
return 'RatingInput'
}
if (field.type === 'number' && field.is_scale && field.scale_max_value) {
return 'ScaleInput'
}
if (['select', 'multi_select'].includes(field.type) && field.without_dropdown) {
return 'FlatSelectInput'
}
@ -302,6 +305,10 @@ export default {
inputProperties.accept = (this.form.is_pro && field.allowed_file_types) ? field.allowed_file_types : ""
} else if (field.type === 'number' && field.is_rating) {
inputProperties.numberOfStars = parseInt(field.rating_max_value)
} else if (field.type === 'number' && field.is_scale) {
inputProperties.minScale = parseInt(field.scale_min_value) ?? 1
inputProperties.maxScale = parseInt(field.scale_max_value) ?? 5
inputProperties.stepScale = parseInt(field.scale_step_value) ?? 1
} else if (field.type === 'number' || (field.type === 'phone_number' && field.use_simple_text_input)) {
inputProperties.pattern = '/\d*'
} else if (field.type === 'phone_number' && !field.use_simple_text_input) {

View File

@ -55,15 +55,15 @@
Please create this form on a device with a larger screen. That will allow you to preview your form changes.
</div>
<form-information />
<form-structure />
<form-customization />
<form-about-submission />
<form-notifications />
<form-security-privacy />
<form-information/>
<form-structure/>
<form-customization/>
<form-notifications/>
<form-about-submission/>
<form-access />
<form-security-privacy/>
<form-custom-seo />
<form-custom-code />
<form-integrations />
<form-custom-code/>
</div>
<form-editor-preview />
@ -95,10 +95,10 @@ import FormCustomization from './form-components/FormCustomization.vue'
import FormCustomCode from './form-components/FormCustomCode.vue'
import FormAboutSubmission from './form-components/FormAboutSubmission.vue'
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 FormAccess from './form-components/FormAccess.vue'
import saveUpdateAlert from '../../../../mixins/forms/saveUpdateAlert.js'
import fieldsLogic from '../../../../mixins/forms/fieldsLogic.js'
@ -108,7 +108,6 @@ export default {
AddFormBlockSidebar,
FormFieldEditSidebar,
FormEditorPreview,
FormIntegrations,
FormNotifications,
FormAboutSubmission,
FormCustomCode,
@ -117,7 +116,8 @@ export default {
FormInformation,
FormErrorModal,
FormSecurityPrivacy,
FormCustomSeo
FormCustomSeo,
FormAccess
},
mixins: [saveUpdateAlert, fieldsLogic],
props: {

View File

@ -1,19 +1,11 @@
<template>
<collapse v-model="isCollapseOpen" class="p-4 w-full border-b">
<template #title>
<h3 class="font-semibold text-lg relative">
<svg
class="h-5 w-5 inline mr-2 -mt-0.5 transition-colors" :class="{'text-blue-600':isCollapseOpen, 'text-gray-500':!isCollapseOpen}"
viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"
>
<editor-options-panel name="About Submissions" :already-opened="true">
<template #icon>
<svg class="h-5 w-5" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.83333 6.08333H9M4.83333 9H11.5M4.83333 14V15.9463C4.83333 16.3903 4.83333 16.6123 4.92436 16.7263C5.00352 16.8255 5.12356 16.8832 5.25045 16.8831C5.39636 16.8829 5.56973 16.7442 5.91646 16.4668L7.90434 14.8765C8.31043 14.5517 8.51347 14.3892 8.73957 14.2737C8.94017 14.1712 9.15369 14.0963 9.37435 14.051C9.62306 14 9.88308 14 10.4031 14H12.5C13.9001 14 14.6002 14 15.135 13.7275C15.6054 13.4878 15.9878 13.1054 16.2275 12.635C16.5 12.1002 16.5 11.4001 16.5 10V5.5C16.5 4.09987 16.5 3.3998 16.2275 2.86502C15.9878 2.39462 15.6054 2.01217 15.135 1.77248C14.6002 1.5 13.9001 1.5 12.5 1.5H5.5C4.09987 1.5 3.3998 1.5 2.86502 1.77248C2.39462 2.01217 2.01217 2.39462 1.77248 2.86502C1.5 3.3998 1.5 4.09987 1.5 5.5V10.6667C1.5 11.4416 1.5 11.8291 1.58519 12.147C1.81635 13.0098 2.49022 13.6836 3.35295 13.9148C3.67087 14 4.05836 14 4.83333 14Z"
stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"
/>
stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
About Submissions
</h3>
</template>
<text-input name="submit_button_text" class="mt-4"
@ -132,46 +124,21 @@
label="Text After Submission"
:required="false"
/>
<date-input :with-time="true" name="closes_at"
:form="form"
label="Closing Date"
help="If filled, then the form won't accept submissions after the given date"
:required="false"
/>
<rich-text-area-input v-if="form.closes_at || form.visibility=='closed'" name="closed_text"
:form="form"
label="Closed form text"
help="This message will be shown when the form will be closed"
:required="false"
/>
<text-input name="max_submissions_count" native-type="number" :min="1" :form="form"
label="Max. Number of Submissions"
help="If filled, the form will only accept X number of submissions"
:required="false"
/>
<rich-text-area-input v-if="form.max_submissions_count && form.max_submissions_count > 0"
name="max_submissions_reached_text"
:form="form"
label="Max Submissions reached text"
help="This message will be shown when the form will have the maximum number of submissions"
:required="false"
/>
</template>
</collapse>
</editor-options-panel>
</template>
<script>
import Collapse from '../../../../common/Collapse.vue'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
import ProTag from '../../../../common/ProTag.vue'
import VTransition from '../../../../common/transitions/VTransition.vue'
export default {
components: { Collapse, ProTag, VTransition },
components: {EditorOptionsPanel, ProTag, VTransition},
props: {},
data () {
return {
submissionOptions: {},
isCollapseOpen: true
submissionOptions: {}
}
},

View File

@ -0,0 +1,66 @@
<template>
<editor-options-panel name="Form Access" :already-opened="false">
<template #icon>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</template>
<text-input name="password" :form="form" class="mt-4"
label="Form Password" help="Leave empty to disable password"
/>
<date-input :with-time="true" name="closes_at" class="mt-4"
:form="form"
label="Close form on a scheduled date"
help="Leave empty to keep the form open"
:required="false"
/>
<rich-text-area-input v-if="form.closes_at || form.visibility=='closed'" name="closed_text"
:form="form" class="mt-4"
label="Closed form text"
help="This message will be shown when the form will be closed"
:required="false"
/>
<text-input name="max_submissions_count" native-type="number" :min="1" :form="form"
label="Limit number of submissions" placeholder="Max submissions" class="mt-4"
help="Leave empty for unlimited submissions"
:required="false"
/>
<rich-text-area-input v-if="form.max_submissions_count && form.max_submissions_count > 0"
name="max_submissions_reached_text" class="mt-4"
:form="form"
label="Max Submissions reached text"
help="This message will be shown when the form will have the maximum number of submissions"
:required="false"
/>
</editor-options-panel>
</template>
<script>
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
export default {
components: { EditorOptionsPanel },
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 () {
},
methods: {}
}
</script>

View File

@ -1,15 +1,9 @@
<template>
<collapse class="p-4 w-full border-b" v-model="isCollapseOpen">
<template #title>
<h3 class="font-semibold text-lg">
<svg class="h-5 w-5 inline mr-2 -mt-1 transition-colors"
:class="{'text-blue-600':isCollapseOpen, 'text-gray-500':!isCollapseOpen}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<editor-options-panel name="Custom Code" :already-opened="false" :has-pro-tag="true">
<template #icon>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2.26953V6.40007C14 6.96012 14 7.24015 14.109 7.45406C14.2049 7.64222 14.3578 7.7952 14.546 7.89108C14.7599 8.00007 15.0399 8.00007 15.6 8.00007H19.7305M14 17.5L16.5 15L14 12.5M10 12.5L7.5 15L10 17.5M20 9.98822V17.2C20 18.8802 20 19.7202 19.673 20.362C19.3854 20.9265 18.9265 21.3854 18.362 21.673C17.7202 22 16.8802 22 15.2 22H8.8C7.11984 22 6.27976 22 5.63803 21.673C5.07354 21.3854 4.6146 20.9265 4.32698 20.362C4 19.7202 4 18.8802 4 17.2V6.8C4 5.11984 4 4.27976 4.32698 3.63803C4.6146 3.07354 5.07354 2.6146 5.63803 2.32698C6.27976 2 7.11984 2 8.8 2H12.0118C12.7455 2 13.1124 2 13.4577 2.08289C13.7638 2.15638 14.0564 2.27759 14.3249 2.44208C14.6276 2.6276 14.887 2.88703 15.4059 3.40589L18.5941 6.59411C19.113 7.11297 19.3724 7.3724 19.5579 7.67515C19.7224 7.94356 19.8436 8.2362 19.9171 8.5423C20 8.88757 20 9.25445 20 9.98822Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Custom Code
<pro-tag />
</h3>
</template>
<p class="mt-4">
The code will be injected in the <span class="font-semibold">head</span> section of your form page.
@ -19,21 +13,19 @@
your actual form page (save changes beforehand)."
label="Custom Code"
/>
</collapse>
</editor-options-panel>
</template>
<script>
import Collapse from '../../../../common/Collapse.vue'
import ProTag from '../../../../common/ProTag.vue'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
import CodeInput from '../../../../forms/CodeInput.vue'
export default {
components: { Collapse, ProTag, CodeInput },
components: { EditorOptionsPanel, CodeInput },
props: {
},
data () {
return {
isCollapseOpen: false
}
},

View File

@ -1,18 +1,11 @@
<template>
<collapse class="p-5 w-full border-b" v-model="isCollapseOpen">
<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 -ml-1 mr-2 -mt-1 transition-colors" :class="{'text-blue-600':isCollapseOpen, 'text-gray-500':!isCollapseOpen}"
>
<editor-options-panel name="Link Settings - SEO" :already-opened="false" :has-pro-tag="true">
<template #icon>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<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).
@ -26,19 +19,17 @@
<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>
</editor-options-panel>
</template>
<script>
import Collapse from '../../../../common/Collapse.vue'
import ProTag from '../../../../common/ProTag.vue'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
export default {
components: { Collapse, ProTag },
components: { EditorOptionsPanel },
props: {},
data () {
return {
isCollapseOpen: false
}
},
computed: {

View File

@ -1,16 +1,12 @@
<template>
<collapse v-model="isCollapseOpen" class="p-4 w-full border-b">
<template #title>
<h3 class="font-semibold text-lg">
<svg class="h-5 w-5 inline mr-2 -mt-1 transition-colors" :class="{'text-blue-600':isCollapseOpen, 'text-gray-500':!isCollapseOpen}" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.66667 9.99984C1.66667 14.6022 5.39763 18.3332 10 18.3332C11.3807 18.3332 12.5 17.2139 12.5 15.8332V15.4165C12.5 15.0295 12.5 14.836 12.5214 14.6735C12.6691 13.5517 13.5519 12.6689 14.6737 12.5212C14.8361 12.4998 15.0297 12.4998 15.4167 12.4998H15.8333C17.214 12.4998 18.3333 11.3805 18.3333 9.99984C18.3333 5.39746 14.6024 1.6665 10 1.6665C5.39763 1.6665 1.66667 5.39746 1.66667 9.99984Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round" />
<path d="M5.83333 10.8332C6.29357 10.8332 6.66667 10.4601 6.66667 9.99984C6.66667 9.5396 6.29357 9.1665 5.83333 9.1665C5.3731 9.1665 5 9.5396 5 9.99984C5 10.4601 5.3731 10.8332 5.83333 10.8332Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round" />
<path d="M13.3333 7.49984C13.7936 7.49984 14.1667 7.12674 14.1667 6.6665C14.1667 6.20627 13.7936 5.83317 13.3333 5.83317C12.8731 5.83317 12.5 6.20627 12.5 6.6665C12.5 7.12674 12.8731 7.49984 13.3333 7.49984Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round" />
<path d="M8.33333 6.6665C8.79357 6.6665 9.16667 6.29341 9.16667 5.83317C9.16667 5.37293 8.79357 4.99984 8.33333 4.99984C7.8731 4.99984 7.5 5.37293 7.5 5.83317C7.5 6.29341 7.8731 6.6665 8.33333 6.6665Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round" />
<editor-options-panel name="Customization" :already-opened="true">
<template #icon>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.66667 9.99984C1.66667 14.6022 5.39763 18.3332 10 18.3332C11.3807 18.3332 12.5 17.2139 12.5 15.8332V15.4165C12.5 15.0295 12.5 14.836 12.5214 14.6735C12.6691 13.5517 13.5519 12.6689 14.6737 12.5212C14.8361 12.4998 15.0297 12.4998 15.4167 12.4998H15.8333C17.214 12.4998 18.3333 11.3805 18.3333 9.99984C18.3333 5.39746 14.6024 1.6665 10 1.6665C5.39763 1.6665 1.66667 5.39746 1.66667 9.99984Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.83333 10.8332C6.29357 10.8332 6.66667 10.4601 6.66667 9.99984C6.66667 9.5396 6.29357 9.1665 5.83333 9.1665C5.3731 9.1665 5 9.5396 5 9.99984C5 10.4601 5.3731 10.8332 5.83333 10.8332Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.3333 7.49984C13.7936 7.49984 14.1667 7.12674 14.1667 6.6665C14.1667 6.20627 13.7936 5.83317 13.3333 5.83317C12.8731 5.83317 12.5 6.20627 12.5 6.6665C12.5 7.12674 12.8731 7.49984 13.3333 7.49984Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.33333 6.6665C8.79357 6.6665 9.16667 6.29341 9.16667 5.83317C9.16667 5.37293 8.79357 4.99984 8.33333 4.99984C7.8731 4.99984 7.5 5.37293 7.5 5.83317C7.5 6.29341 7.8731 6.6665 8.33333 6.6665Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Customization
</h3>
</template>
<select-input name="theme" class="mt-4"
@ -81,21 +77,20 @@
label="Auto save form response"
help="Will save data in browser, if user not submit the form then next time will auto prefill last entered data"
/>
</collapse>
</editor-options-panel>
</template>
<script>
import Collapse from '../../../../common/Collapse.vue'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
import ProTag from '../../../../common/ProTag.vue'
export default {
components: { Collapse, ProTag },
components: { EditorOptionsPanel, ProTag },
props: {
},
data () {
return {
isMounted: false,
isCollapseOpen: true
isMounted: false
}
},

View File

@ -1,13 +1,9 @@
<template>
<collapse v-model="isCollapseOpen" class="p-4 w-full border-b">
<template #title>
<h3 id="v-step-0" class="font-semibold text-lg">
<svg class="h-5 w-5 inline mr-2 -mt-1 transition-colors" :class="{'text-blue-600':isCollapseOpen, 'text-gray-500':!isCollapseOpen}" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 13.3332V9.99984M10 6.6665H10.0083M18.3333 9.99984C18.3333 14.6022 14.6024 18.3332 10 18.3332C5.39763 18.3332 1.66667 14.6022 1.66667 9.99984C1.66667 5.39746 5.39763 1.6665 10 1.6665C14.6024 1.6665 18.3333 5.39746 18.3333 9.99984Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round" />
<editor-options-panel name="Information" :already-opened="true">
<template #icon>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 13.3332V9.99984M10 6.6665H10.0083M18.3333 9.99984C18.3333 14.6022 14.6024 18.3332 10 18.3332C5.39763 18.3332 1.66667 14.6022 1.66667 9.99984C1.66667 5.39746 5.39763 1.6665 10 1.6665C14.6024 1.6665 18.3333 5.39746 18.3333 9.99984Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Information
</h3>
</template>
<text-input name="title" class="mt-4"
:form="form"
@ -66,17 +62,17 @@
</div>
</div>
</modal>
</collapse>
</editor-options-panel>
</template>
<script>
import Collapse from '../../../../common/Collapse.vue'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
import SelectInput from '../../../../forms/SelectInput.vue'
import { mapState } from 'vuex'
import clonedeep from 'clone-deep'
export default {
components: { SelectInput, Collapse },
components: { SelectInput, EditorOptionsPanel },
props: {},
data () {
return {
@ -84,19 +80,18 @@ export default {
copyFormId: null,
visibilityOptions: [
{
name: 'Public',
value: 'public'
name: "Published",
value: "public"
},
{
name: "Draft (form won't be accessible)",
value: 'draft'
name: "Draft - not publicly accessible",
value: "draft"
},
{
name: 'Closed',
value: 'closed'
name: "Closed - won\'t accept new submissions",
value: "closed"
}
],
isCollapseOpen: true
]
}
},

View File

@ -1,74 +0,0 @@
<template>
<collapse class="p-4 w-full border-b" v-model="isCollapseOpen">
<template #title>
<h3 class="font-semibold text-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-2 transition-colors"
:class="{'text-blue-600':isCollapseOpen, 'text-gray-500':!isCollapseOpen}"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"/>
</svg>
Integrations
<pro-tag/>
</h3>
</template>
<text-input name="webhook_url" class="mt-4"
:form="form" help="We will post form submissions to this endpoint."
label="Webhook URL"
/>
<!-- <div>-->
<!-- <p>-->
<!-- <span class="text-uppercase font-semibold text-blue-500">NEW</span> - our Zapier integration is available for-->
<!-- beta testers!-->
<!-- </p>-->
<!-- <p class="w-full text-center mt-5">-->
<!-- <a :href="zapierUrl" target="_blank">-->
<!-- <v-button color="gray" shade="lighter">-->
<!-- <svg class="h-5 w-5 inline text-yellow-500" fill="currentColor" xmlns="http://www.w3.org/2000/svg"-->
<!-- viewBox="0 0 512 512"-->
<!-- >-->
<!-- <path-->
<!-- d="M318 256c0 19-4 36-10 52-16 7-34 10-52 10-19 0-36-3-52-9-7-17-10-34-10-53 0-18 3-36 10-52 16-6 33-10 52-10 18 0 36 4 52 10 6 16 10 34 10 52zm182-41H355l102-102c-8-11-17-22-26-32-10-9-21-18-32-26L297 157V12c-13-2-27-3-41-3s-28 1-41 3v145L113 55c-12 8-22 17-32 26-10 10-19 21-27 32l102 102H12s-3 27-3 41 1 28 3 41h144L54 399c16 23 36 43 59 59l102-102v144c13 2 27 3 41 3s28-1 41-3V356l102 102c11-8 22-17 32-27 9-10 18-20 26-32L355 297h145c2-13 3-27 3-41s-1-28-3-41z"-->
<!-- />-->
<!-- </svg>-->
<!-- Zapier Integration-->
<!-- </v-button>-->
<!-- </a>-->
<!-- </p>-->
<!-- </div>-->
</collapse>
</template>
<script>
import Collapse from '../../../../common/Collapse.vue'
import ProTag from '../../../../common/ProTag.vue'
export default {
components: {Collapse, ProTag},
props: {},
data() {
return {
isCollapseOpen: false
}
},
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)
}
},
zapierUrl: () => window.config.links.zapier_integration
},
watch: {},
mounted() {},
}
</script>

View File

@ -1,40 +1,49 @@
<template>
<collapse class="p-4 w-full border-b" v-model="isCollapseOpen">
<template #title>
<h3 id="v-step-2" class="font-semibold text-lg">
<svg class="h-5 w-5 inline mr-2 transition-colors"
:class="{'text-blue-600':isCollapseOpen, 'text-gray-500':!isCollapseOpen}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<editor-options-panel name="Notifications & Integrations" :already-opened="true" :has-pro-tag="true">
<template #icon>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6M22 6V18C22 19.1 21.1 20 20 20H4C2.9 20 2 19.1 2 18V6M22 6L12 13L2 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Notifications
<pro-tag />
</h3>
</template>
<form-notifications-option class="mt-2" />
<form-notifications-submission-confirmation />
<form-notifications-slack />
<form-notifications-discord />
<form-notifications-webhook />
<v-button color="white"
class="flex items-center mt-3 cursor-pointer relative w-full rounded-lg flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full py-2 px-4 bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100"
:href="zapierUrl" target="_blank"
>
<div class="flex-grow flex items-center">
<svg class="w-5 h-5 inline text-yellow-500" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path
d="M318 256c0 19-4 36-10 52-16 7-34 10-52 10-19 0-36-3-52-9-7-17-10-34-10-53 0-18 3-36 10-52 16-6 33-10 52-10 18 0 36 4 52 10 6 16 10 34 10 52zm182-41H355l102-102c-8-11-17-22-26-32-10-9-21-18-32-26L297 157V12c-13-2-27-3-41-3s-28 1-41 3v145L113 55c-12 8-22 17-32 26-10 10-19 21-27 32l102 102H12s-3 27-3 41 1 28 3 41h144L54 399c16 23 36 43 59 59l102-102v144c13 2 27 3 41 3s28-1 41-3V356l102 102c11-8 22-17 32-27 9-10 18-20 26-32L355 297h145c2-13 3-27 3-41s-1-28-3-41z"
/>
</svg>
<p class="flex-grow text-center font-normal">
Zapier Integration
</p>
</div>
</v-button>
</collapse>
</editor-options-panel>
</template>
<script>
import Collapse from '../../../../common/Collapse.vue'
import ProTag from '../../../../common/ProTag.vue'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
import FormNotificationsOption from './components/FormNotificationsOption.vue'
import FormNotificationsSlack from './components/FormNotificationsSlack.vue'
import FormNotificationsDiscord from './components/FormNotificationsDiscord.vue'
import FormNotificationsSubmissionConfirmation from './components/FormNotificationsSubmissionConfirmation.vue'
import FormNotificationsWebhook from './components/FormNotificationsWebhook.vue'
export default {
components: { FormNotificationsSubmissionConfirmation, FormNotificationsSlack, FormNotificationsDiscord, FormNotificationsOption, Collapse, ProTag },
components: { FormNotificationsSubmissionConfirmation, FormNotificationsSlack, FormNotificationsDiscord, FormNotificationsOption, EditorOptionsPanel, FormNotificationsWebhook },
props: {
},
data () {
return {
isCollapseOpen: true
}
},
@ -47,7 +56,8 @@ export default {
set (value) {
this.$store.commit('open/working_form/set', value)
}
}
},
zapierUrl: () => window.config.links.zapier_integration
},
watch: {

View File

@ -1,13 +1,9 @@
<template>
<collapse class="p-4 w-full border-b" v-model="isCollapseOpen">
<template #title>
<h3 id="v-step-2" class="font-semibold text-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-2 transition-colors"
:class="{'text-blue-600':isCollapseOpen, 'text-gray-500':!isCollapseOpen}"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<editor-options-panel name="Security & Privacy" :already-opened="false">
<template #icon>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg> Security & Privacy
</h3>
</svg>
</template>
<toggle-switch-input name="can_be_indexed" :form="form" class="mt-4"
label="Indexable by Google"
@ -17,23 +13,18 @@
label="Protect your form with a Captcha"
help="If enabled we will make sure respondant is a human"
/>
<text-input name="password" :form="form" class="mt-4"
label="Form Password" help="Leave empty to disable password"
/>
</collapse>
</editor-options-panel>
</template>
<script>
import Collapse from '../../../../common/Collapse.vue'
import ProTag from '../../../../common/ProTag.vue'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
export default {
components: { Collapse, ProTag },
components: { EditorOptionsPanel },
props: {
},
data () {
return {
isCollapseOpen: false
}
},
computed: {

View File

@ -1,30 +1,25 @@
<template>
<collapse class="p-4 w-full border-b" v-model="isCollapseOpen">
<template #title>
<div class="flex">
<h3 id="v-step-1" class="font-semibold block text-lg">
<svg class="h-5 w-5 inline mr-2 -mt-1 transition-colors" :class="{'text-blue-600':isCollapseOpen, 'text-gray-500':!isCollapseOpen}" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<editor-options-panel name="Form Structure" :already-opened="true">
<template #icon>
<svg class="h-5 w-5" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.8333 7.33333C14.7668 7.33333 15.2335 7.33333 15.59 7.15168C15.9036 6.99189 16.1586 6.73692 16.3183 6.42332C16.5 6.0668 16.5 5.60009 16.5 4.66667V4.16667C16.5 3.23325 16.5 2.76654 16.3183 2.41002C16.1586 2.09641 15.9036 1.84145 15.59 1.68166C15.2335 1.5 14.7668 1.5 13.8333 1.5L4.16667 1.5C3.23325 1.5 2.76654 1.5 2.41002 1.68166C2.09641 1.84144 1.84144 2.09641 1.68166 2.41002C1.5 2.76654 1.5 3.23325 1.5 4.16667L1.5 4.66667C1.5 5.60009 1.5 6.0668 1.68166 6.42332C1.84144 6.73692 2.09641 6.99189 2.41002 7.15168C2.76654 7.33333 3.23325 7.33333 4.16667 7.33333L13.8333 7.33333Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.8333 16.5C14.7668 16.5 15.2335 16.5 15.59 16.3183C15.9036 16.1586 16.1586 15.9036 16.3183 15.59C16.5 15.2335 16.5 14.7668 16.5 13.8333V13.3333C16.5 12.3999 16.5 11.9332 16.3183 11.5767C16.1586 11.2631 15.9036 11.0081 15.59 10.8483C15.2335 10.6667 14.7668 10.6667 13.8333 10.6667L4.16667 10.6667C3.23325 10.6667 2.76654 10.6667 2.41002 10.8483C2.09641 11.0081 1.84144 11.2631 1.68166 11.5767C1.5 11.9332 1.5 12.3999 1.5 13.3333L1.5 13.8333C1.5 14.7668 1.5 15.2335 1.68166 15.59C1.84144 15.9036 2.09641 16.1586 2.41002 16.3183C2.76654 16.5 3.23325 16.5 4.16667 16.5H13.8333Z" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/>
</svg> Form Structure
</h3>
</div>
</svg>
</template>
<form-fields-editor class="mt-5" />
</collapse>
</editor-options-panel>
</template>
<script>
import Collapse from '../../../../common/Collapse.vue'
import EditorOptionsPanel from '../../../editors/EditorOptionsPanel.vue'
import FormFieldsEditor from '../FormFieldsEditor.vue'
export default {
components: { Collapse, FormFieldsEditor },
components: { EditorOptionsPanel, FormFieldsEditor },
props: {
},
data () {
return {
isCollapseOpen: true
}
},

View File

@ -0,0 +1,82 @@
<template>
<div>
<button
class="flex items-center mt-3 cursor-pointer relative w-full rounded-lg flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full py-2 px-4 bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100"
@click.prevent="showModal=true"
>
<div class="flex-grow flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-5 h-5 inline"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.96.401v0a.656.656 0 00.658-.663 48.422 48.422 0 00-.37-5.36c-1.886.342-3.81.574-5.766.689a.578.578 0 01-.61-.58v0z"
/>
</svg>
<p class="flex-grow text-center">
Webhook Notifications
</p>
</div>
<div v-if="form.notifies_webhook">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-5 h-5 text-nt-blue"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</button>
<modal :show="showModal" @close="showModal=false">
<h2 class="text-2xl font-bold z-10 truncate mb-5 text-nt-blue">
Webhook Notifications
<pro-tag />
</h2>
<toggle-switch-input name="notifies_webhook" :form="form" class="mt-4"
label="Trigger a webhook notification on form submission"
@change="onToggleChange"
/>
<text-input v-if="form.notifies_webhook" name="webhook_url" :form="form" class="mt-4"
label="Webhook url" help="We will post form submissions to this endpoint"
/>
</modal>
</div>
</template>
<script>
import ProTag from '../../../../../common/ProTag.vue'
export default {
components: { ProTag },
props: {},
data () {
return {
showModal: false
}
},
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 () {
},
methods: {
onToggleChange () {
if (!this.form.notifies_webhook) {
this.form.webhook_url = ''
}
}
}
}
</script>

View File

@ -1,205 +0,0 @@
<template>
<modal :show="show" @close="close">
<div v-if="field">
<div class="flex">
<h2 class="text-2xl font-semibold z-10 truncate mb-5 text-gray-900 flex-grow">
Configure "<span class="truncate">{{ field.name }}</span>" block
</h2>
<div class="flex mr-6">
<div>
<v-button color="light-gray" size="small" @click="removeBlock">
<svg class="h-4 w-4 text-red-600 inline mr-1 -mt-1" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 6H5M5 6H21M5 6V20C5 20.5304 5.21071 21.0391 5.58579 21.4142C5.96086 21.7893 6.46957 22 7 22H17C17.5304 22 18.0391 21.7893 18.4142 21.4142C18.7893 21.0391 19 20.5304 19 20V6H5ZM8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M10 11V17M14 11V17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Remove
</v-button>
</div>
<div class="ml-1">
<v-button size="small" color="light-gray" @click="duplicateBlock">
<svg class="h-4 w-4 text-blue-600 inline mr-1 -mt-1" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 15H4C3.46957 15 2.96086 14.7893 2.58579 14.4142C2.21071 14.0391 2 13.5304 2 13V4C2 3.46957 2.21071 2.96086 2.58579 2.58579C2.96086 2.21071 3.46957 2 4 2H13C13.5304 2 14.0391 2.21071 14.4142 2.58579C14.7893 2.96086 15 3.46957 15 4V5M11 9H20C21.1046 9 22 9.89543 22 11V20C22 21.1046 21.1046 22 20 22H11C9.89543 22 9 21.1046 9 20V11C9 9.89543 9.89543 9 11 9Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Duplicate
</v-button>
</div>
</div>
</div>
<div class="-mx-4 sm:-mx-6 p-5 border-b border-t">
<div class="-mx-4 sm:-mx-6 px-5 pt-0">
<h3 class="font-semibold block text-lg">
General
</h3>
<p class="text-gray-400 mb-5 text-xs">
Exclude this field or make it required.
</p>
<v-checkbox v-model="field.hidden" class="mb-3"
:name="field.id+'_hidden'"
@update:model-value="onFieldHiddenChange"
>
Hidden
</v-checkbox>
<select-input name="width" class="mt-4"
:options="[
{name:'Full',value:'full'},
{name:'1/2 (half width)',value:'1/2'},
{name:'1/3 (a third of the width)',value:'1/3'},
{name:'2/3 (two thirds of the width)',value:'2/3'},
{name:'1/4 (a quarter of the width)',value:'1/4'},
{name:'3/4 (three quarters of the width)',value:'3/4'}
]"
:form="field" label="Field Width"
/>
<select-input v-if="['nf-text','nf-image'].includes(field.type)" name="align" class="mt-4"
:options="[
{name:'Left',value:'left'},
{name:'Center',value:'center'},
{name:'Right',value:'right'},
{name:'Justify',value:'justify'}
]"
:form="field" label="Field Alignment"
/>
</div>
</div>
<div v-if="field.type == 'nf-text'" class="-mx-4 sm:-mx-6 p-5 border-b border-t">
<rich-text-area-input name="content"
:form="field"
label="Content"
:required="false"
/>
</div>
<div v-else-if="field.type == 'nf-page-break'" class="-mx-4 sm:-mx-6 p-5 border-b border-t">
<text-input name="next_btn_text"
:form="field"
label="Text of next button"
:required="true"
/>
<text-input name="previous_btn_text"
:form="field"
label="Text of previous button"
help="Shown on the next page"
:required="true"
/>
</div>
<div v-else-if="field.type == 'nf-divider'" class="-mx-4 sm:-mx-6 p-5 border-b border-t">
<text-input name="name" class="mt-4"
:form="field" :required="true"
label="Field Name"
/>
</div>
<div v-else-if="field.type == 'nf-image'" class="-mx-4 sm:-mx-6 p-5 border-b border-t">
<text-input name="name" class="mt-4"
:form="field" :required="true"
label="Field Name"
/>
<image-input name="image_block" class="mt-4"
:form="field" label="Upload Image" :required="false"
/>
</div>
<div v-else-if="field.type == 'nf-code'" class="-mx-4 sm:-mx-6 p-5 border-b border-t">
<code-input name="content" class="mt-4 h-36" :form="field" label="Content"
help="You can add any html code, including iframes"
/>
</div>
<div v-else class="-mx-4 sm:-mx-6 p-5 border-b border-t">
<p>No settings found.</p>
</div>
<!-- Logic Block -->
<form-block-logic-editor v-model="form" :form="form" :field="field" />
<div class="pt-5 flex justify-end">
<v-button color="white" @click="close">
Close
</v-button>
</div>
</div>
<div v-else class="text-center p-10">
Field not found.
</div>
</modal>
</template>
<script>
import ProTag from '../../../common/ProTag.vue'
import CodeInput from '../../../forms/CodeInput.vue'
const FormBlockLogicEditor = () => import('../components/form-logic-components/FormBlockLogicEditor.vue')
export default {
name: 'FormBlockOptionsModal',
components: { ProTag, FormBlockLogicEditor, CodeInput },
props: {
field: {
type: Object,
required: false
},
form: {
type: Object,
required: false
},
show: {
type: Boolean,
required: false
}
},
data () {
return {
editorToolbarCustom: [
['bold', 'italic', 'underline', 'link']
]
}
},
computed: {},
watch: {
'field.width': {
handler (val) {
if (val === undefined || val === null) {
this.field.width = 'full'
}
},
immediate: true
},
'field.align': {
handler (val) {
if (val === undefined || val === null) {
this.field.align = 'left'
}
},
immediate: true
}
},
mounted () {
},
methods: {
close () {
this.$emit('close')
},
removeBlock () {
this.close()
this.$emit('remove-block', this.field)
},
duplicateBlock () {
this.close()
this.$emit('duplicate-block', this.field)
},
onFieldHiddenChange (val) {
this.field.hidden = val
if (this.field.hidden) {
this.field.required = false
}
},
onFieldHelpPositionChange (val) {
if (!val) {
this.field.help_position = 'below_input'
}
}
}
}
</script>

View File

@ -1,584 +0,0 @@
<template>
<modal :show="show" @close="close">
<div v-if="field">
<div class="flex">
<h2 class="text-2xl font-semibold z-10 truncate mb-5 text-gray-900 flex-grow">
Configure "<span class="truncate">{{ field.name }}</span>" block
</h2>
<div class="flex flex-wrap justify-end mr-6">
<div>
<v-button color="light-gray" size="small" @click="removeBlock">
<svg class="h-4 w-4 text-red-600 inline mr-1 -mt-1" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 6H5M5 6H21M5 6V20C5 20.5304 5.21071 21.0391 5.58579 21.4142C5.96086 21.7893 6.46957 22 7 22H17C17.5304 22 18.0391 21.7893 18.4142 21.4142C18.7893 21.0391 19 20.5304 19 20V6H5ZM8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M10 11V17M14 11V17"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
Remove
</v-button>
</div>
<div class="ml-1">
<v-button size="small" color="light-gray" @click="duplicateBlock">
<svg class="h-4 w-4 text-blue-600 inline mr-1 -mt-1" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 15H4C3.46957 15 2.96086 14.7893 2.58579 14.4142C2.21071 14.0391 2 13.5304 2 13V4C2 3.46957 2.21071 2.96086 2.58579 2.58579C2.96086 2.21071 3.46957 2 4 2H13C13.5304 2 14.0391 2.21071 14.4142 2.58579C14.7893 2.96086 15 3.46957 15 4V5M11 9H20C21.1046 9 22 9.89543 22 11V20C22 21.1046 21.1046 22 20 22H11C9.89543 22 9 21.1046 9 20V11C9 9.89543 9.89543 9 11 9Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
Duplicate
</v-button>
</div>
<change-field-type class="my-1" :field="field" @changeType="onChangeType" />
</div>
</div>
<!-- General -->
<div class="-mx-4 sm:-mx-6 p-5 border-b border-t">
<h3 class="font-semibold block text-lg">
General
</h3>
<p class="text-gray-400 mb-5 text-xs">
Exclude this field or make it required.
</p>
<v-checkbox v-model="field.hidden" class="mb-3"
:name="field.id+'_hidden'"
@update:model-value="onFieldHiddenChange"
>
Hidden
</v-checkbox>
<v-checkbox v-model="field.required" class="mb-3"
:name="field.id+'_required'"
@update:model-value="onFieldRequiredChange"
>
Required
</v-checkbox>
<v-checkbox v-model="field.disabled" class="mb-3"
:name="field.id+'_disabled'"
@update:model-value="onFieldDisabledChange"
>
Disabled
</v-checkbox>
</div>
<!-- Checkbox -->
<div v-if="field.type === 'checkbox'" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
Checkbox
</h3>
<p class="text-gray-400 mb-5 text-xs">
Advanced options for checkbox.
</p>
<v-checkbox v-model="field.use_toggle_switch" class="mt-4"
name="use_toggle_switch" help=""
>
Use toggle switch
</v-checkbox>
<p class="text-gray-400 mb-5 text-xs">
If enabled, checkbox will be replaced with a toggle switch
</p>
</div>
<!-- File Uploads -->
<div v-if="field.type === 'files'" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
File uploads
</h3>
<v-checkbox v-model="field.multiple" class="mt-4"
:name="field.id+'_multiple'"
>
Allow multiple files
</v-checkbox>
<text-input name="allowed_file_types" class="mt-4" :form="field"
label="Allowed file types" placeholder="jpg,jpeg,png,gif"
help="Comma separated values, leave blank to allow all file types"
/>
</div>
<!-- Number Options -->
<div v-if="field.type === 'number'" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
Number Options
</h3>
<v-checkbox v-model="field.is_rating" class="mt-4"
:name="field.id+'_is_rating'" @update:model-value="initRating"
>
Rating
</v-checkbox>
<p class="text-gray-400 mb-5 text-xs">
If enabled then this field will be star rating input.
</p>
<text-input v-if="field.is_rating" name="rating_max_value" native-type="number" :min="1" class="mt-4"
:form="field" required
label="Max rating value"
/>
</div>
<!-- Text Options -->
<div v-if="field.type === 'text' && displayBasedOnAdvanced" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
Text Options
</h3>
<p class="text-gray-400 mb-5 text-xs">
Keep it simple or make it a multi-lines input.
</p>
<v-checkbox v-model="field.multi_lines"
:name="field.id+'_multi_lines'"
@input="field.multi_lines = $event"
>
Multi-lines input
</v-checkbox>
</div>
<!-- Date Options -->
<div v-if="field.type === 'date'" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
Date Options
</h3>
<v-checkbox v-model="field.date_range" class="mt-4"
:name="field.id+'_date_range'"
@update:model-value="onFieldDateRangeChange"
>
Date Range
</v-checkbox>
<p class="text-gray-400 mb-5 text-xs">
Adds an end date. This cannot be used with the time option yet.
</p>
<v-checkbox v-model="field.with_time"
:name="field.id+'_with_time'"
@update:model-value="onFieldWithTimeChange"
>
Date with time
</v-checkbox>
<p class="text-gray-400 mb-5 text-xs">
Include time. Or not. This cannot be used with the date range option yet.
</p>
<select-input v-if="field.with_time" name="timezone" class="mt-4"
:form="field" :options="timezonesOptions"
label="Timezone" :searchable="true"
help="Make sure to select correct timezone. Leave blank otherwise."
/>
<v-checkbox v-model="field.prefill_today"
name="prefill_today"
@update:model-value="onFieldPrefillTodayChange"
>
Prefill with 'today'
</v-checkbox>
<p class="text-gray-400 mb-5 text-xs">
if enabled we will pre-fill this field with the current date
</p>
<v-checkbox v-model="field.disable_past_dates"
name="disable_past_dates" class="mb-3"
@update:model-value="onFieldDisablePastDatesChange"
>
Disable past dates
</v-checkbox>
<v-checkbox v-model="field.disable_future_dates"
name="disable_future_dates" class="mb-3"
@update:model-value="onFieldDisableFutureDatesChange"
>
Disable future dates
</v-checkbox>
</div>
<!-- select/multiselect Options -->
<div v-if="['select','multi_select'].includes(field.type)" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
Select Options
</h3>
<p class="text-gray-400 mb-5 text-xs">
Advanced options for your select/multiselect fields.
</p>
<text-area-input v-model="optionsText" :name="field.id+'_options_text'" class="mt-4"
label="Set selection options"
help="Add one option per line"
@input="onFieldOptionsChange"
/>
<v-checkbox v-model="field.allow_creation"
name="allow_creation" help="" @update:model-value="onFieldAllowCreationChange"
>
Allow respondent to create new options
</v-checkbox>
<v-checkbox v-model="field.without_dropdown" class="mt-4"
name="without_dropdown" help="" @update:model-value="onFieldWithoutDropdownChange"
>
Always show all select options
</v-checkbox>
<p class="text-gray-400 mb-5 text-xs">
Options won't be in a dropdown anymore, but will all be visible
</p>
</div>
<!-- Customization - Placeholder, Prefill, Relabel, Field Help -->
<div v-if="displayBasedOnAdvanced" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
Customization
</h3>
<p class="text-gray-400 mb-5 text-xs">
Change your form field name, pre-fill a value, add hints, etc.
</p>
<text-input name="name" class="mt-4"
:form="field" :required="true"
label="Field Name"
/>
<v-checkbox v-model="field.hide_field_name" class="mb-3"
:name="field.id+'_hide_field_name'"
>
Hide field name
</v-checkbox>
<!-- Pre-fill depends on type -->
<v-checkbox v-if="field.type=='checkbox'" v-model="field.prefill" class="mt-4"
:name="field.id+'_prefill'"
@update:model-value="field.prefill = $event"
>
Pre-filled value
</v-checkbox>
<select-input v-else-if="['select','multi_select'].includes(field.type)" name="prefill" class="mt-4"
:form="field" :options="prefillSelectsOptions"
label="Pre-filled value"
:multiple="field.type==='multi_select'"
/>
<date-input v-else-if="field.type==='date' && field.prefill_today!==true" name="prefill" class="mt-4"
:form="field" :with-time="field.with_time===true"
:date-range="field.date_range===true"
label="Pre-filled value"
/>
<text-area-input v-else-if="field.type === 'text' && field.multi_lines"
name="prefill" class="mt-4"
:form="field"
label="Pre-filled value"
/>
<text-input v-else-if="field.type!=='files'" name="prefill" class="mt-4"
:form="field"
label="Pre-filled value"
:disabled="field.type==='date' && field.prefill_today===true"
/>
<div v-if="['select','multi_select'].includes(field.type)" class="-mt-3 mb-3 text-gray-400 dark:text-gray-500">
<small>
A problem? <a href="#" @click.prevent="field.prefill=null">Click here to clear your pre-fill</a>
</small>
</div>
<!-- Placeholder -->
<text-input v-if="hasPlaceholder" name="placeholder" class="mt-4"
:form="field"
label="Empty Input Text (Placeholder)"
/>
<select-input name="width" class="mt-4"
:options="[
{name:'Full',value:'full'},
{name:'1/2 (half width)',value:'1/2'},
{name:'1/3 (a third of the width)',value:'1/3'},
{name:'2/3 (two thirds of the width)',value:'2/3'},
{name:'1/4 (a quarter of the width)',value:'1/4'},
{name:'3/4 (three quarters of the width)',value:'3/4'},
]"
:form="field" label="Field Width"
/>
<!-- Help -->
<rich-text-area-input name="help" class="mt-4"
:form="field"
:editor-toolbar="editorToolbarCustom"
label="Field Help"
help="Your field help will be shown below/above the field, just like this message."
:help-position="field.help_position"
/>
<select-input name="help_position" class="mt-4"
:options="[
{name:'Below input',value:'below_input'},
{name:'Above input',value:'above_input'},
]"
:form="field" label="Field Help Position"
@input="onFieldHelpPositionChange"
/>
<template v-if="['text','number','url','email','phone_number'].includes(field.type)">
<text-input v-model="field.max_char_limit" name="max_char_limit" native-type="number" :min="1" :max="2000"
:form="field"
label="Max character limit"
help="Maximum character limit of 2000"
:required="false"
/>
<checkbox-input name="show_char_limit" :form="field" class="mt-4"
label="Always show character limit"
/>
</template>
</div>
<!-- Advanced Options -->
<div v-if="field.type === 'text'" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
Advanced Options
</h3>
<v-checkbox v-model="field.generates_uuid"
:name="field.id+'_generates_uuid'"
@update:model-value="onFieldGenUIdChange"
>
Generates a unique id on submission
</v-checkbox>
<p class="text-gray-400 mb-5 text-xs">
If you enable this, we will hide this field and fill it a unique id (UUID format) on each new form submission
</p>
<v-checkbox v-model="field.generates_auto_increment_id"
:name="field.id+'_generates_auto_increment_id'"
@update:model-value="onFieldGenAutoIdChange"
>
Generates an auto-incremented id on submission
</v-checkbox>
<p class="text-gray-400 mb-5 text-xs">
If you enable this, we will hide this field and fill it a unique number on each new form submission
</p>
</div>
<!-- Logic Block -->
<form-block-logic-editor v-model="form" :form="form" :field="field" />
<div class="pt-5 flex justify-end">
<v-button color="white" @click="close">
Close
</v-button>
</div>
</div>
<div v-else class="text-center p-10">
Field not found.
</div>
</modal>
</template>
<script>
import timezones from '../../../../../data/timezones.json'
import ProTag from '../../../common/ProTag.vue'
import ChangeFieldType from './components/ChangeFieldType.vue'
const FormBlockLogicEditor = () => import('../components/form-logic-components/FormBlockLogicEditor.vue')
export default {
name: 'FormFieldOptionsModal',
components: { ProTag, FormBlockLogicEditor, ChangeFieldType },
props: {
field: {
type: Object,
required: false
},
form: {
type: Object,
required: false
},
show: {
type: Boolean,
required: false
}
},
data () {
return {
typesWithoutPlaceholder: ['date', 'checkbox', 'files'],
editorToolbarCustom: [
['bold', 'italic', 'underline', 'link']
]
}
},
computed: {
hasPlaceholder () {
return !this.typesWithoutPlaceholder.includes(this.field.type)
},
prefillSelectsOptions () {
if (!['select', 'multi_select'].includes(this.field.type)) return {}
return this.field[this.field.type].options.map(option => {
return {
name: option.name,
value: option.id
}
})
},
timezonesOptions () {
if (this.field.type !== 'date') return []
return timezones.map((timezone) => {
return {
name: timezone.text,
value: timezone.utc[0]
}
})
},
displayBasedOnAdvanced () {
if (this.field.generates_uuid || this.field.generates_auto_increment_id) {
return false
}
return true
},
optionsText () {
return this.field[this.field.type].options.map(option => {
return option.name
}).join('\n')
}
},
watch: {
'field.width': {
handler (val) {
if (val === undefined || val === null) {
this.field.width = 'full'
}
},
immediate: true
}
},
created () {
if (this.field.width === undefined || this.field.width === null) {
this.field.width = 'full'
}
},
mounted () {
if (['text', 'number', 'url', 'email', 'phone_number'].includes(this.field.type) && !this.field.max_char_limit) {
this.field.max_char_limit = 2000
}
},
methods: {
onChangeType (newType) {
if (['select', 'multi_select'].includes(this.field.type)) {
this.field[newType] = this.field[this.field.type] // Set new options with new type
delete this.field[this.field.type] // remove old type options
}
this.field.type = newType
},
close () {
this.$emit('close')
},
removeBlock () {
this.close()
this.$emit('remove-block', this.field)
},
duplicateBlock () {
this.close()
this.$emit('duplicate-block', this.field)
},
onFieldDisabledChange (val) {
this.field.disabled = val
if (this.field.disabled) {
this.field.hidden = false
}
},
onFieldRequiredChange (val) {
this.field.required = val
if (this.field.required) {
this.field.hidden = false
}
},
onFieldHiddenChange (val) {
this.field.hidden = val
if (this.field.hidden) {
this.field.required = false
this.field.disabled = false
} else {
this.field.generates_uuid = false
this.field.generates_auto_increment_id = false
}
},
onFieldDateRangeChange (val) {
this.field.date_range = val
if (this.field.date_range) {
this.field.with_time = false
this.field.prefill_today = false
}
},
onFieldWithTimeChange (val) {
this.field.with_time = val
if (this.field.with_time) {
this.field.date_range = false
}
},
onFieldGenUIdChange (val) {
this.field.generates_uuid = val
if (this.field.generates_uuid) {
this.field.generates_auto_increment_id = false
this.field.hidden = true
}
},
onFieldGenAutoIdChange (val) {
this.field.generates_auto_increment_id = val
if (this.field.generates_auto_increment_id) {
this.field.generates_uuid = false
this.field.hidden = true
}
},
initRating () {
if (this.field.is_rating && !this.field.rating_max_value) {
this.field.rating_max_value = 5
}
},
onFieldOptionsChange (val) {
const vals = (val) ? val.trim().split('\n') : []
const tmpOpts = vals.map(name => {
return {
name: name,
id: name
}
})
this.field[this.field.type] = { options: tmpOpts }
},
onFieldPrefillTodayChange (val) {
this.field.prefill_today = val
if (this.field.prefill_today) {
this.field.prefill = 'Pre-filled with current date'
this.field.date_range = false
this.field.disable_future_dates = false
this.field.disable_past_dates = false
} else {
this.field.prefill = null
}
},
onFieldAllowCreationChange (val) {
this.field.allow_creation = val
if (this.field.allow_creation) {
this.field.without_dropdown = false
}
},
onFieldWithoutDropdownChange (val) {
this.field.without_dropdown = val
if (this.field.without_dropdown) {
this.field.allow_creation = false
}
},
onFieldDisablePastDatesChange (val) {
this.field.disable_past_dates = val
if (this.field.disable_past_dates) {
this.field.disable_future_dates = false
this.field.prefill_today = false
}
},
onFieldDisableFutureDatesChange (val) {
this.field.disable_future_dates = val
if (this.field.disable_future_dates) {
this.field.disable_past_dates = false
this.field.prefill_today = false
}
},
onFieldHelpPositionChange (val) {
if (!val) {
this.field.help_position = 'below_input'
}
}
}
}
</script>

View File

@ -73,13 +73,36 @@
Rating
</v-checkbox>
<p class="text-gray-400 mb-3 text-xs">
If enabled then this field will be star rating input.
Transform this field into a star rating input.
</p>
<text-input v-if="field.is_rating" name="rating_max_value" native-type="number" :min="1" class="mt-3"
:form="field" required
label="Max rating value"
/>
<v-checkbox v-model="field.is_scale" class="mt-4"
:name="field.id+'_is_scale'" @input="initScale"
>
Scale
</v-checkbox>
<p class="text-gray-400 text-xs mb-5">
Transform this field into a scale/score input.
</p>
<template v-if="field.is_scale">
<text-input name="scale_min_value" native-type="number" class="mt-4"
:form="field" required
label="Min scale value"
/>
<text-input name="scale_max_value" native-type="number" :min="1" class="mt-4"
:form="field" required
label="Max scale value"
/>
<text-input name="scale_step_value" native-type="number" :min="1" class="mt-4"
:form="field" required
label="Scale step svalue"
/>
</template>
</div>
<!-- Text Options -->
@ -267,7 +290,12 @@
:form="field"
label="Pre-filled value"
/>
<text-input v-else-if="field.type!=='files'" name="prefill" class="mt-3"
<file-input v-else-if="field.type==='files'" name="prefill" class="mt-4"
:form="field"
label="Pre-filled file"
:multiple="field.multiple===true" :moveToFormAssets="true"
/>
<text-input v-else-if="!['files', 'signature'].includes(field.type)" name="prefill" class="mt-3"
:form="field"
label="Pre-filled value"
/>
@ -505,9 +533,26 @@ export default {
this.field.hidden = true
}
},
initRating () {
if (this.field.is_rating && !this.field.rating_max_value) {
this.field.rating_max_value = 5
initRating() {
if (this.field.is_rating) {
this.$set(this.field, 'is_scale', false)
if (!this.field.rating_max_value) {
this.$set(this.field, 'rating_max_value', 5)
}
}
},
initScale () {
if (this.field.is_scale) {
this.$set(this.field, 'is_rating', false)
if (!this.field.scale_min_value) {
this.$set(this.field, 'scale_min_value', 1)
}
if (!this.field.scale_max_value) {
this.$set(this.field, 'scale_max_value', 5)
}
if (!this.field.scale_step_value) {
this.$set(this.field, 'scale_step_value', 1)
}
}
},
onFieldOptionsChange (val) {

View File

@ -0,0 +1,37 @@
<template>
<div class="border relative max-w-5xl mx-auto mt-4 lg:mt-10">
<div class="w-full">
<div class="rounded-lg bg-gray-50 dark:bg-gray-800 px-6 py-8 sm:p-10 lg:flex lg:items-center">
<div class="flex-1">
<h3 class="inline-flex px-4 py-1 rounded-full text-md font-bold tracking-wide uppercase bg-white text-gray-800">Custom plan</h3>
<div class="mt-4 text-md text-gray-600 dark:text-gray-400">Get a custom file upload limit, enterprise-level support, custom contract, payment via invoice/PO etc.</div>
</div>
<div class="mt-6 rounded-md lg:mt-0 lg:ml-10 lg:flex-shrink-0">
<v-button color="white" class="w-full mt-4" @click.prevent="customPlanClick">
Contact us
</v-button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'CustomPlan',
components: {},
props: {},
data: () => ({}),
computed: {},
methods: {
customPlanClick () {
window.$crisp.push(['do', 'chat:show'])
window.$crisp.push(['do', 'chat:open'])
window.$crisp.push(['do', 'message:send', ['text', 'Hi, I would like to discuss about a custom plan']])
}
}
}
</script>

View File

@ -98,6 +98,8 @@
</div>
</section>
<custom-plan v-if="!homePage" />
<checkout-details-modal :show="showDetailsModal" :yearly="isYearly" :plan="selectedPlan"
@close="showDetailsModal=false"
/>
@ -109,13 +111,15 @@ import { mapGetters } from 'vuex'
import axios from 'axios'
import MonthlyYearlySelector from './MonthlyYearlySelector.vue'
import CheckoutDetailsModal from './CheckoutDetailsModal.vue'
import CustomPlan from './CustomPlan.vue'
MonthlyYearlySelector.compatConfig = { MODE: 3 }
export default {
components: {
MonthlyYearlySelector,
CheckoutDetailsModal
CheckoutDetailsModal,
CustomPlan
},
props: {
homePage: {

View File

@ -1,4 +1,5 @@
<template>
<div>
<section class="bg-white py-12">
<div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 sm:gap-6 relative z-20">
@ -32,6 +33,49 @@
</div>
</div>
</section>
<template v-if="!onlyMy">
<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="flex items-center justify-between">
<h4 class="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
All Types
</h4>
</div>
<div class="grid grid-cols-1 gap-8 mt-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<router-link v-for="row in types" :key="row.slug"
:to="{params:{slug:row.slug}, name:'templates.types.show'}"
:title="row.name"
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
>
{{ row.name }}
</router-link>
</div>
</div>
</section>
<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="flex items-center justify-between">
<h4 class="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
All Industries
</h4>
</div>
<div class="grid grid-cols-1 gap-8 mt-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<router-link v-for="row in industries" :key="row.slug"
:to="{params:{slug:row.slug}, name:'templates.industries.show'}"
:title="row.name"
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
>
{{ row.name }}
</router-link>
</div>
</div>
</section>
</template>
</div>
</template>
<script>
@ -41,11 +85,12 @@ import Form from 'vform'
import Fuse from 'fuse.js'
import SingleTemplate from './SingleTemplate.vue'
const loadTemplates = function () {
store.commit('open/templates/startLoading')
store.dispatch('open/templates/loadIfEmpty').then(() => {
store.commit('open/templates/stopLoading')
})
const loadTemplates = function (onlyMy) {
if(onlyMy){
store.dispatch('open/templates/loadAll', {'onlymy':true})
} else {
store.dispatch('open/templates/loadIfEmpty')
}
}
export default {
@ -70,7 +115,7 @@ export default {
watch: {},
mounted () {
loadTemplates()
loadTemplates(this.onlyMy)
},
computed: {

View File

@ -27,7 +27,7 @@
class="w-full inline-flex flex-nowrap overflow-hidden [mask-image:_linear-gradient(to_right,transparent_0,_black_128px,_black_calc(100%-128px),transparent_100%)]"
>
<ul ref="templates-slider" class="flex justify-center md:justify-start animate-infinite-scroll">
<li v-for="(template, i) in templates" :key="template.id" class="mx-4 w-72 h-auto">
<li v-for="(template, i) in sliderTemplates" :key="template.id" class="mx-4 w-72 h-auto">
<single-template :slug="template.slug" />
</li>
</ul>
@ -48,7 +48,10 @@ export default {
computed: {
...mapState({
templates: state => state['open/templates'].content
})
}),
sliderTemplates () {
return this.templates.slice(0, 20)
}
},
watch: {
@ -62,8 +65,8 @@ export default {
}
},
mounted () {
store.dispatch('open/templates/loadWithLimit', 10)
mounted() {
store.dispatch('open/templates/loadAll', { limit: 20 })
},
methods: {

View File

@ -0,0 +1,77 @@
<template>
<div v-if="user.active_license" class="border p-5 shadow-md rounded-md">
<div class="w-auto flex flex-col items-center">
<img :src="asset('img/appsumo/as-taco-white-bg.png')" class="max-w-[60px]" alt="AppSumo">
<img :src="asset('img/appsumo/as-Select-dark.png')" class="max-w-[150px]" alt="AppSumo">
</div>
<p class="mt-6">
Your AppSumo <span class="font-semibold">lifetime deal tier {{ licenseTier }}</span> license is active. Here's a reminder of your plan details:
</p>
<ul class="list-disc pl-5 mt-4">
<li>Number of Forms: <span class="font-semibold">{{ tierFeatures.form_quantity }}</span></li>
<li>Custom domains: <span class="font-semibold">{{ tierFeatures.domain_names }}</span></li>
<li>File Size Uploads: <span class="font-semibold">{{ tierFeatures.file_upload_size }}</span></li>
</ul>
<div class="w-max">
<v-button color="outline-gray" shade="lighter" class="mt-4 block" href="https://appsumo.com/account/products/" target="_blank">
Mangage in AppSumo
</v-button>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import VButton from '../../common/Button.vue'
export default {
name: 'AppSumoBilling',
components: { VButton },
data () {
return {
}
},
computed: {
...mapGetters({
user: 'auth/user'
}),
licenseTier () {
return this.user?.active_license?.meta?.tier
},
tierFeatures () {
if (!this.licenseTier) return {}
return {
1: {
form_quantity: 'Unlimited',
file_upload_size: '25mb',
domain_names: '5'
},
2: {
form_quantity: 'Unlimited',
file_upload_size: '50mb',
domain_names: '25'
},
3: {
form_quantity: 'Unlimited',
file_upload_size: '75mb',
domain_names: 'Unlimited'
}
}[this.licenseTier]
}
},
watch: {},
mounted () {},
created () {
},
destroyed () {
},
methods: {}
}
</script>

View File

@ -0,0 +1,50 @@
<template>
<div v-if="hasValidLicense" class="p-6 bg-white border shadow-md rounded-md">
<img :src="asset('img/appsumo/as-taco-white-bg.png')" class="max-w-[60px] mx-auto" alt="AppSumo">
<img :src="asset('img/appsumo/as-Select-dark.png')" class="max-w-[300px] mx-auto" alt="AppSumo">
<p class="mt-6">
<span class="text-green-500">We found your AppSumo Lifetime deal license!</span> Just complete the registration form to finalize the activation of
your license.
</p>
</div>
<div v-else-if="hasLicenseError" class="p-6 bg-white border border-red-500 shadow-md rounded-md">
<img :src="asset('img/appsumo/as-taco-white-bg.png')" class="max-w-[60px] mx-auto" alt="AppSumo">
<img :src="asset('img/appsumo/as-Select-dark.png')" class="max-w-[300px] mx-auto" alt="AppSumo">
<p class="mt-6">
<span class="text-red-600">Invalid AppSumo license</span>. The license was probably already attached to an OpnForm account. Please contact support.
</p>
</div>
</template>
<script>
export default {
name: 'AppSumoRegister',
data () {
return {
hasValidLicense: false,
hasLicenseError: false
}
},
computed: {},
watch: {},
mounted () {
if (this.$route.query.appsumo_license !== undefined && this.$route.query.appsumo_license) {
this.hasValidLicense = true
} else if (this.$route.query.appsumo_error !== undefined && this.$route.query.appsumo_error) {
this.hasLicenseError = true
}
},
created () {
},
destroyed () {
},
methods: {}
}
</script>

View File

@ -25,6 +25,20 @@ export const themes = {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
input: 'relative w-full rounded-lg border-gray-300 flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full px-4 bg-white text-gray-700 placeholder-gray-400 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-600 shadow-sm text-base focus:outline-none focus:ring-2 focus:border-transparent',
help: 'text-gray-400 dark:text-gray-500'
},
ScaleInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
button: 'cursor-pointer text-gray-700 inline-block rounded-lg border-gray-300 px-4 py-2 flex-grow dark:bg-notion-dark-light dark:text-gray-300 text-center',
unselectedButton: 'bg-white hover:bg-gray-50 border',
help: 'text-gray-400 dark:text-gray-500'
},
fileInput: {
input: 'min-h-40 border border-dashed border-gray-300 p-4 rounded-lg',
inputHover: {
light: 'bg-neutral-50',
dark: 'bg-notion-dark-light'
},
uploadedFile: 'border border-gray-300 dark:border-gray-600 bg-white dark:bg-notion-dark-light rounded-lg shadow-sm max-w-[10rem]'
}
},
simple: {
@ -50,6 +64,20 @@ export const themes = {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
input: 'border-transparent flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 text-base focus:outline-none focus:ring-1 focus:ring-opacity-100 focus:border-transparent focus:ring-2',
help: 'text-gray-400 dark:text-gray-500'
},
ScaleInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
button: 'flex-1 appearance-none border-gray-300 dark:border-gray-600 w-full py-2 px-2 bg-gray-50 text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 text-center',
unselectedButton: 'bg-white hover:bg-gray-50 border -mx-4',
help: 'text-gray-400 dark:text-gray-500'
},
fileInput: {
input: 'min-h-40 border border-dashed border-gray-300 p-4',
inputHover: {
light: 'bg-neutral-50',
dark: 'bg-notion-dark-light'
},
uploadedFile: 'border border-gray-300 dark:border-gray-600 bg-white dark:bg-notion-dark-light shadow-sm max-w-[10rem]'
}
},
notion: {
@ -75,6 +103,20 @@ export const themes = {
label: 'text-gray-900 dark:text-gray-100 mb-2 block mt-4',
input: 'rounded border-transparent flex-1 appearance-none shadow-inner-notion border border-gray-300 dark:border-gray-600 w-full text-gray-900 bg-notion-input-background dark:bg-notion-dark-light shadow-inner dark:placeholder-gray-500 placeholder-gray-400 text-base focus:outline-none focus:ring-0 focus:ring-opacity-100 focus:border-transparent focus:ring-0 focus:shadow-focus-notion',
help: 'text-notion-input-help dark:text-gray-500'
},
ScaleInput: {
label: 'text-gray-900 dark:text-gray-100 mb-2 block mt-4',
button: 'rounded border-transparent flex-1 appearance-none shadow-inner-notion w-full py-2 px-2 bg-notion-input-background dark:bg-notion-dark-light text-gray-900 dark:text-gray-100 text-center',
unselectedButton: 'bg-notion-input-background dark:bg-notion-dark-light hover:bg-gray-50 border',
help: 'text-notion-input-help dark:text-gray-500'
},
fileInput: {
input: 'min-h-40 border border-dashed border-gray-300 p-4 rounded bg-notion-input-background',
inputHover: {
light: 'bg-neutral-50',
dark: 'bg-notion-dark-light'
},
uploadedFile: 'border border-gray-300 dark:border-gray-600 bg-white dark:bg-notion-dark-light rounded shadow-sm max-w-[10rem]'
}
}

View File

@ -43,25 +43,17 @@
Log In
</router-link>
</p>
<!-- GitHub Register Button -->
<login-with-github />
</form>
</div>
</template>
<script>
import Form from 'vform'
import LoginWithGithub from '~/components/LoginWithGithub.vue'
import SelectInput from '../../../components/forms/SelectInput.vue'
import { initCrisp } from '../../../middleware/check-auth.js'
export default {
name: 'RegisterForm',
components: {
SelectInput,
LoginWithGithub
},
components: {},
props: {
isQuick: {
type: Boolean,
@ -76,7 +68,8 @@ export default {
email: '',
password: '',
password_confirmation: '',
agree_terms: false
agree_terms: false,
appsumo_license: null
}),
mustVerifyEmail: false
}),
@ -99,6 +92,13 @@ export default {
}
},
mounted () {
// Set appsumo license
if (this.$route.query.appsumo_license !== undefined && this.$route.query.appsumo_license) {
this.form.appsumo_license = this.$route.query.appsumo_license
}
},
methods: {
async register () {
// Register the user.
@ -123,6 +123,15 @@ export default {
initCrisp(data)
this.$crisp.push(['set', 'session:event', [[['register', {}, 'blue']]]])
// AppSumo License
if (data.appsumo_license === false) {
this.alertError('Invalid AppSumo license. This probably happened because this license was already' +
' attached to another OpnForm account. Please contact support.')
} else if (data.appsumo_license === true) {
this.alertSuccess('Your AppSumo license was successfully activated! You now have access to all the' +
' features of the AppSumo deal.')
}
// Redirect
if (this.isQuick) {
this.$emit('afterQuickLogin')

View File

@ -3,16 +3,17 @@
<div class="flex mt-6 mb-10">
<div class="w-full md:max-w-6xl mx-auto px-4 flex items-center md:flex-row-reverse flex-wrap">
<div class="w-full lg:w-1/2 md:p-6">
<app-sumo-register class="mb-10 p-6 lg:hidden" />
<div class="border rounded-md p-6 shadow-md sticky top-4">
<h2 class="font-semibold text-2xl">
Create an account
</h2>
<small>Sign up in less than 2 minutes.</small>
<register-form />
</div>
</div>
<div class="w-full hidden lg:block lg:w-1/2 md:p-6 mt-8 md:mt-0 ">
<app-sumo-register class="mb-10" />
<h1 class="font-bold">
Create beautiful forms and share them anywhere
</h1>
@ -45,9 +46,9 @@
Unlimited submissions
</p>
</div>
<!-- <div class="mt-3 p-6">-->
<!-- <testimonials />-->
<!-- </div>-->
<!-- <div class="mt-3 p-6">-->
<!-- <testimonials />-->
<!-- </div>-->
</div>
</div>
</div>
@ -57,29 +58,27 @@
<script>
import OpenFormFooter from '../../components/pages/OpenFormFooter.vue'
import Testimonials from '../../components/pages/welcome/Testimonials.vue'
import RegisterForm from './components/RegisterForm.vue'
import SeoMeta from '../../mixins/seo-meta.js'
import AppSumoRegister from '../../components/vendor/appsumo/AppSumoRegister.vue'
export default {
components: {
Testimonials,
AppSumoRegister,
OpenFormFooter,
RegisterForm
},
middleware: 'guest',
mixins: [SeoMeta],
middleware: 'guest',
data: () => ({
metaTitle: 'Register',
metaTitle: 'Register'
}),
computed: {
},
computed: {},
methods: {
}
methods: {}
}
</script>

View File

@ -49,14 +49,17 @@
<span class="pr-1">- {{ form.submissions_count }}
submission{{ form.submissions_count > 0 ? 's' : '' }}
</span>
<span class="pr-1 text-blue-500" v-if="form.visibility=='closed'">- Closed</span>
<span class="">- Edited {{ form.last_edited_human }}</span>
<span>- Edited {{ form.last_edited_human }}</span>
</p>
<div v-if="form.visibility=='draft' || (form.tags && form.tags.length > 0)" class="mt-2 flex items-center flex-wrap gap-3">
<div v-if="['draft','closed'].includes(form.visibility) || (form.tags && form.tags.length > 0)" class="mt-2 flex items-center flex-wrap gap-3">
<span v-if="form.visibility=='draft'"
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700">
Draft - not publicly accessible
</span>
<span v-else-if="form.visibility=='closed'"
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700">
Closed - won't accept new submissions
</span>
<span v-for="(tag,i) in form.tags" :key="tag"
class="inline-flex items-center rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
>

View File

@ -73,12 +73,15 @@
Edited {{ form.last_edited_human }}
</li>
</ul>
<div v-if="form.visibility=='draft' || (form.tags && form.tags.length > 0)" class="mt-1 flex items-center flex-wrap gap-3">
<div v-if="['draft','closed'].includes(form.visibility) || (form.tags && form.tags.length > 0)" class="mt-1 flex items-center flex-wrap gap-3">
<span v-if="form.visibility=='draft'"
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
>
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700">
Draft
</span>
<span v-else-if="form.visibility=='closed'"
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700">
Closed
</span>
<span v-for="(tag,i) in form.tags" :key="tag"
class="inline-flex items-center rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
>

View File

@ -1,6 +1,10 @@
<template>
<div>
<h3 class="font-semibold text-2xl text-gray-900">Billing details</h3>
<h3 class="font-semibold text-2xl text-gray-900">
Billing details
</h3>
<template v-if="user.has_customer_id">
<small class="text-gray-600">Manage your billing. Download invoices, update your plan, or cancel it at any
time.</small>
@ -9,6 +13,9 @@
Manage Subscription
</v-button>
</div>
</template>
<app-sumo-billing class="mt-4" />
</div>
</template>
@ -16,11 +23,13 @@
import axios from 'axios'
import VButton from '../../components/common/Button.vue'
import SeoMeta from '../../mixins/seo-meta.js'
import { mapGetters } from 'vuex'
import AppSumoBilling from '../../components/vendor/appsumo/AppSumoBilling.vue'
export default {
components: {VButton},
scrollToTop: false,
components: { AppSumoBilling, VButton },
mixins: [SeoMeta],
scrollToTop: false,
data: () => ({
metaTitle: 'Billing',
@ -28,7 +37,7 @@ export default {
}),
methods: {
openBillingDashboard() {
openBillingDashboard () {
this.billingLoading = true
axios.get('/api/subscription/billing-portal').then((response) => {
const url = response.data.portal_url
@ -39,6 +48,12 @@ export default {
this.billingLoading = false
})
}
},
computed: {
...mapGetters({
user: 'auth/user'
})
}
}
</script>

View File

@ -0,0 +1,232 @@
<template>
<div class="flex flex-col min-h-full">
<breadcrumb :path="breadcrumbs" />
<div v-if="templatesLoading" class="text-center my-4">
<loader class="h-6 w-6 text-nt-blue mx-auto" />
</div>
<p v-else-if="industry === null || !industry" class="text-center my-4">
We could not find this industry.
</p>
<template v-else>
<section class="py-12 sm:py-16 bg-gray-50 border-b border-gray-200">
<div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
<div class="text-center mx-auto">
<div class="font-semibold sm:w-full text-blue-500 mb-3">
{{ industry.name }}
</div>
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight text-gray-900">
{{ industry.meta_title }}
</h1>
<p class="max-w-xl mx-auto text-gray-600 mt-4 text-lg font-normal">
{{ industry.meta_description }}
</p>
</div>
</div>
</section>
<section class="bg-white py-12 sm:py-16">
<div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 sm:gap-6 relative z-20">
<div class="flex items-center gap-4">
<div class="flex-1 sm:flex-none">
<select-input v-model="selectedType" name="type"
:options="typesOptions" class="w-full sm:w-auto md:w-56"
/>
</div>
</div>
<div class="flex-1 w-full md:max-w-xs">
<text-input name="search" :form="searchTemplate" placeholder="Search..." />
</div>
</div>
<div v-if="templatesLoading" class="text-center mt-4">
<loader class="h-6 w-6 text-nt-blue mx-auto" />
</div>
<p v-else-if="enrichedTemplates.length === 0" class="text-center mt-4">
No templates found.
</p>
<div v-else class="relative z-10">
<div class="grid grid-cols-1 mt-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8 sm:gap-y-12">
<single-template v-for="template in enrichedTemplates" :key="template.id" :slug="template.slug" />
</div>
</div>
</div>
</section>
<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">
<p class="text-gray-600 font-normal">
{{ industry.description }}
</p>
</div>
</section>
<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="flex items-center justify-between">
<h4 class="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Other Industries
</h4>
<v-button :to="{name:'templates'}" color="white" size="small" :arrow="true">
View All Templates
</v-button>
</div>
<div class="grid grid-cols-1 gap-8 mt-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<router-link v-for="row in otherIndustries" :key="row.slug"
:to="{params:{slug:row.slug}, name:'templates.industries.show'}"
:title="row.name"
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
>
{{ row.name }}
</router-link>
</div>
</div>
</section>
</template>
<open-form-footer class="mt-8 border-t"/>
</div>
</template>
<script>
import store from '~/store'
import Form from 'vform'
import Fuse from 'fuse.js'
import { mapGetters, mapState } from 'vuex'
import SeoMeta from '../../mixins/seo-meta.js'
import OpenFormFooter from '../../components/pages/OpenFormFooter.vue'
import Breadcrumb from '../../components/common/Breadcrumb.vue'
import SingleTemplate from '../../components/pages/templates/SingleTemplate.vue'
const loadTemplates = function () {
store.commit('open/templates/startLoading')
store.dispatch('open/templates/loadIfEmpty').then(() => {
store.commit('open/templates/stopLoading')
})
}
export default {
components: { Breadcrumb, OpenFormFooter, SingleTemplate },
mixins: [SeoMeta],
beforeRouteEnter (to, from, next) {
loadTemplates()
next()
},
data () {
return {
selectedType: 'all',
searchTemplate: new Form({
search: ''
})
}
},
mounted () {},
computed: {
...mapGetters({
authenticated: 'auth/check',
user: 'auth/user'
}),
...mapState({
templates: state => state['open/templates'].content,
templatesLoading: state => state['open/templates'].loading,
industries: state => state['open/templates'].industries,
types: state => state['open/templates'].types
}),
breadcrumbs () {
if (!this.industry) {
return [{ route: { name: 'templates' }, label: 'Templates' }]
}
return [{ route: { name: 'templates' }, label: 'Templates' }, { label: this.industry.name }]
},
industry () {
return Object.values(this.industries).find((industry) => {
return industry.slug === this.$route.params.slug
})
},
typesOptions () {
return [{ name: 'All Types', value: 'all' }].concat(Object.values(this.types).map((type) => {
return {
name: type.name,
value: type.slug
}
}))
},
otherIndustries() {
return Object.values(this.industries).filter((industry) => {
return industry.slug !== this.$route.params.slug
})
},
enrichedTemplates () {
let enrichedTemplates = this.templates
// Filter by current Industry only
enrichedTemplates = enrichedTemplates.filter((item) => {
return (item.industries && item.industries.length > 0) ? item.industries.includes(this.$route.params.slug) : false
})
// Filter by Selected Type
if (this.selectedType && this.selectedType !== 'all') {
enrichedTemplates = enrichedTemplates.filter((item) => {
return (item.types && item.types.length > 0) ? item.types.includes(this.selectedType) : false
})
}
if (this.searchTemplate.search === '' || this.searchTemplate.search === null) {
return enrichedTemplates
}
// Fuze search
const fuzeOptions = {
keys: [
'name',
'slug',
'description',
'short_description'
]
}
const fuse = new Fuse(enrichedTemplates, fuzeOptions)
return fuse.search(this.searchTemplate.search).map((res) => {
return res.item
})
},
metaTitle () {
return this.industry ? this.industry.meta_title : 'Form Template Industry'
},
metaDescription () {
if (!this.industry) return null
return this.industry.meta_description.substring(0, 140)
}
},
methods: {}
}
</script>
<style lang='scss'>
.nf-text {
@apply space-y-4;
h2 {
@apply text-sm font-normal tracking-widest text-gray-500 uppercase;
}
p {
@apply font-normal leading-7 text-gray-900 dark:text-gray-100;
}
ol {
@apply list-decimal list-inside;
}
ul {
@apply list-disc list-inside;
}
}
</style>

View File

@ -0,0 +1,233 @@
<template>
<div class="flex flex-col min-h-full">
<breadcrumb :path="breadcrumbs" />
<div v-if="templatesLoading" class="text-center my-4">
<loader class="h-6 w-6 text-nt-blue mx-auto" />
</div>
<p v-else-if="type === null || !type" class="text-center my-4">
We could not find this type.
</p>
<template v-else>
<section class="py-12 sm:py-16 bg-gray-50 border-b border-gray-200">
<div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
<div class="text-center mx-auto">
<div class="font-semibold sm:w-full text-blue-500 mb-3">
{{ type.name }}
</div>
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight text-gray-900">
{{ type.meta_title }}
</h1>
<p class="max-w-xl mx-auto text-gray-600 mt-4 text-lg font-normal">
{{ type.meta_description }}
</p>
</div>
</div>
</section>
<section class="bg-white py-12 sm:py-16">
<div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 sm:gap-6 relative z-20">
<div class="flex items-center gap-4">
<div class="flex-1 sm:flex-none">
<select-input v-model="selectedIndustry" name="industry"
:options="industriesOptions" class="w-full sm:w-auto md:w-56"
/>
</div>
</div>
<div class="flex-1 w-full md:max-w-xs">
<text-input name="search" :form="searchTemplate" placeholder="Search..." />
</div>
</div>
<div v-if="templatesLoading" class="text-center mt-4">
<loader class="h-6 w-6 text-nt-blue mx-auto" />
</div>
<p v-else-if="enrichedTemplates.length === 0" class="text-center mt-4">
No templates found.
</p>
<div v-else class="relative z-10">
<div class="grid grid-cols-1 mt-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8 sm:gap-y-12">
<single-template v-for="template in enrichedTemplates" :key="template.id" :slug="template.slug" />
</div>
</div>
</div>
</section>
<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">
<p class="text-gray-600 font-normal">
{{ type.description }}
</p>
</div>
</section>
<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="flex items-center justify-between">
<h4 class="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Other Types
</h4>
<v-button :to="{name:'templates'}" color="white" size="small" :arrow="true">
View All Templates
</v-button>
</div>
<div class="grid grid-cols-1 gap-8 mt-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<router-link v-for="row in otherTypes" :key="row.slug"
:to="{params:{slug:row.slug}, name:'templates.types.show'}"
:title="row.name"
class="text-gray-600 dark:text-gray-400 transition-colors duration-300 hover:text-nt-blue"
>
{{ row.name }}
</router-link>
</div>
</div>
</section>
</template>
<open-form-footer class="mt-8 border-t"/>
</div>
</template>
<script>
import store from '~/store'
import Form from 'vform'
import Fuse from 'fuse.js'
import { mapGetters, mapState } from 'vuex'
import SeoMeta from '../../mixins/seo-meta.js'
import OpenFormFooter from '../../components/pages/OpenFormFooter.vue'
import Breadcrumb from '../../components/common/Breadcrumb.vue'
import SingleTemplate from '../../components/pages/templates/SingleTemplate.vue'
const loadTemplates = function () {
store.commit('open/templates/startLoading')
store.dispatch('open/templates/loadIfEmpty').then(() => {
store.commit('open/templates/stopLoading')
})
}
export default {
components: { Breadcrumb, OpenFormFooter, SingleTemplate },
mixins: [SeoMeta],
beforeRouteEnter (to, from, next) {
loadTemplates()
next()
},
data () {
return {
selectedIndustry: 'all',
searchTemplate: new Form({
search: ''
})
}
},
mounted () {},
computed: {
...mapGetters({
authenticated: 'auth/check',
user: 'auth/user'
}),
...mapState({
templates: state => state['open/templates'].content,
templatesLoading: state => state['open/templates'].loading,
industries: state => state['open/templates'].industries,
types: state => state['open/templates'].types
}),
breadcrumbs () {
if (!this.type) {
return [{ route: { name: 'templates' }, label: 'Templates' }]
}
return [{ route: { name: 'templates' }, label: 'Templates' }, { label: this.type.name }]
},
type () {
return Object.values(this.types).find((type) => {
return type.slug === this.$route.params.slug
})
},
industriesOptions () {
return [{ name: 'All Industries', value: 'all' }].concat(Object.values(this.industries).map((industry) => {
return {
name: industry.name,
value: industry.slug
}
}))
},
otherTypes() {
return Object.values(this.types).filter((type) => {
return type.slug !== this.$route.params.slug
})
},
enrichedTemplates () {
let enrichedTemplates = this.templates
// Filter by current Type only
enrichedTemplates = enrichedTemplates.filter((item) => {
return (item.types && item.types.length > 0) ? item.types.includes(this.$route.params.slug) : false
})
// Filter by Selected Industry
if (this.selectedIndustry && this.selectedIndustry !== 'all') {
enrichedTemplates = enrichedTemplates.filter((item) => {
return (item.industries && item.industries.length > 0) ? item.industries.includes(this.selectedIndustry) : false
})
}
if (this.searchTemplate.search === '' || this.searchTemplate.search === null) {
return enrichedTemplates
}
// Fuze search
const fuzeOptions = {
keys: [
'name',
'slug',
'description',
'short_description'
]
}
const fuse = new Fuse(enrichedTemplates, fuzeOptions)
return fuse.search(this.searchTemplate.search).map((res) => {
return res.item
})
},
metaTitle () {
return this.type ? this.type.meta_title : 'Form Template Type'
},
metaDescription () {
if (!this.type) return null
return this.type.meta_description.substring(0, 140)
}
},
methods: {}
}
</script>
<style lang='scss'>
.nf-text {
@apply space-y-4;
h2 {
@apply text-sm font-normal tracking-widest text-gray-500 uppercase;
}
p {
@apply font-normal leading-7 text-gray-900 dark:text-gray-100;
}
ol {
@apply list-decimal list-inside;
}
ul {
@apply list-disc list-inside;
}
}
</style>

View File

@ -70,6 +70,8 @@ export default [
{ path: '/my-templates', name: 'my_templates', component: page('templates/my_templates.vue') },
{ path: '/form-templates', name: 'templates', component: page('templates/templates.vue') },
{ path: '/form-templates/:slug', name: 'templates.show', component: page('templates/show.vue') },
{ path: '/form-templates/types/:slug', name: 'templates.types.show', component: page('templates/types-show.vue') },
{ path: '/form-templates/industries/:slug', name: 'templates.industries.show', component: page('templates/industries-show.vue') },
{ path: '/:pathMatch(.*)*', component: page('errors/404.vue') }
]

View File

@ -91,12 +91,26 @@ export const actions = {
context.commit('stopLoading')
})
},
loadAll (context) {
loadAll (context, options=null) {
context.commit('startLoading')
context.dispatch('loadTypesAndIndustries')
return axios.get(templatesEndpoint).then((response) => {
// Prepare with options
let queryStr = ''
if(options !== null){
for (const [key, value] of Object.entries(options)) {
queryStr += '&' + encodeURIComponent(key) + '=' + encodeURIComponent(value)
}
queryStr = queryStr.slice(1)
}
return axios.get((queryStr) ? templatesEndpoint + '?' + queryStr : templatesEndpoint).then((response) => {
if(options !== null){
context.commit('set', response.data)
context.commit('setAllLoaded', false)
} else {
context.commit('append', response.data)
context.commit('setAllLoaded', true)
}
context.commit('stopLoading')
}).catch((error) => {
context.commit('stopLoading')
@ -108,17 +122,5 @@ export const actions = {
}
context.commit('stopLoading')
return Promise.resolve()
},
loadWithLimit (context, limit) {
context.commit('startLoading')
context.dispatch('loadTypesAndIndustries')
return axios.get(templatesEndpoint + '?limit=' + limit).then((response) => {
context.commit('set', response.data)
context.commit('setAllLoaded', false)
context.commit('stopLoading')
}).catch((error) => {
context.commit('stopLoading')
})
}
}

View File

@ -129,11 +129,17 @@ Route::group(['middleware' => 'guest:api'], function () {
Route::get('oauth/{driver}/callback', [OAuthController::class, 'handleCallback'])->name('oauth.callback');
});
Route::group(['prefix' => 'appsumo'], function () {
Route::get('oauth/callback', [\App\Http\Controllers\Auth\AppSumoAuthController::class, 'handleCallback'])->name('appsumo.callback');
Route::post('webhook', [\App\Http\Controllers\Webhook\AppSumoController::class, 'handle'])->name('appsumo.webhook');
});
/*
* Public Forms related routes
*/
Route::prefix('forms')->name('forms.')->group(function () {
Route::middleware('password-protected-form')->group(function () {
Route::middleware('protected-form')->group(function () {
Route::post('{slug}/answer', [PublicFormController::class, 'answer'])->name('answer');
// Form content endpoints (user lists, relation lists etc.)

1
tailwind.config.js vendored
View File

@ -74,6 +74,7 @@ module.exports = {
transitionProperty: {
height: 'height',
width: 'width',
maxWidth: 'max-width',
spacing: 'margin, padding'
}
}

View File

@ -54,7 +54,7 @@ it('can not submit form without password for guest user', function () {
->assertStatus(403)
->assertJson([
'status' => 'Unauthorized',
'message' => 'Form is password protected.'
'message' => 'Form is protected.'
]);
});
@ -66,7 +66,7 @@ it('can not submit form with wrong password for guest user', function () {
->assertStatus(403)
->assertJson([
'status' => 'Unauthorized',
'message' => 'Form is password protected.'
'message' => 'Form is protected.'
]);
});