Adding Custom domains (#247)

* WIP

* wip

* Finished doing most of the work
This commit is contained in:
Julien Nahum 2023-11-29 14:53:08 +01:00 committed by GitHub
parent 57fdfb25a0
commit b50f579155
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1210 additions and 267 deletions

View File

@ -74,3 +74,6 @@ ADMIN_EMAILS=
TEMPLATE_EDITOR_EMAILS=
OPEN_AI_API_KEY=
CADDY_SECRET=
CADDY_AUTHORIZED_IP=

462
_ide_helper_models.php Normal file
View File

@ -0,0 +1,462 @@
<?php
// @formatter:off
/**
* A helper file for your Eloquent Models
* Copy the phpDocs from this file to the correct Model,
* And remove them from this file, to prevent double declarations.
*
* @author Barry vd. Heuvel <barryvdh@gmail.com>
*/
namespace App\Models\Forms\AI{
/**
* App\Models\Forms\AI\AiFormCompletion
*
* @property int $id
* @property string $form_prompt
* @property string $status
* @property mixed|null $result
* @property string $ip
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion query()
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereFormPrompt($value)
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereIp($value)
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereResult($value)
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder|AiFormCompletion whereUpdatedAt($value)
*/
class AiFormCompletion extends \Eloquent {}
}
namespace App\Models\Forms{
/**
* App\Models\Forms\Form
*
* @property int $id
* @property int $workspace_id
* @property string $title
* @property string $slug
* @property array $properties
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property bool $notifies
* @property string|null $description
* @property string $submit_button_text
* @property bool $re_fillable
* @property string $re_fill_button_text
* @property string $color
* @property bool $uppercase_labels
* @property bool $no_branding
* @property bool $hide_title
* @property string $submitted_text
* @property string $dark_mode
* @property string|null $webhook_url
* @property bool $send_submission_confirmation
* @property string|null $logo_picture
* @property string|null $cover_picture
* @property string|null $redirect_url
* @property string|null $custom_code
* @property string|null $notification_emails
* @property string $theme
* @property array|null $database_fields_update
* @property string $width
* @property bool $transparent_background
* @property \Illuminate\Support\Carbon|null $closes_at
* @property string|null $closed_text
* @property string $notification_subject
* @property string $notification_body
* @property bool $notifications_include_submission
* @property bool $use_captcha
* @property bool $can_be_indexed
* @property string|null $password
* @property string $notification_sender
* @property array $tags
* @property \Illuminate\Support\Carbon|null $deleted_at
* @property int $creator_id
* @property array $removed_properties
* @property int|null $max_submissions_count
* @property string|null $max_submissions_reached_text
* @property string|null $slack_webhook_url
* @property string $visibility
* @property bool $editable_submissions
* @property string|null $discord_webhook_url
* @property string $editable_submissions_button_text
* @property bool $confetti_on_submission
* @property object $seo_meta
* @property object|null $notification_settings
* @property bool $auto_save
* @property-read \App\Models\User $creator
* @property-read mixed $edit_url
* @property-read mixed $form_pending_submission_key
* @property-read mixed $has_password
* @property-read mixed $is_closed
* @property-read mixed $is_pro
* @property-read mixed $max_number_of_submissions_reached
* @property-read mixed $notifies_discord
* @property-read mixed $notifies_slack
* @property-read mixed $notifies_webhook
* @property-read mixed $share_url
* @property-read int|null $submissions_count
* @property-read int|null $views_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Forms\FormStatistic> $statistics
* @property-read int|null $statistics_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Forms\FormSubmission> $submissions
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Forms\FormView> $views
* @property-read \App\Models\Workspace|null $workspace
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Integration\FormZapierWebhook> $zappierHooks
* @property-read int|null $zappier_hooks_count
* @method static \Database\Factories\FormFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder|Form newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Form newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Form onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|Form query()
* @method static \Illuminate\Database\Eloquent\Builder|Form whereAutoSave($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereCanBeIndexed($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereClosedText($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereClosesAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereColor($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereConfettiOnSubmission($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereCoverPicture($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereCreatorId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereCustomCode($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereDarkMode($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereDatabaseFieldsUpdate($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereDescription($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereDiscordWebhookUrl($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereEditableSubmissions($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereEditableSubmissionsButtonText($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereHideTitle($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereLogoPicture($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereMaxSubmissionsCount($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereMaxSubmissionsReachedText($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereNoBranding($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationBody($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationEmails($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationSender($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationSettings($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationSubject($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationsIncludeSubmission($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereNotifies($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form wherePassword($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereProperties($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereReFillButtonText($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereReFillable($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereRedirectUrl($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereRemovedProperties($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereSendSubmissionConfirmation($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereSeoMeta($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereSlackWebhookUrl($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereSlug($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereSubmitButtonText($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereSubmittedText($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereTags($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereTheme($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereTitle($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereTransparentBackground($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereUppercaseLabels($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereUseCaptcha($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereVisibility($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereWebhookUrl($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereWidth($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form whereWorkspaceId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Form withTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|Form withoutTrashed()
*/
class Form extends \Eloquent {}
}
namespace App\Models\Forms{
/**
* App\Models\Forms\FormStatistic
*
* @property int $id
* @property int $form_id
* @property array $data
* @property string $date
* @property-read \App\Models\Forms\Form|null $form
* @method static \Illuminate\Database\Eloquent\Builder|FormStatistic newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormStatistic newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormStatistic query()
* @method static \Illuminate\Database\Eloquent\Builder|FormStatistic whereData($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormStatistic whereDate($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormStatistic whereFormId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormStatistic whereId($value)
*/
class FormStatistic extends \Eloquent {}
}
namespace App\Models\Forms{
/**
* App\Models\Forms\FormSubmission
*
* @property int $id
* @property int $form_id
* @property array $data
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Forms\Form|null $form
* @method static \Illuminate\Database\Eloquent\Builder|FormSubmission newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormSubmission newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormSubmission query()
* @method static \Illuminate\Database\Eloquent\Builder|FormSubmission whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormSubmission whereData($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormSubmission whereFormId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormSubmission whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormSubmission whereUpdatedAt($value)
*/
class FormSubmission extends \Eloquent {}
}
namespace App\Models\Forms{
/**
* App\Models\Forms\FormView
*
* @property int $id
* @property int $form_id
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Forms\Form|null $form
* @method static \Illuminate\Database\Eloquent\Builder|FormView newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormView newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormView query()
* @method static \Illuminate\Database\Eloquent\Builder|FormView whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormView whereFormId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormView whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormView whereUpdatedAt($value)
*/
class FormView extends \Eloquent {}
}
namespace App\Models\Integration{
/**
* App\Models\Integration\FormZapierWebhook
*
* @property int $id
* @property int $form_id
* @property string $hook_url
* @property \Illuminate\Support\Carbon|null $deleted_at
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Forms\Form|null $form
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook query()
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereFormId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereHookUrl($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook withTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|FormZapierWebhook withoutTrashed()
*/
class FormZapierWebhook extends \Eloquent {}
}
namespace App\Models{
/**
* App\Models\License
*
* @property int $id
* @property string $license_key
* @property int|null $user_id
* @property string $license_provider
* @property string $status
* @property array $meta
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read mixed $custom_domain_limit_count
* @property-read mixed $max_file_size
* @property-read \App\Models\User|null $user
* @method static \Illuminate\Database\Eloquent\Builder|License active()
* @method static \Illuminate\Database\Eloquent\Builder|License newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|License newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|License query()
* @method static \Illuminate\Database\Eloquent\Builder|License whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|License whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|License whereLicenseKey($value)
* @method static \Illuminate\Database\Eloquent\Builder|License whereLicenseProvider($value)
* @method static \Illuminate\Database\Eloquent\Builder|License whereMeta($value)
* @method static \Illuminate\Database\Eloquent\Builder|License whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder|License whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|License whereUserId($value)
*/
class License extends \Eloquent {}
}
namespace App\Models{
/**
* App\Models\OAuthProvider
*
* @property int $id
* @property int $user_id
* @property string $provider
* @property string $provider_user_id
* @property string|null $access_token
* @property string|null $refresh_token
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\User $user
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider query()
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereAccessToken($value)
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereProvider($value)
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereProviderUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereRefreshToken($value)
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereUserId($value)
*/
class OAuthProvider extends \Eloquent {}
}
namespace App\Models{
/**
* App\Models\Template
*
* @property int $id
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property string $name
* @property string $slug
* @property string $description
* @property string|null $image_url
* @property array $structure
* @property array $questions
* @property bool $publicly_listed
* @property array $industries
* @property array $types
* @property string|null $short_description
* @property array $related_templates
* @property int|null $creator_id
* @property-read mixed $share_url
* @method static \Illuminate\Database\Eloquent\Builder|Template newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Template newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Template publiclyListed()
* @method static \Illuminate\Database\Eloquent\Builder|Template query()
* @method static \Illuminate\Database\Eloquent\Builder|Template whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereCreatorId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereDescription($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereImageUrl($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereIndustries($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template wherePubliclyListed($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereQuestions($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereRelatedTemplates($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereShortDescription($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereSlug($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereStructure($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereTypes($value)
* @method static \Illuminate\Database\Eloquent\Builder|Template whereUpdatedAt($value)
*/
class Template extends \Eloquent {}
}
namespace App\Models{
/**
* App\Models\User
*
* @property int $id
* @property string $name
* @property string $email
* @property \Illuminate\Support\Carbon|null $email_verified_at
* @property string|null $password
* @property string|null $remember_token
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property string|null $stripe_id
* @property string|null $pm_type
* @property string|null $pm_last_four
* @property string|null $trial_ends_at
* @property string|null $hear_about_us
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Template> $formTemplates
* @property-read int|null $form_templates_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Forms\Form> $forms
* @property-read int|null $forms_count
* @property-read mixed $admin
* @property-read mixed $has_customer_id
* @property-read mixed $has_forms
* @property-read mixed $is_risky
* @property-read mixed $is_subscribed
* @property-read string $photo_url
* @property-read mixed $template_editor
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\License> $licenses
* @property-read int|null $licenses_count
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
* @property-read int|null $notifications_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\OAuthProvider> $oauthProviders
* @property-read int|null $oauth_providers_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Laravel\Cashier\Subscription> $subscriptions
* @property-read int|null $subscriptions_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Workspace> $workspaces
* @property-read int|null $workspaces_count
* @method static \Database\Factories\UserFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder|User newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|User newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|User query()
* @method static \Illuminate\Database\Eloquent\Builder|User whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereEmail($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereEmailVerifiedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereHearAboutUs($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|User wherePassword($value)
* @method static \Illuminate\Database\Eloquent\Builder|User wherePmLastFour($value)
* @method static \Illuminate\Database\Eloquent\Builder|User wherePmType($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereRememberToken($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereStripeId($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereTrialEndsAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|User withActiveSubscription()
*/
class User extends \Eloquent implements \Tymon\JWTAuth\Contracts\JWTSubject {}
}
namespace App\Models{
/**
* App\Models\Workspace
*
* @property int $id
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property string $name
* @property string|null $icon
* @property mixed|null $custom_domains
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Forms\Form> $forms
* @property-read int|null $forms_count
* @property-read mixed $custom_domain_count_limit
* @property-read mixed $is_enterprise
* @property-read mixed $is_pro
* @property-read mixed $is_risky
* @property-read mixed $max_file_size
* @property-read mixed $submissions_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\User> $users
* @property-read int|null $users_count
* @method static \Illuminate\Database\Eloquent\Builder|Workspace newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Workspace newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Workspace query()
* @method static \Illuminate\Database\Eloquent\Builder|Workspace whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Workspace whereCustomDomains($value)
* @method static \Illuminate\Database\Eloquent\Builder|Workspace whereIcon($value)
* @method static \Illuminate\Database\Eloquent\Builder|Workspace whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Workspace whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|Workspace whereUpdatedAt($value)
*/
class Workspace extends \Eloquent {}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers;
use App\Models\Workspace;
use Illuminate\Http\Request;
class CaddyController extends Controller
{
public function ask(Request $request)
{
$request->validate([
'domain' => 'required|string',
]);
// make sure domain is valid
$domain = $request->input('domain');
if (!preg_match('/^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}$/', $domain)) {
return $this->error([
'success' => false,
'message' => 'Invalid domain',
]);
}
if (Workspace::whereJsonContains('custom_domains',$domain)->exists()) {
return $this->success([
'success' => true,
'message' => 'OK',
]);
}
return $this->error([
'success' => false,
'message' => 'Unauthorized domain',
]);
}
}

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Http\Requests\Workspace\CustomDomainRequest;
use App\Models\Workspace;
use Illuminate\Http\Request;
use App\Service\WorkspaceHelper;
@ -29,6 +30,13 @@ class WorkspaceController extends Controller
return (new WorkspaceHelper($workspace))->getAllUsers();
}
public function saveCustomDomain(CustomDomainRequest $request)
{
$request->workspace->custom_domains = $request->customDomains;
$request->workspace->save();
return $request->workspace;
}
public function delete($id)
{
$workspace = Workspace::findOrFail($id);

View File

@ -2,6 +2,7 @@
namespace App\Http;
use App\Http\Middleware\CustomDomainRestriction;
use App\Http\Middleware\EmbeddableForms;
use App\Http\Middleware\IsAdmin;
use App\Http\Middleware\IsNotSubscribed;
@ -26,6 +27,7 @@ class Kernel extends HttpKernel
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
\App\Http\Middleware\SetLocale::class,
CustomDomainRestriction::class,
];
/**

View File

@ -0,0 +1,39 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class CaddyRequestMiddleware
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next)
{
if (!config('custom-domains.enabled')) {
return response()->json([
'success' => false,
'message' => 'Custom domains not enabled',
], 401);
}
if (config('custom-domains.enabled') && !in_array($request->ip(), config('custom-domains.authorized_ips'))) {
return response()->json([
'success' => false,
'message' => 'Unauthorized',
], 401);
}
$secret = $request->route('secret');
if (config('custom-domains.caddy_secret') && (!$secret || $secret !== config('custom-domains.caddy_secret'))) {
return response()->json([
'success' => false,
'message' => 'Unauthorized',
], 401);
}
return $next($request);
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Http\Middleware;
use App\Models\Forms\Form;
use App\Models\Workspace;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\Builder;
class CustomDomainRestriction
{
const CUSTOM_DOMAIN_HEADER = "User-Custom-Domain";
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next)
{
if (!$request->hasHeader(self::CUSTOM_DOMAIN_HEADER) || !config('custom-domains.enabled')) {
return $next($request);
}
$customDomain = $request->header(self::CUSTOM_DOMAIN_HEADER);
if (!preg_match('/^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}$/', $customDomain)) {
return response()->json([
'success' => false,
'message' => 'Invalid domain',
], 400);
}
// Check if domain is different from current domain
$notionFormsDomain = parse_url(config('app.url'))['host'];
if ($customDomain == $notionFormsDomain) {
return $next($request);
}
// Check if domain is known
if (!$workspace = Workspace::whereJsonContains('custom_domains',$customDomain)->first()) {
return response()->json([
'success' => false,
'message' => 'Unknown domain',
], 400);
}
Workspace::addGlobalScope('domain-restricted', function (Builder $builder) use ($workspace) {
$builder->where('workspaces.id', $workspace->id);
});
Form::addGlobalScope('domain-restricted', function (Builder $builder) use ($workspace) {
$builder->where('forms.workspace_id', $workspace->id);
});
return $next($request);
}
}

View File

@ -125,7 +125,8 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest
'password' => 'sometimes|nullable',
// Custom SEO
'seo_meta' => 'nullable|array'
'seo_meta' => 'nullable|array',
'custom_domain' => 'sometimes|nullable|regex:/^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}$/'
];
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Http\Requests\Workspace;
use App\Models\Workspace;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Request;
class CustomDomainRequest extends FormRequest
{
public Workspace $workspace;
public array $customDomains = [];
public function __construct(Request $request, Workspace $workspace)
{
$this->workspace = Workspace::findOrFail($request->workspaceId);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules()
{
return [
'custom_domains' => [
'present',
'array',
function($attribute, $value, $fail) {
$errors = [];
$domains = collect($value)->filter(function ($domain) {
return !empty( trim($domain) );
})->each(function($domain) use (&$errors) {
if (!preg_match('/^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}$/', $domain)) {
$errors[] = 'Invalid domain: ' . $domain;
}
});
if (count($errors)) {
$fail($errors);
}
$limit = $this->workspace->custom_domain_count_limit;
if ($limit && $domains->count() > $limit) {
$fail('You can only add ' . $limit . ' domain(s).');
}
$this->customDomains = $domains->toArray();
}
]
];
}
protected function passedValidation(){
$this->replace(['custom_domains' => $this->customDomains]);
}
}

View File

@ -53,6 +53,7 @@ class Form extends Model
'visibility',
// Customization
'custom_domain',
'theme',
'width',
'cover_picture',
@ -141,12 +142,15 @@ class Form extends Model
public function getShareUrlAttribute()
{
return url('/forms/'.$this->slug);
if ($this->custom_domain) {
return 'https://' . $this->custom_domain . '/forms/' . $this->slug;
}
return url('/forms/' . $this->slug);
}
public function getEditUrlAttribute()
{
return url('/forms/'.$this->slug.'/show');
return url('/forms/' . $this->slug . '/show');
}
public function getSubmissionsCountAttribute()
@ -156,12 +160,12 @@ class Form extends Model
public function getViewsCountAttribute()
{
if(env('DB_CONNECTION') == 'pgsql') {
if (env('DB_CONNECTION') == 'pgsql') {
return $this->views()->count() +
$this->statistics()->sum(DB::raw("cast(data->>'views' as integer)"));
} elseif(env('DB_CONNECTION') == 'mysql') {
$this->statistics()->sum(DB::raw("cast(data->>'views' as integer)"));
} elseif (env('DB_CONNECTION') == 'mysql') {
return (int)($this->views()->count() +
$this->statistics()->sum(DB::raw("json_extract(data, '$.views')")));
$this->statistics()->sum(DB::raw("json_extract(data, '$.views')")));
}
return 0;
}
@ -219,7 +223,8 @@ class Form extends Model
return !empty($this->password);
}
protected function removedProperties(): Attribute {
protected function removedProperties(): Attribute
{
return Attribute::make(
get: function ($value) {
return $value ? json_decode($value, true) : [];
@ -283,7 +288,7 @@ class Form extends Model
{
return !empty($this->webhook_url);
}
public function getNotifiesDiscordAttribute()
{
return !empty($this->discord_webhook_url);

View File

@ -42,4 +42,13 @@ class License extends Model
3 => 75000000, // 75 MB,
][$this->meta['tier']];
}
public function getCustomDomainLimitCountAttribute()
{
return [
1 => 5,
2 => 25,
3 => null,
][$this->meta['tier']];
}
}

View File

@ -14,10 +14,13 @@ class Workspace extends Model
const MAX_FILE_SIZE_FREE = 5000000; // 5 MB
const MAX_FILE_SIZE_PRO = 50000000; // 50 MB
const MAX_DOMAIN_PRO = 1;
protected $fillable = [
'name',
'icon',
'user_id',
'custom_domain',
];
protected $appends = [
@ -25,6 +28,10 @@ class Workspace extends Model
'is_enterprise'
];
protected $casts = [
'custom_domains' => 'array',
];
public function getIsProAttribute()
{
if(is_null(config('cashier.key'))){
@ -60,6 +67,26 @@ class Workspace extends Model
return self::MAX_FILE_SIZE_FREE;
}
public function getCustomDomainCountLimitAttribute()
{
if(is_null(config('cashier.key'))){
return null;
}
// 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->custom_domain_limit_count;
}
}
return self::MAX_DOMAIN_PRO;
}
return 0;
}
public function getIsEnterpriseAttribute()
{
if(is_null(config('cashier.key'))){

View File

@ -0,0 +1,9 @@
<?php
return [
'enabled' => !empty(env('CADDY_SECRET')) && !empty(env('CADDY_AUTHORIZED_IPS', [])),
'caddy_secret' => env('CADDY_SECRET'),
'authorized_ips' => env('CADDY_AUTHORIZED_IPS', []),
];

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$driver = DB::getDriverName();
Schema::table('workspaces', function (Blueprint $table) use ($driver){
if ($driver === 'mysql') {
$table->json('custom_domains')->default(new Expression("(JSON_OBJECT())"))->nullable(true);
} else {
$table->json('custom_domains')->default('{}')->nullable(true);
}
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('workspaces', function (Blueprint $table) {
$table->dropColumn('custom_domains');
});
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('forms', function (Blueprint $table) {
$table->string('custom_domain')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('forms', function (Blueprint $table) {
$table->dropColumn(['custom_domain']);
});
}
};

16
package-lock.json generated
View File

@ -4253,9 +4253,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001434",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001434.tgz",
"integrity": "sha512-aOBHrLmTQw//WFa2rcF1If9fa3ypkC1wzqqiKHgfdrXTWcU8C4gKVZT77eQAPWN1APys3+uQ0Df07rKauXGEYA==",
"version": "1.0.30001565",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001565.tgz",
"integrity": "sha512-xrE//a3O7TP0vaJ8ikzkD2c2NgcVUvsEe2IvFTntV4Yd1Z9FVzh+gW+enX96L0psrbaFMcVcH2l90xNuGDWc8w==",
"dev": true,
"funding": [
{
@ -4265,6 +4265,10 @@
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
]
},
@ -16655,9 +16659,9 @@
}
},
"caniuse-lite": {
"version": "1.0.30001434",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001434.tgz",
"integrity": "sha512-aOBHrLmTQw//WFa2rcF1If9fa3ypkC1wzqqiKHgfdrXTWcU8C4gKVZT77eQAPWN1APys3+uQ0Df07rKauXGEYA==",
"version": "1.0.30001565",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001565.tgz",
"integrity": "sha512-xrE//a3O7TP0vaJ8ikzkD2c2NgcVUvsEe2IvFTntV4Yd1Z9FVzh+gW+enX96L0psrbaFMcVcH2l90xNuGDWc8w==",
"dev": true
},
"caw": {

View File

@ -22,9 +22,9 @@
<small v-if="help && helpPosition=='below_input'" :class="theme.default.help" class="flex-grow">
<slot name="help"><span class="field-help" v-html="help" /></slot>
</small>
<small v-else class="flex-grow"></small>
<small v-else class="flex-grow" />
<small v-if="showCharLimit && maxCharLimit" :class="theme.default.help">
{{ charCount }}/{{ maxCharLimit }}
{{ charCount }}/{{ maxCharLimift }}
</small>
</div>
<has-error v-if="hasValidation" :form="form" :field="name" />
@ -40,10 +40,10 @@ export default {
props: {
maxCharLimit: { type: Number, required: false, default: null },
showCharLimit: { type: Boolean, required: false, default: false },
showCharLimit: { type: Boolean, required: false, default: false }
},
computed: {
charCount() {
charCount () {
return (this.compVal) ? this.compVal.length : 0
}
}

View File

@ -8,8 +8,11 @@
</svg>
</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).
Customize the link, images and text that appear when you share your form on other sites (Open Graph).
</p>
<select-input v-if="customDomainAllowed" v-model="form.custom_domain" :disabled="customDomainOptions.length <= 0" :options="customDomainOptions" name="type"
class="mt-4" label="Form Domain" placeholder="yourdomain.com"
/>
<text-input v-model="form.seo_meta.page_title" name="page_title" class="mt-4"
label="Page Title" help="Under or approximately 60 characters"
/>
@ -41,6 +44,20 @@ export default {
set (value) {
this.$store.commit('open/working_form/set', value)
}
},
workspace () {
return this.$store.getters['open/workspaces/getCurrent']()
},
customDomainOptions () {
return this.workspace.custom_domains.map((domain) => {
return {
name: domain,
value: domain
}
})
},
customDomainAllowed () {
return window.config.custom_domains_enabled
}
},
watch: {},

View File

@ -1,103 +1,116 @@
<template>
<div>
<div v-if="loadingDuplicate || loadingDelete" class="pr-4 pt-2">
<loader class="h-6 w-6 mx-auto"/>
<loader class="h-6 w-6 mx-auto" />
</div>
<dropdown v-else class="inline" dusk="nav-dropdown">
<template #trigger="{toggle}">
<v-button color="white" class="mr-2" @click="toggle">
<svg class="w-4 h-4 inline -mt-1" viewBox="0 0 16 4" fill="none"
xmlns="http://www.w3.org/2000/svg">
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.00016 2.83366C8.4604 2.83366 8.8335 2.46056 8.8335 2.00033C8.8335 1.54009 8.4604 1.16699 8.00016 1.16699C7.53993 1.16699 7.16683 1.54009 7.16683 2.00033C7.16683 2.46056 7.53993 2.83366 8.00016 2.83366Z"
stroke="#344054" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
d="M8.00016 2.83366C8.4604 2.83366 8.8335 2.46056 8.8335 2.00033C8.8335 1.54009 8.4604 1.16699 8.00016 1.16699C7.53993 1.16699 7.16683 1.54009 7.16683 2.00033C7.16683 2.46056 7.53993 2.83366 8.00016 2.83366Z"
stroke="#344054" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"
/>
<path
d="M13.8335 2.83366C14.2937 2.83366 14.6668 2.46056 14.6668 2.00033C14.6668 1.54009 14.2937 1.16699 13.8335 1.16699C13.3733 1.16699 13.0002 1.54009 13.0002 2.00033C13.0002 2.46056 13.3733 2.83366 13.8335 2.83366Z"
stroke="#344054" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
d="M13.8335 2.83366C14.2937 2.83366 14.6668 2.46056 14.6668 2.00033C14.6668 1.54009 14.2937 1.16699 13.8335 1.16699C13.3733 1.16699 13.0002 1.54009 13.0002 2.00033C13.0002 2.46056 13.3733 2.83366 13.8335 2.83366Z"
stroke="#344054" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"
/>
<path
d="M2.16683 2.83366C2.62707 2.83366 3.00016 2.46056 3.00016 2.00033C3.00016 1.54009 2.62707 1.16699 2.16683 1.16699C1.70659 1.16699 1.3335 1.54009 1.3335 2.00033C1.3335 2.46056 1.70659 2.83366 2.16683 2.83366Z"
stroke="#344054" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
d="M2.16683 2.83366C2.62707 2.83366 3.00016 2.46056 3.00016 2.00033C3.00016 1.54009 2.62707 1.16699 2.16683 1.16699C1.70659 1.16699 1.3335 1.54009 1.3335 2.00033C1.3335 2.46056 1.70659 2.83366 2.16683 2.83366Z"
stroke="#344054" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
</v-button>
</template>
<router-link v-if="isMainPage && user" :to="{name:'forms.show_public', params: {slug: form.slug}}" target="_blank"
class="block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
v-track.view_form_click="{form_id:form.id, form_slug:form.slug}"
>
<a v-if="isMainPage && user" v-track.view_form_click="{form_id:form.id, form_slug:form.slug}" :href="form.share_url"
target="_blank"
class="block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
>
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12C23 12 19 20 12 20C5 20 1 12 1 12Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path
xmlns="http://www.w3.org/2000/svg"
>
<path d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12C23 12 19 20 12 20C5 20 1 12 1 12Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
<path
d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
View form
</router-link>
<router-link v-if="isMainPage" :to="{name:'forms.edit', params: {slug: form.slug}}"
class="block block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
v-track.edit_form_click="{form_id:form.id, form_slug:form.slug}"
>
</a>
<router-link v-if="isMainPage" v-track.edit_form_click="{form_id:form.id, form_slug:form.slug}"
:to="{name:'forms.edit', params: {slug: form.slug}}"
class="block block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
>
<svg class="w-4 h-4 mr-2" width="18" height="17" viewBox="0 0 18 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8.99998 15.6662H16.5M1.5 15.6662H2.89545C3.3031 15.6662 3.50693 15.6662 3.69874 15.6202C3.8688 15.5793 4.03138 15.512 4.1805 15.4206C4.34869 15.3175 4.49282 15.1734 4.78107 14.8852L15.25 4.4162C15.9404 3.72585 15.9404 2.60656 15.25 1.9162C14.5597 1.22585 13.4404 1.22585 12.75 1.9162L2.28105 12.3852C1.9928 12.6734 1.84867 12.8175 1.7456 12.9857C1.65422 13.1348 1.58688 13.2974 1.54605 13.4675C1.5 13.6593 1.5 13.8631 1.5 14.2708V15.6662Z"
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>
Edit
</router-link>
<a href="#" v-if="isMainPage"
class="block block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
@click.prevent="copyLink"
>
<a v-if="isMainPage" href="#"
class="block block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
@click.prevent="copyLink"
>
<svg class="w-4 h-4 mr-2" viewBox="0 0 16 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.00016 8.33317H4.66683C2.82588 8.33317 1.3335 6.84079 1.3335 4.99984C1.3335 3.15889 2.82588 1.6665 4.66683 1.6665H6.00016M10.0002 8.33317H11.3335C13.1744 8.33317 14.6668 6.84079 14.6668 4.99984C14.6668 3.15889 13.1744 1.6665 11.3335 1.6665H10.0002M4.66683 4.99984L11.3335 4.99984" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.00016 8.33317H4.66683C2.82588 8.33317 1.3335 6.84079 1.3335 4.99984C1.3335 3.15889 2.82588 1.6665 4.66683 1.6665H6.00016M10.0002 8.33317H11.3335C13.1744 8.33317 14.6668 6.84079 14.6668 4.99984C14.6668 3.15889 13.1744 1.6665 11.3335 1.6665H10.0002M4.66683 4.99984L11.3335 4.99984" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Copy link to share
</a>
<a href="#"
class="block block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
v-track.duplicate_form_click="{form_id:form.id, form_slug:form.slug}"
@click.prevent="duplicateForm"
>
<a v-track.duplicate_form_click="{form_id:form.id, form_slug:form.slug}"
href="#"
class="block block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
@click.prevent="duplicateForm"
>
<svg class="w-4 h-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2"
/>
/>
</svg>
Duplicate form
</a>
<a href="#" v-if="!isMainPage" v-track.create_template_click="{form_id:form.id, form_slug:form.slug}"
<a v-if="!isMainPage" v-track.create_template_click="{form_id:form.id, form_slug:form.slug}" href="#"
class="block block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
@click.prevent="showFormTemplateModal=true"
>
<svg class="w-4 h-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M17 14v6m-3-3h6M6 10h2a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v2a2 2 0 002 2zm10 0h2a2 2 0 002-2V6a2 2 0 00-2-2h-2a2 2 0 00-2 2v2a2 2 0 002 2zM6 20h2a2 2 0 002-2v-2a2 2 0 00-2-2H6a2 2 0 00-2 2v2a2 2 0 002 2z"/>
d="M17 14v6m-3-3h6M6 10h2a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v2a2 2 0 002 2zm10 0h2a2 2 0 002-2V6a2 2 0 00-2-2h-2a2 2 0 00-2 2v2a2 2 0 002 2zM6 20h2a2 2 0 002-2v-2a2 2 0 00-2-2H6a2 2 0 00-2 2v2a2 2 0 002 2z"
/>
</svg>
Create Template
</a>
<a href="#"
class="block block px-4 py-2 text-md text-red-600 hover:bg-red-50 flex items-center"
v-track.delete_form_click="{form_id:form.id, form_slug:form.slug}"
@click.prevent="showDeleteFormModal=true"
>
<a v-track.delete_form_click="{form_id:form.id, form_slug:form.slug}"
href="#"
class="block block px-4 py-2 text-md text-red-600 hover:bg-red-50 flex items-center"
@click.prevent="showDeleteFormModal=true"
>
<svg class="w-4 h-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
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>
Delete form
</a>
</dropdown>
<!-- Delete Form Modal -->
<modal :show="showDeleteFormModal" icon-color="red" @close="showDeleteFormModal=false" max-width="sm">
<modal :show="showDeleteFormModal" icon-color="red" max-width="sm" @close="showDeleteFormModal=false">
<template #icon>
<svg class="w-10 h-10" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
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"
/>
@ -111,74 +124,78 @@
If you want to permanently delete this form and all of its data, you can do so below.
</p>
<div class="flex mt-4">
<v-button class="sm:w-1/2 mr-4" color="white" @click.prevent="showDeleteFormModal=false">Cancel</v-button>
<v-button class="sm:w-1/2" color="red" :loading="loadingDelete" @click.prevent="deleteForm">Yes, delete it</v-button>
<v-button class="sm:w-1/2 mr-4" color="white" @click.prevent="showDeleteFormModal=false">
Cancel
</v-button>
<v-button class="sm:w-1/2" color="red" :loading="loadingDelete" @click.prevent="deleteForm">
Yes, delete it
</v-button>
</div>
</div>
</modal>
<form-template-modal v-if="!isMainPage && user" :form="form" :show="showFormTemplateModal" @close="showFormTemplateModal=false"/>
<form-template-modal v-if="!isMainPage && user" :form="form" :show="showFormTemplateModal" @close="showFormTemplateModal=false" />
</div>
</template>
<script>
import axios from 'axios'
import {mapGetters, mapState} from 'vuex'
import { mapGetters, mapState } from 'vuex'
import Dropdown from '../../../common/Dropdown.vue'
import FormTemplateModal from '../../../open/forms/components/templates/FormTemplateModal.vue'
export default {
name: 'ExtraMenu',
components: { Dropdown, FormTemplateModal },
props: {
form: { type: Object, required: true },
isMainPage: { type: Boolean, required: false, default: false }
},
name: 'ExtraMenu',
components: { Dropdown, FormTemplateModal },
props: {
form: { type: Object, required: true },
isMainPage: { type: Boolean, required: false, default: false }
},
data: () => ({
loadingDuplicate: false,
loadingDelete: false,
showDeleteFormModal: false,
showFormTemplateModal: false
data: () => ({
loadingDuplicate: false,
loadingDelete: false,
showDeleteFormModal: false,
showFormTemplateModal: false
}),
computed: {
...mapGetters({
user: 'auth/user'
}),
formEndpoint: () => '/api/open/forms/{id}'
},
computed: {
...mapGetters({
user: 'auth/user'
}),
formEndpoint: () => '/api/open/forms/{id}',
methods: {
copyLink () {
const el = document.createElement('textarea')
el.value = this.form.share_url
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
this.alertSuccess('Copied!')
},
methods: {
copyLink(){
const el = document.createElement('textarea')
el.value = this.form.share_url
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
this.alertSuccess('Copied!')
},
duplicateForm() {
if (this.loadingDuplicate) return
this.loadingDuplicate = true
axios.post(this.formEndpoint.replace('{id}', this.form.id) + '/duplicate').then((response) => {
this.$store.commit('open/forms/addOrUpdate', response.data.new_form)
this.$router.push({name: 'forms.show', params: {slug: response.data.new_form.slug}})
this.alertSuccess('Form was successfully duplicated.')
this.loadingDuplicate = false
})
},
deleteForm() {
if (this.loadingDelete) return
this.loadingDelete = true
axios.delete(this.formEndpoint.replace('{id}', this.form.id)).then(() => {
this.$store.commit('open/forms/remove', this.form)
this.$router.push({name: 'home'})
this.alertSuccess('Form was deleted.')
this.loadingDelete = false
})
},
duplicateForm () {
if (this.loadingDuplicate) return
this.loadingDuplicate = true
axios.post(this.formEndpoint.replace('{id}', this.form.id) + '/duplicate').then((response) => {
this.$store.commit('open/forms/addOrUpdate', response.data.new_form)
this.$router.push({ name: 'forms.show', params: { slug: response.data.new_form.slug } })
this.alertSuccess('Form was successfully duplicated.')
this.loadingDuplicate = false
})
},
deleteForm () {
if (this.loadingDelete) return
this.loadingDelete = true
axios.delete(this.formEndpoint.replace('{id}', this.form.id)).then(() => {
this.$store.commit('open/forms/remove', this.form)
this.$router.push({ name: 'home' })
this.alertSuccess('Form was deleted.')
this.loadingDelete = false
})
}
}
}
</script>

View File

@ -3,8 +3,12 @@
<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>
<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, dedicated application instance in a specific region, 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">

View File

@ -2,8 +2,8 @@
<div class="w-full">
<section class="relative">
<div v-if="!homePage" class="absolute inset-0 grid" aria-hidden="true">
<div class="bg-gray-100"></div>
<div class="bg-white"></div>
<div class="bg-gray-100" />
<div class="bg-white" />
</div>
<div class="px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
@ -14,13 +14,14 @@
<span class="ml-2 text-nt-blue">
<svg class="inline w-10 h-10" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.12">
<path d="M15.9998 27.3333L10.6665 12H21.3332L15.9998 27.3333Z" fill="currentColor"/>
<path d="M13.3332 4H9.33317L2.6665 12L10.6665 12L13.3332 4Z" fill="currentColor"/>
<path d="M18.6665 4H22.6665L29.3332 12L21.3332 12L18.6665 4Z" fill="currentColor"/>
<path d="M15.9998 27.3333L10.6665 12H21.3332L15.9998 27.3333Z" fill="currentColor" />
<path d="M13.3332 4H9.33317L2.6665 12L10.6665 12L13.3332 4Z" fill="currentColor" />
<path d="M18.6665 4H22.6665L29.3332 12L21.3332 12L18.6665 4Z" fill="currentColor" />
</g>
<path
d="M3.33345 12H28.6668M13.3334 4L10.6668 12L16.0001 27.3333L21.3334 12L18.6668 4M16.8195 27.0167L28.7644 12.6829C28.9668 12.4399 29.0681 12.3185 29.1067 12.1829C29.1408 12.0633 29.1408 11.9367 29.1067 11.8171C29.0681 11.6815 28.9668 11.5601 28.7644 11.3171L22.9866 4.3838C22.8691 4.24273 22.8103 4.17219 22.7382 4.12148C22.6744 4.07654 22.6031 4.04318 22.5277 4.02289C22.4426 4 22.3508 4 22.1672 4H9.83305C9.64941 4 9.55758 4 9.4725 4.02289C9.39711 4.04318 9.32586 4.07654 9.26202 4.12148C9.18996 4.17219 9.13118 4.24273 9.01361 4.3838L3.23583 11.3171C3.0334 11.5601 2.93218 11.6815 2.8935 11.8171C2.85939 11.9366 2.85939 12.0633 2.8935 12.1829C2.93218 12.3185 3.0334 12.4399 3.23583 12.6829L15.1807 27.0167C15.4621 27.3544 15.6028 27.5232 15.7713 27.5848C15.919 27.6388 16.0812 27.6388 16.229 27.5848C16.3974 27.5232 16.5381 27.3544 16.8195 27.0167Z"
stroke="currentColor" stroke-width="2.66667" stroke-linecap="round" stroke-linejoin="round"/>
stroke="currentColor" stroke-width="2.66667" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
Pro Features
</span>
@ -37,28 +38,31 @@
<h4 class="flex-none text-sm font-semibold leading-6 tracking-widest text-gray-400 uppercase">
What's included
</h4>
<div class="flex-auto h-px bg-gray-200"></div>
<div class="flex-auto h-px bg-gray-200" />
</div>
<ul role="list"
class="grid grid-cols-1 gap-4 mt-4 text-sm font-medium leading-6 text-gray-900 sm:grid-cols-2 sm:gap-x-6 sm:gap-y-2">
class="grid grid-cols-1 gap-4 mt-4 text-sm font-medium leading-6 text-gray-900 sm:grid-cols-2 sm:gap-x-6 sm:gap-y-2"
>
<li v-for="(title, i) in pricingInfo" :key="i" class="flex gap-x-3">
<svg aria-hidden="true" class="w-5 h-5 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M20 6L9 17L4 12" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
xmlns="http://www.w3.org/2000/svg"
>
<path d="M20 6L9 17L4 12" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{{ title }}
</li>
<slot name="pricing-table"></slot>
<slot name="pricing-table" />
</ul>
</div>
<div class="p-2 -mt-2 flex-col lg:mt-0 lg:w-full lg:max-w-md lg:flex-shrink-0">
<div
class="grow h-full py-10 text-center rounded-2xl bg-gray-50 ring-1 ring-inset ring-gray-900/5 lg:flex lg:flex-col lg:justify-center lg:py-12">
class="grow h-full py-10 text-center rounded-2xl bg-gray-50 ring-1 ring-inset ring-gray-900/5 lg:flex lg:flex-col lg:justify-center lg:py-12"
>
<div class="max-w-xs px-8 mx-auto space-y-6">
<div class="flex items-center justify-center mb-10">
<monthly-yearly-selector v-model="isYearly"/>
<monthly-yearly-selector v-model="isYearly" />
</div><!-- lg+ -->
<p class="flex flex-col items-center">
@ -75,11 +79,12 @@
<v-button v-if="!authenticated" class="mr-1" :to="{ name: 'register' }" :arrow="true">
Start free trial
</v-button>
<v-button v-else-if="authenticated && user.is_subscribed" class="mr-1" @click.prevent="openBilling"
:arrow="true">
<v-button v-else-if="authenticated && user.is_subscribed" class="mr-1" :arrow="true"
@click.prevent="openBilling"
>
View Billing
</v-button>
<v-button v-else class="mr-1" @click.prevent="openCustomerCheckout('default')" :arrow="true">
<v-button v-else class="mr-1" :arrow="true" @click.prevent="openCustomerCheckout('default')">
Start free trial
</v-button>
</div>
@ -102,7 +107,7 @@
</template>
<script>
import {mapGetters} from 'vuex'
import { mapGetters } from 'vuex'
import axios from 'axios'
import MonthlyYearlySelector from './MonthlyYearlySelector.vue'
import CheckoutDetailsModal from './CheckoutDetailsModal.vue'
@ -130,7 +135,7 @@ export default {
'Slack notifications',
'Discord notifications',
'Editable submissions',
'Custom domain (soon)',
'1 Custom domain',
'Custom code',
'Larger file uploads (50mb)',
'Remove OpnForm branding',
@ -139,11 +144,11 @@ export default {
}),
methods: {
openCustomerCheckout(plan) {
openCustomerCheckout (plan) {
this.selectedPlan = plan
this.showDetailsModal = true
},
openBilling() {
openBilling () {
this.billingLoading = true
axios.get('/api/subscription/billing-portal').then((response) => {
this.billingLoading = false

View File

@ -0,0 +1,16 @@
export default async (to, from, next) => {
if (!window.config.custom_domains) {
next()
}
const isCustomDomain = getDomain(window.location.href) !== getDomain(window.config.app_url)
if (isCustomDomain && !['forms.show_public'].includes(to.name)) {
// If route isn't a public form, redirect
window.location.href = 'https://opnform.com/'
} else {
next()
}
}
function getDomain (url) {
return (new URL(url)).hostname
}

View File

@ -1,17 +0,0 @@
import store from '~/store'
export default async (to, from, next) => {
/* if (store.getters['auth/check'] && store.getters['auth/user'].workspaces_count === 0) {
if ([
'forms.create',
'forms.show',
'forms.edit',
'home'
].includes(to.name)
) {
next({ name: 'onboarding' })
}
}*/
next()
}

View File

@ -52,5 +52,13 @@ export default {
return this.content
}
}
},
watch: {
value (val) {
if (val !== this.compVal) {
this.compVal = val
}
}
}
}

View File

@ -4,11 +4,13 @@
<div class="flex bg-gray-50">
<div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl px-4">
<div class="pt-4 pb-0">
<a href="#" @click.prevent="goBack" class="flex text-blue mb-2 font-semibold text-sm">
<a href="#" class="flex text-blue mb-2 font-semibold text-sm" @click.prevent="goBack">
<svg class="w-3 h-3 text-blue mt-1 mr-1" viewBox="0 0 6 10" fill="none"
xmlns="http://www.w3.org/2000/svg">
xmlns="http://www.w3.org/2000/svg"
>
<path d="M5 9L1 5L5 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round"/>
stroke-linejoin="round"
/>
</svg>
Go back
</a>
@ -20,24 +22,30 @@
<div class="flex">
<extra-menu :form="form" />
<v-button target="_blank" :to="{name:'forms.show_public', params: {slug: form.slug}}"
color="white" class="mr-2 text-blue-600 hidden sm:block"
v-track.view_form_click="{form_id:form.id, form_slug:form.slug}">
<v-button v-track.view_form_click="{form_id:form.id, form_slug:form.slug}" target="_blank"
:href="form.share_url" color="white"
class="mr-2 text-blue-600 hidden sm:block"
>
<svg class="w-6 h-6 inline -mt-1" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
xmlns="http://www.w3.org/2000/svg"
>
<path d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12C23 12 19 20 12 20C5 20 1 12 1 12Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
<path
d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
</v-button>
<v-button class="text-white" @click="openEdit">
<svg class="inline mr-1 -mt-1" width="18" height="17" viewBox="0 0 18 17" fill="none"
xmlns="http://www.w3.org/2000/svg">
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.99998 15.6662H16.5M1.5 15.6662H2.89545C3.3031 15.6662 3.50693 15.6662 3.69874 15.6202C3.8688 15.5793 4.03138 15.512 4.1805 15.4206C4.34869 15.3175 4.49282 15.1734 4.78107 14.8852L15.25 4.4162C15.9404 3.72585 15.9404 2.60656 15.25 1.9162C14.5597 1.22585 13.4404 1.22585 12.75 1.9162L2.28105 12.3852C1.9928 12.6734 1.84867 12.8175 1.7456 12.9857C1.65422 13.1348 1.58688 13.2974 1.54605 13.4675C1.5 13.6593 1.5 13.8631 1.5 14.2708V15.6662Z"
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>
Edit form
</v-button>
@ -52,16 +60,18 @@
<span>- Edited {{ form.last_edited_human }}</span>
</p>
<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">
<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">
<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"
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"
>
{{ tag }}
</span>
@ -69,14 +79,14 @@
<p v-if="form.closes_at" class="text-yellow-500">
<span v-if="form.is_closed"> This form stopped accepting submissions on the {{
displayClosesDate
}} </span>
displayClosesDate
}} </span>
<span v-else> This form will stop accepting submissions on the {{ displayClosesDate }} </span>
</p>
<p v-if="form.max_submissions_count > 0" class="text-yellow-500">
<span v-if="form.max_number_of_submissions_reached"> The form is now closed because it reached its limit of {{
form.max_submissions_count
}} submissions. </span>
form.max_submissions_count
}} submissions. </span>
<span v-else> This form will stop accepting submissions after {{ form.max_submissions_count }} submissions. </span>
</p>
@ -84,15 +94,16 @@
<div class="border-b border-gray-200 dark:border-gray-700">
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center">
<li class="mr-6" v-for="(tab, i) in tabsList" :key="i+1">
<li v-for="(tab, i) in tabsList" :key="i+1" class="mr-6">
<router-link :to="{ name: tab.route }"
class="hover:no-underline inline-block py-4 rounded-t-lg border-b-2 text-gray-500 hover:text-gray-600"
active-class="text-blue-600 hover:text-blue-900 dark:text-blue-500 dark:hover:text-blue-500 border-blue-600 dark:border-blue-500"
>{{tab.name}}</router-link>
class="hover:no-underline inline-block py-4 rounded-t-lg border-b-2 text-gray-500 hover:text-gray-600"
active-class="text-blue-600 hover:text-blue-900 dark:text-blue-500 dark:hover:text-blue-500 border-blue-600 dark:border-blue-500"
>
{{ tab.name }}
</router-link>
</li>
</ul>
</div>
</div>
</div>
</div>
@ -107,7 +118,7 @@
</div>
</template>
<div v-else-if="loading" class="text-center w-full p-5">
<loader class="h-6 w-6 mx-auto"/>
<loader class="h-6 w-6 mx-auto" />
</div>
<div v-else class="text-center w-full p-5">
Form not found.
@ -119,9 +130,9 @@
import axios from 'axios'
import store from '~/store'
import Form from 'vform'
import {mapGetters, mapState} from 'vuex'
import { mapGetters, mapState } from 'vuex'
import ProTag from '../../../components/common/ProTag.vue'
import VButton from "../../../components/common/Button.vue";
import VButton from '../../../components/common/Button.vue'
import ExtraMenu from '../../../components/pages/forms/show/ExtraMenu.vue'
import SeoMeta from '../../../mixins/seo-meta.js'
import FormCleanings from '../../../components/pages/forms/show/FormCleanings.vue'
@ -143,18 +154,18 @@ export default {
},
mixins: [SeoMeta],
beforeRouteEnter(to, from, next) {
beforeRouteEnter (to, from, next) {
loadForms()
next()
},
beforeRouteLeave(to, from, next) {
beforeRouteLeave (to, from, next) {
this.workingForm = null
next()
},
middleware: 'auth',
data() {
data () {
return {
metaTitle: 'Home',
tabsList: [
@ -183,59 +194,59 @@ export default {
workspacesLoading: state => state['open/workspaces'].loading
}),
workingForm: {
get() {
get () {
return this.$store.state['open/working_form'].content
},
set(value) {
set (value) {
this.$store.commit('open/working_form/set', value)
}
},
workspace() {
workspace () {
if (!this.form) return null
return this.$store.getters['open/workspaces/getById'](this.form.workspace_id)
},
form() {
form () {
return this.$store.getters['open/forms/getBySlug'](this.$route.params.slug)
},
formEndpoint: () => '/api/open/forms/{id}',
loading() {
loading () {
return this.formsLoading || this.workspacesLoading
},
displayClosesDate() {
displayClosesDate () {
if (this.form.closes_at) {
let dateObj = new Date(this.form.closes_at)
return dateObj.getFullYear() + "-" +
String(dateObj.getMonth() + 1).padStart(2, '0') + "-" +
String(dateObj.getDate()).padStart(2, '0') + " " +
String(dateObj.getHours()).padStart(2, '0') + ":" +
const dateObj = new Date(this.form.closes_at)
return dateObj.getFullYear() + '-' +
String(dateObj.getMonth() + 1).padStart(2, '0') + '-' +
String(dateObj.getDate()).padStart(2, '0') + ' ' +
String(dateObj.getHours()).padStart(2, '0') + ':' +
String(dateObj.getMinutes()).padStart(2, '0')
}
return "";
return ''
}
},
watch: {
form() {
form () {
this.workingForm = new Form(this.form)
}
},
mounted() {
mounted () {
if (this.form) {
this.workingForm = new Form(this.form)
}
},
methods: {
openCrisp() {
openCrisp () {
window.$crisp.push(['do', 'chat:show'])
window.$crisp.push(['do', 'chat:open'])
},
goBack() {
this.$router.push({name: 'home'})
goBack () {
this.$router.push({ name: 'home' })
},
openEdit() {
this.$router.push({name: 'forms.edit', params: {slug: this.form.slug}})
openEdit () {
this.$router.push({ name: 'forms.edit', params: { slug: this.form.slug } })
}
}
}

View File

@ -1,50 +1,92 @@
<template>
<div>
<h3 class="font-semibold text-2xl text-gray-900">Workspace settings</h3>
<small class="text-gray-600">Manage your workspaces.</small>
<div v-if="loading" class="w-full text-blue-500 text-center">
<loader class="h-10 w-10 p-5"/>
</div>
<div v-else>
<div v-for="workspace in workspaces" :key="workspace.id"
class="mt-4 p-4 flex group bg-white hover:bg-gray-50 dark:bg-notion-dark items-center"
>
<div class="flex space-x-4 flex-grow items-center cursor-pointer" role="button" @click.prevent="switchWorkspace(workspace)">
<img v-if="isUrl(workspace.icon)" :src="workspace.icon" :alt="workspace.name + ' icon'"
class="rounded-full h-12 w-12"
>
<div v-else class="rounded-2xl bg-gray-100 h-12 w-12 text-2xl pt-2 text-center overflow-hidden"
v-text="workspace.icon"
/>
<div class="space-y-4 py-1">
<div class="font-bold truncate">{{workspace.name}}</div>
</div>
</div>
<div v-if="workspaces.length > 1"
class="block md:hidden group-hover:block text-red-500 p-2 rounded hover:bg-red-50" role="button"
@click="deleteWorkspace(workspace)">
<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>
</div>
<div class="flex flex-wrap items-center gap-y-4 flex-wrap-reverse">
<div class="flex-grow">
<h3 class="font-semibold text-2xl text-gray-900">
Workspace settings
</h3>
<small class="text-gray-600">Manage your workspaces.</small>
</div>
<v-button :loading="loading" class="mt-4" @click="workspaceModal=true">
<svg class="inline text-white mr-1 h-4 w-4" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.99996 1.16699V12.8337M1.16663 7.00033H12.8333" stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"/>
<v-button color="outline-blue" :loading="loading" @click="workspaceModal=true">
<svg class="inline -mt-1 mr-1 h-4 w-4" viewBox="0 0 14 14" fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M6.99996 1.16699V12.8337M1.16663 7.00033H12.8333" stroke="currentColor" stroke-width="1.67"
stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
Create new workspace
</v-button>
</div>
<div v-if="loading" class="w-full text-blue-500 text-center">
<loader class="h-10 w-10 p-5" />
</div>
<div v-else-if="workspace">
<div class="mt-4 flex group bg-white items-center">
<div class="flex space-x-4 flex-grow items-center">
<img v-if="isUrl(workspace.icon)" :src="workspace.icon" :alt="workspace.name + ' icon'"
class="rounded-full h-12 w-12"
>
<div v-else class="rounded-2xl bg-gray-100 h-12 w-12 text-2xl pt-2 text-center overflow-hidden"
v-text="workspace.icon"
/>
<div class="space-y-4 py-1">
<div class="font-bold truncate">
{{ workspace.name }}
</div>
</div>
</div>
</div>
<template v-if="customDomainsEnabled">
<text-area-input v-model="customDomains" name="custom_domain" class="mt-4" :required="false"
:disabled="!workspace.is_pro"
label="Workspace Custom Domains" wrapper-class="" placeholder="yourdomain.com - 1 per line"
/>
<p class="text-gray-500 text-sm">
Read our <a href="#"
@click.prevent="$crisp.push(['do', 'helpdesk:article:open', ['en', 'how-to-use-my-own-domain-9m77g7']])"
>custom
domain instructions</a> to learn how to use your own domain.
</p>
</template>
<div class="flex flex-wrap justify-between gap-2 mt-4">
<v-button v-if="customDomainsEnabled" class="w-full sm:w-auto" :loading="customDomainsLoading" @click="saveChanges">
<svg class="w-4 h-4 text-white inline mr-1 -mt-1" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17 21V13H7V21M7 3V8H15M19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H16L21 8V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
Save Domains
</v-button>
<v-button v-if="workspaces.length > 1" color="white" class="group w-full sm:w-auto" :loading="loading"
@click="deleteWorkspace(workspace)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 -mt-1 inline group-hover:text-red-700" 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>
Remove workspace
</v-button>
</div>
</div>
<!-- Workspace modal -->
<modal :show="workspaceModal" @close="workspaceModal=false" max-width="lg">
<modal :show="workspaceModal" max-width="lg" @close="workspaceModal=false">
<template #icon>
<svg class="w-8 h-8" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 8V16M8 12H16M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
</template>
<template #title>
@ -62,25 +104,28 @@
</div>
<div class="w-full mt-6">
<v-button :loading="form.busy" class="w-full my-3">Save</v-button>
<v-button :loading="form.busy" class="w-full my-3">
Save
</v-button>
</div>
</form>
</div>
</modal>
</div>
</template>
<script>
import Form from 'vform'
import {mapActions, mapState} from 'vuex'
import { mapActions, mapState } from 'vuex'
import SeoMeta from '../../mixins/seo-meta.js'
import TextAreaInput from '../../components/forms/TextAreaInput.vue'
import axios from 'axios'
import * as domain from 'domain'
export default {
components: {},
scrollToTop: false,
components: { TextAreaInput },
mixins: [SeoMeta],
scrollToTop: false,
data: () => ({
metaTitle: 'Workspaces',
@ -88,37 +133,65 @@ export default {
name: '',
emoji: ''
}),
workspaceModal: false
workspaceModal: false,
customDomains: '',
customDomainsLoading: false
}),
mounted() {
mounted () {
this.loadWorkspaces()
this.initCustomDomains()
},
computed: {
...mapState({
workspaces: state => state['open/workspaces'].content,
loading: state => state['open/workspaces'].loading
})
}),
workspace () {
return this.$store.getters['open/workspaces/getCurrent']()
},
customDomainsEnabled () {
return window.config.custom_domains_enabled
}
},
methods: {
...mapActions({
loadWorkspaces: 'open/workspaces/loadIfEmpty'
}),
switchWorkspace(workspace) {
this.$store.commit('open/workspaces/setCurrentId', workspace.id)
this.$router.push({name: 'home'})
this.$store.dispatch('open/forms/load', workspace.id)
saveChanges () {
if (this.customDomainsLoading) return
this.customDomainsLoading = true
// Update the workspace custom domain
axios.put('/api/open/workspaces/' + this.workspace.id + '/custom-domains', {
custom_domains: this.customDomains.split('\n')
.map(domain => domain.trim())
.filter(domain => domain && domain.length > 0)
}).then(() => {
this.alertSuccess('Custom domains saved.')
}).catch((error) => {
this.alertError('Failed to update custom domains: ' + error.response.data.message)
}).finally(() => {
this.customDomainsLoading = false
})
},
deleteWorkspace(workspace) {
initCustomDomains () {
if (!this.workspace) return
this.customDomains = this.workspace.custom_domains.join('\n')
},
deleteWorkspace (workspace) {
if (this.workspaces.length <= 1) {
this.alertError('You cannot delete your only workspace.')
return
}
this.alertConfirm('Do you really want to delete this workspace? All forms created in this workspace will be removed.', () => {
this.$store.dispatch('open/workspaces/delete', workspace.id).then(() => {
this.alertSuccess('Workspace successfully removed.')
})
})
},
isUrl(str) {
isUrl (str) {
const pattern = new RegExp('^(https?:\\/\\/)?' + // protocol
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name
'((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address
@ -127,11 +200,17 @@ export default {
'(\\#[-a-z\\d_]*)?$', 'i') // fragment locator
return !!pattern.test(str)
},
async createWorkspace() {
const {data} = await this.form.post('/api/open/workspaces/create')
async createWorkspace () {
const { data } = await this.form.post('/api/open/workspaces/create')
this.$store.dispatch('open/workspaces/load')
this.workspaceModal = false
}
},
watch: {
workspace () {
this.initCustomDomains()
}
}
}
</script>

View File

@ -3,14 +3,14 @@ import store from '~/store'
import Meta from 'vue-meta'
import routes from './routes'
import Router from 'vue-router'
import {sync} from 'vuex-router-sync'
import { sync } from 'vuex-router-sync'
import * as Sentry from '@sentry/vue'
Vue.use(Meta)
Vue.use(Router)
// The middleware for every page of the application.
const globalMiddleware = ['locale', 'check-auth', 'notion-connection']
const globalMiddleware = ['locale', 'check-auth', 'custom-domains']
// Load middleware modules dynamically.
const requireContext = import.meta.glob('../middleware/**/*.js', { eager: true })
@ -253,7 +253,7 @@ function resolveMiddleware (requireContext) {
.map(file =>
[file.match(/[^/]*(?=\.[^.]*$)/)[0], requireContext[file]]
).forEach(([name, middleware]) => {
middlewares[name] = middleware.default || middleware
})
middlewares[name] = middleware.default || middleware
})
return middlewares
}

View File

@ -61,6 +61,10 @@ export const mutations = {
},
remove (state, itemId) {
state.content = state.content.filter((val) => val.id !== itemId)
if (state.currentId === itemId) {
state.currentId = state.content.length > 0 ? state.content[0].id : null
localStorage.setItem(localStorageCurrentWorkspaceKey, state.currentId)
}
},
startLoading () {
state.loading = true

View File

@ -16,7 +16,8 @@
'crisp_website_id' => config('services.crisp_website_id'),
'ai_features_enabled' => !is_null(config('services.openai.api_key')),
's3_enabled' => config('filesystems.default') === 's3',
'paid_plans_enabled' => !is_null(config('cashier.key'))
'paid_plans_enabled' => !is_null(config('cashier.key')),
'custom_domains_enabled' => config('custom-domains.enabled'),
];
@endphp
<!DOCTYPE html>

View File

@ -69,6 +69,7 @@ Route::group(['middleware' => 'auth:api'], function () {
Route::get('/forms',
[FormController::class, 'index'])->name('forms.index');
Route::put('/custom-domains', [WorkspaceController::class, 'saveCustomDomain'])->name('save-custom-domains');
Route::delete('/', [WorkspaceController::class, 'delete'])->name('delete');
Route::get('form-stats/{formId}', [FormStatsController::class, 'getFormStats'])->name('form.stats');

View File

@ -14,4 +14,4 @@ use Illuminate\Support\Facades\Route;
|
*/
Route::get('{path}', SpaController::class)->where('path', '^(?!(api|stats|mailcoach|vapor|sitemap|dist)).*$');
Route::get('{path}', SpaController::class)->where('path', '^(?!(api|stats|mailcoach|vapor|sitemap|caddy|dist)).*$');

View File

@ -38,3 +38,6 @@ Route::get('local/temp/{path}', function (Request $request, string $path){
})->where('path', '(.*)')->name('local.temp');
Route::get('/sitemap.xml', [\App\Http\Controllers\SitemapController::class, 'getSitemap'])->name('sitemap');
Route::get('caddy/ask-certificate/{secret?}', [\App\Http\Controllers\CaddyController::class, 'ask'])
->name('caddy.ask')->middleware(\App\Http\Middleware\CaddyRequestMiddleware::class);

9
vite.config.js vendored
View File

@ -7,7 +7,8 @@ const plugins = [
laravel({
input: [
'resources/js/app.js'
]
],
refresh: true
}),
vue({
template: {
@ -46,5 +47,11 @@ export default defineConfig({
'~': '/resources/js',
'@': '/resources'
}
},
server: {
hmr: {
host: 'localhost',
protocol: 'ws'
}
}
})