Enable pricing (#151)

* Enable Pro plan - WIP

* no pricing page if have no paid plans

* Set pricing ids in env

* views & submissions FREE for all

* extra param for env

* form password FREE for all

* Custom Code is PRO feature

* Replace codeinput prism with codemirror

* Better form Cleaning message

* Added risky user email spam protection

* fix form cleaning

* Pricing page new UI

* form cleaner

* Polish changes

* Fixed tests

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
formsdev 2023-08-30 13:28:29 +05:30 committed by GitHub
parent 29b153bd76
commit fb79a5bf3e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 1011 additions and 269 deletions

View File

@ -56,7 +56,21 @@ JWT_SECRET=
STRIPE_KEY=
STRIPE_SECRET=
STRIPE_PROD_DEFAULT_PRODUCT_ID=
STRIPE_PROD_DEFAULT_PRICING_MONTHLY=
STRIPE_PROD_DEFAULT_PRICING_YEARLY=
STRIPE_TEST_DEFAULT_PRODUCT_ID=
STRIPE_TEST_DEFAULT_PRICING_MONTHLY=
STRIPE_TEST_DEFAULT_PRICING_YEARLY=
H_CAPTCHA_SITE_KEY=
H_CAPTCHA_SECRET=
MUX_WORKSPACE_ID=
MUX_API_TOKEN=
ADMIN_EMAILS=
TEMPLATE_EDITOR_EMAILS=
OPEN_AI_API_KEY=

View File

@ -34,13 +34,21 @@ class FormController extends Controller
$this->authorize('viewAny', Form::class);
$workspaceIsPro = $workspace->is_pro;
$forms = $workspace->forms()->with(['creator','views','submissions'])->paginate(10)->through(function (Form $form) use ($workspace, $workspaceIsPro){
$forms = $workspace->forms()->with(['creator','views','submissions'])
->orderByDesc('updated_at')
->paginate(10)->through(function (Form $form) use ($workspace, $workspaceIsPro){
// Add attributes for faster loading
$form->extra = (object) [
'loadedWorkspace' => $workspace,
'workspaceIsPro' => $workspaceIsPro,
'userIsOwner' => true,
'cleanings' => $this->formCleaner
->processForm(request(), $form)
->simulateCleaning($workspace)
->getPerformedCleanings()
];
return $form;
});
return FormResource::collection($forms);
@ -91,8 +99,7 @@ class FormController extends Controller
return $this->success([
'message' => $this->formCleaner->hasCleaned() ? 'Form successfully created, but the Pro features you used will be disabled when sharing your form:' : 'Form created.',
'form_cleaning' => $this->formCleaner->getPerformedCleanings(),
'form' => new FormResource($form),
'form' => (new FormResource($form))->setCleanings($this->formCleaner->getPerformedCleanings()),
'users_first_form' => $request->user()->forms()->count() == 1
]);
}
@ -116,8 +123,7 @@ class FormController extends Controller
return $this->success([
'message' => $this->formCleaner->hasCleaned() ? 'Form successfully updated, but the Pro features you used will be disabled when sharing your form:' : 'Form updated.',
'form_cleaning' => $this->formCleaner->getPerformedCleanings(),
'form' => new FormResource($form)
'form' => (new FormResource($form))->setCleanings($this->formCleaner->getPerformedCleanings()),
]);
}

View File

@ -5,8 +5,6 @@ namespace App\Http\Controllers\Forms;
use App\Http\Controllers\Controller;
use App\Models\Forms\Form;
use Carbon\CarbonPeriod;
use App\Models\Forms\FormStatistic;
use Illuminate\Http\Request;
class FormStatsController extends Controller
{
@ -15,9 +13,10 @@ class FormStatsController extends Controller
$this->middleware('auth');
}
public function getFormStats(Request $request)
public function getFormStats(string $formId)
{
$form = $request->form; // Added by ProForm middleware
$form = Form::findOrFail($formId);
$this->authorize('view', $form);
$formStats = $form->statistics()->where('date','>',now()->subDays(29)->startOfDay())->get();

View File

@ -45,9 +45,8 @@ class PublicFormController extends Controller
$form->views()->create();
}
$formResource = new FormResource($form);
$formResource->setCleanings($formCleaner->getPerformedCleanings());
return $formResource;
return (new FormResource($form))
->setCleanings($formCleaner->getPerformedCleanings());
}
public function listUsers(Request $request)

View File

@ -2,13 +2,14 @@
namespace App\Http\Controllers;
use App\Http\Requests\Subscriptions\UpdateStripeDetailsRequest;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Laravel\Cashier\Subscription;
class SubscriptionController extends Controller
{
const SUBSCRIPTION_PLANS = ['monthly_2022', 'yearly_2022'];
const SUBSCRIPTION_PLANS = ['monthly', 'yearly'];
const PRO_SUBSCRIPTION_NAME = 'default';
const ENTERPRISE_SUBSCRIPTION_NAME = 'enterprise';
@ -41,7 +42,7 @@ class SubscriptionController extends Controller
->allowPromotionCodes();
if ($trial != null) {
$checkoutBuilder->trialDays(3);
$checkoutBuilder->trialUntil(now()->addDays(3)->addHour());
}
$checkout = $checkoutBuilder
@ -49,6 +50,11 @@ class SubscriptionController extends Controller
->checkout([
'success_url' => url('/subscriptions/success'),
'cancel_url' => url('/subscriptions/error'),
'billing_address_collection' => 'required',
'customer_update' => [
'address' => 'auto',
'name' => 'never',
]
]);
return $this->success([
@ -56,6 +62,22 @@ class SubscriptionController extends Controller
]);
}
public function updateStripeDetails(UpdateStripeDetailsRequest $request)
{
$user = Auth::user();
if (!$user->hasStripeId()) {
$user->createAsStripeCustomer();
}
$user->updateStripeCustomer([
'email' => $request->email,
'name' => $request->name,
]);
return $this->success([
'message' => 'Details saved.',
]);
}
public function billingPortal()
{
$this->middleware('auth');
@ -69,7 +91,7 @@ class SubscriptionController extends Controller
]);
}
private function getPricing($product = 'pro')
private function getPricing($product = 'default')
{
return App::environment() == 'production' ? config('pricing.production.'.$product.'.pricing') : config('pricing.test.'.$product.'.pricing');
}

View File

@ -26,7 +26,7 @@ class PasswordProtectedForm
'form' => $form,
]);
$userIsFormOwner = Auth::check() && Auth::user()->workspaces()->find($form->workspace_id) !== null;
if (!$userIsFormOwner && $form->is_pro && $form->has_password) {
if (!$userIsFormOwner && $form->has_password) {
if($this->hasCorrectPassword($request, $form)){
return $next($request);
}

View File

@ -14,8 +14,8 @@ use App\Rules\ValidHCaptcha;
class AnswerFormRequest extends FormRequest
{
const MAX_FILE_SIZE_PRO = 5000000;
const MAX_FILE_SIZE_ENTERPRISE = 20000000;
const MAX_FILE_SIZE_FREE = 5000000; // 5 MB
const MAX_FILE_SIZE_PRO = 50000000; // 50 MB
public Form $form;
@ -26,10 +26,10 @@ class AnswerFormRequest extends FormRequest
{
$this->form = $request->form;
$this->maxFileSize = self::MAX_FILE_SIZE_PRO;
$this->maxFileSize = self::MAX_FILE_SIZE_FREE;
$workspace = $this->form->workspace;
if ($workspace && $workspace->is_enterprise) {
$this->maxFileSize = self::MAX_FILE_SIZE_ENTERPRISE;
if ($workspace && $workspace->is_pro) {
$this->maxFileSize = self::MAX_FILE_SIZE_PRO;
}
}
@ -53,9 +53,9 @@ class AnswerFormRequest extends FormRequest
foreach ($this->form->properties as $property) {
$rules = [];
if (!$this->form->is_pro) { // If not pro then not check logic
/*if (!$this->form->is_pro) { // If not pro then not check logic
$property['logic'] = false;
}
}*/
// For get values instead of Id for select/multi select options
$data = $this->toArray();
@ -96,12 +96,12 @@ class AnswerFormRequest extends FormRequest
}
// Validate hCaptcha
if ($this->form->is_pro && $this->form->use_captcha) {
if ($this->form->use_captcha) {
$this->requestRules['h-captcha-response'] = [new ValidHCaptcha()];
}
// Validate submission_id for edit mode
if ($this->form->editable_submissions) {
if ($this->form->is_pro && $this->form->editable_submissions) {
$this->requestRules['submission_id'] = 'string';
}
@ -160,7 +160,7 @@ class AnswerFormRequest extends FormRequest
return ['numeric'];
case 'select':
case 'multi_select':
if ($this->form->is_pro && ($property['allow_creation'] ?? false)) {
if (($property['allow_creation'] ?? false)) {
return ['string'];
}
return [Rule::in($this->getSelectPropertyOptions($property))];
@ -174,7 +174,7 @@ class AnswerFormRequest extends FormRequest
return ['url'];
case 'files':
$allowedFileTypes = [];
if($this->form->is_pro && !empty($property['allowed_file_types'])){
if(!empty($property['allowed_file_types'])){
$allowedFileTypes = explode(",", $property['allowed_file_types']);
}
$this->requestRules[$property['id'].'.*'] = [new StorageFile($this->maxFileSize, $allowedFileTypes, $this->form)];

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Subscriptions;
use Illuminate\Foundation\Http\FormRequest;
class UpdateStripeDetailsRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules()
{
return [
'name' => 'required|string',
'email' => 'required|email',
];
}
}

View File

@ -26,8 +26,8 @@ class FormResource extends JsonResource
$ownerData = $this->userIsFormOwner() ? [
'creator' => new UserResource($this->creator),
'views_count' => $this->when($this->workspaceIsPro(), $this->views_count),
'submissions_count' => $this->when($this->workspaceIsPro(), $this->submissions_count),
'views_count' => $this->views_count,
'submissions_count' => $this->submissions_count,
'notifies' => $this->notifies,
'notifies_slack' => $this->notifies_slack,
'notifies_discord' => $this->notifies_discord,
@ -35,7 +35,7 @@ class FormResource extends JsonResource
'webhook_url' => $this->webhook_url,
'redirect_url' => $this->redirect_url,
'database_fields_update' => $this->database_fields_update,
'cleanings' => $this->cleanings,
'cleanings' => $this->getCleanigns(),
'notification_sender' => $this->notification_sender,
'notification_subject' => $this->notification_subject,
'notification_body' => $this->notification_body,
@ -95,7 +95,7 @@ class FormResource extends JsonResource
private function doesMissPassword(Request $request)
{
if (!$this->workspaceIsPro() || !$this->has_password) return false;
if (!$this->has_password) return false;
return !PasswordProtectedForm::hasCorrectPassword($request, $this->resource);
}
@ -132,4 +132,9 @@ class FormResource extends JsonResource
&& Auth::user()->workspaces()->find($this->workspace_id) !== null
);
}
private function getCleanigns()
{
return $this->extra?->cleanings ?? $this->cleanings;
}
}

View File

@ -18,6 +18,8 @@ class SubmissionConfirmation implements ShouldQueue
{
use InteractsWithQueue;
const RISKY_USERS_LIMIT = 120;
/**
* Handle the event.
*
@ -26,7 +28,13 @@ class SubmissionConfirmation implements ShouldQueue
*/
public function handle(FormSubmitted $event)
{
if (!$event->form->send_submission_confirmation) return;
if (
!$event->form->is_pro ||
!$event->form->send_submission_confirmation ||
$this->riskLimitReached($event) // To avoid phishing abuse we limit this feature for risky users
) {
return;
}
$email = $this->getRespondentEmail($event);
if (!$email) return;
@ -56,6 +64,21 @@ class SubmissionConfirmation implements ShouldQueue
return null;
}
private function riskLimitReached(FormSubmitted $event): bool
{
// This is a per-workspace limit for risky workspaces
if ($event->form->workspace->is_risky) {
if ($event->form->workspace->submissions_count >= self::RISKY_USERS_LIMIT) {
\Log::error('!!!DANGER!!! Dangerous user detected! Attempting many email sending.', [
'form_id' => $event->form->id,
'workspace_id' => $event->form->workspace->id,
]);
return true;
}
}
return false;
}
public static function validateEmail($email): bool {
return (boolean) filter_var($email, FILTER_VALIDATE_EMAIL);
}

View File

@ -179,6 +179,15 @@ class User extends Authenticatable implements JWTSubject //, MustVerifyEmail
return [];
}
public function getIsRiskyAttribute()
{
return $this->created_at->isAfter(now()->subDays(3)) || // created in last 3 days
$this->subscriptions()->where(function ($q) {
$q->where('stripe_status', 'trialing')
->orWhere('stripe_status', 'active');
})->first()?->onTrial();
}
public static function boot ()
{
parent::boot();

View File

@ -24,7 +24,9 @@ class Workspace extends Model
public function getIsProAttribute()
{
return true; // Temporary true for ALL
if(is_null(config('cashier.key'))){
return true; // If no paid plan so TRUE for ALL
}
// Make sure at least one owner is pro
foreach ($this->owners as $owner) {
@ -37,7 +39,9 @@ class Workspace extends Model
public function getIsEnterpriseAttribute()
{
return true; // Temporary true for ALL
if(is_null(config('cashier.key'))){
return true; // If no paid plan so TRUE for ALL
}
foreach ($this->owners as $owner) {
if ($owner->has_enterprise_subscription) {
@ -47,6 +51,28 @@ class Workspace extends Model
return false;
}
public function getIsRiskyAttribute()
{
// A workspace is risky if all of his users are risky
foreach ($this->owners as $owner) {
if (!$owner->is_risky) {
return false;
}
}
return true;
}
public function getSubmissionsCountAttribute()
{
$total = 0;
foreach ($this->forms as $form) {
$total += $form->submissions_count;
}
return $total;
}
/**
* Relationships
*/

View File

@ -11,7 +11,6 @@ use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Stevebauman\Purify\Facades\Purify;
use function App\Service\str_starts_with;
use function collect;
class FormCleaner
@ -26,76 +25,34 @@ class FormCleaner
private array $formDefaults = [
'notifies' => false,
'color' => '#3B82F6',
'hide_title' => false,
'no_branding' => false,
'transparent_background' => false,
'uppercase_labels' => true,
'webhook_url' => null,
'cover_picture' => null,
'logo_picture' => null,
'database_fields_update' => null,
'theme' => 'default',
'use_captcha' => false,
'password' => null,
'slack_webhook_url' => null,
'discord_webhook_url' => null,
'editable_submissions' => false,
'custom_code' => null,
];
private array $fieldDefaults = [
// 'name' => '' TODO: prevent name changing, use alias for column and keep original name as it is
'hide_field_name' => false,
'prefill' => null,
'placeholder' => null,
'help' => null,
'file_upload' => false,
'with_time' => null,
'width' => 'full',
'generates_uuid' => false,
'generates_auto_increment_id' => false,
'logic' => null,
'allow_creation' => false
];
private array $cleaningMessages = [
// For form
'notifies' => "Email notification were disabled.",
'color' => "Form color set to default blue.",
'hide_title' => "Title is not hidden.",
'no_branding' => "OpenForm branding is not hidden.",
'transparent_background' => "Transparent background was disabled.",
'uppercase_labels' => "Labels use uppercase letters",
'webhook_url' => "Webhook disabled.",
'cover_picture' => 'The cover picture was removed.',
'logo_picture' => 'The logo was removed.',
'database_fields_update' => 'Form submission will only create new records (no updates).',
'theme' => 'Default theme was applied.',
'slack_webhook_url' => "Slack webhook disabled.",
'discord_webhook_url' => "Discord webhook disabled.",
'editable_submissions' => 'Users will not be able to edit their submissions.',
'custom_code' => 'Custom code was disabled',
// For fields
'hide_field_name' => 'Hide field name removed.',
'prefill' => "Field prefill removed.",
'placeholder' => "Empty text (placeholder) removed",
'help' => "Help text removed.",
'file_upload' => "Link field is not a file upload.",
'with_time' => "Time was removed from date input.",
'custom_block' => 'The custom block was removed.',
'files' => 'The file upload file was hidden.',
'relation' => 'The relation file was hidden.',
'width' => 'The field width was set to full width',
'allow_creation' => 'Select option creation was disabled.',
// Advanced fields
'generates_uuid' => 'ID generation disabled.',
'generates_auto_increment_id' => 'ID generation disabled.',
'use_captcha' => 'Captcha form protection was disabled.',
// Security & Privacy
'password' => 'Password protection was disabled',
'logic' => 'Logic disabled for this property'
];
/**
@ -144,7 +101,8 @@ class FormCleaner
/**
* Create form cleaner instance from existing form
*/
public function processForm(Request $request, Form $form) : FormCleaner {
public function processForm(Request $request, Form $form) : FormCleaner
{
$data = (new FormResource($form))->toArray($request);
$this->data = $this->commonCleaning($data);
@ -159,10 +117,11 @@ class FormCleaner
* Dry run celanings
* @param User|null $user
*/
public function simulateCleaning(Workspace $workspace): FormCleaner {
if($this->isPro($workspace)) return $this;
public function simulateCleaning(Workspace $workspace): FormCleaner
{
if (!$this->isPro($workspace)) {
$this->data = $this->removeProFeatures($this->data, true);
}
return $this;
}
@ -174,9 +133,9 @@ class FormCleaner
*/
public function performCleaning(Workspace $workspace): FormCleaner
{
if($this->isPro($workspace)) return $this;
if (!$this->isPro($workspace)) {
$this->data = $this->removeProFeatures($this->data);
}
return $this;
}
@ -212,6 +171,7 @@ class FormCleaner
private function cleanProperties(array &$data, $simulation = false): void
{
foreach ($data['properties'] as $key => &$property) {
/*
// Remove pro custom blocks
if (\Str::of($property['type'])->startsWith('nf-')) {
$this->cleanings[$property['name']][] = 'custom_block';
@ -221,6 +181,15 @@ class FormCleaner
continue;
}
// Remove logic
if (($property['logic']['conditions'] ?? null) != null || ($property['logic']['actions'] ?? []) != []) {
$this->cleanings[$property['name']][] = 'logic';
if (!$simulation) {
unset($data['properties'][$key]['logic']);
}
}
*/
// Clean pro field options
$this->cleanField($property, $this->fieldDefaults, $simulation);
}
@ -229,8 +198,18 @@ class FormCleaner
private function clean(array &$data, array $defaults, $simulation = false): void
{
foreach ($defaults as $key => $value) {
if (Arr::get($data, $key) !== $value) {
if (!isset($this->cleanings['form'])) $this->cleanings['form'] = [];
// Get value from form
$formVal = Arr::get($data, $key);
// Transform boolean values
$formVal = (($formVal === 0 || $formVal === "0") ? false : $formVal);
$formVal = (($formVal === 1 || $formVal === "1") ? true : $formVal);
if (!is_null($formVal) && $formVal !== $value) {
if (!isset($this->cleanings['form'])) {
$this->cleanings['form'] = [];
}
$this->cleanings['form'][] = $key;
// If not a simulation, do the cleaning
@ -253,14 +232,14 @@ class FormCleaner
}
// Remove pro types columns
foreach (['files'] as $proType) {
/*foreach (['files'] as $proType) {
if ($data['type'] == $proType && (!isset($data['hidden']) || !$data['hidden'])) {
$this->cleanings[$data['name']][] = $proType;
if (!$simulation) {
$data['hidden'] = true;
}
}
}
}*/
}
}

View File

@ -4,44 +4,20 @@ return [
'production' => [
'default' => [
'product_id' => 'prod_JpQMgFHw0PSuzM',
'product_id' => env('STRIPE_PROD_DEFAULT_PRODUCT_ID'),
'pricing' => [
'yearly' => 'price_1JBlWXLQM1kjk4NvEWonKifC',
'monthly' => 'price_1JBlWELQM1kjk4NvmtrstOpi',
'yearly_2022' => 'price_1LLmZsLQM1kjk4Nv0oLa6MeZ',
'monthly_2022' => 'price_1LLmaYLQM1kjk4NvQER36XPA',
]
],
'enterprise' => [
'product_id' => 'prod_KXUeOAd1H42xMM',
'pricing' => [
'yearly' => 'price_1JsPfeLQM1kjk4NvV8MJ53yV',
'monthly' => 'price_1JsPfeLQM1kjk4NvtSszj1jE',
'yearly_2022' => 'price_1LLmXALQM1kjk4NvXXz5Rxxv',
'monthly_2022' => 'price_1LLmXtLQM1kjk4Nv1DShm9zs',
'monthly' => env('STRIPE_PROD_DEFAULT_PRICING_MONTHLY'),
'yearly' => env('STRIPE_PROD_DEFAULT_PRICING_YEARLY'),
]
]
],
'test' => [
'default' => [
'product_id' => 'prod_LY0BWzSv0Cl5Db',
'product_id' => env('STRIPE_TEST_DEFAULT_PRODUCT_ID'),
'pricing' => [
'yearly' => 'price_1KquBXKTHIweYlJTHYU6UXA1',
'monthly' => 'price_1KquBXKTHIweYlJTOGEWKr0B',
'yearly_2022' => 'price_1LKwvpKTHIweYlJT74vdfJcK',
'monthly_2022' => 'price_1LKwvLKTHIweYlJTOAyghKkJ',
]
],
'enterprise' => [
'product_id' => 'prod_LY0CdM6YtwODqn',
'pricing' => [
'yearly' => 'price_1KquCYKTHIweYlJTiR3TBMTV',
'monthly' => 'price_1KquCYKTHIweYlJT4vQVLcQ7',
'yearly_2022' => 'price_1LKwxCKTHIweYlJTJb71yk4V',
'monthly_2022' => 'price_1LKwwtKTHIweYlJTqO2JrQv4',
'monthly' => env('STRIPE_TEST_DEFAULT_PRICING_MONTHLY'),
'yearly' => env('STRIPE_TEST_DEFAULT_PRICING_YEARLY'),
]
]
]

63
package-lock.json generated
View File

@ -23,15 +23,15 @@
"tinymotion": "^0.2.0",
"vform": "^2.1.1",
"vt-notifications": "^0.4.1",
"vue": "^2.6.14",
"vue": "^2.7.14",
"vue-chartjs": "^4.1.0",
"vue-clickaway": "^2.2.2",
"vue-codemirror": "^4.0.6",
"vue-confetti": "^2.3.0",
"vue-i18n": "^8.25.0",
"vue-meta": "^2.4.0",
"vue-notion": "^0.4.0",
"vue-plugin-load-script": "^1.3.2",
"vue-prism-editor": "^1.2.2",
"vue-router": "^3.5.2",
"vue-signature-pad": "^2.0.5",
"vue-tailwind": "^2.5.0",
@ -4144,6 +4144,11 @@
"node": ">= 4.0"
}
},
"node_modules/codemirror": {
"version": "5.65.15",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.15.tgz",
"integrity": "sha512-YC4EHbbwQeubZzxLl5G4nlbLc1T21QTrKGaOal/Pkm9dVDMZXMH7+ieSPEOZCtO9I68i8/oteJKOxzHC2zR+0g=="
},
"node_modules/collection-visit": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz",
@ -4863,6 +4868,11 @@
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true
},
"node_modules/diff-match-patch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="
},
"node_modules/dijkstrajs": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.2.tgz",
@ -12434,6 +12444,19 @@
"vue": "^2.0.0"
}
},
"node_modules/vue-codemirror": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/vue-codemirror/-/vue-codemirror-4.0.6.tgz",
"integrity": "sha512-ilU7Uf0mqBNSSV3KT7FNEeRIxH4s1fmpG4TfHlzvXn0QiQAbkXS9lLfwuZpaBVEnpP5CSE62iGJjoliTuA8poQ==",
"dependencies": {
"codemirror": "^5.41.0",
"diff-match-patch": "^1.0.0"
},
"engines": {
"node": ">= 4.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/vue-confetti": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/vue-confetti/-/vue-confetti-2.3.0.tgz",
@ -12592,17 +12615,6 @@
"resolved": "https://registry.npmjs.org/vue-prism-component/-/vue-prism-component-1.2.0.tgz",
"integrity": "sha512-0N9CNuQu+36CJpdsZHrhdq7d18oBvjVMjawyKdIr8xuzFWLfdxECZQYbFaYoopPBg3SvkEEMtkhYqdgTQl5Y+A=="
},
"node_modules/vue-prism-editor": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/vue-prism-editor/-/vue-prism-editor-1.3.0.tgz",
"integrity": "sha512-54RfgtMGRMNr9484zKMOZs1wyLDR6EfFylzE2QrMCD9alCvXyYYcS0vX8oUHh+6pMUu6ts59uSN9cHglpU2NRQ==",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"vue": "^2.6.11"
}
},
"node_modules/vue-property-decorator": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/vue-property-decorator/-/vue-property-decorator-9.1.2.tgz",
@ -16031,6 +16043,11 @@
"q": "^1.1.2"
}
},
"codemirror": {
"version": "5.65.15",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.15.tgz",
"integrity": "sha512-YC4EHbbwQeubZzxLl5G4nlbLc1T21QTrKGaOal/Pkm9dVDMZXMH7+ieSPEOZCtO9I68i8/oteJKOxzHC2zR+0g=="
},
"collection-visit": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz",
@ -16584,6 +16601,11 @@
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true
},
"diff-match-patch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="
},
"dijkstrajs": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.2.tgz",
@ -22312,6 +22334,15 @@
"loose-envify": "^1.2.0"
}
},
"vue-codemirror": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/vue-codemirror/-/vue-codemirror-4.0.6.tgz",
"integrity": "sha512-ilU7Uf0mqBNSSV3KT7FNEeRIxH4s1fmpG4TfHlzvXn0QiQAbkXS9lLfwuZpaBVEnpP5CSE62iGJjoliTuA8poQ==",
"requires": {
"codemirror": "^5.41.0",
"diff-match-patch": "^1.0.0"
}
},
"vue-confetti": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/vue-confetti/-/vue-confetti-2.3.0.tgz",
@ -22432,12 +22463,6 @@
"resolved": "https://registry.npmjs.org/vue-prism-component/-/vue-prism-component-1.2.0.tgz",
"integrity": "sha512-0N9CNuQu+36CJpdsZHrhdq7d18oBvjVMjawyKdIr8xuzFWLfdxECZQYbFaYoopPBg3SvkEEMtkhYqdgTQl5Y+A=="
},
"vue-prism-editor": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/vue-prism-editor/-/vue-prism-editor-1.3.0.tgz",
"integrity": "sha512-54RfgtMGRMNr9484zKMOZs1wyLDR6EfFylzE2QrMCD9alCvXyYYcS0vX8oUHh+6pMUu6ts59uSN9cHglpU2NRQ==",
"requires": {}
},
"vue-property-decorator": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/vue-property-decorator/-/vue-property-decorator-9.1.2.tgz",

View File

@ -23,15 +23,15 @@
"tinymotion": "^0.2.0",
"vform": "^2.1.1",
"vt-notifications": "^0.4.1",
"vue": "^2.6.14",
"vue": "^2.7.14",
"vue-chartjs": "^4.1.0",
"vue-clickaway": "^2.2.2",
"vue-codemirror": "^4.0.6",
"vue-confetti": "^2.3.0",
"vue-i18n": "^8.25.0",
"vue-meta": "^2.4.0",
"vue-notion": "^0.4.0",
"vue-plugin-load-script": "^1.3.2",
"vue-prism-editor": "^1.2.2",
"vue-router": "^3.5.2",
"vue-signature-pad": "^2.0.5",
"vue-tailwind": "^2.5.0",

View File

@ -8,9 +8,7 @@
<span
class="ml-2 text-md hidden sm:inline text-black dark:text-white"
>
{{ appName }}</span><span
class="bg-gray-100 text-gray-600 font-semibold inline-block ml-1 px-2 rounded-full text-black text-xs tracking-wider"
>BETA</span>
{{ appName }}</span>
</router-link>
<workspace-dropdown class="ml-6"/>
</div>
@ -23,6 +21,11 @@
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8">
Templates
</router-link>
<router-link :to="{name:'pricing'}" v-if="paidPlansEnabled && (user===null || (user && workspace && !workspace.is_pro)) && $route.name !== 'pricing'"
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8">
<span v-if="user">Upgrade</span>
<span v-else>Pricing</span>
</router-link>
<a href="#" class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1"
@click.prevent="$crisp.push(['do', 'helpdesk:search'])" v-if="hasCrisp"
>
@ -158,6 +161,12 @@ export default {
}
return null
},
workspace () {
return this.$store.getters['open/workspaces/getCurrent']()
},
paidPlansEnabled() {
return window.config.paid_plans_enabled
},
showAuth() {
return this.$route.name && !this.$route.name.startsWith('forms.show_public')
},

View File

@ -5,8 +5,13 @@
<slot name="title" />
</div>
<div class="text-gray-400 hover:text-gray-600 absolute -right-2 -top-1 cursor-pointer p-2" @click="trigger">
<svg class="h-3 w-3 transition transform duration-500" :class="{'rotate-180':showContent}" viewBox="0 0 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L7 7L13 1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 transition transform duration-500"
:class="{'rotate-180':showContent}" viewBox="0 0 20 20" fill="currentColor"
>
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
@ -20,21 +25,23 @@
<script>
import VTransition from './transitions/VTransition.vue'
export default {
name: 'Collapse',
components: { VTransition },
props: {
defaultValue: { type: Boolean, default: false }
defaultValue: { type: Boolean, default: false },
value: { type: Boolean, default: null }
},
data () {
return {
showContent: this.defaultValue
showContent: this.value ?? this.defaultValue
}
},
methods: {
trigger () {
this.showContent = !this.showContent
this.$emit('click', this.showContent)
this.$emit('input', this.showContent)
}
}
}

View File

@ -33,7 +33,7 @@
</p>
<p class="my-4 text-center">
Feel free to <a href="mailto:contact@opnform.com">contact us</a> if you have any feature request.
Feel free to <a href="#" @click.prevent="openChat">contact us</a> if you have any feature request.
</p>
<div class="mb-4 text-center">
<v-button color="gray" shade="light" @click="showPremiumModal=false">
@ -66,13 +66,20 @@ export default {
currentWorkSpace: 'open/workspaces/getCurrent',
}),
shouldDisplayProTag() {
return false; //!this.user.is_subscribed && !(this.currentWorkSpace.is_pro || this.currentWorkSpace.is_enterprise);
if(!window.config.paid_plans_enabled) return false
if (!this.user) return true
return !(this.currentWorkSpace().is_pro || this.currentWorkSpace().is_enterprise)
},
},
mounted () {
},
methods: {}
methods: {
openChat () {
window.$crisp.push(['do', 'chat:show'])
window.$crisp.push(['do', 'chat:open'])
},
}
}
</script>

View File

@ -7,13 +7,13 @@
<span v-if="required" class="text-red-500 required-dot">*</span>
</label>
<prism-editor :id="id?id:name" v-model="compVal" :disabled="disabled"
class="code-editor"
:class="[theme.CodeInput.input,{ '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }]"
<div :class="[theme.CodeInput.input,{ '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }]">
<codemirror :id="id?id:name" v-model="compVal" :disabled="disabled"
:options="cmOptions"
:style="inputStyle" :name="name"
:placeholder="placeholder"
:highlight="highlighter" @change="onChange"
/>
</div>
<small v-if="help" :class="theme.CodeInput.help">
<slot name="help"><span class="field-help" v-html="help" /></slot>
@ -23,31 +23,32 @@
</template>
<script>
// import Prism Editor
import { PrismEditor } from 'vue-prism-editor'
import 'vue-prism-editor/dist/prismeditor.min.css' // import the styles somewhere
// import highlighting library (you can use any library you want just return html string)
import { codemirror } from 'vue-codemirror'
import 'codemirror/lib/codemirror.css'
import 'codemirror/mode/htmlmixed/htmlmixed.js'
import { highlight, languages } from 'prismjs/components/prism-core'
import 'prismjs/components/prism-clike'
import 'prismjs/components/prism-markup'
import 'prismjs/themes/prism-tomorrow.css' // import syntax highlighting styles
import inputMixin from '~/mixins/forms/input.js'
export default {
name: 'CodeInput',
components: { PrismEditor },
components: { codemirror },
mixins: [inputMixin],
methods: {
onChange (event) {
const file = event.target.files[0]
this.$set(this.form, this.name, file)
data () {
return {
cmOptions: {
// codemirror options
tabSize: 4,
mode: 'text/html',
theme: 'default',
lineNumbers: true,
line: true
}
}
},
highlighter (code) {
return highlight(code, languages.markup) // languages.<insert language> to return html with markup
}
}
methods: {}
}
</script>

View File

@ -5,7 +5,9 @@
</small>
<div class="flex">
<v-switch :id="id?id:name" v-model="compVal" class="inline-block mr-2" :disabled="disabled" :name="name" @input="$emit('input',$event)" />
<slot name="label">
<span>{{ label }} <span v-if="required" class="text-red-500 required-dot">*</span></span>
</slot>
</div>
<small v-if="help && helpPosition=='below_input'" :class="theme.default.help">
<slot name="help"><span class="field-help" v-html="help" /></slot>

View File

@ -49,23 +49,7 @@
</div>
</div>
<div v-if="getFormCleaningsMsg"
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 border-yellow-500"
>
<div class="flex-grow">
<p class="mb-0 py-2 px-4 text-yellow-600">
You're seeing this because you are an owner of this form. <br>
All your Pro features are de-activated when sharing this form: <br>
<span v-html="getFormCleaningsMsg" />
</p>
</div>
<div class="text-right">
<v-button color="yellow" shade="light" @click="form.cleanings=false">
Close
</v-button>
</div>
</div>
<form-cleanings v-if="!adminPreview" :hideable="true" class="mb-4 mx-2" :form="form" :specify-form-owner="true" />
<transition
v-if="!form.is_password_protected && (!isPublicFormPage || (!form.is_closed && !form.max_number_of_submissions_reached && form.visibility!='closed'))"
@ -131,9 +115,10 @@ import { themes } from '~/config/form-themes.js'
import VButton from '../../common/Button.vue'
import VTransition from '../../common/transitions/VTransition.vue'
import FormPendingSubmissionKey from '../../../mixins/forms/form-pending-submission-key.js'
import FormCleanings from '../../pages/forms/show/FormCleanings.vue'
export default {
components: { VTransition, VButton, OpenFormButton, OpenForm },
components: { VTransition, VButton, OpenFormButton, OpenForm, FormCleanings },
props: {
form: { type: Object, required: true },
@ -166,22 +151,6 @@ export default {
theme () {
return this.themes[this.themes.hasOwnProperty(this.form.theme) ? this.form.theme : 'default']
},
getFormCleaningsMsg () {
if (this.form.cleanings && Object.keys(this.form.cleanings).length > 0) {
let message = ''
Object.keys(this.form.cleanings).forEach((key) => {
const fieldName = key.charAt(0).toUpperCase() + key.slice(1)
let fieldInfo = '<br/>' + fieldName + "<br/><ul class='list-disc list-inside'>"
this.form.cleanings[key].forEach((msg) => {
fieldInfo = fieldInfo + '<li>' + msg + '</li>'
})
message = message + fieldInfo + '<ul/>'
})
return message
}
return false
},
isPublicFormPage () {
return this.$route.name === 'forms.show_public'
},

View File

@ -52,11 +52,6 @@
</p>
</template>
</div>
<!-- Field options -->
<!-- <div class="flex-grow" v-if="['files'].includes(field.type) || field.type.startsWith('nf-')">-->
<!-- <pro-tag/>-->
<!-- </div>-->
<template v-if="removing == field.id">
<div class="flex text-sm items-center">

View File

@ -1,6 +1,6 @@
<template>
<div class="border border-nt-blue-light bg-blue-50 dark:bg-notion-dark-light rounded-md p-4 mb-5 w-full mx-auto mt-4 select-all">
<div v-if="!form.is_pro" class="relative">
<div v-if="false" class="relative">
<div class="absolute inset-0 z-10">
<div class="p-5 max-w-md mx-auto mt-5">
<p class="text-center">
@ -102,7 +102,7 @@ export default {
},
methods: {
getChartData () {
if (!this.form || !this.form.is_pro) { return null }
if (!this.form) { return null }
this.isLoading = true
axios.get('/api/open/workspaces/' + this.form.workspace_id + '/form-stats/' + this.form.id).then((response) => {
const statsData = response.data

View File

@ -21,8 +21,13 @@
/>
<toggle-switch-input name="editable_submissions" :form="form" class="mt-4"
label="Allow users to edit their submission"
/>
help="Gives user a unique url to update their submission"
>
<template #label>
Editable submissions
<pro-tag class="ml-1" />
</template>
</toggle-switch-input>
<text-input v-if="form.editable_submissions" name="editable_submissions_button_text"
:form="form"
label="Text of editable submissions button"
@ -111,7 +116,6 @@
/>
</template>
<template v-else>
<pro-tag class="float-right"/>
<toggle-switch-input name="re_fillable" :form="form" class="mt-4"
label="Allow users to fill the form again"
/>

View File

@ -12,10 +12,7 @@
</h3>
</template>
<p class="mt-4">
The code will be injected in the <span class="font-semibold">head</span> section of your form page. <a href="#" class="text-gray-500"
@click.prevent="$crisp.push(['do', 'helpdesk:article:open', ['en', 'how-to-inject-custom-code-in-my-form-1amadj3']])"
>Click
here to get an example CSS code.</a>
The code will be injected in the <span class="font-semibold">head</span> section of your form page.
</p>
<code-input name="custom_code" class="mt-4"
:form="form" help="Custom code cannot be previewed in our editor. Please test your code using

View File

@ -10,7 +10,6 @@
</svg>
Customization
<pro-tag />
</h3>
</template>
@ -62,9 +61,12 @@
<toggle-switch-input name="hide_title" :form="form" class="mt-4"
label="Hide Title"
/>
<toggle-switch-input name="no_branding" :form="form" class="mt-4"
label="Remove OpnForm Branding"
/>
<toggle-switch-input name="no_branding" :form="form" class="mt-4">
<template #label>
Remove OpnForm Branding
<pro-tag class="ml-1" />
</template>
</toggle-switch-input>
<toggle-switch-input name="uppercase_labels" :form="form" class="mt-4"
label="Uppercase Input Labels"
/>

View File

@ -17,7 +17,6 @@
label="Protect your form with a Captcha"
help="If enabled we will make sure respondant is a human"
/>
<pro-tag class="float-right" />
<text-input name="password" :form="form" class="mt-4"
label="Form Password" help="Leave empty to disable password"
/>

View File

@ -2,7 +2,6 @@
<div v-if="logic" :key="resetKey" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
Logic
<pro-tag/>
</h3>
<p class="text-gray-400 text-xs mb-5">
Add some logic to this block. Start by adding some conditions, and then add some actions.

View File

@ -99,7 +99,6 @@
<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
<pro-tag/>
</h3>
<v-checkbox v-model="field.is_rating" class="mt-4"
:name="field.id+'_is_rating'" @input="initRating"
@ -136,7 +135,6 @@
<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
<pro-tag/>
</h3>
<v-checkbox v-model="field.date_range" class="mt-4"
:name="field.id+'_date_range'"
@ -191,7 +189,6 @@
<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
<pro-tag/>
</h3>
<p class="text-gray-400 mb-5 text-xs">
Advanced options for your select/multiselect fields.
@ -218,7 +215,6 @@
<div v-if="displayBasedOnAdvanced" class="-mx-4 sm:-mx-6 p-5 border-b">
<h3 class="font-semibold block text-lg">
Customization
<pro-tag/>
</h3>
<p class="text-gray-400 mb-5 text-xs">
@ -322,7 +318,6 @@
<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
<pro-tag/>
</h3>
<v-checkbox v-model="field.generates_uuid"

View File

@ -0,0 +1,107 @@
<template>
<v-transition>
<div v-if="hasCleanings && !hideWarning" class="border border-gray-300 dark:border-gray-600 rounded-md bg-white p-2"
:class="{'hover:bg-yellow-50 dark:hover:bg-yellow-900':!collapseOpened}"
>
<collapse v-model="collapseOpened">
<template #title>
<p class="text-yellow-500 dark:text-yellow-400 font-semibold text-sm p-1 pr-4">
<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 inline"
>
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
Some features that are included in our {{ form.is_pro ? 'Enterprise' : 'Pro' }} plan are disabled when
publicly sharing this form<span v-if="specifyFormOwner"> (only owners of this form can see this)</span>.
</p>
</template>
<div class="border-t mt-1 p-4 pb-2 -mx-2">
<p class="text-gray-500 text-sm" v-html="cleaningContent" />
<p class="text-gray-500 text-sm mb-4 font-semibold">
<router-link :to="{name:'pricing'}">
{{ form.is_pro ? 'Upgrade your OpnForms plan today' : 'Start your free OpnForms trial' }}
</router-link>
to unlock all of our features and build powerful forms.
</p>
<div class="flex flex-wrap items-end w-full">
<div class="flex-grow flex pr-2">
<v-button v-track.upgrade_from_form_cleanings_click size="small" class="inline-block" :to="{name:'pricing'}">
{{ form.is_pro ? 'Upgrade plan' : 'Start free trial' }}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-4 h-4 inline -mt-[2px]"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</v-button>
<v-button color="white" size="small" class="ml-2" @click.prevent="openCrisp">
Contact us
</v-button>
</div>
<v-button v-if="hideable" color="white" size="small" class="mt-2" @click.prevent="hideWarning=true">
Hide warning
</v-button>
</div>
</div>
</collapse>
</div>
</v-transition>
</template>
<script>
import Collapse from '../../../common/Collapse.vue'
import VButton from '../../../common/Button.vue'
import VTransition from '../../../common/transitions/VTransition.vue'
export default {
name: 'FormCleanings',
components: { VTransition, VButton, Collapse },
props: {
form: { type: Object, required: true },
specifyFormOwner: { type: Boolean, default: false },
hideable: { type: Boolean, default: false }
},
data () {
return {
collapseOpened: false,
hideWarning: false
}
},
computed: {
hasCleanings () {
return this.form.cleanings && Object.keys(this.form.cleanings).length > 0
},
cleanings () {
return this.form.cleanings
},
cleaningContent () {
let message = ''
Object.keys(this.cleanings).forEach((key) => {
let fieldName = key.charAt(0).toUpperCase() + key.slice(1)
if (fieldName !== 'Form') {
fieldName = '"' + fieldName + '" field'
}
let fieldInfo = '<span class="font-semibold">' + fieldName + '</span><br/><ul class=\'list-disc list-inside\'>'
this.cleanings[key].forEach((msg) => {
fieldInfo = fieldInfo + '<li>' + msg + '</li>'
})
if (fieldInfo) {
message = message + fieldInfo + '<ul/><br/>'
}
})
return message
}
},
watch: {},
mounted () {
},
methods: {
openCrisp () {
this.$crisp.push(['do', 'chat:show'])
this.$crisp.push(['do', 'chat:open'])
}
}
}
</script>

View File

@ -13,7 +13,6 @@
/>
</svg>
Url form pre-fill
<pro-tag class="ml-2"/>
</v-button>
<modal :show="showUrlFormPrefillModal" @close="showUrlFormPrefillModal=false">
@ -26,7 +25,6 @@
</template>
<template #title>
<span>Url Form Prefill</span>
<pro-tag class="ml-4 pb-3" />
</template>
<div class="p-4">

View File

@ -0,0 +1,94 @@
<template>
<modal :show="show" max-width="lg" @close="close">
<text-input ref="companyName" label="Company Name" name="name" :required="true" :form="form" help="Name that will appear on invoices" />
<text-input label="Email" name="email" native-type="email" :required="true" :form="form" help="Where invoices will be sent" />
<v-button :loading="form.busy || loading" :disabled="form.busy || loading" class="mt-6 block mx-auto"
@click="saveDetails" :arrow="true"
>
Go to checkout
</v-button>
</modal>
</template>
<script>
import { mapGetters } from 'vuex'
import TextInput from '../../forms/TextInput.vue'
import Form from 'vform'
import VButton from '../../common/Button.vue'
import axios from 'axios'
export default {
components: { VButton, TextInput },
props: {
show: {
type: Boolean,
default: false
},
plan: {
type: String,
default: 'pro'
},
yearly: {
type: Boolean,
default: true
}
},
data: () => ({
form: new Form({
name: '',
email: ''
}),
loading: false
}),
watch: {
user () {
this.form.email = this.user.email
},
show () {
// Wait for modal to open and focus on first field
setTimeout(() => {
if (this.$refs.companyName) {
this.$refs.companyName.$el.querySelector('input').focus()
}
}, 300)
this.loading = false
}
},
mounted () {
if (this.user) {
this.form.name = this.user.name
this.form.email = this.user.email
}
},
methods: {
saveDetails () {
if (this.form.busy) return
this.form.put('api/subscription/update-customer-details').then(() => {
this.loading = true
axios.get('/api/subscription/new/' + this.plan + '/' + (!this.yearly ? 'monthly' : 'yearly') + '/checkout/with-trial').then((response) => {
window.location = response.data.checkout_url
}).catch((error) => {
this.alertError(error.response.data.message)
}).finally(() => {
this.loading = false
this.close()
})
})
},
close () {
this.$emit('close')
}
},
computed: {
...mapGetters({
user: 'auth/user'
})
}
}
</script>

View File

@ -0,0 +1,41 @@
<template>
<div class="border border-gray-300 rounded-xl flex p-1 relative">
<button class="font-semibold block flex-grow cursor-pointer">
<div class="p-2 px-3 rounded-lg transition-colors" :class="{'bg-blue-500 text-white':!value}"
@click="set(false)"
>
Monthly
</div>
</button>
<button class="font-semibold block flex-grow cursor-pointer" @click="set(true)">
<div class="p-2 px-4 rounded-lg transition-colors" :class="{'bg-blue-500 text-white':value}">
Yearly
</div>
</button>
<div class="absolute hidden sm:block text-gray-500 text-xs mt-12">
Save 20% with annual plans
</div>
</div>
</template>
<script>
export default {
components: {},
props: {
value: {
type: Boolean,
default: false
}
},
data: () => ({}),
computed: {},
methods: {
set (value) {
this.$emit('input', value)
}
}
}
</script>

View File

@ -0,0 +1,132 @@
<template>
<div class="w-full">
<section class="relative">
<div class="absolute inset-0 grid" aria-hidden="true">
<div class="bg-gray-100"></div>
<div class="bg-white"></div>
</div>
<div class="px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="max-w-5xl mx-auto bg-white shadow-xl rounded-3xl ring-1 ring-gray-200 lg:flex isolate">
<div class="p-8 sm:p-8 lg:flex-auto">
<h3 class="text-3xl font-semibold tracking-tight text-gray-950">
Pro Plan
</h3>
<p class="mt-2 text-base font-medium leading-7 text-gray-600">
OpnForm Pro offers empowering features tailored to the advanced needs of teams and creators. Enjoy our free 3-day trial!
</p>
<div class="flex items-center mt-6 gap-x-4">
<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>
<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">
<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>
</svg>
{{ title }}
</li>
</ul>
</div>
<div class="p-2 -mt-2 lg:mt-0 lg:w-full lg:max-w-md lg:flex-shrink-0">
<div class="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" />
</div><!-- lg+ -->
<p class="flex flex-col items-center">
<span class="text-6xl font-semibold tracking-tight text-gray-950">
<template v-if="isYearly">$16</template>
<template v-else>$19</template>
</span>
<span class="text-sm font-medium leading-6 text-gray-600">
per month
</span>
</p>
<div class="flex justify-center">
<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">
View Billing
</v-button>
<v-button v-else class="mr-1" @click.prevent="openCustomerCheckout('default')" :arrow="true">
Start free trial
</v-button>
</div>
<p class="text-xs font-medium leading-5 text-gray-600">
Invoices and receipts available for easy company reimbursement.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<checkout-details-modal :show="showDetailsModal" :yearly="isYearly" :plan="selectedPlan"
@close="showDetailsModal=false"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import axios from 'axios'
import MonthlyYearlySelector from './MonthlyYearlySelector.vue'
import CheckoutDetailsModal from './CheckoutDetailsModal.vue'
export default {
components: {
MonthlyYearlySelector,
CheckoutDetailsModal,
},
props: {},
data: () => ({
isYearly: true,
selectedPlan: 'pro',
showDetailsModal: false,
pricingInfo: [
'Form confirmation emails',
'Slack notifications',
'Discord notifications',
'Editable submissions',
'Custom domain (soon)',
'Custom code',
'Larger file uploads (50mb)',
'Remove OpnForm branding',
'Priority support'
]
}),
methods: {
openCustomerCheckout (plan) {
this.selectedPlan = plan
this.showDetailsModal = true
},
openBilling () {
this.billingLoading = true
axios.get('/api/subscription/billing-portal').then((response) => {
this.billingLoading = false
const url = response.data.portal_url
window.location = url
})
}
},
computed: {
...mapGetters({
authenticated: 'auth/check',
user: 'auth/user'
})
}
}
</script>

View File

@ -13,7 +13,7 @@ export const themes = {
},
CodeInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold',
input: 'rounded-lg border-gray-300 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 shadow-sm text-base focus:outline-none focus:ring-2 focus:border-transparent p-2',
input: 'rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden',
help: 'text-gray-400 dark:text-gray-500'
},
RichTextAreaInput: {
@ -43,7 +43,7 @@ export const themes = {
},
CodeInput: {
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-2 focus:border-transparent p-2',
input: 'border border-gray-300 dark:border-gray-600 overflow-hidden',
help: 'text-gray-400 dark:text-gray-500'
},
RichTextAreaInput: {
@ -68,7 +68,7 @@ export const themes = {
},
CodeInput: {
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:border-transparent focus:shadow-focus-notion p-2',
input: 'rounded shadow-inner-notion border border-gray-300 dark:border-gray-600 overflow-hidden',
help: 'text-notion-input-help dark:text-gray-500'
},
RichTextAreaInput: {

View File

@ -1,17 +1,8 @@
export default {
methods: {
displayFormModificationAlert (responseData) {
if (responseData.form_cleaning && Object.keys(responseData.form_cleaning).length > 0) {
let message = responseData.message + '<br/>'
Object.keys(responseData.form_cleaning).forEach((key) => {
const fieldName = key.charAt(0).toUpperCase() + key.slice(1)
let fieldInfo = "<br/>" + fieldName + "<br/><ul class='list-disc list-inside'>"
responseData.form_cleaning[key].forEach((msg) => {
fieldInfo = fieldInfo + "<li>" + msg +"</li>"
})
message = message + fieldInfo + "<ul/>"
})
this.alertWarning(message)
if (responseData.form.cleanings && Object.keys(responseData.form.cleanings).length > 0) {
this.alertWarning(responseData.message)
} else {
this.alertSuccess(responseData.message)
}

View File

@ -67,11 +67,13 @@
<span v-else> This form will stop accepting submissions after {{ form.max_submissions_count }} submissions. </span>
</p>
<div class="mt-4 border-b border-gray-200 dark:border-gray-700">
<form-cleanings class="mt-4" :form="form" />
<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">
<router-link :to="{ name: tab.route }"
class="hover:no-underline inline-block py-4 rounded-t-lg border-b-2 border-transparent text-gray-500 hover:text-gray-600"
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>
@ -109,6 +111,7 @@ import ProTag from '../../../components/common/ProTag.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'
const loadForms = function () {
store.commit('open/forms/startLoading')
@ -122,7 +125,8 @@ export default {
components: {
VButton,
ProTag,
ExtraMenu
ExtraMenu,
FormCleanings
},
mixins: [SeoMeta],

View File

@ -0,0 +1,281 @@
<template>
<div>
<section class="relative py-12 bg-gradient-to-b from-white to-gray-100 sm:py-16 lg:py-20 xl:py-24">
<div class="relative px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
<div class="max-w-4xl mx-auto text-center">
<h1 class="text-4xl font-semibold tracking-tight text-gray-900 sm:text-5xl lg:text-6xl">
Simple, transparent pricing. No surprises.
</h1>
<p
class="max-w-2xl mx-auto mt-4 text-base font-medium leading-7 text-gray-500 sm:mt-5 sm:text-xl sm:leading-9">
Just like our codebase, our pricing is 100% transparent. One flat price for all features. No hidden fees.
</p>
</div>
</div>
</section>
<pricing-table/>
<section class="py-12 bg-white sm:py-16 lg:py-24 xl:py-24">
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
<div class="max-w-2xl mx-auto text-center">
<h2 class="text-3xl font-semibold tracking-tight text-gray-900 sm:text-4xl lg:leading-tight">
<span class="text-blue-600">99%</span> of features are available to all users for free and without
limits.
</h2>
</div>
<div
class="grid max-w-5xl grid-cols-2 mx-auto mt-12 text-center gap-y-8 gap-x-4 sm:grid-cols-3 md:gap-x-12 md:text-left sm:mt-16">
<div class="flex flex-col items-center gap-3 md:flex-row">
<svg aria-hidden="true" class="w-6 h-6 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M3 9H21M7.8 3H16.2C17.8802 3 18.7202 3 19.362 3.32698C19.9265 3.6146 20.3854 4.07354 20.673 4.63803C21 5.27976 21 6.11984 21 7.8V16.2C21 17.8802 21 18.7202 20.673 19.362C20.3854 19.9265 19.9265 20.3854 19.362 20.673C18.7202 21 17.8802 21 16.2 21H7.8C6.11984 21 5.27976 21 4.63803 20.673C4.07354 20.3854 3.6146 19.9265 3.32698 19.362C3 18.7202 3 17.8802 3 16.2V7.8C3 6.11984 3 5.27976 3.32698 4.63803C3.6146 4.07354 4.07354 3.6146 4.63803 3.32698C5.27976 3 6.11984 3 7.8 3Z"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<p class="text-base font-semibold sm:text-lg lg:text-xl text-gray-950">
Unlimited forms
</p>
</div>
<div class="flex flex-col items-center gap-3 md:flex-row">
<svg aria-hidden="true" class="w-6 h-6 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M9 3.5V2M5.06066 5.06066L4 4M5.06066 13L4 14.0607M13 5.06066L14.0607 4M3.5 9H2M8.5 8.5L12.6111 21.2778L15.5 18.3889L19.1111 22L22 19.1111L18.3889 15.5L21.2778 12.6111L8.5 8.5Z"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<p class="text-base font-semibold sm:text-lg lg:text-xl text-gray-950">
Unlimited submissions
</p>
</div>
<div class="flex flex-col items-center gap-3 md:flex-row">
<svg aria-hidden="true" class="w-6 h-6 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M17.8 10C18.9201 10 19.4802 10 19.908 9.78201C20.2843 9.59027 20.5903 9.28431 20.782 8.90798C21 8.48016 21 7.92011 21 6.8V6.2C21 5.0799 21 4.51984 20.782 4.09202C20.5903 3.7157 20.2843 3.40973 19.908 3.21799C19.4802 3 18.9201 3 17.8 3L6.2 3C5.0799 3 4.51984 3 4.09202 3.21799C3.71569 3.40973 3.40973 3.71569 3.21799 4.09202C3 4.51984 3 5.07989 3 6.2L3 6.8C3 7.9201 3 8.48016 3.21799 8.90798C3.40973 9.28431 3.71569 9.59027 4.09202 9.78201C4.51984 10 5.07989 10 6.2 10L17.8 10Z"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path
d="M17.8 21C18.9201 21 19.4802 21 19.908 20.782C20.2843 20.5903 20.5903 20.2843 20.782 19.908C21 19.4802 21 18.9201 21 17.8V17.2C21 16.0799 21 15.5198 20.782 15.092C20.5903 14.7157 20.2843 14.4097 19.908 14.218C19.4802 14 18.9201 14 17.8 14L6.2 14C5.0799 14 4.51984 14 4.09202 14.218C3.71569 14.4097 3.40973 14.7157 3.21799 15.092C3 15.5198 3 16.0799 3 17.2L3 17.8C3 18.9201 3 19.4802 3.21799 19.908C3.40973 20.2843 3.71569 20.5903 4.09202 20.782C4.51984 21 5.07989 21 6.2 21H17.8Z"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<p class="text-base font-semibold sm:text-lg lg:text-xl text-gray-950">
Unlimited fields
</p>
</div>
<div class="flex flex-col items-center gap-3 md:flex-row">
<svg aria-hidden="true" class="w-6 h-6 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M9 11L12 14L22 4M16 3H7.8C6.11984 3 5.27976 3 4.63803 3.32698C4.07354 3.6146 3.6146 4.07354 3.32698 4.63803C3 5.27976 3 6.11984 3 7.8V16.2C3 17.8802 3 18.7202 3.32698 19.362C3.6146 19.9265 4.07354 20.3854 4.63803 20.673C5.27976 21 6.11984 21 7.8 21H16.2C17.8802 21 18.7202 21 19.362 20.673C19.9265 20.3854 20.3854 19.9265 20.673 19.362C21 18.7202 21 17.8802 21 16.2V12"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<p class="text-base font-semibold sm:text-lg lg:text-xl text-gray-950">
Multiple input types
</p>
</div>
<div class="flex flex-col items-center gap-3 md:flex-row">
<svg aria-hidden="true" class="w-6 h-6 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M22 11V8.2C22 7.0799 22 6.51984 21.782 6.09202C21.5903 5.71569 21.2843 5.40973 20.908 5.21799C20.4802 5 19.9201 5 18.8 5H5.2C4.0799 5 3.51984 5 3.09202 5.21799C2.71569 5.40973 2.40973 5.71569 2.21799 6.09202C2 6.51984 2 7.0799 2 8.2V11.8C2 12.9201 2 13.4802 2.21799 13.908C2.40973 14.2843 2.71569 14.5903 3.09202 14.782C3.51984 15 4.0799 15 5.2 15H11M12 10H12.005M17 10H17.005M7 10H7.005M19.25 17V15.25C19.25 14.2835 18.4665 13.5 17.5 13.5C16.5335 13.5 15.75 14.2835 15.75 15.25V17M12.25 10C12.25 10.1381 12.1381 10.25 12 10.25C11.8619 10.25 11.75 10.1381 11.75 10C11.75 9.86193 11.8619 9.75 12 9.75C12.1381 9.75 12.25 9.86193 12.25 10ZM17.25 10C17.25 10.1381 17.1381 10.25 17 10.25C16.8619 10.25 16.75 10.1381 16.75 10C16.75 9.86193 16.8619 9.75 17 9.75C17.1381 9.75 17.25 9.86193 17.25 10ZM7.25 10C7.25 10.1381 7.13807 10.25 7 10.25C6.86193 10.25 6.75 10.1381 6.75 10C6.75 9.86193 6.86193 9.75 7 9.75C7.13807 9.75 7.25 9.86193 7.25 10ZM15.6 21H19.4C19.9601 21 20.2401 21 20.454 20.891C20.6422 20.7951 20.7951 20.6422 20.891 20.454C21 20.2401 21 19.9601 21 19.4V18.6C21 18.0399 21 17.7599 20.891 17.546C20.7951 17.3578 20.6422 17.2049 20.454 17.109C20.2401 17 19.9601 17 19.4 17H15.6C15.0399 17 14.7599 17 14.546 17.109C14.3578 17.2049 14.2049 17.3578 14.109 17.546C14 17.7599 14 18.0399 14 18.6V19.4C14 19.9601 14 20.2401 14.109 20.454C14.2049 20.6422 14.3578 20.7951 14.546 20.891C14.7599 21 15.0399 21 15.6 21Z"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<p class="text-base font-semibold sm:text-lg lg:text-xl text-gray-950">
Form password
</p>
</div>
<div class="flex flex-col items-center gap-3 md:flex-row">
<svg aria-hidden="true" class="w-6 h-6 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M18 15C16.3431 15 15 16.3431 15 18C15 19.6569 16.3431 21 18 21C19.6569 21 21 19.6569 21 18C21 16.3431 19.6569 15 18 15ZM18 15V8C18 7.46957 17.7893 6.96086 17.4142 6.58579C17.0391 6.21071 16.5304 6 16 6H13M6 9C7.65685 9 9 7.65685 9 6C9 4.34315 7.65685 3 6 3C4.34315 3 3 4.34315 3 6C3 7.65685 4.34315 9 6 9ZM6 9V21"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<p class="text-base font-semibold sm:text-lg lg:text-xl text-gray-950">
Webhooks
</p>
</div>
<div class="flex flex-col items-center gap-3 md:flex-row">
<svg aria-hidden="true" class="w-6 h-6 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M21 8H3M16 2V5M8 2V5M12 18V12M9 15H15M7.8 22H16.2C17.8802 22 18.7202 22 19.362 21.673C19.9265 21.3854 20.3854 20.9265 20.673 20.362C21 19.7202 21 18.8802 21 17.2V8.8C21 7.11984 21 6.27976 20.673 5.63803C20.3854 5.07354 19.9265 4.6146 19.362 4.32698C18.7202 4 17.8802 4 16.2 4H7.8C6.11984 4 5.27976 4 4.63803 4.32698C4.07354 4.6146 3.6146 5.07354 3.32698 5.63803C3 6.27976 3 7.11984 3 8.8V17.2C3 18.8802 3 19.7202 3.32698 20.362C3.6146 20.9265 4.07354 21.3854 4.63803 21.673C5.27976 22 6.11984 22 7.8 22Z"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<p class="text-base font-semibold sm:text-lg lg:text-xl text-gray-950">
Closing date
</p>
</div>
<div class="flex flex-col items-center col-span-2 gap-3 md:flex-row sm:col-span-1">
<svg aria-hidden="true" class="w-6 h-6 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M12 6C12.5523 6 13 5.55228 13 5C13 4.44772 12.5523 4 12 4C11.4477 4 11 4.44772 11 5C11 5.55228 11.4477 6 12 6Z"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path
d="M12 13C12.5523 13 13 12.5523 13 12C13 11.4477 12.5523 11 12 11C11.4477 11 11 11.4477 11 12C11 12.5523 11.4477 13 12 13Z"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path
d="M12 20C12.5523 20 13 19.5523 13 19C13 18.4477 12.5523 18 12 18C11.4477 18 11 18.4477 11 19C11 19.5523 11.4477 20 12 20Z"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path
d="M19 6C19.5523 6 20 5.55228 20 5C20 4.44772 19.5523 4 19 4C18.4477 4 18 4.44772 18 5C18 5.55228 18.4477 6 19 6Z"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path
d="M19 13C19.5523 13 20 12.5523 20 12C20 11.4477 19.5523 11 19 11C18.4477 11 18 11.4477 18 12C18 12.5523 18.4477 13 19 13Z"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path
d="M19 20C19.5523 20 20 19.5523 20 19C20 18.4477 19.5523 18 19 18C18.4477 18 18 18.4477 18 19C18 19.5523 18.4477 20 19 20Z"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path
d="M5 6C5.55228 6 6 5.55228 6 5C6 4.44772 5.55228 4 5 4C4.44772 4 4 4.44772 4 5C4 5.55228 4.44772 6 5 6Z"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path
d="M5 13C5.55228 13 6 12.5523 6 12C6 11.4477 5.55228 11 5 11C4.44772 11 4 11.4477 4 12C4 12.5523 4.44772 13 5 13Z"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path
d="M5 20C5.55228 20 6 19.5523 6 19C6 18.4477 5.55228 18 5 18C4.44772 18 4 18.4477 4 19C4 19.5523 4.44772 20 5 20Z"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<p class="text-base font-semibold sm:text-lg lg:text-xl text-gray-950">
And much more...
</p>
</div>
</div>
<div class="max-w-3xl p-6 mx-auto mt-12 sm:mt-16 bg-yellow-50 ring-1 ring-inset ring-yellow-200 rounded-2xl">
<div class="flex items-start gap-4">
<svg aria-hidden="true" class="w-8 h-8 shrink-0 stroke-yellow-500" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M12 21L11.8999 20.8499C11.2053 19.808 10.858 19.287 10.3991 18.9098C9.99286 18.5759 9.52476 18.3254 9.02161 18.1726C8.45325 18 7.82711 18 6.57482 18H5.2C4.07989 18 3.51984 18 3.09202 17.782C2.71569 17.5903 2.40973 17.2843 2.21799 16.908C2 16.4802 2 15.9201 2 14.8V6.2C2 5.07989 2 4.51984 2.21799 4.09202C2.40973 3.71569 2.71569 3.40973 3.09202 3.21799C3.51984 3 4.07989 3 5.2 3H5.6C7.84021 3 8.96031 3 9.81596 3.43597C10.5686 3.81947 11.1805 4.43139 11.564 5.18404C12 6.03968 12 7.15979 12 9.4M12 21V9.4M12 21L12.1001 20.8499C12.7947 19.808 13.142 19.287 13.6009 18.9098C14.0071 18.5759 14.4752 18.3254 14.9784 18.1726C15.5467 18 16.1729 18 17.4252 18H18.8C19.9201 18 20.4802 18 20.908 17.782C21.2843 17.5903 21.5903 17.2843 21.782 16.908C22 16.4802 22 15.9201 22 14.8V6.2C22 5.07989 22 4.51984 21.782 4.09202C21.5903 3.71569 21.2843 3.40973 20.908 3.21799C20.4802 3 19.9201 3 18.8 3H18.4C16.1598 3 15.0397 3 14.184 3.43597C13.4314 3.81947 12.8195 4.43139 12.436 5.18404C12 6.03968 12 7.15979 12 9.4"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<div>
<p class="text-lg font-semibold text-yellow-600">
Nonprofit & Student Discount 50%
</p>
<p class="mt-1 text-base font-medium leading-7 text-yellow-600">
Whether your nonprofit is large or small, OpnForms online Form Builder helps your organization help
others. It takes just a few minutes to create and publish your forms online. As an exclusive benefit,
we offer nonprofits & students a 50-percent discount!
</p>
</div>
</div>
</div>
</div>
</section>
<section class="py-12 bg-gray-50 border-t border-gray-200 sm:py-16 lg:py-20 xl:py-24">
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
<div class="max-w-3xl mx-auto text-center">
<h2 class="text-3xl font-semibold tracking-tight text-gray-900 sm:text-4xl lg:leading-tight">
Got any question?
</h2>
<p class="max-w-2xl mx-auto mt-4 text-base font-medium leading-7 text-gray-600 sm:text-lg sm:leading-8">
We've compiled a list of the most common questions we get asked.
</p>
</div>
<dl
class="grid max-w-5xl grid-cols-1 mx-auto mt-12 gap-y-12 sm:grid-cols-2 sm:mt-16 sm:gap-x-8 sm:gap-y-12 lg:gap-x-10">
<div v-for="q in [
{
'question':'Is there a free version of OpnForm available?',
'answer':'Yes, OpnForm offers a free version with access to 99% of features, including unlimited forms, submissions, fields, and more. Our goal is to provide robust functionality for all users without limitations.',
},
{
'question':'What does the Pro Plan include?',
'answer':'The OpnForm Pro Plan is designed to meet the advanced needs of teams and creators. It includes features like form confirmation emails, Slack and Discord notifications, editable submissions, custom domain, custom code integration, larger file uploads, removal of OpnForm branding, priority support, and more.',
},
{
'question':'Can I try the Pro Plan before subscribing?',
'answer':'Absolutely! We offer a free 3-day trial of the OpnForm Pro Plan. This allows you to explore all the empowering features and experience the value it brings to your form-building process. The trial is automatically applied.',
},
{
'question':'Is there a discount for annual plans?',
'answer':'Yes, we offer a 20% discount for annual Pro Plan subscriptions. By choosing the yearly billing option, you can enjoy the same great features at a reduced cost.',
},
{
'question':'How does the nonprofit and student discount work?',
'answer':'OpnForm is committed to supporting nonprofits and students. We provide an exclusive 50% discount on the Pro Plan for nonprofit organizations and students. This discount helps you make the most of our form builder while staying within your budget.',
},
{
'question':'Can I cancel or change my plan at any time?',
'answer':'Yes, you have the flexibility to upgrade, downgrade, or cancel your OpnForm Pro Plan at any time. Changes will take effect immediately, and you\'ll only be billed based on the plan you\'re currently on.',
},
]" :key="q.question">
<dt class="text-base font-semibold leading-7 text-gray-950 sm:text-lg sm:leading-8">
{{ q.question }}
</dt>
<dd class="mt-2 text-base font-medium leading-7 text-gray-600">
{{ q.answer }}
</dd>
</div>
</dl>
<div class="mt-12 text-center sm:mt-16">
<p class="text-base font-medium text-gray-950">
Didn't find the answer? <a href="#" @click.prevent="contactUs"
class="font-semibold text-blue-600 hover:underline">Contact us</a>
</p>
</div>
</div>
</section>
<open-form-footer/>
</div>
</template>
<script>
import {mapGetters} from 'vuex'
import OpenFormFooter from '../components/pages/OpenFormFooter.vue'
import PricingTable from '../components/pages/pricing/PricingTable.vue'
import SeoMeta from '../mixins/seo-meta.js'
export default {
components: {OpenFormFooter, PricingTable},
mixins: [SeoMeta],
layout: 'default',
props: {},
beforeRouteEnter(to, from, next) {
if (!window.config.paid_plans_enabled) { // If no paid plan so no need this page
next({name: 'home'})
return
}
next()
},
data: () => ({
metaTitle: 'Pricing',
metaDescription: 'All of our core features are free, and there is no quantity limit. You can also created more advanced and customized forms with OpnForms Pro.',
}),
mounted() {
},
computed: {
...mapGetters({
authenticated: 'auth/check',
user: 'auth/user'
})
},
methods: {
contactUs() {
window.$crisp.push(['do', 'chat:show'])
window.$crisp.push(['do', 'chat:open'])
}
}
}
</script>

View File

@ -16,7 +16,7 @@
<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">
<router-link :to="{ name: tab.route }"
class="hover:no-underline inline-block py-4 rounded-t-lg border-b-2 border-transparent text-gray-500 hover:text-gray-600"
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>

View File

@ -61,6 +61,7 @@ export default [
// Guest Routes
{ path: '/', name: 'welcome', component: page('welcome.vue') },
{ path: '/pricing', name: 'pricing', component: page('pricing.vue') },
{ path: '/ai-form-builder', name: 'aiformbuilder', component: page('ai-form-builder.vue') },
{ path: '/integrations', name: 'integrations', component: page('integrations.vue') },
{ path: '/forms/:slug', name: 'forms.show_public', component: page('forms/show-public.vue') },

View File

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

View File

@ -41,6 +41,7 @@ Route::group(['middleware' => 'auth:api'], function () {
Route::patch('settings/password', [PasswordController::class, 'update']);
Route::prefix('subscription')->name('subscription.')->group(function () {
Route::put('/update-customer-details', [SubscriptionController::class, 'updateStripeDetails'])->name('update-stripe-details');
Route::get('/new/{subscription}/{plan}/checkout/{trial?}', [SubscriptionController::class, 'checkout'])
->name('checkout')
->where('subscription', '('.implode('|', SubscriptionController::SUBSCRIPTION_NAMES).')')
@ -70,11 +71,9 @@ Route::group(['middleware' => 'auth:api'], function () {
[FormController::class, 'index'])->name('forms.index');
Route::delete('/', [WorkspaceController::class, 'delete'])->name('delete');
Route::middleware('pro-form')->group(function () {
Route::get('form-stats/{formId}', [FormStatsController::class, 'getFormStats'])->name('form.stats');
});
});
});
Route::prefix('forms')->name('forms.')->group(function () {
Route::post('/', [FormController::class, 'store'])->name('store');

View File

@ -50,7 +50,7 @@ it('creates confirmation emails without the submitted data', function () {
});
it('sends a confirmation email if needed', function () {
$user = $this->actingAsUser();
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace, [
'send_submission_confirmation' => true,

View File

@ -38,6 +38,8 @@ it('check formstat chart data', function () {
}
// Now check chart data
$response = $this->getJson(route('open.workspaces.form.stats', [$workspace->id, $form->id]));
$this->getJson(route('open.workspaces.form.stats', [$workspace->id, $form->id]))
->assertSuccessful()
->assertJson(function (\Illuminate\Testing\Fluent\AssertableJson $json) use ($views, $submissions) {

View File

@ -3,7 +3,7 @@
use Tests\Helpers\FormSubmissionDataFactory;
it('can update form with existing record', function () {
$user = $this->actingAsUser();
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace, [
'editable_submissions' => true,

3
vite.config.js vendored
View File

@ -11,7 +11,8 @@ export default defineConfig({
laravel({
input: [
'resources/js/app.js'
]
],
valetTls: 'opnform.test'
}),
vue({
template: {