diff --git a/.env.docker b/.env.docker index b907e68..e1fc74b 100644 --- a/.env.docker +++ b/.env.docker @@ -53,9 +53,6 @@ MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" JWT_TTL=1440 JWT_SECRET= -STRIPE_KEY= -STRIPE_SECRET= - MUX_WORKSPACE_ID= MUX_API_TOKEN= diff --git a/.github/workflows/laravel.yml b/.github/workflows/laravel.yml index aa89141..6d24a2b 100644 --- a/.github/workflows/laravel.yml +++ b/.github/workflows/laravel.yml @@ -12,7 +12,7 @@ jobs: services: postgres: # Docker Hub image - image: postgres + image: postgres:14 # Provide the password for postgres env: POSTGRES_PASSWORD: postgres @@ -27,6 +27,24 @@ jobs: ports: # Maps tcp port 5432 on service container to the host - 5432:5432 + mysql: + # Docker Hub image + image: mysql:8 + # Provide the password for mysql + env: + MYSQL_ROOT_PASSWORD: test + MYSQL_DATABASE: test + MYSQL_USER: test + MYSQL_PASSWORD: test + # Set health checks to wait until mysql has started + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + ports: + # Maps tcp port 3306 on service container to the host + - 3306:3306 concurrency: group: 'run-tests' @@ -35,8 +53,22 @@ jobs: fail-fast: true matrix: php: [ 8.2 ] + connection: [ pgsql, mysql ] + include: + - connection: pgsql + host: localhost + port: 5432 + user: postgres + password: postgres + database: postgres + - connection: mysql + host: '127.0.0.1' + port: 3306 + user: root + password: test + database: test - name: Run Feature & Unit tests (PHP ${{ matrix.php }}) + name: Run Feature & Unit tests (PHP ${{ matrix.php }} - ${{ matrix.connection }}) steps: - name: Checkout code @@ -80,6 +112,13 @@ jobs: - name: Run tests (Unit and Feature) run: ./vendor/bin/pest -p + env: + DB_CONNECTION: ${{ matrix.connection }} + DB_HOST: ${{ matrix.host }} + DB_PORT: ${{ matrix.port }} + DB_DATABASE: ${{ matrix.database }} + DB_USERNAME: ${{ matrix.user }} + DB_PASSWORD: ${{ matrix.password }} - name: "Archive log results" if: always() diff --git a/app/Http/Controllers/Auth/AppSumoAuthController.php b/app/Http/Controllers/Auth/AppSumoAuthController.php new file mode 100644 index 0000000..505d2d9 --- /dev/null +++ b/app/Http/Controllers/Auth/AppSumoAuthController.php @@ -0,0 +1,117 @@ +validate($request, [ + 'code' => 'required', + ]); + $accessToken = $this->retrieveAccessToken($request->code); + $license = $this->fetchOrCreateLicense($accessToken); + + // If user connected, attach license + if (Auth::check()) return $this->attachLicense($license); + + // otherwise start login flow by passing the encrypted license key id + if (is_null($license->user_id)) { + return redirect(url('/register?appsumo_license='.encrypt($license->id))); + } + + return redirect(url('/register?appsumo_error=1')); + } + + private function retrieveAccessToken(string $requestCode): string + { + return Http::withHeaders([ + 'Content-type' => 'application/json' + ])->post('https://appsumo.com/openid/token/', [ + 'grant_type' => 'authorization_code', + 'code' => $requestCode, + 'redirect_uri' => route('appsumo.callback'), + 'client_id' => config('services.appsumo.client_id'), + 'client_secret' => config('services.appsumo.client_secret'), + ])->throw()->json('access_token'); + } + + private function fetchOrCreateLicense(string $accessToken): License + { + // Fetch license from API + $licenseKey = Http::get('https://appsumo.com/openid/license_key/?access_token=' . $accessToken) + ->throw() + ->json('license_key'); + + // Fetch or create license model + $license = License::where('license_provider','appsumo')->where('license_key',$licenseKey)->first(); + if (!$license) { + $licenseData = Http::withHeaders([ + 'X-AppSumo-Licensing-Key' => config('services.appsumo.api_key'), + ])->get('https://api.licensing.appsumo.com/v2/licenses/'.$licenseKey)->json(); + + // Create new license + $license = License::create([ + 'license_key' => $licenseKey, + 'license_provider' => 'appsumo', + 'status' => $licenseData['status'] === 'active' ? License::STATUS_ACTIVE : License::STATUS_INACTIVE, + 'meta' => $licenseData, + ]); + } + + return $license; + } + + private function attachLicense(License $license) { + if (!Auth::check()) { + throw new AuthenticationException('User not authenticated'); + } + + // Attach license if not already attached + if (is_null($license->user_id)) { + $license->user_id = Auth::id(); + $license->save(); + return redirect(url('/home?appsumo_connect=1')); + } + + // Licensed already attached + return redirect(url('/home?appsumo_error=1')); + } + + /** + * @param User $user + * @param string|null $licenseHash + * @return string|null + * + * Returns null if no license found + * Returns true if license was found and attached + * Returns false if there was an error (license not found or already attached) + */ + public static function registerWithLicense(User $user, ?string $licenseHash): ?bool + { + if (!$licenseHash) { + return null; + } + $licenseId = decrypt($licenseHash); + $license = License::find($licenseId); + + if ($license && is_null($license->user_id)) { + $license->user_id = $user->id; + $license->save(); + return true; + } + + return false; + } +} diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index 736eff8..df7e99a 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; +use App\Http\Resources\UserResource; use App\Models\Workspace; use App\Models\User; use Illuminate\Contracts\Auth\MustVerifyEmail; @@ -15,6 +16,8 @@ class RegisterController extends Controller { use RegistersUsers; + private ?bool $appsumoLicense = null; + /** * Create a new controller instance. * @@ -28,8 +31,8 @@ class RegisterController extends Controller /** * The user has been registered. * - * @param \Illuminate\Http\Request $request - * @param \App\User $user + * @param \Illuminate\Http\Request $request + * @param \App\User $user * @return \Illuminate\Http\JsonResponse */ protected function registered(Request $request, User $user) @@ -38,13 +41,17 @@ class RegisterController extends Controller return response()->json(['status' => trans('verification.sent')]); } - return response()->json($user); + return response()->json(array_merge( + (new UserResource($user))->toArray($request), + [ + 'appsumo_license' => $this->appsumoLicense, + ])); } /** * Get a validator for an incoming registration request. * - * @param array $data + * @param array $data * @return \Illuminate\Contracts\Validation\Validator */ protected function validator(array $data) @@ -54,8 +61,9 @@ class RegisterController extends Controller 'email' => 'required|email:filter|max:255|unique:users|indisposable', 'password' => 'required|min:6|confirmed', 'hear_about_us' => 'required|string', - 'agree_terms' => ['required',Rule::in([true])] - ],[ + 'agree_terms' => ['required', Rule::in([true])], + 'appsumo_license' => ['nullable'], + ], [ 'agree_terms' => 'Please agree with the terms and conditions.' ]); } @@ -63,7 +71,7 @@ class RegisterController extends Controller /** * Create a new user instance after a valid registration. * - * @param array $data + * @param array $data * @return \App\User */ protected function create(array $data) @@ -87,6 +95,8 @@ class RegisterController extends Controller ] ], false); + $this->appsumoLicense = AppSumoAuthController::registerWithLicense($user, $data['appsumo_license'] ?? null); + return $user; } } diff --git a/app/Http/Controllers/SitemapController.php b/app/Http/Controllers/SitemapController.php index 8596b1f..7dca3e1 100644 --- a/app/Http/Controllers/SitemapController.php +++ b/app/Http/Controllers/SitemapController.php @@ -21,7 +21,7 @@ class SitemapController extends Controller ['/login', 0.4], ['/register', 0.4], ['/password/reset', 0.3], - ['/templates', 0.9], + ['/form-templates', 0.9], ]; public function getSitemap(Request $request) @@ -31,6 +31,8 @@ class SitemapController extends Controller $sitemap->add($this->createUrl($url[0], $url[1])); } $this->addTemplatesUrls($sitemap); + $this->addTemplatesTypesUrls($sitemap); + $this->addTemplatesIndustriesUrls($sitemap); return $sitemap->toResponse($request); } @@ -48,4 +50,20 @@ class SitemapController extends Controller } }); } + + private function addTemplatesTypesUrls(Sitemap $sitemap) + { + $types = json_decode(file_get_contents(resource_path('data/forms/templates/types.json')), true); + foreach ($types as $type) { + $sitemap->add($this->createUrl('/form-templates/types/' . $type['slug'], 0.7)); + } + } + + private function addTemplatesIndustriesUrls(Sitemap $sitemap) + { + $industries = json_decode(file_get_contents(resource_path('data/forms/templates/industries.json')), true); + foreach ($industries as $industry) { + $sitemap->add($this->createUrl('/form-templates/industries/' . $industry['slug'], 0.7)); + } + } } diff --git a/app/Http/Controllers/TemplateController.php b/app/Http/Controllers/TemplateController.php index 474d0ad..151f0d6 100644 --- a/app/Http/Controllers/TemplateController.php +++ b/app/Http/Controllers/TemplateController.php @@ -13,17 +13,26 @@ class TemplateController extends Controller { public function index(Request $request) { - $limit = null; - if ($request->offsetExists('limit') && $request->get('limit') > 0) { - $limit = (int) $request->get('limit'); - } + $limit = (int)$request->get('limit', 0); + $onlyMy = (bool)$request->get('onlymy', false); - $templates = Template::where('publicly_listed', true) - ->when(Auth::check(), function ($query) { - $query->orWhere('creator_id', Auth::id()); + $templates = Template::when(Auth::check(), function ($query) use ($onlyMy) { + if ($onlyMy) { + $query->where('creator_id', Auth::id()); + } else { + $query->where(function ($query) { + $query->where('publicly_listed', true) + ->orWhere('creator_id', Auth::id()); + }); + } + }) + ->when(!Auth::check(), function ($query) { + $query->where('publicly_listed', true); + }) + ->when($limit > 0, function ($query) use ($limit) { + $query->limit($limit); }) ->orderByDesc('created_at') - ->limit($limit) ->get(); return FormTemplateResource::collection($templates); diff --git a/app/Http/Controllers/Webhook/AppSumoController.php b/app/Http/Controllers/Webhook/AppSumoController.php new file mode 100644 index 0000000..dfd0375 --- /dev/null +++ b/app/Http/Controllers/Webhook/AppSumoController.php @@ -0,0 +1,107 @@ +validateSignature($request); + + if ($request->test) { + Log::info('[APPSUMO] test request received', $request->toArray()); + return $this->success([ + 'message' => 'Webhook received.', + 'event' => $request->event, + 'success' => true, + ]); + } + + Log::info('[APPSUMO] request received', $request->toArray()); + + // Call the right function depending on the event using match() + match ($request->event) { + 'activate' => $this->handleActivateEvent($request), + 'upgrade', 'downgrade' => $this->handleChangeEvent($request), + 'deactivate' => $this->handleDeactivateEvent($request), + default => null, + }; + + return $this->success([ + 'message' => 'Webhook received.', + 'event' => $request->event, + 'success' => true, + ]); + } + + private function handleActivateEvent($request) + { + $this->createLicense($request->json()->all()); + } + + private function handleChangeEvent($request) + { + $license = $this->deactivateLicense($request->prev_license_key); + $this->createLicense(array_merge($request->json()->all(), [ + 'user_id' => $license->user_id, + ])); + } + + private function handleDeactivateEvent($request) + { + $this->deactivateLicense($request->license_key); + } + + private function createLicense(array $licenseData): License + { + $license = License::firstOrNew([ + 'license_key' => $licenseData['license_key'], + 'license_provider' => 'appsumo', + 'status' => License::STATUS_ACTIVE, + ]); + $license->meta = $licenseData; + $license->user_id = $licenseData['user_id'] ?? null; + $license->save(); + + Log::info('[APPSUMO] creating new license', + [ + 'license_key' => $license->license_key, + 'license_id' => $license->id, + ]); + + return $license; + } + + private function deactivateLicense(string $licenseKey): License + { + $license = License::where([ + 'license_key' => $licenseKey, + 'license_provider' => 'appsumo', + ])->firstOrFail(); + $license->update([ + 'status' => License::STATUS_INACTIVE, + ]); + Log::info('[APPSUMO] De-activating license', [ + 'license_key' => $licenseKey, + 'license_id' => $license->id, + ]); + + return $license; + } + + private function validateSignature(Request $request) + { + $signature = $request->header('x-appsumo-signature'); + $payload = $request->getContent(); + + if ($signature === hash_hmac('sha256', $payload, config('services.appsumo.api_key'))) { + throw new UnauthorizedException('Invalid signature.'); + } + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index a786d57..970e320 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -80,6 +80,6 @@ class Kernel extends HttpKernel 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, 'pro-form' => \App\Http\Middleware\Form\ProForm::class, - 'password-protected-form' => \App\Http\Middleware\Form\PasswordProtectedForm::class, + 'protected-form' => \App\Http\Middleware\Form\ProtectedForm::class, ]; } diff --git a/app/Http/Middleware/Form/PasswordProtectedForm.php b/app/Http/Middleware/Form/ProtectedForm.php similarity index 53% rename from app/Http/Middleware/Form/PasswordProtectedForm.php rename to app/Http/Middleware/Form/ProtectedForm.php index b4e2e15..6bbc947 100644 --- a/app/Http/Middleware/Form/PasswordProtectedForm.php +++ b/app/Http/Middleware/Form/ProtectedForm.php @@ -7,7 +7,7 @@ use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -class PasswordProtectedForm +class ProtectedForm { const PASSWORD_HEADER_NAME = 'form-password'; @@ -20,26 +20,34 @@ class PasswordProtectedForm */ public function handle(Request $request, Closure $next) { - if ($request->route('slug')) { - $form = Form::where('slug',$request->route('slug'))->firstOrFail(); - $request->merge([ - 'form' => $form, - ]); - $userIsFormOwner = Auth::check() && Auth::user()->workspaces()->find($form->workspace_id) !== null; - if (!$userIsFormOwner && $form->has_password) { - if($this->hasCorrectPassword($request, $form)){ - return $next($request); - } - - return response([ - 'status' => 'Unauthorized', - 'message' => 'Form is password protected.', - ], 403); - } + if (!$request->route('slug')) { + return $next($request); } + + $form = Form::where('slug',$request->route('slug'))->firstOrFail(); + $request->merge([ + 'form' => $form, + ]); + $userIsFormOwner = Auth::check() && Auth::user()->ownsForm($form); + if (!$userIsFormOwner && $this->isProtected($request, $form)) { + return response([ + 'status' => 'Unauthorized', + 'message' => 'Form is protected.', + ], 403); + } + return $next($request); } + public static function isProtected(Request $request, Form $form) + { + if (!$form->has_password) { + return false; + } + + return !self::hasCorrectPassword($request, $form); + } + public static function hasCorrectPassword(Request $request, Form $form) { return $request->headers->has(self::PASSWORD_HEADER_NAME) && $request->headers->get(self::PASSWORD_HEADER_NAME) == hash('sha256', $form->password); diff --git a/app/Http/Requests/AnswerFormRequest.php b/app/Http/Requests/AnswerFormRequest.php index 503e7b5..2a3039f 100644 --- a/app/Http/Requests/AnswerFormRequest.php +++ b/app/Http/Requests/AnswerFormRequest.php @@ -12,12 +12,10 @@ use Illuminate\Validation\Rule; use Illuminate\Http\Request; use App\Rules\ValidHCaptcha; use App\Rules\ValidPhoneInputRule; +use App\Rules\ValidUrl; class AnswerFormRequest extends FormRequest { - const MAX_FILE_SIZE_FREE = 5000000; // 5 MB - const MAX_FILE_SIZE_PRO = 50000000; // 50 MB - public Form $form; protected array $requestRules = []; @@ -26,12 +24,7 @@ class AnswerFormRequest extends FormRequest public function __construct(Request $request) { $this->form = $request->form; - - $this->maxFileSize = self::MAX_FILE_SIZE_FREE; - $workspace = $this->form->workspace; - if ($workspace && $workspace->is_pro) { - $this->maxFileSize = self::MAX_FILE_SIZE_PRO; - } + $this->maxFileSize = $this->form->workspace->max_file_size; } /** @@ -171,7 +164,7 @@ class AnswerFormRequest extends FormRequest $this->requestRules[$property['id'].'.*'] = [new StorageFile($this->maxFileSize, [], $this->form)]; return ['array']; } - return ['url']; + return [new ValidUrl]; case 'files': $allowedFileTypes = []; if(!empty($property['allowed_file_types'])){ diff --git a/app/Http/Requests/UploadAssetRequest.php b/app/Http/Requests/UploadAssetRequest.php index 05aaf72..51413a6 100644 --- a/app/Http/Requests/UploadAssetRequest.php +++ b/app/Http/Requests/UploadAssetRequest.php @@ -16,15 +16,20 @@ class UploadAssetRequest extends FormRequest */ public function rules() { + $fileTypes = [ + 'png', + 'jpeg', + 'jpg', + 'bmp', + 'gif', + 'svg' + ]; + if ($this->offsetExists('type') && $this->get('type') === 'files') { + $fileTypes = []; + } + return [ - 'url' => ['required',new StorageFile(self::FORM_ASSET_MAX_SIZE, [ - 'png', - 'jpeg', - 'jpg', - 'bmp', - 'gif', - 'svg' - ])], + 'url' => ['required', new StorageFile(self::FORM_ASSET_MAX_SIZE, $fileTypes)], ]; } } diff --git a/app/Http/Resources/FormResource.php b/app/Http/Resources/FormResource.php index 9cdc44c..7f6a86f 100644 --- a/app/Http/Resources/FormResource.php +++ b/app/Http/Resources/FormResource.php @@ -2,7 +2,7 @@ namespace App\Http\Resources; -use App\Http\Middleware\Form\PasswordProtectedForm; +use App\Http\Middleware\Form\ProtectedForm; use App\Http\Resources\UserResource; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Facades\Auth; @@ -20,8 +20,8 @@ class FormResource extends JsonResource */ public function toArray($request) { - if(!$this->userIsFormOwner() && $this->doesMissPassword($request)){ - return $this->getPasswordProtectedForm(); + if(!$this->userIsFormOwner() && ProtectedForm::isProtected($request, $this->resource)){ + return $this->getProtectedForm(); } $ownerData = $this->userIsFormOwner() ? [ @@ -29,6 +29,7 @@ class FormResource extends JsonResource 'views_count' => $this->views_count, 'submissions_count' => $this->submissions_count, 'notifies' => $this->notifies, + 'notifies_webhook' => $this->notifies_webhook, 'notifies_slack' => $this->notifies_slack, 'notifies_discord' => $this->notifies_discord, 'send_submission_confirmation' => $this->send_submission_confirmation, @@ -95,14 +96,7 @@ class FormResource extends JsonResource return $this; } - private function doesMissPassword(Request $request) - { - if (!$this->has_password) return false; - - return !PasswordProtectedForm::hasCorrectPassword($request, $this->resource); - } - - private function getPasswordProtectedForm() + private function getProtectedForm() { return [ 'id' => $this->id, @@ -130,8 +124,7 @@ class FormResource extends JsonResource private function userIsFormOwner() { return $this->extra?->userIsOwner ?? ( - Auth::check() - && Auth::user()->workspaces()->find($this->workspace_id) !== null + Auth::check() && Auth::user()->ownsForm($this->resource) ); } diff --git a/app/Http/Resources/FormSubmissionResource.php b/app/Http/Resources/FormSubmissionResource.php index e500dd1..6018e9e 100644 --- a/app/Http/Resources/FormSubmissionResource.php +++ b/app/Http/Resources/FormSubmissionResource.php @@ -46,7 +46,9 @@ class FormSubmissionResource extends JsonResource }); foreach ($fileFields as $field) { if (isset($data[$field['id']]) && !empty($data[$field['id']])) { - $data[$field['id']] = collect($data[$field['id']])->map(function ($file) { + $data[$field['id']] = collect($data[$field['id']])->filter(function ($file) { + return $file !== null && $file; + })->map(function ($file) { return [ 'file_url' => route('open.forms.submissions.file', [$this->form_id, $file]), 'file_name' => $file, diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index cfabaa0..5e8de1d 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -21,6 +21,7 @@ class UserResource extends JsonResource 'template_editor' => $this->template_editor, 'has_customer_id' => $this->has_customer_id, 'has_forms' => $this->has_forms, + 'active_license' => $this->licenses()->active()->first(), ] : []; return array_merge(parent::toArray($request), $personalData); diff --git a/app/Jobs/Form/GenerateAiForm.php b/app/Jobs/Form/GenerateAiForm.php index 3e19a3e..86125f4 100644 --- a/app/Jobs/Form/GenerateAiForm.php +++ b/app/Jobs/Form/GenerateAiForm.php @@ -3,11 +3,9 @@ namespace App\Jobs\Form; use App\Console\Commands\GenerateTemplate; -use App\Http\Requests\AiGenerateFormRequest; use App\Models\Forms\AI\AiFormCompletion; use App\Service\OpenAi\GptCompleter; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; @@ -40,6 +38,7 @@ class GenerateAiForm implements ShouldQueue ]); $completer = (new GptCompleter(config('services.openai.api_key'))) + ->useStreaming() ->setSystemMessage('You are a robot helping to generate forms.'); try { @@ -53,29 +52,11 @@ class GenerateAiForm implements ShouldQueue 'result' => $this->cleanOutput($completer->getArray()) ]); } catch (\Exception $e) { - $this->completion->update([ - 'status' => AiFormCompletion::STATUS_FAILED, - 'result' => ['error' => $e->getMessage()] - ]); + $this->onError($e); } } - public function generateForm(AiGenerateFormRequest $request) - { - $completer = (new GptCompleter(config('services.openai.api_key'))) - ->setSystemMessage('You are a robot helping to generate forms.'); - $completer->completeChat([ - ["role" => "user", "content" => Str::of(GenerateTemplate::FORM_STRUCTURE_PROMPT) - ->replace('[REPLACE]', $request->form_prompt)->toString()] - ], 3000); - - return $this->success([ - 'message' => 'Form successfully generated!', - 'form' => $this->cleanOutput($completer->getArray()) - ]); - } - private function cleanOutput($formData) { // Add property uuids @@ -85,4 +66,19 @@ class GenerateAiForm implements ShouldQueue return $formData; } + + /** + * Handle a job failure. + */ + public function failed(\Throwable $exception): void + { + $this->onError($exception); + } + + private function onError(\Throwable $e) { + $this->completion->update([ + 'status' => AiFormCompletion::STATUS_FAILED, + 'result' => ['error' => $e->getMessage()] + ]); + } } diff --git a/app/Jobs/Form/StoreFormSubmissionJob.php b/app/Jobs/Form/StoreFormSubmissionJob.php index 54f2933..8361f04 100644 --- a/app/Jobs/Form/StoreFormSubmissionJob.php +++ b/app/Jobs/Form/StoreFormSubmissionJob.php @@ -4,9 +4,11 @@ namespace App\Jobs\Form; use App\Events\Forms\FormSubmitted; use App\Http\Controllers\Forms\PublicFormController; +use App\Http\Controllers\Forms\FormController; use App\Http\Requests\AnswerFormRequest; use App\Models\Forms\Form; use App\Models\Forms\FormSubmission; +use App\Service\Forms\FormLogicPropertyResolver; use App\Service\Storage\StorageFileNameParser; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Filesystem\Filesystem; @@ -162,6 +164,14 @@ class StoreFormSubmissionJob implements ShouldQueue return null; } + if(filter_var($value, FILTER_VALIDATE_URL) !== FALSE && str_contains($value, parse_url(config('app.url'))['host'])) { // In case of prefill we have full url so convert to s3 + $fileName = basename($value); + $path = FormController::ASSETS_UPLOAD_PATH . '/' . $fileName; + $newPath = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $this->form->id); + Storage::move($path, $newPath.'/'.$fileName); + return $fileName; + } + if($this->isSkipForUpload($value)) { return $value; } @@ -215,8 +225,10 @@ class StoreFormSubmissionJob implements ShouldQueue collect($this->form->properties)->filter(function ($property) { return isset($property['hidden']) && isset($property['prefill']) - && $property['hidden'] - && !is_null($property['prefill']); + && FormLogicPropertyResolver::isHidden($property, $this->submissionData) + && !is_null($property['prefill']) + && !in_array($property['type'], ['files']) + && !($property['type'] == 'url' && isset($property['file_upload']) && $property['file_upload']); })->each(function (array $property) use (&$formData) { if ($property['type'] === 'date' && isset($property['prefill_today']) && $property['prefill_today']) { $formData[$property['id']] = now()->format((isset($property['with_time']) && $property['with_time']) ? 'Y-m-d H:i' : 'Y-m-d'); diff --git a/app/Mail/Forms/SubmissionConfirmationMail.php b/app/Mail/Forms/SubmissionConfirmationMail.php index 4ea01a3..6532e94 100644 --- a/app/Mail/Forms/SubmissionConfirmationMail.php +++ b/app/Mail/Forms/SubmissionConfirmationMail.php @@ -10,6 +10,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Str; use Illuminate\Support\Arr; +use Vinkla\Hashids\Facades\Hashids; class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue { @@ -44,7 +45,7 @@ class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue 'fields' => $formatter->getFieldsWithValue(), 'form' => $form, 'noBranding' => $form->no_branding, - 'submission_id' => $this->event->data['submission_id'] ?? null + 'submission_id' => (isset($this->event->data['submission_id']) && $this->event->data['submission_id']) ? Hashids::encode($this->event->data['submission_id']) : null ]); } diff --git a/app/Models/Forms/Form.php b/app/Models/Forms/Form.php index 2e2ae0d..ede1be7 100644 --- a/app/Models/Forms/Form.php +++ b/app/Models/Forms/Form.php @@ -279,6 +279,11 @@ class Form extends Model } + public function getNotifiesWebhookAttribute() + { + return !empty($this->webhook_url); + } + public function getNotifiesDiscordAttribute() { return !empty($this->discord_webhook_url); diff --git a/app/Models/License.php b/app/Models/License.php new file mode 100644 index 0000000..bb72f34 --- /dev/null +++ b/app/Models/License.php @@ -0,0 +1,45 @@ + 'array', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } + + public function scopeActive($query) + { + return $query->where('status', self::STATUS_ACTIVE); + } + + public function getMaxFileSizeAttribute() + { + return [ + 1 => 25000000, // 25 MB, + 2 => 50000000, // 50 MB, + 3 => 75000000, // 75 MB, + ][$this->meta['tier']]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 538136b..764d927 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -13,6 +13,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Cashier\Billable; use Tymon\JWTAuth\Contracts\JWTSubject; + class User extends Authenticatable implements JWTSubject { use Notifiable, HasFactory, Billable; @@ -60,6 +61,11 @@ class User extends Authenticatable implements JWTSubject protected $withCount = ['workspaces']; + public function ownsForm(Form $form) + { + return $this->workspaces()->find($form->workspace_id) !== null; + } + /** * Get the profile photo URL attribute. * @@ -80,7 +86,9 @@ class User extends Authenticatable implements JWTSubject public function getIsSubscribedAttribute() { - return $this->subscribed() || in_array($this->email, config('opnform.extra_pro_users_emails')); + return $this->subscribed() + || in_array($this->email, config('opnform.extra_pro_users_emails')) + || !is_null($this->activeLicense()); } public function getHasCustomerIdAttribute() @@ -138,7 +146,7 @@ class User extends Authenticatable implements JWTSubject public function forms() { - return $this->hasMany(Form::class,'creator_id'); + return $this->hasMany(Form::class, 'creator_id'); } public function formTemplates() @@ -146,6 +154,16 @@ class User extends Authenticatable implements JWTSubject return $this->hasMany(Template::class, 'creator_id'); } + public function licenses() + { + return $this->hasMany(License::class); + } + + public function activeLicense(): ?License + { + return $this->licenses()->active()->first(); + } + /** * ================================= * Oauth Related @@ -187,26 +205,26 @@ class User extends Authenticatable implements JWTSubject })->first()?->onTrial(); } - public static function boot () + public static function boot() { parent::boot(); - static::deleting(function(User $user) { + static::deleting(function (User $user) { // Remove user's workspace if he's the only one with this workspace foreach ($user->workspaces as $workspace) { if ($workspace->users()->count() == 1) { $workspace->delete(); } } - }); + }); } public function scopeWithActiveSubscription($query) { - return $query->whereHas('subscriptions', function($query) { - $query->where(function($q){ - $q->where('stripe_status', 'trialing') - ->orWhere('stripe_status', 'active'); - }); + return $query->whereHas('subscriptions', function ($query) { + $query->where(function ($q) { + $q->where('stripe_status', 'trialing') + ->orWhere('stripe_status', 'active'); + }); }); } diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index 5974a59..c0efa23 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -2,8 +2,8 @@ namespace App\Models; +use App\Http\Requests\AnswerFormRequest; use App\Models\Forms\Form; -use App\Models\User; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -11,6 +11,9 @@ class Workspace extends Model { use HasFactory; + const MAX_FILE_SIZE_FREE = 5000000; // 5 MB + const MAX_FILE_SIZE_PRO = 50000000; // 50 MB + protected $fillable = [ 'name', 'icon', @@ -37,6 +40,26 @@ class Workspace extends Model return false; } + public function getMaxFileSizeAttribute() + { + if(is_null(config('cashier.key'))){ + return self::MAX_FILE_SIZE_PRO; + } + + // Return max file size depending on subscription + foreach ($this->owners as $owner) { + if ($owner->is_subscribed) { + if ($license = $owner->activeLicense()) { + // In case of special License + return $license->max_file_size; + } + } + return self::MAX_FILE_SIZE_PRO; + } + + return self::MAX_FILE_SIZE_FREE; + } + public function getIsEnterpriseAttribute() { if(is_null(config('cashier.key'))){ diff --git a/app/Rules/StorageFile.php b/app/Rules/StorageFile.php index f2f0a30..b7df2eb 100644 --- a/app/Rules/StorageFile.php +++ b/app/Rules/StorageFile.php @@ -40,6 +40,11 @@ class StorageFile implements Rule */ public function passes($attribute, $value): bool { + // If full path then no need to validate + if (filter_var($value, FILTER_VALIDATE_URL) !== FALSE) { + return true; + } + // This is use when updating a record, and file uploads aren't changed. if($this->form){ $newPath = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $this->form->id); diff --git a/app/Rules/ValidUrl.php b/app/Rules/ValidUrl.php new file mode 100644 index 0000000..cc694ec --- /dev/null +++ b/app/Rules/ValidUrl.php @@ -0,0 +1,33 @@ + '/integrations', 'templates' => '/form-templates', 'templates_show' => '/form-templates/{slug}', + 'templates_types_show' => '/form-templates/types/{slug}', + 'templates_industries_show' => '/form-templates/industries/{slug}', ]; /** @@ -192,4 +194,26 @@ class SeoMetaResolver 'image' => $template->image_url ]; } + + private function getTemplatesTypesShowMeta(): array + { + $types = json_decode(file_get_contents(resource_path('data/forms/templates/types.json')), true); + $type = $types[array_search($this->patternData['slug'], array_column($types, 'slug'))]; + + return [ + 'title' => $type['meta_title'], + 'description' => Str::of($type['meta_description'])->limit(140), + ]; + } + + private function getTemplatesIndustriesShowMeta(): array + { + $industries = json_decode(file_get_contents(resource_path('data/forms/templates/industries.json')), true); + $industry = $industries[array_search($this->patternData['slug'], array_column($industries, 'slug'))]; + + return [ + 'title' => $industry['meta_title'], + 'description' => Str::of($industry['meta_description'])->limit(140), + ]; + } } diff --git a/config/filesystems.php b/config/filesystems.php index 94c8112..617e720 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -63,6 +63,7 @@ return [ 'bucket' => env('AWS_BUCKET'), 'url' => env('AWS_URL'), 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', true), ], ], diff --git a/config/services.php b/config/services.php index fe0ad40..7e2b9d1 100644 --- a/config/services.php +++ b/config/services.php @@ -45,7 +45,7 @@ return [ ], 'notion' => [ - 'worker' => env('NOTION_WORKER','https://notion-forms-worker.notionforms.workers.dev/v1') + 'worker' => env('NOTION_WORKER', 'https://notion-forms-worker.notionforms.workers.dev/v1') ], 'openai' => [ @@ -53,8 +53,14 @@ return [ ], 'unsplash' => [ - 'access_key' => env('UNSPLASH_ACCESS_KEY'), - 'secret_key' => env('UNSPLASH_SECRET_KEY'), + 'access_key' => env('UNSPLASH_ACCESS_KEY'), + 'secret_key' => env('UNSPLASH_SECRET_KEY'), + ], + + 'appsumo' => [ + 'client_id' => env('APPSUMO_CLIENT_ID'), + 'client_secret' => env('APPSUMO_CLIENT_SECRET'), + 'api_key' => env('APPSUMO_API_KEY'), ], 'google_analytics_code' => env('GOOGLE_ANALYTICS_CODE'), diff --git a/database/migrations/2021_05_19_140326_create_forms_table.php b/database/migrations/2021_05_19_140326_create_forms_table.php index 7622b97..7aa63a9 100644 --- a/database/migrations/2021_05_19_140326_create_forms_table.php +++ b/database/migrations/2021_05_19_140326_create_forms_table.php @@ -1,7 +1,9 @@ id(); $table->foreignIdFor(\App\Models\Workspace::class,'workspace_id'); $table->string('title'); @@ -22,14 +26,14 @@ class CreateFormsTable extends Migration $table->timestamps(); $table->boolean('notifies')->default(false); $table->text('description')->nullable(); - $table->text('submit_button_text')->default('Submit'); + $table->text('submit_button_text')->default(new Expression("('Submit')")); $table->boolean('re_fillable')->default(false); - $table->text('re_fill_button_text')->default('Fill Again'); + $table->text('re_fill_button_text')->default(new Expression("('Fill Again')")); $table->string('color')->default('#3B82F6'); $table->boolean('uppercase_labels')->default(true); $table->boolean('no_branding')->default(false); $table->boolean('hide_title')->default(false); - $table->text('submitted_text')->default('Amazing, we saved your answers. Thank you for your time and have a great day!'); + $table->text('submitted_text')->default(new Expression("('Amazing, we saved your answers. Thank you for your time and have a great day!')")); $table->string('dark_mode')->default('auto'); $table->string('webhook_url')->nullable(); $table->boolean('send_submission_confirmation')->default(false); @@ -45,13 +49,17 @@ class CreateFormsTable extends Migration $table->timestamp('closes_at')->nullable(); $table->text('closed_text')->nullable(); $table->string('notification_subject')->default("We saved your answers"); - $table->text('notification_body')->default('

Hello there 👋
This is a confirmation that your submission was successfully saved.

'); + $table->text('notification_body')->default(new Expression("('

Hello there 👋
This is a confirmation that your submission was successfully saved.

')")); $table->boolean('notifications_include_submission')->default(true); $table->boolean('use_captcha')->default(false); $table->boolean('can_be_indexed')->default(true); $table->string('password')->nullable()->default(null); $table->string('notification_sender')->default("OpenForm"); - $table->jsonb('tags')->default('[]'); + if ($driver === 'mysql') { + $table->jsonb('tags')->default(new Expression('(JSON_ARRAY())')); + } else { + $table->jsonb('tags')->default('[]'); + } }); } diff --git a/database/migrations/2021_05_24_234028_create_form_submissions_table.php b/database/migrations/2021_05_24_234028_create_form_submissions_table.php index 64cadd7..b7e48af 100644 --- a/database/migrations/2021_05_24_234028_create_form_submissions_table.php +++ b/database/migrations/2021_05_24_234028_create_form_submissions_table.php @@ -1,7 +1,9 @@ id(); $table->foreignIdFor(\App\Models\Forms\Form::class,'form_id'); - $table->jsonb('data')->default('{}'); + if ($driver === 'mysql') { + $table->jsonb('data')->default(new Expression("(JSON_OBJECT())")); + } else { + $table->jsonb('data')->default("{}"); + } $table->timestamps(); }); } diff --git a/database/migrations/2022_05_10_144947_form_statistic.php b/database/migrations/2022_05_10_144947_form_statistic.php index 85ace26..7cafb53 100644 --- a/database/migrations/2022_05_10_144947_form_statistic.php +++ b/database/migrations/2022_05_10_144947_form_statistic.php @@ -1,7 +1,9 @@ id(); $table->foreignIdFor(\App\Models\Forms\Form::class,'form_id'); - $table->jsonb('data')->default('{}'); + if ($driver === 'mysql') { + $table->jsonb('data')->default(new Expression("(JSON_OBJECT())")); + } else { + $table->jsonb('data')->default("{}"); + } $table->date('date'); }); } diff --git a/database/migrations/2022_08_18_133641_add_removed_properties_to_forms.php b/database/migrations/2022_08_18_133641_add_removed_properties_to_forms.php index 19997ed..03389a2 100644 --- a/database/migrations/2022_08_18_133641_add_removed_properties_to_forms.php +++ b/database/migrations/2022_08_18_133641_add_removed_properties_to_forms.php @@ -1,7 +1,9 @@ jsonb('removed_properties')->default('[]'); + $driver = DB::getDriverName(); + + Schema::table('forms', function (Blueprint $table) use ($driver) { + if ($driver === 'mysql') { + $table->jsonb('removed_properties')->default(new Expression("(JSON_ARRAY())")); + } else { + $table->jsonb('removed_properties')->default("[]"); + } }); } diff --git a/database/migrations/2022_09_22_092205_create_templates_table.php b/database/migrations/2022_09_22_092205_create_templates_table.php index 4642c58..0f7094f 100644 --- a/database/migrations/2022_09_22_092205_create_templates_table.php +++ b/database/migrations/2022_09_22_092205_create_templates_table.php @@ -1,7 +1,9 @@ id(); $table->timestamps(); $table->string('name'); $table->string('slug'); $table->text('description'); $table->string('image_url'); - $table->jsonb('structure')->default('{}'); + if ($driver === 'mysql') { + $table->jsonb('structure')->default(new Expression("(JSON_OBJECT())")); + } else { + $table->jsonb('structure')->default('{}'); + } }); } diff --git a/database/migrations/2022_09_26_084721_add_questions_to_templates.php b/database/migrations/2022_09_26_084721_add_questions_to_templates.php index 449fe40..1fd0c50 100644 --- a/database/migrations/2022_09_26_084721_add_questions_to_templates.php +++ b/database/migrations/2022_09_26_084721_add_questions_to_templates.php @@ -1,7 +1,9 @@ jsonb('questions')->default('{}'); + $driver = DB::getDriverName(); + + Schema::table('templates', function (Blueprint $table) use ($driver) { + if ($driver === 'mysql') { + $table->jsonb('questions')->default(new Expression("(JSON_ARRAY())")); + } else { + $table->jsonb('questions')->default('[]'); + } }); } diff --git a/database/migrations/2023_03_13_094806_add_editable_submissions_button_text_to_forms.php b/database/migrations/2023_03_13_094806_add_editable_submissions_button_text_to_forms.php index e77e68b..d412da9 100644 --- a/database/migrations/2023_03_13_094806_add_editable_submissions_button_text_to_forms.php +++ b/database/migrations/2023_03_13_094806_add_editable_submissions_button_text_to_forms.php @@ -1,6 +1,7 @@ text('editable_submissions_button_text')->default('Edit submission'); + $table->text('editable_submissions_button_text')->default(new Expression("('Edit submission')")); }); } diff --git a/database/migrations/2023_07_20_073728_add_seo_meta_to_forms.php b/database/migrations/2023_07_20_073728_add_seo_meta_to_forms.php index e0a67d7..01fa247 100644 --- a/database/migrations/2023_07_20_073728_add_seo_meta_to_forms.php +++ b/database/migrations/2023_07_20_073728_add_seo_meta_to_forms.php @@ -1,7 +1,9 @@ json('seo_meta')->default('{}'); + $driver = DB::getDriverName(); + + Schema::table('forms', function (Blueprint $table) use ($driver) { + if ($driver === 'mysql') { + $table->json('seo_meta')->default(new Expression("(JSON_OBJECT())")); + } else { + $table->json('seo_meta')->default("{}"); + } }); } diff --git a/database/migrations/2023_08_23_100710_add_notification_settings_to_forms.php b/database/migrations/2023_08_23_100710_add_notification_settings_to_forms.php index b091f6b..37f3202 100644 --- a/database/migrations/2023_08_23_100710_add_notification_settings_to_forms.php +++ b/database/migrations/2023_08_23_100710_add_notification_settings_to_forms.php @@ -1,7 +1,9 @@ json('notification_settings')->default('{}')->nullable(true); + $driver = DB::getDriverName(); + + Schema::table('forms', function (Blueprint $table) use ($driver) { + if ($driver === 'mysql') { + $table->json('notification_settings')->default(new Expression("(JSON_OBJECT())"))->nullable(true); + } else { + $table->json('notification_settings')->default('{}')->nullable(true); + } }); } diff --git a/database/migrations/2023_09_01_052507_add_fields_to_templates.php b/database/migrations/2023_09_01_052507_add_fields_to_templates.php index 3adfc83..a4b8e74 100644 --- a/database/migrations/2023_09_01_052507_add_fields_to_templates.php +++ b/database/migrations/2023_09_01_052507_add_fields_to_templates.php @@ -1,7 +1,9 @@ boolean('publicly_listed')->default(false); - $table->jsonb('industries')->default('[]'); - $table->jsonb('types')->default('[]'); + + if ($driver === 'mysql') { + $table->jsonb('industries')->default(new Expression("(JSON_ARRAY())")); + } else { + $table->jsonb('industries')->default('[]'); + } + + if ($driver === 'mysql') { + $table->jsonb('types')->default(new Expression("(JSON_ARRAY())")); + } else { + $table->jsonb('types')->default('[]'); + } + $table->string('short_description')->nullable(); - $table->jsonb('related_templates')->default('[]'); + + if ($driver === 'mysql') { + $table->jsonb('related_templates')->default(new Expression("(JSON_ARRAY())")); + } else { + $table->jsonb('related_templates')->default('[]'); + } + $table->string('image_url',500)->nullable()->change(); }); } diff --git a/database/migrations/2023_10_30_133259_create_licenses_table.php b/database/migrations/2023_10_30_133259_create_licenses_table.php new file mode 100644 index 0000000..034cde5 --- /dev/null +++ b/database/migrations/2023_10_30_133259_create_licenses_table.php @@ -0,0 +1,39 @@ +id(); + $table->string('license_key'); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('license_provider'); + $table->string('status'); + $table->json('meta'); + $table->timestamps(); + + $table->index(['license_key', 'license_provider']); + $table->index(['user_id', 'license_provider']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('licenses'); + } +}; diff --git a/public/img/appsumo/as-Select-dark.png b/public/img/appsumo/as-Select-dark.png new file mode 100644 index 0000000..44dc132 Binary files /dev/null and b/public/img/appsumo/as-Select-dark.png differ diff --git a/public/img/appsumo/as-taco-white-bg.png b/public/img/appsumo/as-taco-white-bg.png new file mode 100644 index 0000000..16442a4 Binary files /dev/null and b/public/img/appsumo/as-taco-white-bg.png differ diff --git a/resources/js/components/common/Button.vue b/resources/js/components/common/Button.vue index f393d75..0e83048 100644 --- a/resources/js/components/common/Button.vue +++ b/resources/js/components/common/Button.vue @@ -1,27 +1,30 @@ @@ -61,14 +64,19 @@ export default { default: null }, + href: { + type: String, + default: null + }, + target: { type: String, default: '_self' - }, + } }, computed: { - btnClasses() { + btnClasses () { const sizes = this.sizes const colorShades = this.colorShades return `v-btn ${sizes['p-y']} ${sizes['p-x']} @@ -76,14 +84,14 @@ export default { ${colorShades?.text} transition ease-in duration-200 text-center text-${sizes?.font} font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg flex items-center hover:no-underline` }, - colorShades() { + colorShades () { if (this.color === 'blue') { return { main: 'bg-blue-600', hover: 'hover:bg-blue-700', ring: 'focus:ring-blue-500', 'ring-offset': 'focus:ring-offset-blue-200', - text: 'text-white', + text: 'text-white' } } else if (this.color === 'outline-blue') { return { @@ -91,7 +99,15 @@ export default { hover: 'hover:bg-blue-600', ring: 'focus:ring-blue-500', 'ring-offset': 'focus:ring-offset-blue-200', - text: 'text-blue-600 hover:text-white', + text: 'text-blue-600 hover:text-white' + } + } else if (this.color === 'outline-gray') { + return { + main: 'bg-transparent border border-gray-300', + hover: 'hover:bg-gray-500', + ring: 'focus:ring-gray-500', + 'ring-offset': 'focus:ring-offset-gray-200', + text: 'text-gray-500 hover:text-white' } } else if (this.color === 'red') { return { @@ -99,7 +115,7 @@ export default { hover: 'hover:bg-red-700', ring: 'focus:ring-red-500', 'ring-offset': 'focus:ring-offset-red-200', - text: 'text-white', + text: 'text-white' } } else if (this.color === 'gray') { return { @@ -107,7 +123,7 @@ export default { hover: 'hover:bg-gray-700', ring: 'focus:ring-gray-500', 'ring-offset': 'focus:ring-offset-gray-200', - text: 'text-white', + text: 'text-white' } } else if (this.color === 'light-gray') { return { @@ -115,7 +131,7 @@ export default { hover: 'hover:bg-gray-100', ring: 'focus:ring-gray-500', 'ring-offset': 'focus:ring-offset-gray-300', - text: 'text-gray-700', + text: 'text-gray-700' } } else if (this.color === 'green') { return { @@ -123,7 +139,7 @@ export default { hover: 'hover:bg-green-700', ring: 'focus:ring-green-500', 'ring-offset': 'focus:ring-offset-green-200', - text: 'text-white', + text: 'text-white' } } else if (this.color === 'yellow') { return { @@ -131,7 +147,7 @@ export default { hover: 'hover:bg-yellow-700', ring: 'focus:ring-yellow-500', 'ring-offset': 'focus:ring-offset-yellow-200', - text: 'text-white', + text: 'text-white' } } else if (this.color === 'white') { return { @@ -139,12 +155,12 @@ export default { hover: 'hover:bg-gray-200', ring: 'focus:ring-white-500', 'ring-offset': 'focus:ring-offset-white-200', - text: 'text-gray-700', + text: 'text-gray-700' } } console.error('Unknown color') }, - sizes() { + sizes () { if (this.size === 'small') { return { font: 'sm', @@ -161,8 +177,8 @@ export default { }, methods: { - onClick(event) { - this.$emit('click',event) + onClick (event) { + this.$emit('click', event) } } } diff --git a/resources/js/components/forms/FileInput.vue b/resources/js/components/forms/FileInput.vue index b678725..22ae29a 100644 --- a/resources/js/components/forms/FileInput.vue +++ b/resources/js/components/forms/FileInput.vue @@ -1,180 +1,116 @@ + - - - - -

- Upload {{ multiple ? 'file(s)' : 'a file' }} -

- -
-
-
-
-
- -

- Uploading your file... -

-
- -
-
-
-
-

- {{ file.file.name }} -

- -
-
-
-
-
-
-
diff --git a/resources/js/components/forms/components/UploadedFile.vue b/resources/js/components/forms/components/UploadedFile.vue new file mode 100644 index 0000000..f0baa11 --- /dev/null +++ b/resources/js/components/forms/components/UploadedFile.vue @@ -0,0 +1,63 @@ + + + diff --git a/resources/js/components/forms/index.js b/resources/js/components/forms/index.js index 6dc03df..fd6f7a7 100644 --- a/resources/js/components/forms/index.js +++ b/resources/js/components/forms/index.js @@ -15,6 +15,7 @@ import ImageInput from './ImageInput.vue' import RatingInput from './RatingInput.vue' import FlatSelectInput from './FlatSelectInput.vue' import ToggleSwitchInput from './ToggleSwitchInput.vue' +import ScaleInput from './ScaleInput.vue' export function registerComponents (app) { [ @@ -32,7 +33,8 @@ export function registerComponents (app) { ImageInput, RatingInput, FlatSelectInput, - ToggleSwitchInput + ToggleSwitchInput, + ScaleInput ].forEach(Component => { Component.name ? app.component(Component.name, Component) : app.component(Component.name, Component) }) diff --git a/resources/js/components/open/editors/EditorOptionsPanel.vue b/resources/js/components/open/editors/EditorOptionsPanel.vue new file mode 100644 index 0000000..47c6926 --- /dev/null +++ b/resources/js/components/open/editors/EditorOptionsPanel.vue @@ -0,0 +1,46 @@ + + + diff --git a/resources/js/components/open/forms/OpenFormField.vue b/resources/js/components/open/forms/OpenFormField.vue index eda7871..b96ae6d 100644 --- a/resources/js/components/open/forms/OpenFormField.vue +++ b/resources/js/components/open/forms/OpenFormField.vue @@ -150,6 +150,9 @@ export default { if (field.type === 'number' && field.is_rating && field.rating_max_value) { return 'RatingInput' } + if (field.type === 'number' && field.is_scale && field.scale_max_value) { + return 'ScaleInput' + } if (['select', 'multi_select'].includes(field.type) && field.without_dropdown) { return 'FlatSelectInput' } @@ -302,6 +305,10 @@ export default { inputProperties.accept = (this.form.is_pro && field.allowed_file_types) ? field.allowed_file_types : "" } else if (field.type === 'number' && field.is_rating) { inputProperties.numberOfStars = parseInt(field.rating_max_value) + } else if (field.type === 'number' && field.is_scale) { + inputProperties.minScale = parseInt(field.scale_min_value) ?? 1 + inputProperties.maxScale = parseInt(field.scale_max_value) ?? 5 + inputProperties.stepScale = parseInt(field.scale_step_value) ?? 1 } else if (field.type === 'number' || (field.type === 'phone_number' && field.use_simple_text_input)) { inputProperties.pattern = '/\d*' } else if (field.type === 'phone_number' && !field.use_simple_text_input) { diff --git a/resources/js/components/open/forms/components/FormEditor.vue b/resources/js/components/open/forms/components/FormEditor.vue index 05f078e..01c6eed 100644 --- a/resources/js/components/open/forms/components/FormEditor.vue +++ b/resources/js/components/open/forms/components/FormEditor.vue @@ -55,15 +55,15 @@ Please create this form on a device with a larger screen. That will allow you to preview your form changes. - - - - - - + + + + + + + - - + @@ -95,10 +95,10 @@ import FormCustomization from './form-components/FormCustomization.vue' import FormCustomCode from './form-components/FormCustomCode.vue' import FormAboutSubmission from './form-components/FormAboutSubmission.vue' import FormNotifications from './form-components/FormNotifications.vue' -import FormIntegrations from './form-components/FormIntegrations.vue' import FormEditorPreview from './form-components/FormEditorPreview.vue' import FormSecurityPrivacy from './form-components/FormSecurityPrivacy.vue' import FormCustomSeo from './form-components/FormCustomSeo.vue' +import FormAccess from './form-components/FormAccess.vue' import saveUpdateAlert from '../../../../mixins/forms/saveUpdateAlert.js' import fieldsLogic from '../../../../mixins/forms/fieldsLogic.js' @@ -108,7 +108,6 @@ export default { AddFormBlockSidebar, FormFieldEditSidebar, FormEditorPreview, - FormIntegrations, FormNotifications, FormAboutSubmission, FormCustomCode, @@ -117,7 +116,8 @@ export default { FormInformation, FormErrorModal, FormSecurityPrivacy, - FormCustomSeo + FormCustomSeo, + FormAccess }, mixins: [saveUpdateAlert, fieldsLogic], props: { diff --git a/resources/js/components/open/forms/components/form-components/FormAboutSubmission.vue b/resources/js/components/open/forms/components/form-components/FormAboutSubmission.vue index 2dc00de..4d90b49 100644 --- a/resources/js/components/open/forms/components/form-components/FormAboutSubmission.vue +++ b/resources/js/components/open/forms/components/form-components/FormAboutSubmission.vue @@ -1,19 +1,11 @@ diff --git a/resources/js/components/open/forms/components/form-components/FormCustomCode.vue b/resources/js/components/open/forms/components/form-components/FormCustomCode.vue index bada8b1..f15767d 100644 --- a/resources/js/components/open/forms/components/form-components/FormCustomCode.vue +++ b/resources/js/components/open/forms/components/form-components/FormCustomCode.vue @@ -1,15 +1,9 @@