Refactor form rendering (#747)
* Update Dependencies and Refactor Form Components - Upgraded various Sentry-related packages in `package-lock.json` to version 9.15.0, ensuring compatibility with the latest features and improvements. - Refactored `FormProgressbar.vue` to utilize `config` for color and visibility settings instead of `form`, enhancing flexibility in form management. - Removed the `FormTimer.vue` component as it was deemed unnecessary, streamlining the form component structure. - Updated `OpenCompleteForm.vue` and `OpenForm.vue` to integrate `formManager`, improving the overall management of form states and properties. - Enhanced `UrlFormPrefill.vue` to utilize `formManager` for better handling of pre-filled URL generation. These changes aim to improve the maintainability and performance of the form components while ensuring they leverage the latest dependency updates. * Refactor Form Components to Utilize Composables and Improve State Management - Updated `FormProgressbar.vue` to access `config.value` and `data.value` for better reactivity. - Refactored `OpenCompleteForm.vue` to use `useFormManager` for managing form state, enhancing clarity and maintainability. - Modified `OpenForm.vue` to leverage `structure.value` and `config.value`, improving the handling of form properties and structure. - Enhanced `UrlFormPrefill.vue` to initialize `useFormManager` within the setup function, streamlining form management. - Updated `FormManager.js` and `FormStructureService.js` to improve validation logic and state management, ensuring better performance and reliability. These changes aim to enhance the overall maintainability and performance of the form components by leveraging composables for state management and improving the reactivity of form properties. * Refactor Form Components to Enhance State Management and Structure - Updated `FormProgressbar.vue` to utilize `props.formManager?.config` and `structureService` for improved reactivity and data handling. - Refactored `OpenCompleteForm.vue` to initialize `formManager` outside of `onMounted`, enhancing SSR compatibility and clarity in form state management. - Modified `OpenForm.vue` to leverage `formManager.form` for data binding, streamlining the handling of form properties. - Updated `OpenFormField.vue` to utilize `formManager` directly for field logic checks, improving maintainability and consistency. - Removed obsolete `FormManager.js`, `FormInitializationService.js`, `FormPaymentService.js`, `FormStructureService.js`, `FormSubmissionService.js`, and `FormTimerService.js` files to simplify the codebase and reduce complexity. These changes aim to enhance the maintainability and performance of the form components by leveraging composables for state management and improving the reactivity of form properties. * Enhance OpenCompleteForm and OpenForm Components with Auto-Submit and State Management Improvements - Updated `OpenCompleteForm.vue` to include an auto-submit feature that triggers form submission when the `auto_submit` parameter is present in the URL. This improves user experience by streamlining the submission process. - Refactored `OpenForm.vue` to remove the loader display logic, simplifying the component structure and enhancing clarity. - Enhanced `pendingSubmission.js` to include a `clear` method for better management of pending submissions. - Modified `Form.js` to ensure that the submission payload correctly merges additional data, improving the robustness of form submissions. - Updated `useFormInitialization.js` to handle pending submissions and URL parameters more effectively, ensuring a smoother user experience. These changes aim to improve the overall functionality and maintainability of the form components by enhancing state management and user interaction capabilities. * Enhance Partial Submission Functionality and Sync Mechanism - Updated `usePartialSubmission.js` to improve the synchronization mechanism by increasing the debounce time from 1 second to 2 seconds, reducing the frequency of sync operations. - Introduced a new `syncImmediately` function to allow immediate synchronization during critical events such as page unload, enhancing data reliability. - Refactored the `syncToServer` function to handle computed ref patterns for form data more effectively, ensuring accurate data submission. - Modified event handlers to utilize the new immediate sync functionality, improving responsiveness during visibility changes and window blur events. - Enhanced the `stopSync` function to perform a final sync before stopping, ensuring no data is lost during component unmounting. These changes aim to improve the reliability and performance of partial submissions, ensuring that user data is consistently synchronized with the server during critical interactions. * Refactor OpenFormField Component to Use Composition API and Enhance Field Logic - Converted `OpenFormField.vue` to utilize the Composition API with `<script setup>`, improving readability and maintainability. - Defined props using `defineProps` for better type safety and clarity. - Refactored computed properties and methods to leverage the new setup structure, enhancing performance and organization. - Updated `FormFieldEdit.vue` to ensure the structure service is used for setting the page for the selected field, improving navigation consistency. - Enhanced `useFormStructure.js` with additional validation for field indices and introduced a new `setPageForField` method to manage page navigation more effectively. - Modified `working_form.js` to streamline the management of the current page index and ensure proper integration with the structure service. These changes aim to improve the overall structure and functionality of the form components, enhancing user experience and maintainability. * Enhance Captcha Handling and Refactor OpenForm Component - Added error handling for resetting hCaptcha and reCAPTCHA in `HCaptchaV2.vue` and `RecaptchaV2.vue`, improving user experience by providing fallback mechanisms when the reset fails. - Refactored `OpenForm.vue` to replace the `CaptchaInput` component with `CaptchaWrapper`, streamlining the captcha integration and enhancing maintainability. - Removed obsolete captcha registration logic from `useFormManager.js` and `useFormValidation.js`, simplifying the form management process and improving code clarity. These changes aim to improve the reliability and user experience of captcha handling within the form components, ensuring smoother interactions and better error management. * Refactor PaymentInput Component to Enhance Stripe Elements Integration - Replaced the `useStripeElements` composable with a new `createStripeElements` function, allowing for lazy initialization of Stripe elements based on provided props. - Introduced a local instance for Stripe elements, improving fallback handling when payment data is not available. - Updated computed properties to ensure proper access to Stripe state and methods, enhancing reliability in payment processing. - Modified the `CaptchaWrapper` component to remove the dark mode prop, simplifying its interface. - Refactored `OpenCompleteForm`, `OpenForm`, and `OpenFormField` components to derive theme and dark mode settings directly from `formManager`, improving consistency across form components. These changes aim to streamline the integration of Stripe elements, enhance maintainability, and improve the overall user experience in payment processing within the form components. * Enhance Payment Input and Form Components for Improved Stripe Integration - Updated the `PaymentInput.client.vue` component to provide more informative messages during payment preview, including detailed error messages for missing configurations. - Refactored the handling of Stripe elements to ensure proper initialization and state management, improving reliability in payment processing. - Enhanced the `OpenCompleteForm.vue` and `OpenFormField.vue` components to streamline payment data retrieval and submission processes. - Improved error handling in `OpenCompleteForm.vue` to provide clearer feedback on submission issues. - Refactored `useStripeElements.js` to support lazy initialization of Stripe elements with an optional account ID, enhancing flexibility in payment configurations. These changes aim to improve the user experience during payment processing by providing clearer feedback and ensuring robust integration with Stripe elements. * Refactor FormPaymentController and Update Payment Intent Route - Updated the `FormPaymentController` to utilize `CreatePaymentIntentRequest` for improved request validation and handling. - Changed the `createIntent` method to accept a POST request instead of GET, aligning with RESTful practices for creating resources. - Enhanced the payment intent creation logic to use a description from the payment block if available, improving clarity in payment processing. - Modified the `useFormPayment` composable to reflect the change to a POST request, ensuring proper API interaction and logging. These changes aim to enhance the payment processing flow by improving request handling and aligning with best practices for API design. * Refactor UrlFormPrefill Component to Utilize Composition API - Converted `UrlFormPrefill.vue` to use the Composition API with `<script setup>`, enhancing readability and maintainability. - Defined props using `defineProps` for better type safety and clarity. - Refactored the initialization of `formManager` to streamline setup and improve logging during form management. - Updated the URL generation method to utilize reactive references, ensuring better state management and performance. - Removed obsolete props and methods, simplifying the component structure. These changes aim to improve the overall structure and functionality of the `UrlFormPrefill` component, enhancing user experience and maintainability. * Enhance OpenCompleteForm and FormManager with Improved State Management and Debugging - Added `workingFormStore` to `OpenCompleteForm.vue` for better integration with the working form state. - Introduced a watcher in `OpenCompleteForm.vue` to share the structure service with the working form store when in admin edit context, enhancing form management capabilities. - Updated `useFormManager.js` to include a watcher for `currentPage`, improving debugging by logging page changes. - Modified payment processing logic in `useFormManager.js` to conditionally skip payment validation in non-LIVE modes, enhancing flexibility during development. - Refactored `useFormStructure.js` to eliminate unnecessary calls to `toValue`, improving performance and clarity in state management. - Adjusted `useFormValidation.js` to directly access `managerState.currentPage`, streamlining error handling during form validation. These changes aim to improve the overall functionality and maintainability of the form components by enhancing state management, debugging capabilities, and flexibility in payment processing. * Refactor Form Components for Improved Logic and State Management - Updated `OpenForm.vue` to simplify the conditional rendering of the previous button by removing the loading state check, enhancing clarity in button visibility logic. - Refactored `useFormInitialization.js` to streamline the `updateSpecialFields` function by removing the fields parameter and directly iterating over `formConfig.value.properties`, improving code readability and maintainability. - Modified `useFormManager.js` to eliminate the passing of fields in the initialization options, simplifying the initialization process. - Enhanced `useFormPayment.js` to check for existing payment intent IDs directly in the form data, improving the payment processing logic and reducing redundant checks. - Updated `useFormStructure.js` to include a computed property for `currentPage`, enhancing the state management of the form structure. - Refactored `working_form.js` to replace the structure service's current page access, improving the integration with the form state. These changes aim to enhance the overall functionality and maintainability of the form components by improving state management and simplifying logic across various form-related files. * Refactor Form Components and Improve Submission Logic - Updated `OpenCompleteForm.vue` to enhance the submission logic by directly using `submissionId` from the variable instead of the route query, improving clarity and reducing dependency on route parameters. - Modified `triggerSubmit` function in `OpenCompleteForm.vue` to streamline the handling of submission results, ensuring that `submittedData` is set directly from the result and simplifying the conditional checks for `submission_id` and `is_first_submission`. - Removed the `pendingSubmission.js` file as it was no longer needed, consolidating the submission handling logic within the relevant components and composables. - Enhanced `usePartialSubmission.js` to improve the management of submission hashes and ensure that the service integrates seamlessly with the new structure, prioritizing local storage for hash retrieval. - Updated `useFormInitialization.js` to improve error handling and ensure that the form resets correctly when loading submissions fails, enhancing user experience. - Refactored `useFormManager.js` to instantiate the new `usePendingSubmission` service, ensuring that local storage handling is properly integrated into the form management process. These changes aim to improve the overall functionality, maintainability, and user experience of the form components by streamlining submission logic and enhancing state management. * Refactor FormProgressbar Component for Improved Logic and Clarity - Updated the `FormProgressbar.vue` component to simplify the condition for displaying the progress bar by directly using the `showProgressBar` computed property instead of accessing it through the `config` object. - Refactored the computed properties to ensure they directly reference the necessary values from `formManager`, enhancing clarity and maintainability. - Modified the logic for calculating progress to utilize `config.value?.properties` instead of `structureService`, streamlining the progress calculation process. These changes aim to enhance the overall functionality and maintainability of the `FormProgressbar` component by improving the clarity of the progress display logic and ensuring accurate progress calculations. * Refactor OpenCompleteForm and Index Page for Improved Logic and Clarity - Updated `OpenCompleteForm.vue` to remove unnecessary margin from the password protected message, enhancing the visual layout. - Added `addPasswordError` function to `defineExpose` in `OpenCompleteForm.vue`, allowing better error handling for password validation. - Refactored the usage of the translation function in `index.vue` to destructure `t` from `useI18n`, improving code clarity and consistency. These changes aim to enhance the overall functionality and maintainability of the form components by streamlining error handling and improving the clarity of the code structure. * Enhance Form Submission Logic and Validation Rules - Updated `PublicFormController.php` to dispatch the job for handling form submissions asynchronously, improving performance and responsiveness. - Modified `AnswerFormRequest.php` to add validation rules for `completion_time` and make `submission_id` nullable, enhancing data integrity and flexibility. - Added debugging output in `StoreFormSubmissionJob.php` to log form data and completion time, aiding in troubleshooting and monitoring. These changes aim to improve the overall functionality and maintainability of the form submission process by optimizing job handling and enhancing validation mechanisms. * Update ESLint Configuration and Add Vue Plugin - Modified `.eslintrc.cjs` to ensure proper formatting by removing an unnecessary trailing comma. - Updated `package.json` and `package-lock.json` to include `eslint-plugin-vue` version 10.1.0, enhancing linting capabilities for Vue components. These changes aim to improve code quality and maintainability by ensuring consistent linting rules and support for Vue-specific linting features. * Enhance User Management Tests and Form Components - Updated `UserManagementTest.php` to include validation for Google reCAPTCHA by adding the `g-recaptcha-response` parameter in registration tests, ensuring comprehensive coverage of user registration scenarios. - Modified `FormPaymentTest.php` to change the HTTP method from GET to POST for creating payment intents, aligning with RESTful practices and improving the accuracy of test cases. - Enhanced `FlatSelectInput.vue` to support slot-based rendering for selected options and options, improving flexibility in how options are displayed and selected. - Refactored `OpenCompleteForm.vue` to correct indentation in the form submission logic, enhancing code readability. - Updated `FormSubmissionSettings.vue` to replace the `select-input` component with `flat-select-input`, improving consistency in form component usage. - Enhanced `useFormManager.js` to add logging for form submissions and handle postMessage communication for iframe integration, improving debugging and integration capabilities. These changes aim to improve the robustness of the testing suite and enhance the functionality and maintainability of form components by ensuring proper validation and consistent component usage. * Refactor ESLint Configuration and Improve Error Handling in Components - Deleted the obsolete `.eslintrc.cjs` file to streamline ESLint configuration management. - Updated `eslint.config.cjs` to include ignores for `.nuxt/**`, `node_modules/**`, and `dist/**`, enhancing linting efficiency. - Refactored error handling in various components (e.g., `DateInput.vue`, `FlatSelectInput.vue`, `CaptchaInput.vue`, etc.) by removing the error variable in catch blocks, simplifying the code and maintaining functionality. - Improved the logic in `FormBlockLogicEditor.vue` to check for non-empty logic objects, enhancing validation accuracy. These changes aim to improve code quality and maintainability by optimizing ESLint configurations and enhancing error handling across components. * Fix Logic in useFormInitialization for Matrix Field Prefill Handling - Updated the `updateSpecialFields` function in `useFormInitialization.js` to correct the logic for handling matrix fields. The condition now checks the form directly instead of using a separate `formData` parameter, ensuring that prefill data is applied correctly when the field is empty. These changes aim to enhance the accuracy of form initialization by ensuring proper handling of matrix fields during the form setup process. * Add findFirstPageWithError function to useFormValidation for improved error handling - Introduced the `findFirstPageWithError` function in `useFormValidation.js` to identify the index of the first page containing validation errors. This function checks for existing errors and iterates through the nested array of field groups to determine if any page has errors, returning the appropriate index or -1 if no errors are found. These changes aim to enhance the form validation process by providing a clear mechanism to locate pages with validation issues, improving user experience during form submissions. * Enhance OpenCompleteForm Logic and Add Confetti Feature - Updated `OpenCompleteForm.vue` to improve conditional rendering by changing `v-if` to `v-else-if` for better clarity in form submission states. - Introduced a new computed property `shouldDisplayForm` to centralize the logic for determining form visibility based on submission status and admin controls. - Modified `useFormManager.js` to include a confetti effect upon successful form submission, enhancing user engagement during the submission process. These changes aim to improve the user experience by refining form visibility logic and adding a celebratory feature upon successful submissions. * Refactor Form Submission Job and Storage File Logic - Updated `StoreFormSubmissionJob.php` to streamline the constructor by combining the constructor body into a single line for improved readability. - Removed debugging output in `StoreFormSubmissionJob.php` to clean up the code and enhance performance. - Simplified the constructor in `StorageFile.php` by consolidating it into a single line, improving code clarity. - Enhanced the logic in `StorageFile.php` to utilize a file name parser for checking file existence, ensuring more accurate file handling. These changes aim to improve code readability and maintainability by simplifying constructors and enhancing file handling logic. * Refactor Constructors in StoreFormSubmissionJob and StorageFile - Updated the constructors in `StoreFormSubmissionJob.php` and `StorageFile.php` to include an explicit body, enhancing code clarity and consistency in constructor definitions. - Improved readability by ensuring a uniform structure across class constructors. These changes aim to improve code maintainability and readability by standardizing the constructor format in the affected classes. --------- Co-authored-by: Chirag Chhatrala <chirag.chhatrala@gmail.com>
This commit is contained in:
parent
6b03808d36
commit
053abbf31b
|
|
@ -5,6 +5,7 @@ namespace App\Http\Controllers\Forms;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Models\OAuthProvider;
|
||||
use App\Http\Requests\Forms\GetStripeAccountRequest;
|
||||
use App\Http\Requests\Forms\CreatePaymentIntentRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
|
@ -77,7 +78,7 @@ class FormPaymentController extends Controller
|
|||
return $this->success(['stripeAccount' => $provider->provider_user_id]);
|
||||
}
|
||||
|
||||
public function createIntent(Request $request)
|
||||
public function createIntent(CreatePaymentIntentRequest $request)
|
||||
{
|
||||
// Disable payment features on self-hosted instances
|
||||
if (config('app.self_hosted')) {
|
||||
|
|
@ -122,7 +123,8 @@ class FormPaymentController extends Controller
|
|||
Stripe::setApiKey(config('cashier.secret'));
|
||||
|
||||
$intent = PaymentIntent::create([
|
||||
'description' => 'Form - ' . $form->title,
|
||||
// Use description from payment block if available, fallback to form title
|
||||
'description' => $paymentBlock['description'] ?? ('Form - ' . $form->title),
|
||||
'amount' => (int) ($paymentBlock['amount'] * 100), // Stripe requires amount in cents
|
||||
'currency' => strtolower($paymentBlock['currency']),
|
||||
'payment_method_types' => ['card'],
|
||||
|
|
|
|||
|
|
@ -156,14 +156,13 @@ class PublicFormController extends Controller
|
|||
// Update submission data with generated values for redirect URL
|
||||
$submissionData = $job->getProcessedData();
|
||||
} else {
|
||||
$job->handle();
|
||||
$encodedSubmissionId = Hashids::encode($job->getSubmissionId());
|
||||
dispatch($job);
|
||||
}
|
||||
|
||||
// Return the response
|
||||
return $this->success(array_merge([
|
||||
'message' => 'Form submission saved.',
|
||||
'submission_id' => $encodedSubmissionId,
|
||||
'submission_id' => $encodedSubmissionId ?? null,
|
||||
'is_first_submission' => $isFirstSubmission,
|
||||
], $formSubmissionProcessor->getRedirectData($form, $submissionData)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,10 @@ class AnswerFormRequest extends FormRequest
|
|||
$this->requestRules['submission_id'] = 'string';
|
||||
}
|
||||
|
||||
// Add rules for metadata fields
|
||||
$this->requestRules['completion_time'] = 'nullable|integer';
|
||||
$this->requestRules['submission_id'] = 'nullable|string';
|
||||
|
||||
return $this->requestRules;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Forms;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CreatePaymentIntentRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
// Allow public access for now, protected by middleware
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
// No body parameters need validation as they are derived from the form itself.
|
||||
return [
|
||||
// Keep empty for now, can add header/other validation later if needed.
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -32,11 +32,12 @@ class StorageFile implements ValidationRule
|
|||
if (filter_var($value, FILTER_VALIDATE_URL) !== false) {
|
||||
return true;
|
||||
}
|
||||
$fileNameParser = StorageFileNameParser::parse($value);
|
||||
|
||||
// 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);
|
||||
if (Storage::exists($newPath.'/'.$value)) {
|
||||
if (Storage::exists($newPath . '/' . $fileNameParser->getMovedFileName())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +47,7 @@ class StorageFile implements ValidationRule
|
|||
return false;
|
||||
}
|
||||
|
||||
$filePath = PublicFormController::TMP_FILE_UPLOAD_PATH.$uuid;
|
||||
$filePath = PublicFormController::TMP_FILE_UPLOAD_PATH . $uuid;
|
||||
if (! Storage::exists($filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -58,7 +59,7 @@ class StorageFile implements ValidationRule
|
|||
}
|
||||
|
||||
if (count($this->fileTypes) > 0) {
|
||||
$this->error = 'Incorrect file type. Allowed only: '.implode(',', $this->fileTypes);
|
||||
$this->error = 'Incorrect file type. Allowed only: ' . implode(',', $this->fileTypes);
|
||||
return collect($this->fileTypes)->map(function ($type) {
|
||||
return strtolower($type);
|
||||
})->contains(strtolower($fileNameParser->extension));
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class ValidReCaptcha implements ImplicitRule
|
|||
'response' => $value,
|
||||
])->json('success');
|
||||
}
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (!$this->passes($attribute, $value)) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
* This setting controls whether data should be sent to Ray.
|
||||
*
|
||||
* By default, `ray()` will only transmit data in non-production environments.
|
||||
*/
|
||||
'enable' => env('RAY_ENABLED', true),
|
||||
|
||||
/*
|
||||
* When enabled, all cache events will automatically be sent to Ray.
|
||||
*/
|
||||
'send_cache_to_ray' => env('SEND_CACHE_TO_RAY', false),
|
||||
|
||||
/*
|
||||
* When enabled, all things passed to `dump` or `dd`
|
||||
* will be sent to Ray as well.
|
||||
*/
|
||||
'send_dumps_to_ray' => env('SEND_DUMPS_TO_RAY', true),
|
||||
|
||||
/*
|
||||
* When enabled all job events will automatically be sent to Ray.
|
||||
*/
|
||||
'send_jobs_to_ray' => env('SEND_JOBS_TO_RAY', false),
|
||||
|
||||
/*
|
||||
* When enabled, all things logged to the application log
|
||||
* will be sent to Ray as well.
|
||||
*/
|
||||
'send_log_calls_to_ray' => env('SEND_LOG_CALLS_TO_RAY', true),
|
||||
|
||||
/*
|
||||
* When enabled, all queries will automatically be sent to Ray.
|
||||
*/
|
||||
'send_queries_to_ray' => env('SEND_QUERIES_TO_RAY', false),
|
||||
|
||||
/**
|
||||
* When enabled, all duplicate queries will automatically be sent to Ray.
|
||||
*/
|
||||
'send_duplicate_queries_to_ray' => env('SEND_DUPLICATE_QUERIES_TO_RAY', false),
|
||||
|
||||
/*
|
||||
* When enabled, slow queries will automatically be sent to Ray.
|
||||
*/
|
||||
'send_slow_queries_to_ray' => env('SEND_SLOW_QUERIES_TO_RAY', false),
|
||||
|
||||
/**
|
||||
* Queries that are longer than this number of milliseconds will be regarded as slow.
|
||||
*/
|
||||
'slow_query_threshold_in_ms' => env('RAY_SLOW_QUERY_THRESHOLD_IN_MS', 500),
|
||||
|
||||
/*
|
||||
* When enabled, all update queries will automatically be sent to Ray.
|
||||
*/
|
||||
'send_update_queries_to_ray' => env('SEND_UPDATE_QUERIES_TO_RAY', false),
|
||||
|
||||
/*
|
||||
* When enabled, all insert queries will automatically be sent to Ray.
|
||||
*/
|
||||
'send_insert_queries_to_ray' => env('SEND_INSERT_QUERIES_TO_RAY', false),
|
||||
|
||||
/*
|
||||
* When enabled, all delete queries will automatically be sent to Ray.
|
||||
*/
|
||||
'send_delete_queries_to_ray' => env('SEND_DELETE_QUERIES_TO_RAY', false),
|
||||
|
||||
/*
|
||||
* When enabled, all select queries will automatically be sent to Ray.
|
||||
*/
|
||||
'send_select_queries_to_ray' => env('SEND_SELECT_QUERIES_TO_RAY', false),
|
||||
|
||||
/*
|
||||
* When enabled, all requests made to this app will automatically be sent to Ray.
|
||||
*/
|
||||
'send_requests_to_ray' => env('SEND_REQUESTS_TO_RAY', false),
|
||||
|
||||
/**
|
||||
* When enabled, all Http Client requests made by this app will be automatically sent to Ray.
|
||||
*/
|
||||
'send_http_client_requests_to_ray' => env('SEND_HTTP_CLIENT_REQUESTS_TO_RAY', false),
|
||||
|
||||
/*
|
||||
* When enabled, all views that are rendered automatically be sent to Ray.
|
||||
*/
|
||||
'send_views_to_ray' => env('SEND_VIEWS_TO_RAY', false),
|
||||
|
||||
/*
|
||||
* When enabled, all exceptions will be automatically sent to Ray.
|
||||
*/
|
||||
'send_exceptions_to_ray' => env('SEND_EXCEPTIONS_TO_RAY', true),
|
||||
|
||||
/*
|
||||
* When enabled, all deprecation notices will be automatically sent to Ray.
|
||||
*/
|
||||
'send_deprecated_notices_to_ray' => env('SEND_DEPRECATED_NOTICES_TO_RAY', false),
|
||||
|
||||
/*
|
||||
* The host used to communicate with the Ray app.
|
||||
* When using Docker on Mac or Windows, you can replace localhost with 'host.docker.internal'
|
||||
* When using Docker on Linux, you can replace localhost with '172.17.0.1'
|
||||
* When using Homestead with the VirtualBox provider, you can replace localhost with '10.0.2.2'
|
||||
* When using Homestead with the Parallels provider, you can replace localhost with '10.211.55.2'
|
||||
*/
|
||||
'host' => env('RAY_HOST', 'localhost'),
|
||||
|
||||
/*
|
||||
* The port number used to communicate with the Ray app.
|
||||
*/
|
||||
'port' => env('RAY_PORT', 23517),
|
||||
|
||||
/*
|
||||
* Absolute base path for your sites or projects in Homestead,
|
||||
* Vagrant, Docker, or another remote development server.
|
||||
*/
|
||||
'remote_path' => env('RAY_REMOTE_PATH', null),
|
||||
|
||||
/*
|
||||
* Absolute base path for your sites or projects on your local
|
||||
* computer where your IDE or code editor is running on.
|
||||
*/
|
||||
'local_path' => env('RAY_LOCAL_PATH', null),
|
||||
|
||||
/*
|
||||
* When this setting is enabled, the package will not try to format values sent to Ray.
|
||||
*/
|
||||
'always_send_raw_values' => false,
|
||||
];
|
||||
|
|
@ -298,7 +298,7 @@ Route::prefix('forms')->name('forms.')->group(function () {
|
|||
Route::middleware('protected-form')->group(function () {
|
||||
Route::post('{slug}/answer', [PublicFormController::class, 'answer'])->name('answer')->middleware(HandlePrecognitiveRequests::class);
|
||||
Route::get('{slug}/stripe-connect/get-account', [FormPaymentController::class, 'getAccount'])->name('stripe-connect.get-account')->middleware(HandlePrecognitiveRequests::class);
|
||||
Route::get('{slug}/stripe-connect/payment-intent', [FormPaymentController::class, 'createIntent'])->name('stripe-connect.create-intent')->middleware(HandlePrecognitiveRequests::class);
|
||||
Route::post('{slug}/stripe-connect/payment-intent', [FormPaymentController::class, 'createIntent'])->name('stripe-connect.create-intent')->middleware(HandlePrecognitiveRequests::class);
|
||||
|
||||
// Form content endpoints (user lists, relation lists etc.)
|
||||
Route::get(
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ it('cannot create payment intent for non-public form', function () {
|
|||
// Update form visibility to private
|
||||
$this->form->update(['visibility' => 'private']);
|
||||
|
||||
$this->getJson(route('forms.stripe-connect.create-intent', $this->form->slug))
|
||||
$this->postJson(route('forms.stripe-connect.create-intent', $this->form->slug))
|
||||
->assertStatus(404)
|
||||
->assertJson([
|
||||
'message' => 'Form not found.'
|
||||
|
|
@ -56,7 +56,7 @@ it('cannot create payment intent for form without payment block', function () {
|
|||
|
||||
$this->form->update(['properties' => $properties]);
|
||||
|
||||
$this->getJson(route('forms.stripe-connect.create-intent', $this->form->slug))
|
||||
$this->postJson(route('forms.stripe-connect.create-intent', $this->form->slug))
|
||||
->assertStatus(400)
|
||||
->assertJson([
|
||||
'type' => 'error',
|
||||
|
|
@ -75,7 +75,7 @@ it('cannot create payment intent with invalid stripe account', function () {
|
|||
|
||||
$this->form->update(['properties' => $properties]);
|
||||
|
||||
$this->getJson(route('forms.stripe-connect.create-intent', $this->form->slug))
|
||||
$this->postJson(route('forms.stripe-connect.create-intent', $this->form->slug))
|
||||
->assertStatus(400)
|
||||
->assertJson([
|
||||
'message' => 'Failed to find Stripe account'
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@
|
|||
use App\Models\UserInvite;
|
||||
use Carbon\Carbon;
|
||||
use App\Rules\ValidHCaptcha;
|
||||
use App\Rules\ValidReCaptcha;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = $this->actingAsProUser();
|
||||
$this->workspace = $this->createUserWorkspace($this->user);
|
||||
Http::fake([
|
||||
ValidHCaptcha::H_CAPTCHA_VERIFY_URL => Http::response(['success' => true])
|
||||
ValidHCaptcha::H_CAPTCHA_VERIFY_URL => Http::response(['success' => true]),
|
||||
ValidReCaptcha::RECAPTCHA_VERIFY_URL => Http::response(['success' => true])
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -37,6 +39,7 @@ it('can register with invite token', function () {
|
|||
'agree_terms' => true,
|
||||
'invite_token' => $token,
|
||||
'h-captcha-response' => 'test-token',
|
||||
'g-recaptcha-response' => 'test-token',
|
||||
]);
|
||||
$response->assertSuccessful();
|
||||
expect($this->workspace->users()->count())->toBe(2);
|
||||
|
|
@ -66,6 +69,7 @@ it('cannot register with expired invite token', function () {
|
|||
'agree_terms' => true,
|
||||
'invite_token' => $token,
|
||||
'h-captcha-response' => 'test-token',
|
||||
'g-recaptcha-response' => 'test-token',
|
||||
]);
|
||||
$response->assertStatus(400)->assertJson([
|
||||
'message' => 'Invite token has expired.',
|
||||
|
|
@ -96,6 +100,7 @@ it('cannot re-register with accepted invite token', function () {
|
|||
'agree_terms' => true,
|
||||
'invite_token' => $token,
|
||||
'h-captcha-response' => 'test-token',
|
||||
'g-recaptcha-response' => 'test-token',
|
||||
]);
|
||||
$response->assertSuccessful();
|
||||
expect($this->workspace->users()->count())->toBe(2);
|
||||
|
|
@ -113,6 +118,7 @@ it('cannot re-register with accepted invite token', function () {
|
|||
'agree_terms' => true,
|
||||
'invite_token' => $token,
|
||||
'h-captcha-response' => 'test-token',
|
||||
'g-recaptcha-response' => 'test-token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)->assertJson([
|
||||
|
|
@ -148,6 +154,7 @@ it('can cancel user invite', function () {
|
|||
'agree_terms' => true,
|
||||
'invite_token' => $token,
|
||||
'h-captcha-response' => 'test-token',
|
||||
'g-recaptcha-response' => 'test-token',
|
||||
]);
|
||||
$response->assertStatus(400)->assertJson([
|
||||
'message' => 'Invite token is invalid.',
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@nuxt/eslint-config"],
|
||||
parser: "vue-eslint-parser",
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
},
|
||||
rules: {
|
||||
"vue/require-default-prop": "off",
|
||||
"vue/no-mutating-props": "off",
|
||||
semi: ["error", "never"],
|
||||
"vue/no-v-html": "off",
|
||||
"prefer-rest-params": "off",
|
||||
"vue/valid-template-root": "off",
|
||||
"no-undef": "off",
|
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||
},
|
||||
};
|
||||
|
|
@ -204,14 +204,14 @@ const formattedDate = (value) => {
|
|||
if (props.withTime) {
|
||||
try {
|
||||
return format(new Date(value), props.dateFormat + (props.timeFormat == 12 ? ' p':' HH:mm'))
|
||||
} catch (e) {
|
||||
console.error('Error formatting date', e)
|
||||
} catch {
|
||||
console.error('Error formatting date')
|
||||
return ''
|
||||
}
|
||||
}
|
||||
try {
|
||||
return format(new Date(value), props.dateFormat)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,24 @@
|
|||
<template
|
||||
v-if="options && options.length"
|
||||
>
|
||||
<div v-if="multiple && compVal && compVal.length" class="px-3 py-2">
|
||||
<slot
|
||||
name="selected"
|
||||
:option="selectedOptions"
|
||||
:option-name="multiple ? getSelectedOptionsNames().join(', ') : getOptionName(selectedOptions)"
|
||||
>
|
||||
<div class="flex items-center truncate">
|
||||
<span
|
||||
class="truncate"
|
||||
:class="[
|
||||
theme.FlatSelectInput.fontSize,
|
||||
]"
|
||||
>
|
||||
{{ getSelectedOptionsNames().join(', ') }}
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
v-for="(option) in options"
|
||||
:key="option[optionKey]"
|
||||
|
|
@ -58,9 +76,15 @@
|
|||
:prevent="!disableOptions.includes(option[optionKey])"
|
||||
class="w-full"
|
||||
>
|
||||
<p class="flex-grow">
|
||||
{{ option[displayKey] }}
|
||||
</p>
|
||||
<slot
|
||||
name="option"
|
||||
:option="option"
|
||||
:selected="isSelected(option[optionKey])"
|
||||
>
|
||||
<p class="flex-grow">
|
||||
{{ option[displayKey] }}
|
||||
</p>
|
||||
</slot>
|
||||
</UTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -120,7 +144,17 @@ export default {
|
|||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
selectedOptions() {
|
||||
if (!this.compVal) return []
|
||||
|
||||
if (this.multiple) {
|
||||
return this.options.filter(option => this.compVal.includes(option[this.optionKey]))
|
||||
}
|
||||
|
||||
return this.options.find(option => option[this.optionKey] === this.compVal) || null
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onSelect(value) {
|
||||
if (this.disabled || this.disableOptions.includes(value)) {
|
||||
|
|
@ -156,6 +190,18 @@ export default {
|
|||
}
|
||||
return this.compVal === value
|
||||
},
|
||||
getOptionName(option) {
|
||||
return option ? option[this.displayKey] : ''
|
||||
},
|
||||
getSelectedOptionsNames() {
|
||||
if (!this.compVal) return []
|
||||
|
||||
if (this.multiple) {
|
||||
return this.selectedOptions.map(option => option[this.displayKey])
|
||||
}
|
||||
|
||||
return [this.getOptionName(this.selectedOptions)]
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
@ -44,10 +44,17 @@
|
|||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="shouldShowPreviewMessage"
|
||||
v-if="shouldShowPreviewMessage || (props.isAdminPreview && (!stripeState.stripeAccountId || !publishableKey || !isStripeJsLoaded))"
|
||||
class="my-4 p-4 text-center text-sm text-blue-700 bg-blue-100 dark:bg-blue-900/50 dark:text-blue-300 rounded-md"
|
||||
>
|
||||
<p>Please save the form to activate the payment preview.</p>
|
||||
<p v-if="shouldShowPreviewMessage">Please save the form to activate the payment preview.</p>
|
||||
<p v-else>
|
||||
Payment component configuration incomplete.
|
||||
{{ !stripeState?.stripeAccountId ? 'Stripe account not connected': 'Stripe account connected' }}.
|
||||
{{ !publishableKey ? 'Missing Stripe publishable key.' : '' }}
|
||||
{{ !isStripeJsLoaded ? 'Stripe.js not loaded.' : '' }}
|
||||
</p>
|
||||
<p class="mt-2">The complete payment form will be visible to users when viewing the published form.</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="stripeState && stripeState.isLoadingAccount"
|
||||
|
|
@ -80,8 +87,8 @@
|
|||
<StripeElements
|
||||
ref="stripeElementsRef"
|
||||
:stripe-key="publishableKey"
|
||||
:stripe-account="stripeState.stripeAccountId"
|
||||
:instance-options="{ stripeAccount: stripeState.stripeAccountId }"
|
||||
:stripe-account="String(stripeState.stripeAccountId)"
|
||||
:instance-options="{ stripeAccount: String(stripeState.stripeAccountId) }"
|
||||
:elements-options="{ locale: props.locale }"
|
||||
@ready="onStripeReady"
|
||||
@error="onStripeError"
|
||||
|
|
@ -135,7 +142,16 @@
|
|||
</StripeElements>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Loader class="mx-auto h-6 w-6" />
|
||||
<div v-if="props.isAdminPreview" class="my-4 p-4 text-center text-sm text-blue-700 bg-blue-100 dark:bg-blue-900/50 dark:text-blue-300 rounded-md">
|
||||
<p>Payment component initializing. {{ !!stripeState?.stripeAccountId ? 'Stripe account connected': 'No Stripe account connected' }}.</p>
|
||||
<p class="mt-2">The payment form will be visible to users when viewing the published form.</p>
|
||||
<p v-if="!publishableKey" class="mt-2 text-red-500">Missing Stripe publishable key in configuration.</p>
|
||||
<p v-if="!stripeState?.stripeAccountId" class="mt-2 text-red-500">Missing Stripe account connection. ID: {{ props.oauthProviderId }}</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center justify-center py-4">
|
||||
<Loader class="mx-auto h-6 w-6" />
|
||||
<p class="text-sm text-gray-500 mt-2">Initializing payment system...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -144,7 +160,12 @@
|
|||
<slot name="help" />
|
||||
</template>
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
<!-- If we have a specific error for this block, show it -->
|
||||
<div v-if="props.error" class="text-sm text-red-500 mt-1">
|
||||
{{ props.error }}
|
||||
</div>
|
||||
<!-- Otherwise, show the default error slot -->
|
||||
<slot v-else name="error" />
|
||||
</template>
|
||||
</InputWrapper>
|
||||
</template>
|
||||
|
|
@ -156,7 +177,6 @@ import InputWrapper from './components/InputWrapper.vue'
|
|||
import { loadStripe } from '@stripe/stripe-js'
|
||||
import { StripeElements, StripeElement } from 'vue-stripe-js'
|
||||
import stripeCurrencies from "~/data/stripe_currencies.json"
|
||||
import { useStripeElements } from '~/composables/useStripeElements'
|
||||
import { useAlert } from '~/composables/useAlert'
|
||||
import { useFeatureFlag } from '~/composables/useFeatureFlag'
|
||||
|
||||
|
|
@ -168,20 +188,12 @@ const props = defineProps({
|
|||
oauthProviderId: { type: [String, Number], default: null },
|
||||
isAdminPreview: { type: Boolean, default: false },
|
||||
color: { type: String, default: '#000000' },
|
||||
isDark: { type: Boolean, default: false }
|
||||
isDark: { type: Boolean, default: false },
|
||||
paymentData: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits([])
|
||||
const { compVal, hasError, inputWrapperProps } = useFormInput(props, { emit })
|
||||
const stripeElements = useStripeElements()
|
||||
const {
|
||||
state: stripeState,
|
||||
prepareStripeState,
|
||||
setStripeInstance,
|
||||
setElementsInstance,
|
||||
setCardElement,
|
||||
setBillingDetails
|
||||
} = stripeElements || {}
|
||||
|
||||
const route = useRoute()
|
||||
const alert = useAlert()
|
||||
|
|
@ -189,23 +201,31 @@ const alert = useAlert()
|
|||
const publishableKey = computed(() => {
|
||||
return useFeatureFlag('billing.stripe_publishable_key', '')
|
||||
})
|
||||
|
||||
const card = ref(null)
|
||||
const stripeElementsRef = ref(null)
|
||||
const cardHolderName = ref('')
|
||||
const cardHolderEmail = ref('')
|
||||
const isCardFocused = ref(false)
|
||||
|
||||
// Keep the flag for Stripe.js loading but remove manual instance creation
|
||||
const isStripeJsLoaded = ref(false)
|
||||
|
||||
// Get Stripe elements from paymentData
|
||||
const stripeElements = computed(() => props.paymentData?.stripeElements)
|
||||
const stripeState = computed(() => stripeElements.value?.state || {})
|
||||
const setStripeInstance = computed(() => stripeElements.value?.setStripeInstance)
|
||||
const setElementsInstance = computed(() => stripeElements.value?.setElementsInstance)
|
||||
const setCardElement = computed(() => stripeElements.value?.setCardElement)
|
||||
const setBillingDetails = computed(() => stripeElements.value?.setBillingDetails)
|
||||
const prepareStripeState = computed(() => stripeElements.value?.prepareStripeState)
|
||||
|
||||
// Computed to determine if we should show success state
|
||||
const showSuccessState = computed(() => {
|
||||
return stripeState?.intentId || (compVal.value && isPaymentIntentId(compVal.value))
|
||||
return stripeState.value?.intentId || (compVal.value && isPaymentIntentId(compVal.value))
|
||||
})
|
||||
|
||||
// Computed to determine if we should always show preview message in editor
|
||||
const shouldShowPreviewMessage = computed(() => {
|
||||
return props.isAdminPreview && (!formSlug.value || !stripeState || !stripeElements)
|
||||
return props.isAdminPreview && stripeState.value?.showPreviewMessage
|
||||
})
|
||||
|
||||
// Helper function to check if a string looks like a Stripe payment intent ID
|
||||
|
|
@ -216,87 +236,120 @@ const isPaymentIntentId = (value) => {
|
|||
// Initialize Stripe.js if needed
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Validate publishable key
|
||||
if (!publishableKey.value || typeof publishableKey.value !== 'string' || publishableKey.value.trim() === '') {
|
||||
if (stripeState) {
|
||||
stripeState.isLoadingAccount = false
|
||||
stripeState.hasAccountLoadingError = true
|
||||
stripeState.errorMessage = 'Missing Stripe configuration. Please check your settings.'
|
||||
}
|
||||
return
|
||||
}
|
||||
console.debug('[PaymentInput] Mounting with:', {
|
||||
oauthProviderId: props.oauthProviderId,
|
||||
hasPaymentData: !!props.paymentData,
|
||||
publishableKey: publishableKey.value,
|
||||
stripeElementsInstance: !!stripeElements.value
|
||||
})
|
||||
|
||||
// We'll check if Stripe is already available globally
|
||||
if (typeof window !== 'undefined' && !window.Stripe) {
|
||||
// Initialize Stripe.js globally first if needed
|
||||
if (typeof window !== 'undefined' && !window.Stripe && publishableKey.value) {
|
||||
console.debug('[PaymentInput] Loading Stripe.js with key:', publishableKey.value)
|
||||
await loadStripe(publishableKey.value)
|
||||
isStripeJsLoaded.value = true
|
||||
} else {
|
||||
} else if (typeof window !== 'undefined' && window.Stripe) {
|
||||
isStripeJsLoaded.value = true
|
||||
}
|
||||
console.debug('[PaymentInput] Stripe.js loaded status:', isStripeJsLoaded.value)
|
||||
|
||||
// If stripeElements or stripeState is not available, we need to handle that
|
||||
if (!stripeElements || !stripeState) {
|
||||
console.warn('Stripe elements provider not found or not properly initialized.')
|
||||
// Skip initialization if missing essential data
|
||||
if (!props.oauthProviderId || !props.paymentData || !publishableKey.value) {
|
||||
console.debug('[PaymentInput] Skipping initialization - missing requirements:', {
|
||||
oauthProviderId: props.oauthProviderId,
|
||||
paymentData: !!props.paymentData,
|
||||
publishableKey: !!publishableKey.value
|
||||
})
|
||||
|
||||
// Set error state if publishable key is missing
|
||||
if (!publishableKey.value && stripeState.value) {
|
||||
stripeState.value.hasAccountLoadingError = true
|
||||
stripeState.value.errorMessage = 'Missing Stripe configuration. Please check your settings.'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If compVal already contains a payment intent ID, sync it to stripeState
|
||||
if (compVal.value && isPaymentIntentId(compVal.value) && stripeState) {
|
||||
stripeState.intentId = compVal.value
|
||||
if (compVal.value && isPaymentIntentId(compVal.value) && stripeState.value) {
|
||||
console.debug('[PaymentInput] Syncing existing payment intent:', compVal.value)
|
||||
stripeState.value.intentId = compVal.value
|
||||
}
|
||||
|
||||
// For unsaved forms in admin preview, show the preview message
|
||||
if (props.isAdminPreview && !formSlug.value && stripeState) {
|
||||
stripeState.isLoadingAccount = false
|
||||
stripeState.showPreviewMessage = true
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch account but don't manually create Stripe instance
|
||||
// Fetch account details from the API, even in preview mode
|
||||
const slug = formSlug.value
|
||||
if (slug && props.oauthProviderId && prepareStripeState) {
|
||||
const result = await prepareStripeState(slug, props.oauthProviderId, props.isAdminPreview)
|
||||
if (slug && props.oauthProviderId && prepareStripeState.value) {
|
||||
console.debug('[PaymentInput] Preparing Stripe state with:', {
|
||||
slug,
|
||||
oauthProviderId: props.oauthProviderId,
|
||||
isAdminPreview: props.isAdminPreview
|
||||
})
|
||||
const result = await prepareStripeState.value(slug, props.oauthProviderId, props.isAdminPreview)
|
||||
console.debug('[PaymentInput] Stripe state preparation result:', result)
|
||||
|
||||
if (!result.success && result.message && !result.requiresSave) {
|
||||
// Show error only if it's not the "Save the form" message
|
||||
alert.error(result.message)
|
||||
}
|
||||
} else if (props.isAdminPreview && stripeState) {
|
||||
// If we're in admin preview and any required parameter is missing, show preview message
|
||||
stripeState.isLoadingAccount = false
|
||||
stripeState.showPreviewMessage = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PaymentInput] Stripe initialization error:', error)
|
||||
if (stripeState.value) {
|
||||
stripeState.value.hasAccountLoadingError = true
|
||||
stripeState.value.errorMessage = 'Failed to initialize Stripe. Please refresh and try again.'
|
||||
}
|
||||
alert.error('Failed to initialize Stripe. Please refresh and try again.')
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for provider ID changes
|
||||
watch(() => props.oauthProviderId, async (newVal, oldVal) => {
|
||||
if (newVal && newVal !== oldVal && prepareStripeState) {
|
||||
if (newVal && newVal !== oldVal && prepareStripeState.value) {
|
||||
const slug = formSlug.value
|
||||
if (slug) {
|
||||
await prepareStripeState(slug, newVal, props.isAdminPreview)
|
||||
await prepareStripeState.value(slug, newVal, props.isAdminPreview)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Update onStripeReady to always use the stripe instance from the component
|
||||
// Update onStripeReady to use the computed methods
|
||||
const onStripeReady = ({ stripe, elements }) => {
|
||||
console.debug('[PaymentInput] onStripeReady called with:', {
|
||||
hasStripe: !!stripe,
|
||||
hasElements: !!elements,
|
||||
setStripeInstance: !!setStripeInstance.value,
|
||||
setElementsInstance: !!setElementsInstance.value
|
||||
})
|
||||
|
||||
if (!stripe) {
|
||||
console.warn('[PaymentInput] No Stripe instance in onStripeReady')
|
||||
return
|
||||
}
|
||||
|
||||
if (setStripeInstance) {
|
||||
setStripeInstance(stripe)
|
||||
if (setStripeInstance.value) {
|
||||
console.debug('[PaymentInput] Setting Stripe instance')
|
||||
setStripeInstance.value(stripe)
|
||||
} else {
|
||||
console.warn('[PaymentInput] No setStripeInstance method available')
|
||||
}
|
||||
|
||||
if (elements && setElementsInstance) {
|
||||
setElementsInstance(elements)
|
||||
if (elements && setElementsInstance.value) {
|
||||
console.debug('[PaymentInput] Setting Elements instance')
|
||||
setElementsInstance.value(elements)
|
||||
} else {
|
||||
console.warn('[PaymentInput] Missing elements or setElementsInstance')
|
||||
}
|
||||
}
|
||||
|
||||
const onStripeError = (_error) => {
|
||||
alert.error('Failed to load payment component. Please check configuration or refresh.')
|
||||
const onStripeError = (error) => {
|
||||
console.error('[PaymentInput] Stripe initialization error:', error)
|
||||
const errorMessage = error?.message || 'Failed to load payment component'
|
||||
|
||||
alert.error('Failed to load payment component. ' + errorMessage)
|
||||
|
||||
if (stripeState.value) {
|
||||
stripeState.value.hasAccountLoadingError = true
|
||||
stripeState.value.errorMessage = errorMessage + '. Please check configuration or refresh.'
|
||||
}
|
||||
}
|
||||
|
||||
// Card focus/blur event handlers
|
||||
|
|
@ -309,30 +362,41 @@ const onCardBlur = () => {
|
|||
}
|
||||
|
||||
const onCardReady = (_element) => {
|
||||
if (card.value?.stripeElement) {
|
||||
if (setCardElement) {
|
||||
setCardElement(card.value.stripeElement)
|
||||
}
|
||||
console.debug('[PaymentInput] Card ready:', {
|
||||
hasCardRef: !!card.value,
|
||||
hasStripeElement: !!card.value?.stripeElement,
|
||||
hasSetCardElement: !!setCardElement.value
|
||||
})
|
||||
|
||||
if (card.value?.stripeElement && setCardElement.value) {
|
||||
console.debug('[PaymentInput] Setting card element')
|
||||
setCardElement.value(card.value.stripeElement)
|
||||
} else {
|
||||
console.warn('[PaymentInput] Cannot set card element - missing dependencies')
|
||||
}
|
||||
}
|
||||
|
||||
// Billing details
|
||||
watch(cardHolderName, (newValue) => {
|
||||
setBillingDetails({ name: newValue })
|
||||
if (setBillingDetails.value) {
|
||||
setBillingDetails.value({ name: newValue })
|
||||
}
|
||||
})
|
||||
|
||||
watch(cardHolderEmail, (newValue) => {
|
||||
setBillingDetails({ email: newValue })
|
||||
if (setBillingDetails.value) {
|
||||
setBillingDetails.value({ email: newValue })
|
||||
}
|
||||
})
|
||||
|
||||
// Payment intent sync
|
||||
watch(() => stripeState?.intentId, (newValue) => {
|
||||
watch(() => stripeState.value?.intentId, (newValue) => {
|
||||
if (newValue) compVal.value = newValue
|
||||
})
|
||||
|
||||
watch(compVal, (newValue) => {
|
||||
if (newValue && stripeState && newValue !== stripeState.intentId) {
|
||||
stripeState.intentId = newValue
|
||||
if (newValue && stripeState.value && newValue !== stripeState.value.intentId) {
|
||||
stripeState.value.intentId = newValue
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
|
|
@ -351,7 +415,6 @@ const currencySymbol = computed(() => {
|
|||
})
|
||||
|
||||
const cardOptions = computed(() => {
|
||||
// Extract placeholder color from theme
|
||||
const darkPlaceholderColor = props.theme.default?.input?.includes('dark:placeholder-gray-500') ? '#6B7280' : '#9CA3AF'
|
||||
const lightPlaceholderColor = props.theme.default?.input?.includes('placeholder-gray-400') ? '#9CA3AF' : '#A0AEC0'
|
||||
|
||||
|
|
@ -378,7 +441,6 @@ const cardOptions = computed(() => {
|
|||
})
|
||||
|
||||
const formSlug = computed(() => {
|
||||
// Return the slug from route params regardless of route name
|
||||
if (route.params && route.params.slug) {
|
||||
return route.params.slug
|
||||
}
|
||||
|
|
@ -393,7 +455,9 @@ const resetCard = async () => {
|
|||
|
||||
if (stripeElementsRef.value?.elements) {
|
||||
card.value.stripeElement.mount(stripeElementsRef.value.elements)
|
||||
setCardElement(card.value.stripeElement)
|
||||
if (setCardElement.value) {
|
||||
setCardElement.value(card.value.stripeElement)
|
||||
}
|
||||
} else {
|
||||
console.error('Cannot remount card, Stripe Elements instance not found.')
|
||||
}
|
||||
|
|
@ -403,14 +467,21 @@ const resetCard = async () => {
|
|||
// Add watcher to check when stripeElementsRef becomes available for fallback access
|
||||
watch(() => stripeElementsRef.value, async (newRef) => {
|
||||
if (newRef) {
|
||||
console.debug('[PaymentInput] StripeElementsRef updated:', {
|
||||
hasInstance: !!newRef.instance,
|
||||
hasElements: !!newRef.elements
|
||||
})
|
||||
|
||||
// If @ready event hasn't fired, try accessing the instance directly
|
||||
if (newRef.instance && setStripeInstance && !stripeState.isStripeInstanceReady) {
|
||||
setStripeInstance(newRef.instance)
|
||||
if (newRef.instance && setStripeInstance.value && !stripeState.value?.isStripeInstanceReady) {
|
||||
console.debug('[PaymentInput] Setting Stripe instance from ref')
|
||||
setStripeInstance.value(newRef.instance)
|
||||
}
|
||||
|
||||
if (newRef.elements && setElementsInstance) {
|
||||
setElementsInstance(newRef.elements)
|
||||
if (newRef.elements && setElementsInstance.value) {
|
||||
console.debug('[PaymentInput] Setting Elements instance from ref')
|
||||
setElementsInstance.value(newRef.elements)
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export default {
|
|||
if (props.nativeType !== "file") return
|
||||
|
||||
const file = event.target.files[0]
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
|
||||
props.form[props.name] = file
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ const resizeIframe = (height) => {
|
|||
|
||||
try {
|
||||
window.parentIFrame?.size(height)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div v-if="isCaptchaRequired" class="mb-3 px-2 mt-4 mx-auto w-max">
|
||||
<ClientOnly>
|
||||
<CaptchaInput
|
||||
ref="captchaInputRef"
|
||||
:provider="provider"
|
||||
:form="form"
|
||||
:language="language"
|
||||
:dark-mode="darkMode"
|
||||
/>
|
||||
<template #fallback>
|
||||
<USkeleton class="h-[78px] w-[304px]" />
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import CaptchaInput from './CaptchaInput.vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
formManager: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const captchaInputRef = ref(null)
|
||||
const runtimeConfig = useRuntimeConfig().public
|
||||
|
||||
// Get form and config from the formManager
|
||||
const form = computed(() => props.formManager.form)
|
||||
const config = computed(() => props.formManager.config.value)
|
||||
const structure = computed(() => props.formManager.structure)
|
||||
const isLastPage = computed(() => structure.value?.isLastPage.value ?? true)
|
||||
const language = computed(() => config.value?.language || 'en')
|
||||
const provider = computed(() => config.value?.captcha_provider || 'recaptcha')
|
||||
const darkMode = computed(() => props.formManager.darkMode.value)
|
||||
|
||||
// Determine if captcha should be shown
|
||||
const isCaptchaRequired = computed(() => {
|
||||
if (!config.value?.use_captcha || !isLastPage.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
const captchaProvider = config.value.captcha_provider
|
||||
if (captchaProvider === 'recaptcha') {
|
||||
return !!runtimeConfig.recaptchaSiteKey
|
||||
} else if (captchaProvider === 'hcaptcha') {
|
||||
return !!runtimeConfig.hCaptchaSiteKey
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// Get field name based on provider
|
||||
const captchaFieldName = computed(() => {
|
||||
return provider.value === 'recaptcha'
|
||||
? 'g-recaptcha-response'
|
||||
: 'h-captcha-response'
|
||||
})
|
||||
|
||||
// Reset captcha when page changes
|
||||
watch(() => props.formManager.state.currentPage, () => {
|
||||
if (captchaInputRef.value) {
|
||||
captchaInputRef.value.reset()
|
||||
}
|
||||
})
|
||||
|
||||
// Simplified watcher for form submission
|
||||
watch(() => props.formManager.state.isProcessing, (isProcessing, wasProcessing) => {
|
||||
// Case 1: Form is starting to process and captcha is required but missing
|
||||
if (isProcessing && isCaptchaRequired.value && !form.value[captchaFieldName.value]) {
|
||||
form.value.errors.set(captchaFieldName.value, 'Please complete the captcha verification')
|
||||
}
|
||||
|
||||
// Case 2: Form submission just ended AND there are errors
|
||||
if (!isProcessing && wasProcessing && form.value.errors.any() && captchaInputRef.value) {
|
||||
// Reset the captcha when validation fails
|
||||
captchaInputRef.value.reset()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -148,7 +148,7 @@ const renderHcaptcha = async () => {
|
|||
'open-callback': () => emit('opened'),
|
||||
'close-callback': () => emit('closed')
|
||||
})
|
||||
} catch (error) {
|
||||
} catch {
|
||||
scriptLoadPromise = null // Reset promise on error
|
||||
}
|
||||
}
|
||||
|
|
@ -162,7 +162,7 @@ onBeforeUnmount(() => {
|
|||
if (window.hcaptcha && widgetId !== null) {
|
||||
try {
|
||||
window.hcaptcha.remove(widgetId)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
|
|
@ -179,6 +179,17 @@ onBeforeUnmount(() => {
|
|||
// Expose reset method that properly reloads the captcha
|
||||
defineExpose({
|
||||
reset: async () => {
|
||||
if (window.hcaptcha && widgetId !== null) {
|
||||
try {
|
||||
// Use the official API to reset the captcha widget
|
||||
window.hcaptcha.reset(widgetId)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error resetting hCaptcha, falling back to re-render', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to full re-render if reset fails or hcaptcha isn't available
|
||||
cleanupHcaptcha()
|
||||
await renderHcaptcha()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ const renderRecaptcha = async () => {
|
|||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
} catch {
|
||||
scriptLoadPromise = null // Reset promise on error
|
||||
}
|
||||
}
|
||||
|
|
@ -143,7 +143,7 @@ onBeforeUnmount(() => {
|
|||
if (window.grecaptcha && widgetId !== null) {
|
||||
try {
|
||||
window.grecaptcha.reset(widgetId)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
|
|
@ -164,16 +164,15 @@ defineExpose({
|
|||
try {
|
||||
// Try simple reset first
|
||||
window.grecaptcha.reset(widgetId)
|
||||
} catch (e) {
|
||||
// If simple reset fails, do a full cleanup and reload
|
||||
cleanupRecaptcha()
|
||||
await renderRecaptcha()
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error resetting reCAPTCHA, falling back to re-render', error)
|
||||
}
|
||||
} else {
|
||||
// If no widget exists, do a full reload
|
||||
cleanupRecaptcha()
|
||||
await renderRecaptcha()
|
||||
}
|
||||
|
||||
// If simple reset fails or no widget exists, do a full reload
|
||||
cleanupRecaptcha()
|
||||
await renderRecaptcha()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<template>
|
||||
<!-- No changes to template section -->
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
formManager: { type: Object, required: true },
|
||||
theme: { type: Object, required: false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* No changes to style section */
|
||||
</style>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<template v-if="form.show_progress_bar">
|
||||
<template v-if="showProgressBar">
|
||||
<div
|
||||
v-if="isIframe"
|
||||
class="mb-4 p-2"
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
<div
|
||||
class="h-full transition-all duration-300 rounded-r-full"
|
||||
:class="{ 'w-0': formProgress === 0 }"
|
||||
:style="{ width: formProgress + '%', background: form.color }"
|
||||
:style="{ width: formProgress + '%', background: config?.color }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
<div
|
||||
class="h-full transition-all duration-300"
|
||||
:class="{ 'w-0': formProgress === 0 }"
|
||||
:style="{ width: formProgress + '%', background: form.color }"
|
||||
:style="{ width: formProgress + '%', background: config?.color }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -31,28 +31,31 @@
|
|||
import { useIsIframe } from '~/composables/useIsIframe'
|
||||
|
||||
const props = defineProps({
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
formData: {
|
||||
formManager: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const config = computed(() => props.formManager?.config.value)
|
||||
const form = computed(() => props.formManager?.form)
|
||||
const showProgressBar = computed(() => {
|
||||
return config.value?.show_progress_bar
|
||||
})
|
||||
|
||||
|
||||
const isIframe = useIsIframe()
|
||||
|
||||
const formProgress = computed(() => {
|
||||
const requiredFields = props.fields.filter(field => field.required)
|
||||
const allFields = config.value?.properties ?? []
|
||||
const requiredFields = allFields.filter(field => field?.required)
|
||||
|
||||
if (requiredFields.length === 0) {
|
||||
return 100
|
||||
}
|
||||
const completedFields = requiredFields.filter(field => ![null, undefined, ''].includes(props.formData[field.id]))
|
||||
|
||||
const currentFormData = form.value.data() || {}
|
||||
const completedFields = requiredFields.filter(field => field && ![null, undefined, ''].includes(currentFormData[field.id]))
|
||||
const progress = (completedFields.length / requiredFields.length) * 100
|
||||
return Math.round(progress)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
<template></template>
|
||||
|
||||
<script setup>
|
||||
import { pendingSubmission as pendingSubmissionFun } from "~/composables/forms/pendingSubmission.js"
|
||||
|
||||
const props = defineProps({
|
||||
form: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const pendingSubmission = pendingSubmissionFun(props.form)
|
||||
const startTime = ref(null)
|
||||
const completionTime = ref(parseInt(pendingSubmission.getTimer() ?? null))
|
||||
const timer = ref(null)
|
||||
|
||||
watch(() => completionTime.value, () => {
|
||||
if (completionTime.value) {
|
||||
pendingSubmission.setTimer(completionTime.value)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const startTimer = () => {
|
||||
if (!startTime.value) {
|
||||
startTime.value = parseInt(pendingSubmission.getTimer() ?? 1)
|
||||
completionTime.value = startTime.value
|
||||
timer.value = setInterval(() => {
|
||||
completionTime.value += 1
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const stopTimer = () => {
|
||||
if (timer.value) {
|
||||
clearInterval(timer.value)
|
||||
timer.value = null
|
||||
startTime.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const resetTimer = () => {
|
||||
stopTimer()
|
||||
completionTime.value = null
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
completionTime,
|
||||
startTimer,
|
||||
stopTimer,
|
||||
resetTimer
|
||||
})
|
||||
</script>
|
||||
|
|
@ -3,380 +3,399 @@
|
|||
v-if="form"
|
||||
class="open-complete-form"
|
||||
:dir="form?.layout_rtl ? 'rtl' : 'ltr'"
|
||||
:style="{ '--font-family': form.font_family, 'direction': form?.layout_rtl ? 'rtl' : 'ltr' }"
|
||||
:style="{ '--font-family': form.font_family, 'direction': form?.layout_rtl ? 'rtl' : 'ltr', '--form-color': form.color }"
|
||||
>
|
||||
<link
|
||||
v-if="formModeStrategy.display.showFontLink && form.font_family"
|
||||
rel="stylesheet"
|
||||
:href="getFontUrl"
|
||||
>
|
||||
|
||||
<div v-if="isPublicFormPage && form.is_password_protected">
|
||||
<p class="form-description mb-4 text-gray-700 dark:text-gray-300 px-2">
|
||||
{{ $t('forms.password_protected') }}
|
||||
</p>
|
||||
<div class="form-group flex flex-wrap w-full">
|
||||
<div class="relative mb-3 w-full px-2">
|
||||
<text-input
|
||||
:theme="theme"
|
||||
:form="passwordForm"
|
||||
name="password"
|
||||
native-type="password"
|
||||
label="Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center w-full text-center">
|
||||
<open-form-button
|
||||
:theme="theme"
|
||||
:color="form.color"
|
||||
class="my-4"
|
||||
@click="passwordEntered"
|
||||
<ClientOnly>
|
||||
<Teleport to="head">
|
||||
<link
|
||||
v-if="showFontLink && form.font_family"
|
||||
:key="form.font_family"
|
||||
:href="getFontUrl"
|
||||
rel="stylesheet"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
>
|
||||
{{ $t('forms.submit') }}
|
||||
</open-form-button>
|
||||
</Teleport>
|
||||
</ClientOnly>
|
||||
|
||||
<v-transition name="fade" mode="out-in">
|
||||
<!-- Auto-submit loading state -->
|
||||
<div v-if="isAutoSubmit" key="auto-submit" class="text-center p-6">
|
||||
<Loader class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-transition name="fade">
|
||||
<div
|
||||
v-if="!form.is_password_protected && form.password && !hidePasswordDisabledMsg"
|
||||
class="m-2 my-4 flex flex-grow items-end p-4 rounded-md dark:text-yellow-500 bg-yellow-50 dark:bg-yellow-600/20 dark:border-yellow-500"
|
||||
>
|
||||
<p class="mb-0 text-yellow-600 dark:text-yellow-600 text-sm">
|
||||
We disabled the password protection for this form because you are an owner of it.
|
||||
</p>
|
||||
<UButton
|
||||
color="yellow"
|
||||
size="xs"
|
||||
@click="hidePasswordDisabledMsg = true"
|
||||
>
|
||||
Close
|
||||
</ubutton>
|
||||
</div>
|
||||
</v-transition>
|
||||
|
||||
<div
|
||||
v-if="isPublicFormPage && (form.is_closed || form.visibility=='closed')"
|
||||
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 dark:bg-yellow-600/20 border-yellow-500 dark:border-yellow-500/20"
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<div
|
||||
class="mb-0 py-2 px-4 text-yellow-600"
|
||||
v-html="form.closed_text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isPublicFormPage && form.max_number_of_submissions_reached"
|
||||
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 dark:bg-yellow-600/20 border-yellow-500 dark:border-yellow-500/20"
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<div
|
||||
class="mb-0 py-2 px-4 text-yellow-600 dark:text-yellow-600"
|
||||
v-html="form.max_submissions_reached_text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form-cleanings
|
||||
v-if="formModeStrategy.display.showFormCleanings"
|
||||
:hideable="true"
|
||||
class="mb-4 mx-2"
|
||||
:form="form"
|
||||
:specify-form-owner="true"
|
||||
/>
|
||||
|
||||
<v-transition name="fade">
|
||||
<div
|
||||
v-if="!submitted"
|
||||
key="form"
|
||||
>
|
||||
<open-form
|
||||
v-if="form && !form.is_closed"
|
||||
:form="form"
|
||||
:loading="loading"
|
||||
:fields="form.properties"
|
||||
:theme="theme"
|
||||
:dark-mode="darkMode"
|
||||
:mode="mode"
|
||||
@submit="submitForm"
|
||||
>
|
||||
<template #submit-btn="{submitForm: handleSubmit}">
|
||||
<!-- Main form content -->
|
||||
<div v-else key="form-content">
|
||||
<div v-if="isPublicFormPage && form.is_password_protected">
|
||||
<p class="form-description text-gray-700 dark:text-gray-300 px-2">
|
||||
{{ t('forms.password_protected') }}
|
||||
</p>
|
||||
<div class="form-group flex flex-wrap w-full">
|
||||
<div class="relative w-full px-2">
|
||||
<text-input
|
||||
:theme="theme"
|
||||
:form="passwordForm"
|
||||
name="password"
|
||||
native-type="password"
|
||||
label="Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center w-full text-center">
|
||||
<open-form-button
|
||||
:loading="loading"
|
||||
:theme="theme"
|
||||
:color="form.color"
|
||||
class="mt-2 px-8 mx-1"
|
||||
:class="submitButtonClass"
|
||||
@click.prevent="handleSubmit"
|
||||
class="my-4"
|
||||
@click="passwordEntered"
|
||||
>
|
||||
{{ form.submit_button_text }}
|
||||
{{ t('forms.submit') }}
|
||||
</open-form-button>
|
||||
</template>
|
||||
</open-form>
|
||||
<p
|
||||
v-if="!form.no_branding"
|
||||
class="text-center w-full mt-2"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!form.is_password_protected && form.password && !hidePasswordDisabledMsg"
|
||||
class="m-2 my-4">
|
||||
<UAlert
|
||||
:close-button="{ icon: 'i-heroicons-x-mark-20-solid', color: 'gray', variant: 'link', padded: false }"
|
||||
color="yellow"
|
||||
variant="subtle"
|
||||
icon="i-material-symbols-info-outline"
|
||||
@close="hidePasswordDisabledMsg = true"
|
||||
title="Password protection has been disabled since you are the owner of this form."
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
v-if="isPublicFormPage && (form.is_closed || form.visibility=='closed')"
|
||||
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 dark:bg-yellow-600/20 border-yellow-500 dark:border-yellow-500/20"
|
||||
>
|
||||
<a
|
||||
href="https://opnform.com?utm_source=form&utm_content=powered_by"
|
||||
class="text-gray-400 hover:text-gray-500 dark:text-gray-600 dark:hover:text-gray-500 cursor-pointer hover:underline text-xs"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('forms.powered_by') }} <span class="font-semibold">{{ $t('app.name') }}</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
key="submitted"
|
||||
class="px-2"
|
||||
>
|
||||
<TextBlock
|
||||
v-if="form.submitted_text"
|
||||
class="form-description text-gray-700 dark:text-gray-300 whitespace-pre-wrap"
|
||||
:content="form.submitted_text"
|
||||
:mentions-allowed="true"
|
||||
<div class="flex-grow">
|
||||
<div
|
||||
class="mb-0 py-2 px-4 text-yellow-600"
|
||||
v-html="form.closed_text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="isPublicFormPage && form.max_number_of_submissions_reached"
|
||||
class="border shadow-sm p-2 my-4 flex items-center rounded-md bg-yellow-100 dark:bg-yellow-600/20 border-yellow-500 dark:border-yellow-500/20"
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<div
|
||||
class="mb-0 py-2 px-4 text-yellow-600 dark:text-yellow-600"
|
||||
v-html="form.max_submissions_reached_text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form-cleanings
|
||||
v-if="showFormCleanings"
|
||||
:hideable="true"
|
||||
class="mb-4 mx-2"
|
||||
:form="form"
|
||||
:form-data="submittedData"
|
||||
:specify-form-owner="true"
|
||||
/>
|
||||
<open-form-button
|
||||
v-if="form.re_fillable"
|
||||
:theme="theme"
|
||||
:color="form.color"
|
||||
class="my-4"
|
||||
@click="restart"
|
||||
>
|
||||
{{ form.re_fill_button_text }}
|
||||
</open-form-button>
|
||||
<p
|
||||
v-if="form.editable_submissions && submissionId"
|
||||
class="mt-5"
|
||||
>
|
||||
<a
|
||||
target="_parent"
|
||||
:href="form.share_url+'?submission_id='+submissionId"
|
||||
class="text-nt-blue hover:underline"
|
||||
|
||||
<v-transition name="fade" v-if="form && !form.is_password_protected">
|
||||
<div
|
||||
v-if="!isFormSubmitted"
|
||||
key="form"
|
||||
>
|
||||
{{ form.editable_submissions_button_text }}
|
||||
</a>
|
||||
</p>
|
||||
<p
|
||||
v-if="!form.no_branding"
|
||||
class="mt-5"
|
||||
>
|
||||
<a
|
||||
target="_parent"
|
||||
href="https://opnform.com/?utm_source=form&utm_content=create_form_free"
|
||||
class="text-nt-blue hover:underline"
|
||||
<open-form
|
||||
v-if="formManager && form && shouldDisplayForm"
|
||||
:form-manager="formManager"
|
||||
:theme="theme"
|
||||
@submit="triggerSubmit"
|
||||
>
|
||||
<template #submit-btn="{loading}">
|
||||
<open-form-button
|
||||
:loading="loading || isProcessing"
|
||||
:theme="theme"
|
||||
:color="form.color"
|
||||
class="mt-2 px-8 mx-1"
|
||||
:class="submitButtonClass"
|
||||
@click.prevent="triggerSubmit"
|
||||
>
|
||||
{{ form.submit_button_text }}
|
||||
</open-form-button>
|
||||
</template>
|
||||
</open-form>
|
||||
<p
|
||||
v-if="!form.no_branding"
|
||||
class="text-center w-full mt-2"
|
||||
>
|
||||
<a
|
||||
href="https://opnform.com?utm_source=form&utm_content=powered_by"
|
||||
class="text-gray-400 hover:text-gray-500 dark:text-gray-600 dark:hover:text-gray-500 cursor-pointer hover:underline text-xs"
|
||||
target="_blank"
|
||||
>
|
||||
{{ t('forms.powered_by') }} <span class="font-semibold">{{ t('app.name') }}</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
key="submitted"
|
||||
class="px-2"
|
||||
>
|
||||
{{ $t('forms.create_form_free') }}
|
||||
</a>
|
||||
</p>
|
||||
<TextBlock
|
||||
v-if="form.submitted_text"
|
||||
class="form-description text-gray-700 dark:text-gray-300 whitespace-pre-wrap"
|
||||
:content="form.submitted_text"
|
||||
:mentions-allowed="true"
|
||||
:form="form"
|
||||
:form-data="submittedData"
|
||||
/>
|
||||
<open-form-button
|
||||
v-if="form.re_fillable"
|
||||
:theme="theme"
|
||||
:color="form.color"
|
||||
class="my-4"
|
||||
@click="restart"
|
||||
>
|
||||
{{ form.re_fill_button_text }}
|
||||
</open-form-button>
|
||||
<p
|
||||
v-if="form.editable_submissions && submissionId"
|
||||
class="mt-5"
|
||||
>
|
||||
<a
|
||||
target="_parent"
|
||||
:href="form.share_url+'?submission_id='+submissionId"
|
||||
class="text-nt-blue hover:underline"
|
||||
>
|
||||
{{ form.editable_submissions_button_text }}
|
||||
</a>
|
||||
</p>
|
||||
<p
|
||||
v-if="!form.no_branding"
|
||||
class="mt-5"
|
||||
>
|
||||
<a
|
||||
target="_parent"
|
||||
href="https://opnform.com/?utm_source=form&utm_content=create_form_free"
|
||||
class="text-nt-blue hover:underline"
|
||||
>
|
||||
{{ t('forms.create_form_free') }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</v-transition>
|
||||
<FirstSubmissionModal
|
||||
:show="showFirstSubmissionModal"
|
||||
:form="form"
|
||||
@close="showFirstSubmissionModal=false"
|
||||
/>
|
||||
</div>
|
||||
</v-transition>
|
||||
<FirstSubmissionModal
|
||||
:show="showFirstSubmissionModal"
|
||||
:form="form"
|
||||
@close="showFirstSubmissionModal=false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { useFormManager } from '~/lib/forms/composables/useFormManager'
|
||||
import { FormMode } from "~/lib/forms/FormModeStrategy.js"
|
||||
import ThemeBuilder from "~/lib/forms/themes/ThemeBuilder.js"
|
||||
import OpenForm from './OpenForm.vue'
|
||||
import OpenFormButton from './OpenFormButton.vue'
|
||||
import FormCleanings from '../../pages/forms/show/FormCleanings.vue'
|
||||
import VTransition from '~/components/global/transitions/VTransition.vue'
|
||||
import { pendingSubmission } from "~/composables/forms/pendingSubmission.js"
|
||||
import { usePartialSubmission } from "~/composables/forms/usePartialSubmission.js"
|
||||
import clonedeep from "clone-deep"
|
||||
import ThemeBuilder from "~/lib/forms/themes/ThemeBuilder.js"
|
||||
import FirstSubmissionModal from '~/components/open/forms/components/FirstSubmissionModal.vue'
|
||||
import { FormMode, createFormModeStrategy } from "~/lib/forms/FormModeStrategy.js"
|
||||
import TextBlock from '~/components/forms/TextBlock.vue'
|
||||
import { useForm } from '~/composables/useForm'
|
||||
import { useAlert } from '~/composables/useAlert'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useIsIframe } from '~/composables/useIsIframe'
|
||||
import Loader from '~/components/global/Loader.vue'
|
||||
|
||||
export default {
|
||||
components: { VTransition, OpenFormButton, OpenForm, FormCleanings, FirstSubmissionModal },
|
||||
|
||||
props: {
|
||||
form: { type: Object, required: true },
|
||||
mode: {
|
||||
type: String,
|
||||
default: FormMode.LIVE,
|
||||
validator: (value) => Object.values(FormMode).includes(value)
|
||||
},
|
||||
submitButtonClass: { type: String, default: '' },
|
||||
darkMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
const props = defineProps({
|
||||
form: { type: Object, required: true },
|
||||
mode: {
|
||||
type: String,
|
||||
default: FormMode.LIVE,
|
||||
validator: (value) => Object.values(FormMode).includes(value)
|
||||
},
|
||||
submitButtonClass: { type: String, default: '' },
|
||||
darkMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
emits: ['submitted', 'password-entered', 'restarted'],
|
||||
const emit = defineEmits(['submitted', 'password-entered', 'restarted'])
|
||||
|
||||
setup(props) {
|
||||
const { setLocale } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
return {
|
||||
setLocale,
|
||||
authStore,
|
||||
authenticated: computed(() => authStore.check),
|
||||
isIframe: useIsIframe(),
|
||||
pendingSubmission: pendingSubmission(props.form),
|
||||
partialSubmission: usePartialSubmission(props.form),
|
||||
confetti: useConfetti()
|
||||
}
|
||||
},
|
||||
const { t, setLocale } = useI18n()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const alert = useAlert()
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
submitted: false,
|
||||
passwordForm: useForm({
|
||||
password: null
|
||||
}),
|
||||
hidePasswordDisabledMsg: false,
|
||||
submissionId: false,
|
||||
submittedData: null,
|
||||
showFirstSubmissionModal: false
|
||||
}
|
||||
},
|
||||
const passwordForm = useForm({ password: null })
|
||||
const hidePasswordDisabledMsg = ref(false)
|
||||
const submissionId = ref(route.query.submission_id || null)
|
||||
const submittedData = ref(null)
|
||||
const showFirstSubmissionModal = ref(false)
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Gets the comprehensive strategy based on the form mode
|
||||
*/
|
||||
formModeStrategy() {
|
||||
return createFormModeStrategy(this.mode)
|
||||
},
|
||||
isEmbedPopup () {
|
||||
return import.meta.client && window.location.href.includes('popup=true')
|
||||
},
|
||||
theme () {
|
||||
return new ThemeBuilder(this.form.theme, {
|
||||
size: this.form.size,
|
||||
borderRadius: this.form.border_radius
|
||||
}).getAllComponents()
|
||||
},
|
||||
isPublicFormPage () {
|
||||
return this.$route.name === 'forms-slug'
|
||||
},
|
||||
getFontUrl() {
|
||||
if(!this.form || !this.form.font_family) return null
|
||||
const family = this.form?.font_family.replace(/ /g, '+')
|
||||
return `https://fonts.googleapis.com/css?family=${family}:wght@400,500,700,800,900&display=swap`
|
||||
},
|
||||
isFormOwner() {
|
||||
return this.authenticated && this.form && this.form.creator_id === this.authStore.user.id
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'form.language': {
|
||||
handler(newLanguage) {
|
||||
if (newLanguage && typeof newLanguage === 'string') {
|
||||
this.setLocale(newLanguage)
|
||||
} else {
|
||||
this.setLocale('en') // Default to English if invalid locale
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.setLocale('en')
|
||||
},
|
||||
// Check for auto_submit parameter during setup
|
||||
const isAutoSubmit = ref(import.meta.client && window.location.href.includes('auto_submit=true'))
|
||||
|
||||
methods: {
|
||||
submitForm (form, onFailure) {
|
||||
// Check if we should perform actual submission based on the mode
|
||||
if (!this.formModeStrategy.validation.performActualSubmission) {
|
||||
this.submitted = true
|
||||
this.$emit('submitted', true)
|
||||
return
|
||||
}
|
||||
// Create a reactive reference directly from the prop
|
||||
const darkModeRef = toRef(props, 'darkMode')
|
||||
|
||||
if (form.busy) return
|
||||
this.loading = true
|
||||
// Add back the local theme computation
|
||||
const theme = computed(() => {
|
||||
return new ThemeBuilder(props.form.theme, {
|
||||
size: props.form.size,
|
||||
borderRadius: props.form.border_radius
|
||||
}).getAllComponents()
|
||||
})
|
||||
|
||||
if (this.form?.enable_partial_submissions) {
|
||||
this.partialSubmission.stopSync()
|
||||
}
|
||||
let formManager = null
|
||||
if (props.form) {
|
||||
formManager = useFormManager(props.form, props.mode, {
|
||||
darkMode: darkModeRef
|
||||
})
|
||||
formManager.initialize({
|
||||
submissionId: submissionId,
|
||||
urlParams: import.meta.client ? new URLSearchParams(window.location.search) : null,
|
||||
})
|
||||
}
|
||||
|
||||
form.post('/forms/' + this.form.slug + '/answer').then((data) => {
|
||||
this.submittedData = form.data()
|
||||
useAmplitude().logEvent('form_submission', {
|
||||
workspace_id: this.form.workspace_id,
|
||||
form_id: this.form.id
|
||||
})
|
||||
|
||||
const payload = clonedeep({
|
||||
type: 'form-submitted',
|
||||
form: {
|
||||
slug: this.form.slug,
|
||||
id: this.form.id,
|
||||
redirect_target_url: (this.form.is_pro && data.redirect && data.redirect_url) ? data.redirect_url : null
|
||||
},
|
||||
submission_data: form.data(),
|
||||
completion_time: form['completion_time']
|
||||
// Share the structure service with the working form store only when in admin edit context
|
||||
watch(() => formManager?.strategy?.value?.admin?.showAdminControls, (showAdminControls) => {
|
||||
if (workingFormStore && formManager?.structure && showAdminControls) {
|
||||
workingFormStore.setStructureService(formManager.structure)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Add a watcher to update formManager's darkMode whenever darkModeRef changes
|
||||
watch(darkModeRef, (newDarkMode) => {
|
||||
if (formManager) {
|
||||
formManager.setDarkMode(newDarkMode)
|
||||
}
|
||||
})
|
||||
|
||||
// If auto_submit is true, trigger submit after component is mounted
|
||||
onMounted(() => {
|
||||
if (isAutoSubmit.value && formManager) {
|
||||
// Using nextTick to ensure form is fully rendered and initialized
|
||||
nextTick(() => {
|
||||
triggerSubmit()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const isPublicFormPage = computed(() => {
|
||||
return route.name === 'forms-slug'
|
||||
})
|
||||
|
||||
const getFontUrl = computed(() => {
|
||||
if(!props.form?.font_family) return null
|
||||
const family = props.form.font_family.replace(/ /g, '+')
|
||||
return `https://fonts.googleapis.com/css?family=${family}:wght@400,500,700,800,900&display=swap`
|
||||
})
|
||||
|
||||
const isFormOwner = computed(() => {
|
||||
return authStore.check && props.form && props.form.creator_id === authStore.user.id
|
||||
})
|
||||
|
||||
const isFormSubmitted = computed(() => formManager?.state.isSubmitted ?? false)
|
||||
const isProcessing = computed(() => formManager?.state.isProcessing ?? false)
|
||||
const showFormCleanings = computed(() => formManager?.strategy.value.display.showFormCleanings ?? false)
|
||||
const showFontLink = computed(() => formManager?.strategy.value.display.showFontLink ?? false)
|
||||
const shouldDisplayForm = computed(() => {
|
||||
return (!props.form.is_closed && !props.form.max_number_of_submissions_reached) || formManager?.strategy?.value.admin?.showAdminControls
|
||||
})
|
||||
|
||||
watch(() => props.form.language, (newLanguage) => {
|
||||
if (newLanguage && typeof newLanguage === 'string') {
|
||||
setLocale(newLanguage)
|
||||
} else {
|
||||
setLocale('en')
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
setLocale('en')
|
||||
})
|
||||
|
||||
const handleScrollToError = () => {
|
||||
if (import.meta.server) return
|
||||
|
||||
nextTick(() => {
|
||||
const firstErrorElement = document.querySelector('.form-group [error], .form-group .has-error')
|
||||
if (firstErrorElement) {
|
||||
const headerOffset = 60 // Offset for fixed headers, adjust as needed
|
||||
const elementPosition = firstErrorElement.getBoundingClientRect().top
|
||||
const offsetPosition = elementPosition + window.scrollY - headerOffset
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (this.isIframe) {
|
||||
window.parent.postMessage(payload, '*')
|
||||
}
|
||||
window.postMessage(payload, '*')
|
||||
this.pendingSubmission.remove()
|
||||
this.pendingSubmission.removeTimer()
|
||||
const triggerSubmit = async () => {
|
||||
if (!formManager || isProcessing.value) return
|
||||
|
||||
if (data.redirect && data.redirect_url) {
|
||||
window.location.href = data.redirect_url
|
||||
formManager.submit()
|
||||
.then(result => {
|
||||
if (result) {
|
||||
submittedData.value = result || {}
|
||||
|
||||
if (result?.submission_id) {
|
||||
submissionId.value = result.submission_id
|
||||
}
|
||||
|
||||
if (data.submission_id) {
|
||||
this.submissionId = data.submission_id
|
||||
if (isFormOwner.value && !useIsIframe() && result?.is_first_submission) {
|
||||
showFirstSubmissionModal.value = true
|
||||
}
|
||||
if (this.isFormOwner && !this.isIframe && data?.is_first_submission) {
|
||||
this.showFirstSubmissionModal = true
|
||||
}
|
||||
this.loading = false
|
||||
this.submitted = true
|
||||
this.$emit('submitted', true)
|
||||
|
||||
// If enabled display confetti
|
||||
if (this.form.confetti_on_submission) {
|
||||
this.confetti.play()
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (this.form?.enable_partial_submissions) {
|
||||
this.partialSubmission.startSync()
|
||||
}
|
||||
|
||||
console.error(error)
|
||||
if (error.response && error.data) {
|
||||
useAlert().formValidationError(error.data)
|
||||
}
|
||||
this.loading = false
|
||||
onFailure()
|
||||
})
|
||||
},
|
||||
restart () {
|
||||
this.submitted = false
|
||||
this.$emit('restarted', true)
|
||||
},
|
||||
passwordEntered () {
|
||||
if (this.passwordForm.password !== '' && this.passwordForm.password !== null) {
|
||||
this.$emit('password-entered', this.passwordForm.password)
|
||||
} else {
|
||||
this.addPasswordError(this.$t('forms.password_required'))
|
||||
|
||||
emit('submitted', true)
|
||||
} else {
|
||||
console.warn('Form submission failed via composable, but no error thrown?')
|
||||
alert.error(t('forms.submission_error'))
|
||||
}
|
||||
},
|
||||
addPasswordError (msg) {
|
||||
this.passwordForm.errors.set('password', msg)
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.response && error.response.status === 422 && error.data) {
|
||||
useAlert().formValidationError(error.data)
|
||||
} else if (error.message) {
|
||||
useAlert().error(error.message)
|
||||
}
|
||||
handleScrollToError()
|
||||
}).finally(() => {
|
||||
isAutoSubmit.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const restart = async () => {
|
||||
if (!formManager) return
|
||||
await formManager.restart()
|
||||
submittedData.value = null
|
||||
submissionId.value = null
|
||||
emit('restarted', true)
|
||||
}
|
||||
|
||||
const passwordEntered = () => {
|
||||
if (passwordForm.password) {
|
||||
emit('password-entered', passwordForm.password)
|
||||
} else {
|
||||
addPasswordError(t('forms.password_required'))
|
||||
}
|
||||
}
|
||||
|
||||
const addPasswordError = (msg) => {
|
||||
passwordForm.errors.set('password', msg)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
addPasswordError
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
|||
|
|
@ -1,22 +1,11 @@
|
|||
<template>
|
||||
<div v-if="isAutoSubmit">
|
||||
<p class="text-center p-4">
|
||||
<Loader class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
v-else-if="dataForm"
|
||||
:style="computedStyle"
|
||||
v-if="form"
|
||||
@submit.prevent=""
|
||||
>
|
||||
<FormTimer
|
||||
ref="form-timer"
|
||||
:form="form"
|
||||
/>
|
||||
<FormProgressbar
|
||||
:form="form"
|
||||
:fields="fields"
|
||||
:form-data="dataFormValue"
|
||||
:form-manager="formManager"
|
||||
:theme="theme"
|
||||
/>
|
||||
<transition
|
||||
name="fade"
|
||||
|
|
@ -37,51 +26,36 @@
|
|||
ghost-class="ghost-item"
|
||||
filter=".not-draggable"
|
||||
:animation="200"
|
||||
:disabled="!formModeStrategy.admin.allowDragging"
|
||||
:disabled="!allowDragging"
|
||||
@change="handleDragDropped"
|
||||
>
|
||||
<template #item="{element}">
|
||||
<open-form-field
|
||||
:field="element"
|
||||
:show-hidden="showHidden"
|
||||
:form="form"
|
||||
:data-form="dataForm"
|
||||
:data-form-value="dataFormValue"
|
||||
:form-manager="formManager"
|
||||
:theme="theme"
|
||||
:dark-mode="darkMode"
|
||||
:mode="mode"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Captcha -->
|
||||
|
||||
<ClientOnly>
|
||||
<div v-if="form.use_captcha && isLastPage && hasCaptchaProviders && isCaptchaProviderAvailable" class="mb-3 px-2 mt-4 mx-auto w-max">
|
||||
<CaptchaInput
|
||||
ref="captcha"
|
||||
:provider="form.captcha_provider"
|
||||
:form="dataForm"
|
||||
:language="form.language"
|
||||
:dark-mode="darkMode"
|
||||
/>
|
||||
</div>
|
||||
<template #fallback>
|
||||
<USkeleton class="h-[78px] w-[304px]" />
|
||||
</template>
|
||||
</ClientOnly>
|
||||
<!-- Replace Captcha with CaptchaWrapper -->
|
||||
<CaptchaWrapper
|
||||
v-if="form.use_captcha"
|
||||
:form-manager="formManager"
|
||||
:theme="theme"
|
||||
/>
|
||||
|
||||
<!-- Submit, Next and previous buttons -->
|
||||
<div class="flex flex-wrap justify-center w-full">
|
||||
<open-form-button
|
||||
v-if="formPageIndex>0 && previousFieldsPageBreak && !loading"
|
||||
v-if="formPageIndex>0 && previousFieldsPageBreak"
|
||||
native-type="button"
|
||||
:color="form.color"
|
||||
:theme="theme"
|
||||
class="mt-2 px-8 mx-1"
|
||||
@click="previousPage"
|
||||
@click="handlePreviousClick"
|
||||
>
|
||||
{{ previousFieldsPageBreak.previous_btn_text }}
|
||||
</open-form-button>
|
||||
|
|
@ -89,8 +63,7 @@
|
|||
<slot
|
||||
v-if="isLastPage"
|
||||
name="submit-btn"
|
||||
:submit-form="submitForm"
|
||||
:loading="dataForm.busy"
|
||||
:loading="form.busy"
|
||||
/>
|
||||
<open-form-button
|
||||
v-else-if="currentFieldsPageBreak"
|
||||
|
|
@ -98,8 +71,8 @@
|
|||
:color="form.color"
|
||||
:theme="theme"
|
||||
class="mt-2 px-8 mx-1"
|
||||
:loading="dataForm.busy"
|
||||
@click.stop="nextPage"
|
||||
:loading="form.busy"
|
||||
@click.stop="handleNextClick"
|
||||
>
|
||||
{{ currentFieldsPageBreak.next_btn_text }}
|
||||
</open-form-button>
|
||||
|
|
@ -107,7 +80,7 @@
|
|||
{{ $t('forms.wrong_form_structure') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="paymentBlock"
|
||||
v-if="hasPaymentBlock"
|
||||
class="mt-6 flex justify-center w-full"
|
||||
>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 flex text-center max-w-md">
|
||||
|
|
@ -118,761 +91,76 @@
|
|||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import draggable from 'vuedraggable'
|
||||
import OpenFormButton from './OpenFormButton.vue'
|
||||
import CaptchaInput from '~/components/forms/components/CaptchaInput.vue'
|
||||
import CaptchaWrapper from '~/components/forms/components/CaptchaWrapper.vue'
|
||||
import OpenFormField from './OpenFormField.vue'
|
||||
import { pendingSubmission } from "~/composables/forms/pendingSubmission.js"
|
||||
import { usePartialSubmission } from "~/composables/forms/usePartialSubmission.js"
|
||||
import FormLogicPropertyResolver from "~/lib/forms/FormLogicPropertyResolver.js"
|
||||
import CachedDefaultTheme from "~/lib/forms/themes/CachedDefaultTheme.js"
|
||||
import FormTimer from './FormTimer.vue'
|
||||
import FormProgressbar from './FormProgressbar.vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { FormMode, createFormModeStrategy } from "~/lib/forms/FormModeStrategy.js"
|
||||
import clonedeep from 'clone-deep'
|
||||
import { provideStripeElements } from '~/composables/useStripeElements'
|
||||
import { useWorkingFormStore } from '~/stores/working_form'
|
||||
|
||||
export default {
|
||||
name: 'OpenForm',
|
||||
components: {draggable, OpenFormField, OpenFormButton, CaptchaInput, FormTimer, FormProgressbar},
|
||||
props: {
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
theme: {
|
||||
type: Object, default: () => {
|
||||
const theme = inject("theme", null)
|
||||
if (theme) {
|
||||
return theme.value
|
||||
}
|
||||
return CachedDefaultTheme.getInstance()
|
||||
}
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
defaultDataForm: { type: [Object, null] },
|
||||
mode: {
|
||||
type: String,
|
||||
default: FormMode.LIVE,
|
||||
validator: (value) => Object.values(FormMode).includes(value)
|
||||
},
|
||||
darkMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['submit'],
|
||||
setup(props) {
|
||||
const recordsStore = useRecordsStore()
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
const dataForm = ref(useForm())
|
||||
const config = useRuntimeConfig()
|
||||
const props = defineProps({
|
||||
formManager: { type: Object, required: true },
|
||||
theme: { type: Object, required: true }
|
||||
})
|
||||
|
||||
// Provide Stripe elements to be used by child components
|
||||
const stripeElements = provideStripeElements()
|
||||
|
||||
const hasCaptchaProviders = computed(() => {
|
||||
return config.public.hCaptchaSiteKey || config.public.recaptchaSiteKey
|
||||
})
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
|
||||
return {
|
||||
dataForm,
|
||||
recordsStore,
|
||||
workingFormStore,
|
||||
stripeElements,
|
||||
isIframe: useIsIframe(),
|
||||
draggingNewBlock: computed(() => workingFormStore.draggingNewBlock),
|
||||
pendingSubmission: import.meta.client ? pendingSubmission(props.form) : { get: () => ({}), set: () => {} },
|
||||
partialSubmission: import.meta.client ? usePartialSubmission(props.form, dataForm) : { startSync: () => {}, stopSync: () => {} },
|
||||
formPageIndex: storeToRefs(workingFormStore).formPageIndex,
|
||||
// Derive everything from formManager
|
||||
const state = computed(() => props.formManager.state)
|
||||
const form = computed(() => props.formManager.config.value)
|
||||
const formPageIndex = computed(() => props.formManager.state.currentPage)
|
||||
const strategy = computed(() => props.formManager.strategy.value)
|
||||
const structure = computed(() => props.formManager.structure)
|
||||
|
||||
// Used for admin previews
|
||||
selectedFieldIndex: computed(() => workingFormStore.selectedFieldIndex),
|
||||
showEditFieldSidebar: computed(() => workingFormStore.showEditFieldSidebar),
|
||||
hasCaptchaProviders
|
||||
}
|
||||
},
|
||||
const hasPaymentBlock = computed(() => {
|
||||
return structure.value?.currentPageHasPaymentBlock.value ?? false
|
||||
})
|
||||
|
||||
data() {
|
||||
return {
|
||||
isAutoSubmit: false,
|
||||
partialSubmissionStarted: false,
|
||||
isInitialLoad: true,
|
||||
// Flag to prevent recursion in field group updates
|
||||
isUpdatingFieldGroups: false,
|
||||
}
|
||||
},
|
||||
const currentFields = computed(() => {
|
||||
return structure.value?.getPageFields(state.value.currentPage) ?? []
|
||||
})
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Create field groups (or Page) using page breaks if any
|
||||
*/
|
||||
fieldGroups() {
|
||||
if (!this.fields) return []
|
||||
const groups = []
|
||||
let currentGroup = []
|
||||
this.fields.forEach((field) => {
|
||||
if (field.type === 'nf-page-break' && this.isFieldHidden(field)) return
|
||||
currentGroup.push(field)
|
||||
if (field.type === 'nf-page-break') {
|
||||
groups.push(currentGroup)
|
||||
currentGroup = []
|
||||
}
|
||||
})
|
||||
groups.push(currentGroup)
|
||||
return groups
|
||||
},
|
||||
/**
|
||||
* Gets the comprehensive strategy based on the form mode
|
||||
*/
|
||||
formModeStrategy() {
|
||||
return createFormModeStrategy(this.mode)
|
||||
},
|
||||
/**
|
||||
* Determines if hidden fields should be shown based on the mode
|
||||
*/
|
||||
showHidden() {
|
||||
return this.formModeStrategy.display.showHiddenFields
|
||||
},
|
||||
/**
|
||||
* Determines if the form is in admin preview mode
|
||||
*/
|
||||
isAdminPreview() {
|
||||
return this.formModeStrategy.admin.showAdminControls
|
||||
},
|
||||
currentFields: {
|
||||
get() {
|
||||
return this.fieldGroups[this.formPageIndex]
|
||||
},
|
||||
set(val) {
|
||||
// On re-order from the form, set the new order
|
||||
// Add the previous groups and next to val, and set the properties on working form
|
||||
const newFields = []
|
||||
this.fieldGroups.forEach((group, index) => {
|
||||
if (index < this.formPageIndex) {
|
||||
newFields.push(...group)
|
||||
} else if (index === this.formPageIndex) {
|
||||
newFields.push(...val)
|
||||
} else {
|
||||
newFields.push(...group)
|
||||
}
|
||||
})
|
||||
// set the properties on working_form store
|
||||
this.workingFormStore.setProperties(newFields)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Returns the page break block for the current group of fields
|
||||
*/
|
||||
currentFieldsPageBreak() {
|
||||
// Last block from current group
|
||||
if (!this.currentFields?.length) return null
|
||||
const block = this.currentFields[this.currentFields.length - 1]
|
||||
if (block && block.type === 'nf-page-break') return block
|
||||
return null
|
||||
},
|
||||
previousFieldsPageBreak() {
|
||||
if (this.formPageIndex === 0) return null
|
||||
const previousFields = this.fieldGroups[this.formPageIndex - 1]
|
||||
const block = previousFields[previousFields.length - 1]
|
||||
if (block && block.type === 'nf-page-break') return block
|
||||
return null
|
||||
},
|
||||
/**
|
||||
* Returns true if we're on the last page
|
||||
* @returns {boolean}xs
|
||||
*/
|
||||
isLastPage() {
|
||||
return this.formPageIndex === (this.fieldGroups.length - 1)
|
||||
},
|
||||
isPublicFormPage() {
|
||||
return this.$route.name === 'forms-slug'
|
||||
},
|
||||
dataFormValue() {
|
||||
// For get values instead of Id for select/multi select options
|
||||
const data = this.dataForm.data()
|
||||
const selectionFields = this.fields.filter((field) => {
|
||||
return ['select', 'multi_select'].includes(field.type)
|
||||
})
|
||||
selectionFields.forEach((field) => {
|
||||
if (data[field.id] !== undefined && data[field.id] !== null && Array.isArray(data[field.id])) {
|
||||
data[field.id] = data[field.id].map(option_nfid => {
|
||||
const tmpop = field[field.type].options.find((op) => {
|
||||
return (op.id === option_nfid)
|
||||
})
|
||||
return (tmpop) ? tmpop.name : option_nfid
|
||||
})
|
||||
}
|
||||
})
|
||||
return data
|
||||
},
|
||||
computedStyle() {
|
||||
return {
|
||||
'--form-color': this.form.color
|
||||
}
|
||||
},
|
||||
paymentBlock() {
|
||||
return (this.currentFields) ? this.currentFields.find(field => field.type === 'payment') : null
|
||||
},
|
||||
isCaptchaProviderAvailable() {
|
||||
const config = useRuntimeConfig()
|
||||
if (this.form.captcha_provider === 'recaptcha') {
|
||||
return !!config.public.recaptchaSiteKey
|
||||
} else if (this.form.captcha_provider === 'hcaptcha') {
|
||||
return !!config.public.hCaptchaSiteKey
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
const isLastPage = computed(() => {
|
||||
const result = structure.value?.isLastPage.value ?? true
|
||||
return result
|
||||
})
|
||||
|
||||
watch: {
|
||||
// Monitor only critical changes that require full reinitialization
|
||||
'form.database_id': function() {
|
||||
// Only reinitialize when database changes
|
||||
this.initForm()
|
||||
},
|
||||
'fields.length': function() {
|
||||
// Only reinitialize when fields are added or removed
|
||||
this.updateFieldGroupsSafely()
|
||||
},
|
||||
// Watch for changes to individual field properties
|
||||
'fields': {
|
||||
deep: true,
|
||||
handler() {
|
||||
// Skip update if only triggered by internal fieldGroups changes
|
||||
if (this.isUpdatingFieldGroups) return
|
||||
|
||||
// Safely update field groups without causing recursive updates
|
||||
this.updateFieldGroupsSafely()
|
||||
}
|
||||
},
|
||||
dataFormValue: {
|
||||
deep: true,
|
||||
handler(newValue, oldValue) {
|
||||
if (this.isPublicFormPage && this.form && this.form.auto_save) {
|
||||
this.pendingSubmission.set(this.dataFormValue)
|
||||
}
|
||||
// Start partial submission sync on first form change
|
||||
if (!this.adminPreview && this.form?.enable_partial_submissions && oldValue && Object.keys(oldValue).length > 0 && !this.partialSubmissionStarted) {
|
||||
this.partialSubmission.startSync()
|
||||
this.partialSubmissionStarted = true
|
||||
}
|
||||
}
|
||||
},
|
||||
const currentFieldsPageBreak = computed(() =>
|
||||
structure.value?.currentPageBreak.value
|
||||
)
|
||||
const previousFieldsPageBreak = computed(() =>
|
||||
structure.value?.previousPageBreak.value
|
||||
)
|
||||
|
||||
// These watchers ensure the form shows the correct page for the field being edited in admin preview
|
||||
selectedFieldIndex: {
|
||||
handler(newIndex) {
|
||||
if (this.isAdminPreview && this.showEditFieldSidebar) {
|
||||
this.setPageForField(newIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
showEditFieldSidebar: {
|
||||
handler(newValue) {
|
||||
if (this.isAdminPreview && newValue) {
|
||||
this.setPageForField(this.selectedFieldIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
this.initForm()
|
||||
},
|
||||
mounted() {
|
||||
nextTick(() => {
|
||||
if (this.$refs['form-timer']) {
|
||||
this.$refs['form-timer'].startTimer()
|
||||
}
|
||||
})
|
||||
if (import.meta.client && window.location.href.includes('auto_submit=true')) {
|
||||
this.isAutoSubmit = true
|
||||
this.submitForm()
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (!this.adminPreview && this.form?.enable_partial_submissions) {
|
||||
this.partialSubmission.stopSync()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async submitForm() {
|
||||
try {
|
||||
// Process payment if needed
|
||||
if (!await this.doPayment()) {
|
||||
return false // Payment failed or was required but not completed
|
||||
}
|
||||
this.dataForm.busy = false
|
||||
const allowDragging = computed(() => strategy.value.admin.allowDragging)
|
||||
const draggingNewBlock = computed(() => workingFormStore.draggingNewBlock)
|
||||
|
||||
// Add submission_id for editable submissions (from main)
|
||||
if (this.form.editable_submissions && this.form.submission_id) {
|
||||
this.dataForm.submission_id = this.form.submission_id
|
||||
}
|
||||
const handlePreviousClick = () => {
|
||||
props.formManager.previousPage()
|
||||
if (import.meta.client) window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// Stop timer and get completion time (from main)
|
||||
this.$refs['form-timer'].stopTimer()
|
||||
this.dataForm.completion_time = this.$refs['form-timer'].completionTime
|
||||
const handleNextClick = async () => {
|
||||
await props.formManager.nextPage()
|
||||
if (import.meta.client) window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// Add submission hash for partial submissions (from HEAD)
|
||||
if (this.form?.enable_partial_submissions) {
|
||||
this.dataForm.submission_hash = this.partialSubmission.getSubmissionHash()
|
||||
}
|
||||
const handleDragDropped = (data) => {
|
||||
if (!structure.value) return
|
||||
|
||||
// Add validation strategy check (from main)
|
||||
if (!this.formModeStrategy.validation.validateOnSubmit) {
|
||||
this.$emit('submit', this.dataForm, this.onSubmissionFailure)
|
||||
return
|
||||
}
|
||||
const getAbsoluteIndex = (relativeIndex) => {
|
||||
return structure.value.getTargetDropIndex(relativeIndex, state.value.currentPage)
|
||||
}
|
||||
|
||||
this.$emit('submit', this.dataForm, this.onSubmissionFailure)
|
||||
} catch (error) {
|
||||
this.handleValidationError(error)
|
||||
} finally {
|
||||
this.dataForm.busy = false
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handle form submission failure
|
||||
*/
|
||||
onSubmissionFailure() {
|
||||
this.$refs['form-timer'].startTimer()
|
||||
this.isAutoSubmit = false
|
||||
this.dataForm.busy = false
|
||||
|
||||
if (this.fieldGroups.length > 1) {
|
||||
this.showFirstPageWithError()
|
||||
}
|
||||
this.scrollToFirstError()
|
||||
},
|
||||
showFirstPageWithError() {
|
||||
for (let i = 0; i < this.fieldGroups.length; i++) {
|
||||
if (this.fieldGroups[i].some(field => this.dataForm.errors.has(field.id))) {
|
||||
this.formPageIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollToFirstError() {
|
||||
if (import.meta.server) return
|
||||
const firstErrorElement = document.querySelector('.has-error')
|
||||
if (firstErrorElement) {
|
||||
window.scroll({
|
||||
top: window.scrollY + firstErrorElement.getBoundingClientRect().top - 60,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
},
|
||||
async getSubmissionData() {
|
||||
if (!this.form || !this.form.editable_submissions || !this.form.submission_id) {
|
||||
return null
|
||||
}
|
||||
await this.recordsStore.loadRecord(
|
||||
opnFetch('/forms/' + this.form.slug + '/submissions/' + this.form.submission_id).then((data) => {
|
||||
return {submission_id: this.form.submission_id, id: this.form.submission_id, ...data.data}
|
||||
}).catch((error) => {
|
||||
if (error?.data?.errors) {
|
||||
useAlert().formValidationError(error.data)
|
||||
} else {
|
||||
useAlert().error(error?.data?.message || 'Something went wrong')
|
||||
}
|
||||
return null
|
||||
})
|
||||
)
|
||||
return this.recordsStore.getByKey(this.form.submission_id)
|
||||
},
|
||||
|
||||
/**
|
||||
* Form initialization
|
||||
*/
|
||||
async initForm() {
|
||||
// Only do a full initialization when necessary
|
||||
// Store current page index and form data to avoid overwriting existing values
|
||||
const currentFormData = this.dataForm ? clonedeep(this.dataForm.data()) : {}
|
||||
|
||||
// Handle special cases first
|
||||
if (this.defaultDataForm) {
|
||||
// If we have default data form, initialize with that
|
||||
await nextTick(() => {
|
||||
this.dataForm.resetAndFill(this.defaultDataForm)
|
||||
})
|
||||
this.updateFieldGroupsSafely()
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize the field groups without resetting form data
|
||||
this.updateFieldGroupsSafely()
|
||||
|
||||
// Check if we need to handle form submission states
|
||||
if (await this.checkForEditableSubmission()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.checkForPendingSubmission()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Standard initialization with default values
|
||||
this.initFormWithDefaultValues(currentFormData)
|
||||
},
|
||||
|
||||
checkForEditableSubmission() {
|
||||
return this.tryInitFormFromEditableSubmission()
|
||||
},
|
||||
|
||||
checkForPendingSubmission() {
|
||||
if (this.tryInitFormFromPendingSubmission()) {
|
||||
this.updateFieldGroupsSafely()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
async tryInitFormFromEditableSubmission() {
|
||||
if (this.isPublicFormPage && this.form.editable_submissions) {
|
||||
const submissionId = useRoute().query?.submission_id
|
||||
if (submissionId) {
|
||||
this.form.submission_id = submissionId
|
||||
const data = await this.getSubmissionData()
|
||||
if (data) {
|
||||
this.dataForm.resetAndFill(data)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
tryInitFormFromPendingSubmission() {
|
||||
if (!this.pendingSubmission || !this.isPublicFormPage || !this.form.auto_save) {
|
||||
return false
|
||||
}
|
||||
|
||||
const pendingData = this.pendingSubmission.get()
|
||||
if (pendingData && Object.keys(pendingData).length !== 0) {
|
||||
this.updatePendingDataFields(pendingData)
|
||||
this.dataForm.resetAndFill(pendingData)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
updatePendingDataFields(pendingData) {
|
||||
this.fields.forEach(field => {
|
||||
if (field.type === 'date' && field.prefill_today) {
|
||||
pendingData[field.id] = new Date().toISOString()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
initFormWithDefaultValues(currentFormData = {}) {
|
||||
// Only set page 0 on first load, otherwise maintain current position
|
||||
if (this.formPageIndex === undefined || this.isInitialLoad) {
|
||||
this.formPageIndex = 0
|
||||
this.isInitialLoad = false
|
||||
}
|
||||
|
||||
// Initialize form data with default values
|
||||
const formData = { ...currentFormData }
|
||||
const urlPrefill = this.isPublicFormPage ? new URLSearchParams(window.location.search) : null
|
||||
|
||||
this.fields.forEach(field => {
|
||||
if (field.type.startsWith('nf-') && !['nf-page-body-input', 'nf-page-logo', 'nf-page-cover'].includes(field.type)) return
|
||||
|
||||
this.handleUrlPrefill(field, formData, urlPrefill)
|
||||
this.handleDefaultPrefill(field, formData)
|
||||
})
|
||||
|
||||
// Reset form with new data
|
||||
this.dataForm.resetAndFill(formData)
|
||||
},
|
||||
handleUrlPrefill(field, formData, urlPrefill) {
|
||||
if (!urlPrefill) return
|
||||
|
||||
const prefillValue = (() => {
|
||||
const val = urlPrefill.get(field.id)
|
||||
try {
|
||||
return typeof val === 'string' && val.startsWith('{') ? JSON.parse(val) : val
|
||||
} catch (e) {
|
||||
return val
|
||||
}
|
||||
})()
|
||||
const arrayPrefillValue = urlPrefill.getAll(field.id + '[]')
|
||||
|
||||
if (typeof prefillValue === 'object' && prefillValue !== null) {
|
||||
formData[field.id] = { ...prefillValue }
|
||||
} else if (prefillValue !== null) {
|
||||
formData[field.id] = field.type === 'checkbox' ? this.parseBooleanValue(prefillValue) : prefillValue
|
||||
} else if (arrayPrefillValue.length > 0) {
|
||||
formData[field.id] = arrayPrefillValue
|
||||
}
|
||||
},
|
||||
parseBooleanValue(value) {
|
||||
return value === 'true' || value === '1'
|
||||
},
|
||||
handleDefaultPrefill(field, formData) {
|
||||
if (field.type === 'date' && field.prefill_today) {
|
||||
formData[field.id] = new Date().toISOString()
|
||||
} else if (field.type === 'matrix' && !formData[field.id]) {
|
||||
formData[field.id] = {...field.prefill}
|
||||
} else if (!(field.id in formData)) {
|
||||
formData[field.id] = field.prefill
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Page Navigation
|
||||
*/
|
||||
previousPage() {
|
||||
this.formPageIndex--
|
||||
this.scrollToTop()
|
||||
},
|
||||
async nextPage() {
|
||||
if (!this.formModeStrategy.validation.validateOnNextPage) {
|
||||
if (!this.isLastPage) {
|
||||
this.formPageIndex++
|
||||
}
|
||||
this.scrollToTop()
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
this.dataForm.busy = true
|
||||
const fieldsToValidate = this.currentFields
|
||||
.filter(f => f.type !== 'payment')
|
||||
.map(f => f.id)
|
||||
|
||||
// Validate non-payment fields first
|
||||
if (fieldsToValidate.length > 0) {
|
||||
await this.dataForm.validate('POST', `/forms/${this.form.slug}/answer`, {}, fieldsToValidate)
|
||||
}
|
||||
|
||||
// Process payment if needed
|
||||
if (!await this.doPayment()) {
|
||||
return false // Payment failed or was required but not completed
|
||||
}
|
||||
|
||||
// If validation and payment are successful, proceed
|
||||
if (!this.isLastPage) {
|
||||
this.formPageIndex++
|
||||
this.scrollToTop()
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
this.handleValidationError(error)
|
||||
return false
|
||||
} finally {
|
||||
this.dataForm.busy = false
|
||||
}
|
||||
},
|
||||
async doPayment() {
|
||||
// Check if there's a payment block in the current step
|
||||
if (!this.paymentBlock) {
|
||||
return true // No payment needed for this step
|
||||
}
|
||||
this.dataForm.busy = true
|
||||
|
||||
// Use the stripeElements from setup instead of calling useStripeElements
|
||||
const { state: stripeState, processPayment, isCardPopulated, isReadyForPayment } = this.stripeElements
|
||||
|
||||
// Skip if payment is already processed in the stripe state
|
||||
if (stripeState.intentId) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Skip if payment ID already exists in the form data
|
||||
const paymentFieldValue = this.dataFormValue[this.paymentBlock.id]
|
||||
if (paymentFieldValue && typeof paymentFieldValue === 'string' && paymentFieldValue.startsWith('pi_')) {
|
||||
// If we have a valid payment intent ID in the form data, sync it to the stripe state
|
||||
stripeState.intentId = paymentFieldValue
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for the stripe object itself, not just the ready flag
|
||||
if (stripeState.isStripeInstanceReady && !stripeState.stripe) {
|
||||
stripeState.isStripeInstanceReady = false
|
||||
}
|
||||
|
||||
// Only process payment if required or card has data
|
||||
const shouldProcessPayment = this.paymentBlock.required || isCardPopulated.value
|
||||
|
||||
if (shouldProcessPayment) {
|
||||
// If not ready yet, try a brief wait
|
||||
if (!isReadyForPayment.value) {
|
||||
try {
|
||||
this.dataForm.busy = true
|
||||
|
||||
// Just wait a second to see if state updates (it should be reactive now)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// Check if we're ready now
|
||||
if (!isReadyForPayment.value) {
|
||||
// Provide detailed diagnostics
|
||||
let errorMsg = 'Payment system not ready. '
|
||||
const details = []
|
||||
|
||||
if (!stripeState.stripeAccountId) {
|
||||
details.push('No Stripe account connected')
|
||||
}
|
||||
|
||||
if (!stripeState.isStripeInstanceReady) {
|
||||
details.push('Stripe.js not initialized')
|
||||
}
|
||||
|
||||
if (!stripeState.isCardElementReady) {
|
||||
details.push('Card element not initialized')
|
||||
}
|
||||
|
||||
errorMsg += details.join(', ') + '. Please refresh and try again.'
|
||||
useAlert().error(errorMsg)
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
return false
|
||||
} finally {
|
||||
this.dataForm.busy = false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this.dataForm.busy = true
|
||||
const result = await processPayment(this.form.slug, this.paymentBlock.required)
|
||||
|
||||
if (!result.success) {
|
||||
// Handle payment error
|
||||
if (result.error?.message) {
|
||||
this.dataForm.errors.set(this.paymentBlock.id, result.error.message)
|
||||
useAlert().error(result.error.message)
|
||||
} else {
|
||||
useAlert().error('Payment processing failed. Please try again.')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Payment successful
|
||||
if (result.paymentIntent?.status === 'succeeded') {
|
||||
useAlert().success('Thank you! Your payment is successful.')
|
||||
return true
|
||||
}
|
||||
|
||||
// Fallback error
|
||||
useAlert().error('Something went wrong with the payment. Please try again.')
|
||||
return false
|
||||
} catch (error) {
|
||||
useAlert().error(error?.message || 'Payment failed')
|
||||
return false
|
||||
} finally {
|
||||
this.dataForm.busy = false
|
||||
}
|
||||
}
|
||||
|
||||
return true // Payment not required or no card data
|
||||
},
|
||||
scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
},
|
||||
handleValidationError(error) {
|
||||
console.error(error)
|
||||
if (error?.data) {
|
||||
useAlert().formValidationError(error.data)
|
||||
}
|
||||
this.dataForm.busy = false
|
||||
},
|
||||
isFieldHidden(field) {
|
||||
return (new FormLogicPropertyResolver(field, this.dataFormValue)).isHidden()
|
||||
},
|
||||
getTargetFieldIndex(currentFieldPageIndex) {
|
||||
return this.formPageIndex > 0
|
||||
? this.fieldGroups.slice(0, this.formPageIndex).reduce((sum, group) => sum + group.length, 0) + currentFieldPageIndex
|
||||
: currentFieldPageIndex
|
||||
},
|
||||
handleDragDropped(data) {
|
||||
if (data.added) {
|
||||
const targetIndex = this.getTargetFieldIndex(data.added.newIndex)
|
||||
this.workingFormStore.addBlock(data.added.element, targetIndex, false)
|
||||
}
|
||||
if (data.moved) {
|
||||
const oldTargetIndex = this.getTargetFieldIndex(data.moved.oldIndex)
|
||||
const newTargetIndex = this.getTargetFieldIndex(data.moved.newIndex)
|
||||
this.workingFormStore.moveField(oldTargetIndex, newTargetIndex)
|
||||
}
|
||||
},
|
||||
setPageForField(fieldIndex) {
|
||||
if (fieldIndex === -1) return
|
||||
|
||||
let currentIndex = 0
|
||||
for (let i = 0; i < this.fieldGroups.length; i++) {
|
||||
currentIndex += this.fieldGroups[i].length
|
||||
if (currentIndex > fieldIndex) {
|
||||
this.formPageIndex = i
|
||||
return
|
||||
}
|
||||
}
|
||||
this.formPageIndex = this.fieldGroups.length - 1
|
||||
},
|
||||
|
||||
// New method for updating field groups
|
||||
updateFieldGroups() {
|
||||
if (!this.fields || this.fields.length === 0) return
|
||||
|
||||
// Preserve the current page index if possible
|
||||
const currentPageIndex = this.formPageIndex
|
||||
|
||||
// Use a local variable instead of directly modifying computed property
|
||||
// We'll use this to determine totalPages and currentPageIndex
|
||||
const calculatedGroups = this.fields.reduce((groups, field, index) => {
|
||||
// If the field is a page break, start a new group
|
||||
if (field.type === 'nf-page-break' && index !== 0) {
|
||||
groups.push([])
|
||||
}
|
||||
// Add the field to the current group
|
||||
if (groups.length === 0) groups.push([])
|
||||
groups[groups.length - 1].push(field)
|
||||
return groups
|
||||
}, [])
|
||||
|
||||
// If we don't have any groups (shouldn't happen), create a default group
|
||||
if (calculatedGroups.length === 0) {
|
||||
calculatedGroups.push([])
|
||||
}
|
||||
|
||||
// Update page navigation
|
||||
const totalPages = calculatedGroups.length
|
||||
|
||||
// Try to maintain the current page index if valid
|
||||
if (currentPageIndex !== undefined && currentPageIndex < totalPages) {
|
||||
this.formPageIndex = currentPageIndex
|
||||
} else {
|
||||
this.formPageIndex = 0
|
||||
}
|
||||
|
||||
// Force a re-render of the component, which will update fieldGroups computed property
|
||||
this.$forceUpdate()
|
||||
},
|
||||
|
||||
// Helper method to prevent recursive updates
|
||||
updateFieldGroupsSafely() {
|
||||
// Set flag to prevent recursive updates
|
||||
this.isUpdatingFieldGroups = true
|
||||
|
||||
// Call the actual update method
|
||||
this.updateFieldGroups()
|
||||
|
||||
// Clear the flag after a short delay to allow Vue to process the update
|
||||
this.$nextTick(() => {
|
||||
this.isUpdatingFieldGroups = false
|
||||
})
|
||||
}
|
||||
if (data.added) {
|
||||
const targetIndex = getAbsoluteIndex(data.added.newIndex)
|
||||
workingFormStore.addBlock(data.added.element, targetIndex, false)
|
||||
}
|
||||
if (data.moved) {
|
||||
const oldTargetIndex = getAbsoluteIndex(data.moved.oldIndex)
|
||||
const newTargetIndex = getAbsoluteIndex(data.moved.newIndex)
|
||||
workingFormStore.moveField(oldTargetIndex, newTargetIndex)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -147,294 +147,257 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {computed} from 'vue'
|
||||
<script setup>
|
||||
import FormLogicPropertyResolver from "~/lib/forms/FormLogicPropertyResolver.js"
|
||||
import CachedDefaultTheme from "~/lib/forms/themes/CachedDefaultTheme.js"
|
||||
import {default as _has} from 'lodash/has'
|
||||
import { default as _has } from 'lodash/has'
|
||||
import { FormMode, createFormModeStrategy } from "~/lib/forms/FormModeStrategy.js"
|
||||
import { useWorkingFormStore } from '~/stores/working_form'
|
||||
|
||||
export default {
|
||||
name: 'OpenFormField',
|
||||
components: {},
|
||||
props: {
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
dataForm: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
dataFormValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
theme: {
|
||||
type: Object, default: () => {
|
||||
const theme = inject("theme", null)
|
||||
if (theme) {
|
||||
return theme.value
|
||||
// Define props
|
||||
const props = defineProps({
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
formManager: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
theme: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// Derive everything from formManager
|
||||
const form = computed(() => props.formManager?.config?.value || {})
|
||||
const dataForm = computed(() => props.formManager?.form || {})
|
||||
const darkMode = computed(() => props.formManager?.darkMode?.value || false)
|
||||
const showHidden = computed(() => props.formManager?.strategy?.value?.display?.showHiddenFields || false)
|
||||
|
||||
// Setup stores and reactive state
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
const selectedFieldIndex = computed(() => workingFormStore.selectedFieldIndex)
|
||||
const showEditFieldSidebar = computed(() => workingFormStore.showEditFieldSidebar)
|
||||
const strategy = computed(() => props.formManager?.strategy?.value || createFormModeStrategy(FormMode.LIVE))
|
||||
const isAdminPreview = computed(() => strategy.value?.admin?.showAdminControls || false)
|
||||
|
||||
// Computed properties
|
||||
const getFieldComponents = computed(() => {
|
||||
const field = props.field
|
||||
if (field.type === 'text' && field.multi_lines) {
|
||||
return 'TextAreaInput'
|
||||
}
|
||||
if (field.type === 'url' && field.file_upload) {
|
||||
return 'FileInput'
|
||||
}
|
||||
if (['select', 'multi_select'].includes(field.type) && field.without_dropdown) {
|
||||
return 'FlatSelectInput'
|
||||
}
|
||||
if (field.type === 'checkbox' && field.use_toggle_switch) {
|
||||
return 'ToggleSwitchInput'
|
||||
}
|
||||
if (field.type === 'signature') {
|
||||
return 'SignatureInput'
|
||||
}
|
||||
if (field.type === 'phone_number' && !field.use_simple_text_input) {
|
||||
return 'PhoneInput'
|
||||
}
|
||||
|
||||
return {
|
||||
text: 'TextInput',
|
||||
rich_text: 'RichTextAreaInput',
|
||||
number: 'TextInput',
|
||||
rating: 'RatingInput',
|
||||
scale: 'ScaleInput',
|
||||
slider: 'SliderInput',
|
||||
select: 'SelectInput',
|
||||
multi_select: 'SelectInput',
|
||||
date: 'DateInput',
|
||||
files: 'FileInput',
|
||||
checkbox: 'CheckboxInput',
|
||||
url: 'TextInput',
|
||||
email: 'TextInput',
|
||||
phone_number: 'TextInput',
|
||||
matrix: 'MatrixInput',
|
||||
barcode: 'BarcodeInput',
|
||||
payment: 'PaymentInput'
|
||||
}[field.type]
|
||||
})
|
||||
|
||||
const isPublicFormPage = computed(() => useRoute().name === 'forms-slug')
|
||||
|
||||
const isFieldHidden = computed(() => !showHidden.value && shouldBeHidden.value)
|
||||
|
||||
const shouldBeHidden = computed(() =>
|
||||
(new FormLogicPropertyResolver(props.field, dataForm.value)).isHidden()
|
||||
)
|
||||
|
||||
const isFieldRequired = computed(() =>
|
||||
(new FormLogicPropertyResolver(props.field, dataForm.value)).isRequired()
|
||||
)
|
||||
|
||||
const isFieldDisabled = computed(() =>
|
||||
(new FormLogicPropertyResolver(props.field, dataForm.value)).isDisabled()
|
||||
)
|
||||
|
||||
const beingEdited = computed(() =>
|
||||
isAdminPreview.value &&
|
||||
showEditFieldSidebar.value &&
|
||||
form.value.properties.findIndex((item) => item.id === props.field.id) === selectedFieldIndex.value
|
||||
)
|
||||
|
||||
// Methods
|
||||
function editFieldOptions() {
|
||||
if (!isAdminPreview.value) return
|
||||
workingFormStore.openSettingsForField(props.field)
|
||||
}
|
||||
|
||||
function setFieldAsSelected() {
|
||||
if (!isAdminPreview.value || !workingFormStore.showEditFieldSidebar) return
|
||||
workingFormStore.openSettingsForField(props.field)
|
||||
}
|
||||
|
||||
function openAddFieldSidebar() {
|
||||
if (!isAdminPreview.value) return
|
||||
workingFormStore.openAddFieldSidebar(props.field)
|
||||
}
|
||||
|
||||
function removeField() {
|
||||
if (!isAdminPreview.value) return
|
||||
workingFormStore.removeField(props.field)
|
||||
}
|
||||
|
||||
function getFieldWidthClasses(field) {
|
||||
if (!field.width || field.width === 'full') return 'col-span-full'
|
||||
else if (field.width === '1/2') {
|
||||
return 'sm:col-span-6 col-span-full'
|
||||
} else if (field.width === '1/3') {
|
||||
return 'sm:col-span-4 col-span-full'
|
||||
} else if (field.width === '2/3') {
|
||||
return 'sm:col-span-8 col-span-full'
|
||||
} else if (field.width === '1/4') {
|
||||
return 'sm:col-span-3 col-span-full'
|
||||
} else if (field.width === '3/4') {
|
||||
return 'sm:col-span-9 col-span-full'
|
||||
}
|
||||
}
|
||||
|
||||
function getFieldAlignClasses(field) {
|
||||
if (!field.align || field.align === 'left') return 'text-left'
|
||||
else if (field.align === 'right') {
|
||||
return 'text-right'
|
||||
} else if (field.align === 'center') {
|
||||
return 'text-center'
|
||||
} else if (field.align === 'justify') {
|
||||
return 'text-justify'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the right input component options for the field/options
|
||||
*/
|
||||
function inputProperties(field) {
|
||||
const inputProperties = {
|
||||
key: field.id,
|
||||
name: field.id,
|
||||
form: dataForm.value,
|
||||
label: (field.hide_field_name) ? null : field.name + ((shouldBeHidden.value) ? ' (Hidden Field)' : ''),
|
||||
color: form.value.color,
|
||||
placeholder: field.placeholder,
|
||||
help: field.help,
|
||||
helpPosition: (field.help_position) ? field.help_position : 'below_input',
|
||||
uppercaseLabels: form.value.uppercase_labels == 1 || form.value.uppercase_labels == true,
|
||||
theme: props.theme || CachedDefaultTheme.getInstance(),
|
||||
maxCharLimit: (field.max_char_limit) ? parseInt(field.max_char_limit) : null,
|
||||
showCharLimit: field.show_char_limit || false,
|
||||
isDark: darkMode.value,
|
||||
locale: (form.value?.language) ? form.value.language : 'en'
|
||||
}
|
||||
|
||||
|
||||
if (field.type === 'matrix') {
|
||||
inputProperties.rows = field.rows
|
||||
inputProperties.columns = field.columns
|
||||
}
|
||||
|
||||
if (field.type === 'barcode') {
|
||||
inputProperties.decoders = field.decoders
|
||||
}
|
||||
|
||||
if (['select','multi_select'].includes(field.type) && !isFieldRequired.value) {
|
||||
inputProperties.clearable = true
|
||||
}
|
||||
|
||||
if (['select', 'multi_select'].includes(field.type)) {
|
||||
inputProperties.options = (_has(field, field.type))
|
||||
? field[field.type].options.map(option => {
|
||||
return {
|
||||
name: option.name,
|
||||
value: option.name
|
||||
}
|
||||
return CachedDefaultTheme.getInstance()
|
||||
}
|
||||
},
|
||||
showHidden: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
darkMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: FormMode.LIVE
|
||||
})
|
||||
: []
|
||||
inputProperties.multiple = (field.type === 'multi_select')
|
||||
inputProperties.allowCreation = (field.allow_creation === true)
|
||||
inputProperties.searchable = (inputProperties.options.length > 4)
|
||||
} else if (field.type === 'date') {
|
||||
inputProperties.dateFormat = field.date_format
|
||||
inputProperties.timeFormat = field.time_format
|
||||
if (field.with_time) {
|
||||
inputProperties.withTime = true
|
||||
}
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
return {
|
||||
workingFormStore,
|
||||
currentWorkspace: computed(() => useWorkspacesStore().getCurrent),
|
||||
selectedFieldIndex: computed(() => workingFormStore.selectedFieldIndex),
|
||||
showEditFieldSidebar: computed(() => workingFormStore.showEditFieldSidebar),
|
||||
formModeStrategy: computed(() => createFormModeStrategy(props.mode)),
|
||||
isAdminPreview: computed(() => createFormModeStrategy(props.mode).admin.showAdminControls)
|
||||
if (field.date_range) {
|
||||
inputProperties.dateRange = true
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Get the right input component for the field/options combination
|
||||
*/
|
||||
getFieldComponents() {
|
||||
const field = this.field
|
||||
if (field.type === 'text' && field.multi_lines) {
|
||||
return 'TextAreaInput'
|
||||
}
|
||||
if (field.type === 'url' && field.file_upload) {
|
||||
return 'FileInput'
|
||||
}
|
||||
if (['select', 'multi_select'].includes(field.type) && field.without_dropdown) {
|
||||
return 'FlatSelectInput'
|
||||
}
|
||||
if (field.type === 'checkbox' && field.use_toggle_switch) {
|
||||
return 'ToggleSwitchInput'
|
||||
}
|
||||
if (field.type === 'signature') {
|
||||
return 'SignatureInput'
|
||||
}
|
||||
if (field.type === 'phone_number' && !field.use_simple_text_input) {
|
||||
return 'PhoneInput'
|
||||
}
|
||||
|
||||
return {
|
||||
text: 'TextInput',
|
||||
rich_text: 'RichTextAreaInput',
|
||||
number: 'TextInput',
|
||||
rating: 'RatingInput',
|
||||
scale: 'ScaleInput',
|
||||
slider: 'SliderInput',
|
||||
select: 'SelectInput',
|
||||
multi_select: 'SelectInput',
|
||||
date: 'DateInput',
|
||||
files: 'FileInput',
|
||||
checkbox: 'CheckboxInput',
|
||||
url: 'TextInput',
|
||||
email: 'TextInput',
|
||||
phone_number: 'TextInput',
|
||||
matrix: 'MatrixInput',
|
||||
barcode: 'BarcodeInput',
|
||||
payment: 'PaymentInput'
|
||||
}[field.type]
|
||||
},
|
||||
isPublicFormPage() {
|
||||
return this.$route.name === 'forms-slug'
|
||||
},
|
||||
isFieldHidden() {
|
||||
return !this.showHidden && this.shouldBeHidden
|
||||
},
|
||||
shouldBeHidden() {
|
||||
return (new FormLogicPropertyResolver(this.field, this.dataFormValue)).isHidden()
|
||||
},
|
||||
isFieldRequired() {
|
||||
return (new FormLogicPropertyResolver(this.field, this.dataFormValue)).isRequired()
|
||||
},
|
||||
isFieldDisabled() {
|
||||
return (new FormLogicPropertyResolver(this.field, this.dataFormValue)).isDisabled()
|
||||
},
|
||||
beingEdited() {
|
||||
return this.isAdminPreview && this.showEditFieldSidebar && this.form.properties.findIndex((item) => {
|
||||
return item.id === this.field.id
|
||||
}) === this.selectedFieldIndex
|
||||
},
|
||||
selectionFieldsOptions() {
|
||||
// For auto update hidden options
|
||||
let fieldsOptions = []
|
||||
|
||||
if (['select', 'multi_select', 'status'].includes(this.field.type)) {
|
||||
fieldsOptions = [...this.field[this.field.type].options]
|
||||
if (this.field.hidden_options && this.field.hidden_options.length > 0) {
|
||||
fieldsOptions = fieldsOptions.filter((option) => {
|
||||
return this.field.hidden_options.indexOf(option.id) < 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return fieldsOptions
|
||||
},
|
||||
fieldSideBarOpened() {
|
||||
return this.isAdminPreview && (this.form && this.selectedFieldIndex !== null) ? (this.form.properties[this.selectedFieldIndex] && this.showEditFieldSidebar) : false
|
||||
if (field.disable_past_dates) {
|
||||
inputProperties.disablePastDates = true
|
||||
} else if (field.disable_future_dates) {
|
||||
inputProperties.disableFutureDates = true
|
||||
}
|
||||
},
|
||||
|
||||
watch: {},
|
||||
|
||||
mounted() {
|
||||
},
|
||||
|
||||
methods: {
|
||||
editFieldOptions() {
|
||||
if (!this.formModeStrategy.admin.showAdminControls) return
|
||||
this.workingFormStore.openSettingsForField(this.field)
|
||||
},
|
||||
setFieldAsSelected () {
|
||||
if (!this.formModeStrategy.admin.showAdminControls || !this.workingFormStore.showEditFieldSidebar) return
|
||||
this.workingFormStore.openSettingsForField(this.field)
|
||||
},
|
||||
openAddFieldSidebar() {
|
||||
if (!this.formModeStrategy.admin.showAdminControls) return
|
||||
this.workingFormStore.openAddFieldSidebar(this.field)
|
||||
},
|
||||
removeField () {
|
||||
if (!this.formModeStrategy.admin.showAdminControls) return
|
||||
this.workingFormStore.removeField(this.field)
|
||||
},
|
||||
getFieldWidthClasses(field) {
|
||||
if (!field.width || field.width === 'full') return 'col-span-full'
|
||||
else if (field.width === '1/2') {
|
||||
return 'sm:col-span-6 col-span-full'
|
||||
} else if (field.width === '1/3') {
|
||||
return 'sm:col-span-4 col-span-full'
|
||||
} else if (field.width === '2/3') {
|
||||
return 'sm:col-span-8 col-span-full'
|
||||
} else if (field.width === '1/4') {
|
||||
return 'sm:col-span-3 col-span-full'
|
||||
} else if (field.width === '3/4') {
|
||||
return 'sm:col-span-9 col-span-full'
|
||||
} else if (field.type === 'files' || (field.type === 'url' && field.file_upload)) {
|
||||
inputProperties.multiple = (field.multiple !== undefined && field.multiple)
|
||||
inputProperties.cameraUpload = (field.camera_upload !== undefined && field.camera_upload)
|
||||
let maxFileSize = (form.value?.workspace && form.value?.workspace.max_file_size) ? form.value?.workspace?.max_file_size : 10
|
||||
if (field?.max_file_size > 0) {
|
||||
maxFileSize = Math.min(field.max_file_size, maxFileSize)
|
||||
}
|
||||
inputProperties.mbLimit = maxFileSize
|
||||
inputProperties.accept = (form.value.is_pro && field.allowed_file_types) ? field.allowed_file_types : ''
|
||||
} else if (field.type === 'rating') {
|
||||
inputProperties.numberOfStars = parseInt(field.rating_max_value) ?? 5
|
||||
} else if (field.type === 'scale') {
|
||||
inputProperties.minScale = parseFloat(field.scale_min_value) ?? 1
|
||||
inputProperties.maxScale = parseFloat(field.scale_max_value) ?? 5
|
||||
inputProperties.stepScale = parseFloat(field.scale_step_value) ?? 1
|
||||
} else if (field.type === 'slider') {
|
||||
inputProperties.minSlider = parseInt(field.slider_min_value) ?? 0
|
||||
inputProperties.maxSlider = parseInt(field.slider_max_value) ?? 50
|
||||
inputProperties.stepSlider = parseInt(field.slider_step_value) ?? 5
|
||||
} 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) {
|
||||
inputProperties.unavailableCountries = field.unavailable_countries ?? []
|
||||
} else if (field.type === 'text' && field.secret_input) {
|
||||
inputProperties.nativeType = 'password'
|
||||
} else if (field.type === 'payment') {
|
||||
inputProperties.direction = form.value.layout_rtl ? 'rtl' : 'ltr'
|
||||
inputProperties.currency = field.currency
|
||||
inputProperties.amount = field.amount
|
||||
inputProperties.oauthProviderId = field.stripe_account_id
|
||||
|
||||
// Get paymentData from formManager if available
|
||||
if (props.formManager?.payment) {
|
||||
try {
|
||||
inputProperties.paymentData = props.formManager.payment.getPaymentData(field)
|
||||
} catch (error) {
|
||||
console.error("Error getting payment data:", error)
|
||||
}
|
||||
},
|
||||
getFieldAlignClasses(field) {
|
||||
if (!field.align || field.align === 'left') return 'text-left'
|
||||
else if (field.align === 'right') {
|
||||
return 'text-right'
|
||||
} else if (field.align === 'center') {
|
||||
return 'text-center'
|
||||
} else if (field.align === 'justify') {
|
||||
return 'text-justify'
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get the right input component options for the field/options
|
||||
*/
|
||||
inputProperties(field) {
|
||||
const inputProperties = {
|
||||
key: field.id,
|
||||
name: field.id,
|
||||
form: this.dataForm,
|
||||
label: (field.hide_field_name) ? null : field.name + ((this.shouldBeHidden) ? ' (Hidden Field)' : ''),
|
||||
color: this.form.color,
|
||||
placeholder: field.placeholder,
|
||||
help: field.help,
|
||||
helpPosition: (field.help_position) ? field.help_position : 'below_input',
|
||||
uppercaseLabels: this.form.uppercase_labels == 1 || this.form.uppercase_labels == true,
|
||||
theme: this.theme,
|
||||
maxCharLimit: (field.max_char_limit) ? parseInt(field.max_char_limit) : null,
|
||||
showCharLimit: field.show_char_limit || false,
|
||||
isDark: this.darkMode,
|
||||
locale: (this.form?.language) ? this.form.language : 'en'
|
||||
}
|
||||
|
||||
if (field.type === 'matrix') {
|
||||
inputProperties.rows = field.rows
|
||||
inputProperties.columns = field.columns
|
||||
}
|
||||
|
||||
if (field.type === 'barcode') {
|
||||
inputProperties.decoders = field.decoders
|
||||
}
|
||||
|
||||
if (['select','multi_select'].includes(field.type) && !this.isFieldRequired) {
|
||||
inputProperties.clearable = true
|
||||
}
|
||||
|
||||
if (['select', 'multi_select'].includes(field.type)) {
|
||||
inputProperties.options = (_has(field, field.type))
|
||||
? field[field.type].options.map(option => {
|
||||
return {
|
||||
name: option.name,
|
||||
value: option.name
|
||||
}
|
||||
})
|
||||
: []
|
||||
inputProperties.multiple = (field.type === 'multi_select')
|
||||
inputProperties.allowCreation = (field.allow_creation === true)
|
||||
inputProperties.searchable = (inputProperties.options.length > 4)
|
||||
} else if (field.type === 'date') {
|
||||
inputProperties.dateFormat = field.date_format
|
||||
inputProperties.timeFormat = field.time_format
|
||||
if (field.with_time) {
|
||||
inputProperties.withTime = true
|
||||
}
|
||||
if (field.date_range) {
|
||||
inputProperties.dateRange = true
|
||||
}
|
||||
if (field.disable_past_dates) {
|
||||
inputProperties.disablePastDates = true
|
||||
} else if (field.disable_future_dates) {
|
||||
inputProperties.disableFutureDates = true
|
||||
}
|
||||
} else if (field.type === 'files' || (field.type === 'url' && field.file_upload)) {
|
||||
inputProperties.multiple = (field.multiple !== undefined && field.multiple)
|
||||
inputProperties.cameraUpload = (field.camera_upload !== undefined && field.camera_upload)
|
||||
let maxFileSize = (this.form?.workspace && this.form?.workspace.max_file_size) ? this.form?.workspace?.max_file_size : 10
|
||||
if (field?.max_file_size > 0) {
|
||||
maxFileSize = Math.min(field.max_file_size, maxFileSize)
|
||||
}
|
||||
inputProperties.mbLimit = maxFileSize
|
||||
inputProperties.accept = (this.form.is_pro && field.allowed_file_types) ? field.allowed_file_types : ''
|
||||
} else if (field.type === 'rating') {
|
||||
inputProperties.numberOfStars = parseInt(field.rating_max_value) ?? 5
|
||||
} else if (field.type === 'scale') {
|
||||
inputProperties.minScale = parseFloat(field.scale_min_value) ?? 1
|
||||
inputProperties.maxScale = parseFloat(field.scale_max_value) ?? 5
|
||||
inputProperties.stepScale = parseFloat(field.scale_step_value) ?? 1
|
||||
} else if (field.type === 'slider') {
|
||||
inputProperties.minSlider = parseInt(field.slider_min_value) ?? 0
|
||||
inputProperties.maxSlider = parseInt(field.slider_max_value) ?? 50
|
||||
inputProperties.stepSlider = parseInt(field.slider_step_value) ?? 5
|
||||
} 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) {
|
||||
inputProperties.unavailableCountries = field.unavailable_countries ?? []
|
||||
} else if (field.type === 'text' && field.secret_input) {
|
||||
inputProperties.nativeType = 'password'
|
||||
} else if (field.type === 'payment') {
|
||||
inputProperties.direction = this.form.layout_rtl ? 'rtl' : 'ltr'
|
||||
inputProperties.currency = field.currency
|
||||
inputProperties.amount = field.amount
|
||||
inputProperties.oauthProviderId = field.stripe_account_id
|
||||
}
|
||||
|
||||
return inputProperties
|
||||
}
|
||||
}
|
||||
|
||||
return inputProperties
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<modal
|
||||
:show="show"
|
||||
compact-header
|
||||
;backdrop-blur="true"
|
||||
:backdrop-blur="true"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<template #title>
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ function coverPictureSrc(val) {
|
|||
try {
|
||||
// Is valid url
|
||||
new URL(val)
|
||||
} catch (_) {
|
||||
} catch {
|
||||
// Is file
|
||||
return URL.createObjectURL(val)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@
|
|||
class="w-full"
|
||||
:class="{'flex flex-wrap gap-x-4':submissionOptions.submissionMode === 'redirect'}"
|
||||
>
|
||||
<select-input
|
||||
<flat-select-input
|
||||
:form="submissionOptions"
|
||||
name="submissionMode"
|
||||
class="w-full max-w-xs"
|
||||
|
|
@ -150,7 +150,7 @@
|
|||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</select-input>
|
||||
</flat-select-input>
|
||||
<template v-if="submissionOptions.submissionMode === 'redirect'">
|
||||
<MentionInput
|
||||
name="redirect_url"
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
|
||||
import { defineComponent } from "vue"
|
||||
import QueryBuilder from "query-builder-vue-3"
|
||||
import ColumnCondition from "./ColumnCondition.vue"
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ export default {
|
|||
field.id !== this.field.id &&
|
||||
_has(field, "logic") &&
|
||||
field.logic !== null &&
|
||||
field.logic !== {}
|
||||
Object.keys(field.logic || {}).length > 0
|
||||
)
|
||||
})
|
||||
.map((field) => {
|
||||
|
|
|
|||
|
|
@ -128,7 +128,9 @@ const field = computed(() => {
|
|||
// This prevents page jumps when editing field properties
|
||||
onMounted(() => {
|
||||
if (selectedFieldIndex.value !== null) {
|
||||
workingFormStore.setPageForField(selectedFieldIndex.value)
|
||||
if (workingFormStore.structureService) {
|
||||
workingFormStore.structureService.setPageForField(selectedFieldIndex.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ const save = () => {
|
|||
.catch((error) => {
|
||||
try {
|
||||
alert.error(error.data.message)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
alert.error("An error occurred while saving the integration")
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ import { default as _has } from 'lodash/has'
|
|||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
// eslint-disable-next-line vue/require-prop-types
|
||||
|
||||
property: {
|
||||
required: true
|
||||
},
|
||||
// eslint-disable-next-line vue/require-prop-types
|
||||
|
||||
value: {
|
||||
required: true
|
||||
}
|
||||
|
|
@ -45,13 +45,13 @@ export default {
|
|||
if (this.property?.with_time) {
|
||||
try {
|
||||
return format(new Date(val), dateFormat + (timeFormat == 12 ? ' p':' HH:mm'))
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
try {
|
||||
return format(new Date(val), dateFormat)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@
|
|||
<script setup>
|
||||
import {watch, ref} from "vue"
|
||||
|
||||
// eslint-disable-next-line vue/require-prop-types
|
||||
|
||||
const props = defineProps(['user', 'showEditUserModal'])
|
||||
const emit = defineEmits(['close', 'fetchUsers'])
|
||||
|
||||
|
|
|
|||
|
|
@ -72,18 +72,16 @@
|
|||
|
||||
<div class="rounded-lg p-5 bg-gray-100 dark:bg-gray-900 mt-4">
|
||||
<open-form
|
||||
v-if="form"
|
||||
v-if="formManager"
|
||||
:theme="theme"
|
||||
:loading="false"
|
||||
:form="form"
|
||||
:fields="form.properties"
|
||||
:mode="FormMode.PREFILL"
|
||||
:form-manager="formManager"
|
||||
@submit="generateUrl"
|
||||
>
|
||||
<template #submit-btn="{ submitForm }">
|
||||
<template #submit-btn="{loading}">
|
||||
<v-button
|
||||
class="mt-2 px-8 mx-1"
|
||||
@click.prevent="submitForm"
|
||||
:loading="loading"
|
||||
@click.prevent="generateUrl"
|
||||
>
|
||||
Generate Pre-filled URL
|
||||
</v-button>
|
||||
|
|
@ -106,47 +104,69 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import ThemeBuilder from "~/lib/forms/themes/ThemeBuilder"
|
||||
import FormUrlPrefill from "../../../open/forms/components/FormUrlPrefill.vue"
|
||||
import OpenForm from "../../../open/forms/OpenForm.vue"
|
||||
import { FormMode } from "~/lib/forms/FormModeStrategy.js"
|
||||
import { useFormManager } from '~/lib/forms/composables/useFormManager'
|
||||
|
||||
export default {
|
||||
name: "UrlFormPrefill",
|
||||
components: { FormUrlPrefill, OpenForm },
|
||||
props: {
|
||||
form: { type: Object, required: true },
|
||||
extraQueryParam: { type: String, default: "" },
|
||||
},
|
||||
const props = defineProps({
|
||||
form: { type: Object, required: true },
|
||||
extraQueryParam: { type: String, default: "" },
|
||||
})
|
||||
|
||||
data: () => ({
|
||||
prefillFormData: null,
|
||||
showUrlFormPrefillModal: false,
|
||||
}),
|
||||
// State variables
|
||||
const prefillFormData = ref(null)
|
||||
const showUrlFormPrefillModal = ref(false)
|
||||
const content = ref(null)
|
||||
|
||||
computed: {
|
||||
theme () {
|
||||
return new ThemeBuilder(this.form.theme, {
|
||||
size: this.form.size,
|
||||
borderRadius: this.form.border_radius
|
||||
}).getAllComponents()
|
||||
},
|
||||
FormMode() {
|
||||
return FormMode
|
||||
// Theme computation
|
||||
const theme = computed(() => {
|
||||
return new ThemeBuilder(props.form.theme, {
|
||||
size: props.form.size,
|
||||
borderRadius: props.form.border_radius
|
||||
}).getAllComponents()
|
||||
})
|
||||
|
||||
// Set up form manager with proper mode
|
||||
let formManager = null
|
||||
const setupFormManager = () => {
|
||||
if (!props.form) return null
|
||||
|
||||
formManager = useFormManager(props.form, FormMode.PREFILL, {
|
||||
darkMode: false
|
||||
})
|
||||
formManager.initialize()
|
||||
|
||||
return formManager
|
||||
}
|
||||
|
||||
// Initialize form manager
|
||||
formManager = setupFormManager()
|
||||
|
||||
// Watch for form changes to reinitialize form manager
|
||||
watch(() => props.form, (newForm) => {
|
||||
if (newForm) {
|
||||
formManager = setupFormManager()
|
||||
} else {
|
||||
formManager = null
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Method to generate URL
|
||||
const generateUrl = () => {
|
||||
if (!formManager) return
|
||||
|
||||
const formData = formManager.data.value
|
||||
|
||||
prefillFormData.value = formData
|
||||
|
||||
nextTick().then(() => {
|
||||
if (content.value) {
|
||||
content.value.parentElement.parentElement.parentElement.scrollTop =
|
||||
content.value.offsetHeight
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
generateUrl(formData) {
|
||||
this.prefillFormData = formData
|
||||
this.$nextTick().then(() => {
|
||||
if (this.$refs.content) {
|
||||
this.$refs.content.parentElement.parentElement.parentElement.scrollTop =
|
||||
this.$refs.content.offsetHeight
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ function destroy() {
|
|||
.catch((error) => {
|
||||
try {
|
||||
alert.error(error.data.message)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
alert.error("An error occurred while disconnecting an account")
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ function disconnect() {
|
|||
.catch((error) => {
|
||||
try {
|
||||
alert.error(error.data.message)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
alert.error("An error occurred while disconnecting an account")
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export default {
|
|||
try {
|
||||
new URL(str)
|
||||
}
|
||||
catch (_) {
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
import { hash } from "~/lib/utils.js"
|
||||
import { useStorage } from "@vueuse/core"
|
||||
|
||||
export const pendingSubmission = (form) => {
|
||||
const formPendingSubmissionKey = computed(() => {
|
||||
return form
|
||||
? form.form_pending_submission_key + "-" + hash(window.location.href)
|
||||
: ""
|
||||
})
|
||||
const formPendingSubmissionTimerKey = computed(() => {
|
||||
return formPendingSubmissionKey.value + "-timer"
|
||||
})
|
||||
|
||||
const enabled = computed(() => {
|
||||
return form?.auto_save ?? false
|
||||
})
|
||||
|
||||
const set = (value) => {
|
||||
if (import.meta.server || !enabled.value) return
|
||||
useStorage(formPendingSubmissionKey.value).value =
|
||||
value === null ? value : JSON.stringify(value)
|
||||
}
|
||||
|
||||
const remove = () => {
|
||||
return set(null)
|
||||
}
|
||||
|
||||
const get = (defaultValue = {}) => {
|
||||
if (import.meta.server || !enabled.value) return
|
||||
const pendingSubmission = useStorage(formPendingSubmissionKey.value).value
|
||||
return pendingSubmission ? JSON.parse(pendingSubmission) : defaultValue
|
||||
}
|
||||
|
||||
const setSubmissionHash = (hash) => {
|
||||
set({
|
||||
...get(),
|
||||
submission_hash: hash
|
||||
})
|
||||
}
|
||||
|
||||
const getSubmissionHash = () => {
|
||||
return get()?.submission_hash ?? null
|
||||
}
|
||||
|
||||
const setTimer = (value) => {
|
||||
if (import.meta.server) return
|
||||
useStorage(formPendingSubmissionTimerKey.value).value = value
|
||||
}
|
||||
|
||||
const removeTimer = () => {
|
||||
return setTimer(null)
|
||||
}
|
||||
|
||||
const getTimer = (defaultValue = null) => {
|
||||
if (import.meta.server) return
|
||||
return useStorage(formPendingSubmissionTimerKey.value).value ?? defaultValue
|
||||
}
|
||||
|
||||
return {
|
||||
formPendingSubmissionKey,
|
||||
enabled,
|
||||
set,
|
||||
get,
|
||||
remove,
|
||||
setSubmissionHash,
|
||||
getSubmissionHash,
|
||||
setTimer,
|
||||
removeTimer,
|
||||
getTimer,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +1,124 @@
|
|||
import { opnFetch } from "./../useOpnApi.js"
|
||||
import { pendingSubmission as pendingSubmissionFunction } from "./pendingSubmission.js"
|
||||
import { watch, onBeforeUnmount, ref } from 'vue'
|
||||
import { watch, onBeforeUnmount, ref, toValue } from 'vue'
|
||||
|
||||
// Create a Map to store submission hashes for different forms
|
||||
// This map might be better managed globally or passed if needed across instances
|
||||
const submissionHashes = ref(new Map())
|
||||
|
||||
export const usePartialSubmission = (form, formData = {}) => {
|
||||
const pendingSubmission = pendingSubmissionFunction(form)
|
||||
/**
|
||||
* Composable for handling partial form submissions (auto-syncing to server).
|
||||
*
|
||||
* @param {Object} formConfig - The form configuration object (not reactive needed here, just for slug).
|
||||
* @param {import('vue').ComputedRef<Object>} formDataRef - Computed reference to the reactive form data.
|
||||
* @param {Object} pendingSubmissionService - The instantiated service from usePendingSubmission.
|
||||
*/
|
||||
export function usePartialSubmission(formConfig, formDataRef, pendingSubmissionService) {
|
||||
|
||||
let syncTimeout = null
|
||||
let dataWatcher = null
|
||||
|
||||
const getSubmissionHash = () => {
|
||||
return pendingSubmission.getSubmissionHash() ?? submissionHashes.value.get(pendingSubmission.formPendingSubmissionKey.value)
|
||||
// Prioritize hash from the pendingSubmission service (localStorage)
|
||||
const storedHash = pendingSubmissionService.getSubmissionHash()
|
||||
if (storedHash) return storedHash
|
||||
// Fallback to the in-memory map for this instance if needed (should ideally be synced)
|
||||
const key = pendingSubmissionService.formPendingSubmissionKey?.value // Use optional chaining
|
||||
return key ? submissionHashes.value.get(key) : null
|
||||
}
|
||||
|
||||
const setSubmissionHash = (hash) => {
|
||||
submissionHashes.value.set(pendingSubmission.formPendingSubmissionKey.value, hash)
|
||||
pendingSubmission.setSubmissionHash(hash)
|
||||
// Set in both localStorage (via service) and the local map
|
||||
pendingSubmissionService.setSubmissionHash(hash)
|
||||
const key = pendingSubmissionService.formPendingSubmissionKey?.value
|
||||
if (key) {
|
||||
submissionHashes.value.set(key, hash)
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedSync = () => {
|
||||
if (syncTimeout) clearTimeout(syncTimeout)
|
||||
// Clear existing timeout to reset the timer
|
||||
if (syncTimeout) {
|
||||
clearTimeout(syncTimeout)
|
||||
}
|
||||
|
||||
// Set a new timeout - increased to 2 seconds for less frequent syncing
|
||||
syncTimeout = setTimeout(() => {
|
||||
syncToServer()
|
||||
}, 1000) // 1 second debounce
|
||||
}, 2000) // 2 second debounce
|
||||
}
|
||||
|
||||
// Add a function to execute sync immediately without debouncing
|
||||
// This is used for critical moments like page unload
|
||||
const syncImmediately = () => {
|
||||
if (syncTimeout) {
|
||||
clearTimeout(syncTimeout)
|
||||
syncTimeout = null
|
||||
}
|
||||
return syncToServer()
|
||||
}
|
||||
|
||||
const syncToServer = async () => {
|
||||
const config = toValue(formConfig) // Ensure we have the latest config value
|
||||
// Check if partial submissions are enabled and if we have data
|
||||
if (!form?.enable_partial_submissions) return
|
||||
if (!config?.enable_partial_submissions) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get current form data - handle both function and direct object patterns
|
||||
const currentData = typeof formData.value?.data === 'function'
|
||||
? formData.value.data()
|
||||
: formData.value
|
||||
// Get current form data from the reactive ref
|
||||
const currentData = formDataRef.value // Directly use .value from computed ref
|
||||
|
||||
// Skip if no data or empty data
|
||||
if (!currentData || Object.keys(currentData).length === 0) return
|
||||
if (!currentData || Object.keys(currentData).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await opnFetch(`/forms/${form.slug}/answer`, {
|
||||
const response = await opnFetch(`/forms/${config.slug}/answer`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
...currentData,
|
||||
'is_partial': true,
|
||||
'submission_hash': getSubmissionHash()
|
||||
'submission_hash': getSubmissionHash() // Use the updated getter
|
||||
}
|
||||
})
|
||||
if (response.submission_hash) {
|
||||
setSubmissionHash(response.submission_hash)
|
||||
setSubmissionHash(response.submission_hash) // Use the updated setter
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to sync partial submission', error)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Add these handlers as named functions so we can remove them later
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
debouncedSync()
|
||||
// When tab becomes hidden, sync immediately
|
||||
syncImmediately()
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
debouncedSync()
|
||||
// When window loses focus, sync immediately
|
||||
syncImmediately()
|
||||
}
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
syncToServer()
|
||||
// Before page unloads, sync immediately
|
||||
syncImmediately()
|
||||
}
|
||||
|
||||
const startSync = () => {
|
||||
if (dataWatcher) return
|
||||
if (dataWatcher || import.meta.server) { // Prevent starting multiple times or on server
|
||||
return
|
||||
}
|
||||
|
||||
// Initial sync
|
||||
debouncedSync()
|
||||
|
||||
// Watch formData directly with Vue's reactivity
|
||||
// Watch formDataRef using Vue's reactivity
|
||||
dataWatcher = watch(
|
||||
formData,
|
||||
() => {
|
||||
formDataRef,
|
||||
(_newValue) => {
|
||||
debouncedSync()
|
||||
},
|
||||
{ deep: true }
|
||||
|
|
@ -93,7 +131,17 @@ export const usePartialSubmission = (form, formData = {}) => {
|
|||
}
|
||||
|
||||
const stopSync = () => {
|
||||
submissionHashes.value = new Map()
|
||||
if (import.meta.server) return
|
||||
|
||||
// Final sync before stopping if we have a hash
|
||||
if (getSubmissionHash()) {
|
||||
syncImmediately()
|
||||
}
|
||||
|
||||
const key = pendingSubmissionService.formPendingSubmissionKey?.value
|
||||
if (key) {
|
||||
submissionHashes.value.delete(key) // Clear from instance map on stop
|
||||
}
|
||||
|
||||
if (dataWatcher) {
|
||||
dataWatcher()
|
||||
|
|
@ -114,18 +162,14 @@ export const usePartialSubmission = (form, formData = {}) => {
|
|||
// Ensure cleanup when component is unmounted
|
||||
onBeforeUnmount(() => {
|
||||
stopSync()
|
||||
|
||||
// Final sync attempt before unmounting
|
||||
if(getSubmissionHash()) {
|
||||
syncToServer()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
startSync,
|
||||
stopSync,
|
||||
syncToServer,
|
||||
getSubmissionHash,
|
||||
setSubmissionHash
|
||||
syncToServer: debouncedSync, // Expose the debounced version externally
|
||||
syncImmediately, // Also expose the immediate sync for critical situations
|
||||
getSubmissionHash, // Use the getter that prioritizes localStorage
|
||||
setSubmissionHash // Use the setter that updates both
|
||||
}
|
||||
}
|
||||
|
|
@ -138,7 +138,7 @@ class Form {
|
|||
if (method.toLowerCase() === "get") {
|
||||
config.params = { ...this.data(), ...config.params }
|
||||
} else {
|
||||
config.body = { ...this.data(), ...config.data }
|
||||
config.body = { ...this.data(), ...config.data, ...config.body }
|
||||
|
||||
if (hasFiles(config.data) && !config.transformRequest) {
|
||||
config.transformRequest = [(data) => serialize(data)]
|
||||
|
|
|
|||
|
|
@ -119,21 +119,14 @@ export const useAuth = () => {
|
|||
body: { code, utm_data: utmData }
|
||||
})
|
||||
|
||||
console.log(`[useAuth] handleSocialCallback executed for provider: ${provider}`)
|
||||
console.log(`[useAuth] Checking window.opener:`, window.opener, `Is closed:`, window.opener ? window.opener.closed : 'N/A')
|
||||
|
||||
// Send message to parent window if applicable
|
||||
if (window.opener && !window.opener.closed) {
|
||||
console.log(`[useAuth] Attempting to send message ${WindowMessageTypes.OAUTH_PROVIDER_CONNECTED}:${provider} to opener`, window.opener)
|
||||
useWindowMessage(WindowMessageTypes.OAUTH_PROVIDER_CONNECTED).send(window.opener, {
|
||||
eventType: `${WindowMessageTypes.OAUTH_PROVIDER_CONNECTED}:${provider}`,
|
||||
useMessageChannel: false,
|
||||
waitForAcknowledgment: false
|
||||
})
|
||||
console.log(`[useAuth] Message supposedly sent for ${provider}`)
|
||||
} else {
|
||||
console.log('[useAuth] Not sending message: window.opener check failed.', { opener: window.opener, closed: window.opener ? window.opener.closed : 'N/A' })
|
||||
}
|
||||
}
|
||||
|
||||
return authenticateUser({
|
||||
tokenData,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export const useCheckoutUrl = (name, email, plan, yearly, currency) => {
|
|||
|
||||
// Filter out empty params
|
||||
const filteredParams = Object.fromEntries(
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
|
||||
Object.entries(params).filter(([_, value]) => value !== null && value !== undefined && value !== '')
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
import { computed, provide, inject, reactive } from 'vue'
|
||||
import { computed, reactive, readonly } from 'vue'
|
||||
import { useI18n } from '#imports'
|
||||
import { opnFetch } from '~/composables/useOpnApi.js'
|
||||
|
||||
// Symbol for injection key
|
||||
export const STRIPE_ELEMENTS_KEY = Symbol('stripe-elements')
|
||||
/**
|
||||
* Creates a Stripe elements instance with state management
|
||||
* @param {String} initialAccountId - Optional account ID to initialize with
|
||||
* @returns {Object} Stripe elements API with state and methods
|
||||
*/
|
||||
export const createStripeElements = (initialAccountId = null) => {
|
||||
// Ensure we're on the client side
|
||||
if (import.meta.server) {
|
||||
console.warn('Stripe Elements can only be initialized in the browser')
|
||||
return null
|
||||
}
|
||||
|
||||
export const createStripeElements = () => {
|
||||
// Get the translation function
|
||||
// Initialize i18n at the top level
|
||||
const { t } = useI18n()
|
||||
|
||||
// Use reactive for the state to ensure changes propagate
|
||||
|
|
@ -26,12 +35,20 @@ export const createStripeElements = () => {
|
|||
cardHolderEmail: '',
|
||||
|
||||
// Account & payment state
|
||||
stripeAccountId: null,
|
||||
stripeAccountId: initialAccountId, // Initialize with provided account ID
|
||||
intentId: null,
|
||||
showPreviewMessage: false,
|
||||
errorMessage: ''
|
||||
error: null
|
||||
})
|
||||
|
||||
// Reset state on initialization
|
||||
state.stripe = null
|
||||
state.elements = null
|
||||
state.card = null
|
||||
state.isStripeInstanceReady = false
|
||||
state.isCardElementReady = false
|
||||
state.error = null
|
||||
|
||||
// Computed properties
|
||||
const isReadyForPayment = computed(() => {
|
||||
return state.isStripeInstanceReady &&
|
||||
|
|
@ -54,45 +71,54 @@ export const createStripeElements = () => {
|
|||
state.stripe = null
|
||||
state.elements = null
|
||||
state.card = null
|
||||
state.stripeAccountId = null
|
||||
state.intentId = null
|
||||
state.showPreviewMessage = false
|
||||
state.stripeAccountId = null
|
||||
state.errorMessage = ''
|
||||
state.error = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the Stripe account ID required for connecting to the proper account
|
||||
* @param {string} formSlug - The slug of the form
|
||||
* @param {string} providerId - The OAuth provider ID
|
||||
* @param {boolean} isEditorPreview - Whether this is in editor preview mode
|
||||
* @returns {Promise<Object>} - Object containing success/error information
|
||||
* Prepares the Stripe state by fetching account details
|
||||
* @param {String} formSlug - Form slug
|
||||
* @param {String|Number} providerId - OAuth provider ID
|
||||
* @param {Boolean} isEditorPreview - Whether this is in editor preview mode
|
||||
* @returns {Promise<Object>} Result object with success and message
|
||||
*/
|
||||
const prepareStripeState = async (formSlug, providerId, isEditorPreview = false) => {
|
||||
if (!formSlug || !providerId) {
|
||||
resetStripeState()
|
||||
return { success: false, message: t('forms.payment.errors.missingFormOrProvider') }
|
||||
return { success: false, message: 'Missing form slug or OAuth provider ID' }
|
||||
}
|
||||
|
||||
// Always ensure provider ID is a string
|
||||
const providerIdStr = String(providerId)
|
||||
|
||||
resetStripeState()
|
||||
state.isLoadingAccount = true
|
||||
|
||||
try {
|
||||
console.debug('[useStripeElements] Preparing Stripe state for:', { formSlug, providerId: providerIdStr, isEditorPreview })
|
||||
|
||||
// Construct fetch options, adding providerId only for editor preview
|
||||
const fetchOptions = {}
|
||||
if (isEditorPreview) {
|
||||
fetchOptions.query = { oauth_provider_id: providerId }
|
||||
fetchOptions.query = { oauth_provider_id: providerIdStr }
|
||||
}
|
||||
|
||||
const response = await opnFetch(`/forms/${formSlug}/stripe-connect/get-account`, fetchOptions)
|
||||
console.debug('[useStripeElements] Got account response:', response)
|
||||
|
||||
if (response?.type === 'success' && response?.stripeAccount) {
|
||||
// Explicitly set the account ID in state
|
||||
state.stripeAccountId = response.stripeAccount
|
||||
// Ensure account ID is stored as string
|
||||
state.stripeAccountId = String(response.stripeAccount)
|
||||
state.isLoadingAccount = false
|
||||
|
||||
// We'll rely on the StripeElements component to create the Stripe instance
|
||||
// Don't try to create it here
|
||||
// If card is already set, mark card element as ready
|
||||
if (state.card && state.stripe) {
|
||||
state.isCardElementReady = true
|
||||
}
|
||||
|
||||
return { success: true, accountId: response.stripeAccount }
|
||||
return { success: true, accountId: String(response.stripeAccount) }
|
||||
} else {
|
||||
state.hasAccountLoadingError = true
|
||||
state.isLoadingAccount = false
|
||||
|
|
@ -101,7 +127,7 @@ export const createStripeElements = () => {
|
|||
state.showPreviewMessage = true
|
||||
}
|
||||
|
||||
state.errorMessage = response?.message || t('forms.payment.errors.failedAccountDetails')
|
||||
state.errorMessage = response?.message || 'Failed to get account details'
|
||||
return {
|
||||
success: false,
|
||||
message: state.errorMessage,
|
||||
|
|
@ -109,10 +135,11 @@ export const createStripeElements = () => {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[useStripeElements] Error preparing state:', error)
|
||||
state.hasAccountLoadingError = true
|
||||
state.isLoadingAccount = false
|
||||
|
||||
const message = error?.data?.message || t('forms.payment.errors.setupError')
|
||||
const message = error?.data?.message || 'Payment setup error'
|
||||
|
||||
if (message.includes('save the form and try again')) {
|
||||
state.showPreviewMessage = true
|
||||
|
|
@ -129,30 +156,44 @@ export const createStripeElements = () => {
|
|||
|
||||
/**
|
||||
* Sets the Stripe instance in the state
|
||||
* @param {Object} instance - The Stripe instance from vue-stripe-js
|
||||
*/
|
||||
const setStripeInstance = (instance) => {
|
||||
// Check if the instance is actually a Stripe instance by looking for known methods
|
||||
const isValidStripeInstance = instance &&
|
||||
typeof instance === 'object' &&
|
||||
typeof instance.confirmCardPayment === 'function' &&
|
||||
typeof instance.createToken === 'function'
|
||||
|
||||
if (instance && isValidStripeInstance) {
|
||||
// Only set if the instance is different to avoid unnecessary updates
|
||||
if (state.stripe !== instance) {
|
||||
console.debug('[useStripeElements] Setting Stripe instance:', {
|
||||
hasInstance: !!instance
|
||||
})
|
||||
|
||||
try {
|
||||
if (!instance) {
|
||||
console.warn('[useStripeElements] No Stripe instance provided')
|
||||
return
|
||||
}
|
||||
|
||||
const isValidStripeInstance = instance &&
|
||||
typeof instance === 'object' &&
|
||||
typeof instance.confirmCardPayment === 'function' &&
|
||||
typeof instance.createToken === 'function'
|
||||
|
||||
if (isValidStripeInstance) {
|
||||
console.debug('[useStripeElements] Valid Stripe instance detected')
|
||||
state.stripe = instance
|
||||
state.isStripeInstanceReady = true
|
||||
|
||||
// If we have all required components, ensure card element is ready
|
||||
if (state.card && state.stripeAccountId) {
|
||||
console.debug('[useStripeElements] Card element ready with account')
|
||||
state.isCardElementReady = true
|
||||
}
|
||||
} else {
|
||||
console.warn('[useStripeElements] Invalid Stripe instance provided')
|
||||
state.isStripeInstanceReady = false
|
||||
}
|
||||
} else {
|
||||
state.stripe = null
|
||||
state.isStripeInstanceReady = false
|
||||
} catch (error) {
|
||||
console.error('[useStripeElements] Error setting Stripe instance:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Elements instance in the state
|
||||
* @param {Object} elementsInstance - The Elements instance from vue-stripe-js
|
||||
*/
|
||||
const setElementsInstance = (elementsInstance) => {
|
||||
if (elementsInstance) {
|
||||
|
|
@ -162,7 +203,6 @@ export const createStripeElements = () => {
|
|||
|
||||
/**
|
||||
* Sets the Card Element in the state
|
||||
* @param {Object} cardElement - The Card Element instance
|
||||
*/
|
||||
const setCardElement = (cardElement) => {
|
||||
if (cardElement) {
|
||||
|
|
@ -176,7 +216,6 @@ export const createStripeElements = () => {
|
|||
|
||||
/**
|
||||
* Sets the billing details in the state
|
||||
* @param {Object} details - The billing details object {name, email}
|
||||
*/
|
||||
const setBillingDetails = ({ name, email }) => {
|
||||
if (name !== undefined) state.cardHolderName = name
|
||||
|
|
@ -185,12 +224,8 @@ export const createStripeElements = () => {
|
|||
|
||||
/**
|
||||
* Processes a payment using the Stripe API
|
||||
* @param {string} formSlug - The slug of the form
|
||||
* @param {boolean} isRequired - Whether payment is required to proceed
|
||||
* @returns {Promise<Object>} - Object containing payment result or error
|
||||
*/
|
||||
const processPayment = async (formSlug, isRequired = true) => {
|
||||
// Check if Stripe is fully initialized
|
||||
if (!isReadyForPayment.value) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -198,7 +233,6 @@ export const createStripeElements = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Check if the stripe instance exists
|
||||
if (!state.stripe) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -206,7 +240,6 @@ export const createStripeElements = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Additional validation for card
|
||||
if (!state.card) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -214,7 +247,6 @@ export const createStripeElements = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Check if payment is required but card is empty
|
||||
if (isRequired && state.card._empty) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -222,9 +254,7 @@ export const createStripeElements = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Only validate billing details if payment is required or card has data
|
||||
if (isRequired || !state.card._empty) {
|
||||
// Validate card holder name
|
||||
if (!state.cardHolderName) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -232,7 +262,6 @@ export const createStripeElements = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate billing email
|
||||
if (!state.cardHolderEmail) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -240,7 +269,6 @@ export const createStripeElements = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(state.cardHolderEmail)) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -250,13 +278,11 @@ export const createStripeElements = () => {
|
|||
}
|
||||
|
||||
try {
|
||||
// Get payment intent from server
|
||||
const responseIntent = await opnFetch('/forms/' + formSlug + '/stripe-connect/payment-intent')
|
||||
|
||||
if (responseIntent?.type === 'success') {
|
||||
const intentSecret = responseIntent?.intent?.secret
|
||||
|
||||
// Confirm card payment with Stripe
|
||||
const result = await state.stripe.confirmCardPayment(intentSecret, {
|
||||
payment_method: {
|
||||
card: state.card,
|
||||
|
|
@ -268,7 +294,6 @@ export const createStripeElements = () => {
|
|||
receipt_email: state.cardHolderEmail,
|
||||
})
|
||||
|
||||
// Store payment intent ID on success
|
||||
if (result?.paymentIntent?.status === 'succeeded') {
|
||||
state.intentId = result.paymentIntent.id
|
||||
}
|
||||
|
|
@ -284,7 +309,6 @@ export const createStripeElements = () => {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Include more details about the error
|
||||
const errorMessage = error?.message || t('forms.payment.errors.processingFailed')
|
||||
const errorType = error?.type || 'unknown'
|
||||
const errorCode = error?.code || 'unknown'
|
||||
|
|
@ -300,38 +324,54 @@ export const createStripeElements = () => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the intent ID
|
||||
* @param {String} intentId - The payment intent ID
|
||||
*/
|
||||
const setIntentId = (intentId) => {
|
||||
console.debug('[useStripeElements] Setting intentId:', intentId)
|
||||
if (intentId && typeof intentId === 'string' && intentId.startsWith('pi_')) {
|
||||
state.intentId = intentId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Stripe Account ID in the state
|
||||
* @param {String|Number} accountId - The Stripe account ID
|
||||
*/
|
||||
const setAccountId = (accountId) => {
|
||||
if (accountId) {
|
||||
// Always convert to string - Stripe.js requires a string account ID
|
||||
const accountIdStr = String(accountId)
|
||||
console.debug('[useStripeElements] Setting Stripe account ID:', accountIdStr)
|
||||
state.stripeAccountId = accountIdStr
|
||||
|
||||
// If we have all required components, ensure card element is ready
|
||||
if (state.card && state.stripe) {
|
||||
state.isCardElementReady = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stripeElements = {
|
||||
state,
|
||||
// Expose readonly state to prevent mutations outside of proper methods
|
||||
state: readonly(state),
|
||||
|
||||
// Read-only computed values
|
||||
isReadyForPayment,
|
||||
isCardPopulated,
|
||||
processPayment,
|
||||
|
||||
// Methods
|
||||
resetStripeState,
|
||||
prepareStripeState,
|
||||
processPayment,
|
||||
setStripeInstance,
|
||||
setElementsInstance,
|
||||
setCardElement,
|
||||
setBillingDetails
|
||||
setBillingDetails,
|
||||
setIntentId,
|
||||
setAccountId
|
||||
}
|
||||
|
||||
// Return the API
|
||||
return stripeElements
|
||||
}
|
||||
|
||||
// Use this in the provider component (OpenForm)
|
||||
export const provideStripeElements = () => {
|
||||
const stripeElements = createStripeElements()
|
||||
|
||||
// Provide the entire stripeElements object to ensure reactivity
|
||||
provide(STRIPE_ELEMENTS_KEY, stripeElements)
|
||||
|
||||
return stripeElements
|
||||
}
|
||||
|
||||
// Use this in consumer components (PaymentInput)
|
||||
export const useStripeElements = () => {
|
||||
const stripeElements = inject(STRIPE_ELEMENTS_KEY)
|
||||
if (!stripeElements) {
|
||||
console.error('stripeElements was not provided. Make sure to call provideStripeElements in a parent component')
|
||||
}
|
||||
return stripeElements
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
const globals = require("globals")
|
||||
const js = require("@eslint/js")
|
||||
const pluginVue = require("eslint-plugin-vue")
|
||||
|
||||
module.exports = [
|
||||
js.configs.recommended,
|
||||
{ ignores: [".nuxt/**", "node_modules/**", "dist/**"] },
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,vue}"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
parser: require("vue-eslint-parser"),
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module"
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
vue: pluginVue
|
||||
},
|
||||
rules: {
|
||||
"vue/require-default-prop": "off",
|
||||
"vue/no-mutating-props": "off",
|
||||
semi: ["error", "never"],
|
||||
"vue/no-v-html": "off",
|
||||
"prefer-rest-params": "off",
|
||||
"vue/valid-template-root": "off",
|
||||
"no-undef": "off",
|
||||
"no-unused-vars": ["error", {
|
||||
"argsIgnorePattern": "^_",
|
||||
}],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
|
@ -34,7 +34,7 @@ const hexToHSL = (hex) => {
|
|||
}
|
||||
|
||||
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) }
|
||||
} catch (error) {
|
||||
} catch {
|
||||
console.error('Invalid HEX color', hex)
|
||||
return { h: 0, s: 0, l: 0 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -290,14 +290,14 @@ function textConditionMet(propertyCondition, value) {
|
|||
try {
|
||||
const regex = new RegExp(propertyCondition.value)
|
||||
return regex.test(value)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
case 'does_not_match_regex':
|
||||
try {
|
||||
const regex = new RegExp(propertyCondition.value)
|
||||
return !regex.test(value)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ export function createFormModeStrategy(mode) {
|
|||
admin: {
|
||||
allowDragging: false,
|
||||
showAdminControls: false,
|
||||
isEditingMode: false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +77,6 @@ export function createFormModeStrategy(mode) {
|
|||
// Editing submission - same validation as LIVE mode, but show hidden fields
|
||||
// This ensures edit mode behaves like live mode for validation
|
||||
strategy.display.showHiddenFields = true
|
||||
strategy.admin.isEditingMode = true
|
||||
break
|
||||
|
||||
case FormMode.TEST:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
import { toValue } from 'vue'
|
||||
import { opnFetch } from '~/composables/useOpnApi.js'
|
||||
|
||||
/**
|
||||
* @fileoverview Composable for initializing form data, with complete handling of
|
||||
* form state persistence, URL parameters, and default values.
|
||||
*/
|
||||
export function useFormInitialization(formConfig, form, pendingSubmission) {
|
||||
/**
|
||||
* Applies URL parameters to the form data.
|
||||
* @param {URLSearchParams} params - The URL search parameters.
|
||||
*/
|
||||
const applyUrlParameters = (params) => {
|
||||
if (!params) return
|
||||
|
||||
// First, handle regular parameters
|
||||
params.forEach((value, key) => {
|
||||
// Skip array parameters for now
|
||||
if (key.endsWith('[]')) return
|
||||
|
||||
try {
|
||||
// Try to parse JSON if the value starts with '{'
|
||||
const parsedValue = (typeof value === 'string' && value.startsWith('{'))
|
||||
? JSON.parse(value)
|
||||
: value
|
||||
|
||||
form[key] = parsedValue
|
||||
} catch {
|
||||
// If parsing fails, use the original value
|
||||
form[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
// Handle array parameters (key[])
|
||||
const paramKeys = [...new Set([...params.keys()])]
|
||||
paramKeys.forEach(key => {
|
||||
if (key.endsWith('[]')) {
|
||||
const arrayValues = params.getAll(key)
|
||||
if (arrayValues.length > 0) {
|
||||
const baseKey = key.slice(0, -2)
|
||||
form[baseKey] = arrayValues
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies default data to form fields that don't already have values.
|
||||
* @param {Object} defaultData - Default data object.
|
||||
*/
|
||||
const applyDefaultValues = (defaultData) => {
|
||||
if (!defaultData || Object.keys(defaultData).length === 0) return
|
||||
|
||||
for (const key in defaultData) {
|
||||
if (Object.hasOwnProperty.call(defaultData, key) && form[key] === undefined) {
|
||||
form[key] = defaultData[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates special fields like dates with today's date if configured.
|
||||
* @param {Array} fields - Form fields
|
||||
*/
|
||||
const updateSpecialFields = () => {
|
||||
formConfig.value.properties.forEach(field => {
|
||||
// Handle date fields with prefill_today
|
||||
if (field.type === 'date' && field.prefill_today) {
|
||||
form[field.id] = new Date().toISOString()
|
||||
}
|
||||
// Handle matrix fields with prefill data
|
||||
else if (field.type === 'matrix' && !form[field.id] && field.prefill) {
|
||||
form[field.id] = {...field.prefill}
|
||||
} else if (field.id && !form[field.id]) {
|
||||
form[field.id] = field.prefill
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to load form data from an existing submission.
|
||||
* @param {String} submissionId - ID of the submission to load
|
||||
* @returns {Promise<Boolean>} - Whether loading was successful
|
||||
*/
|
||||
const tryLoadFromSubmissionId = async (submissionId) => {
|
||||
const submissionIdValue = toValue(submissionId)
|
||||
if (!submissionIdValue) return false
|
||||
const config = toValue(formConfig) // Get the form config value
|
||||
const slug = config?.slug // Extract the slug
|
||||
|
||||
if (!slug) {
|
||||
console.error('Cannot load submission: Form slug is missing from config.')
|
||||
form.reset() // Reset if config is invalid
|
||||
return false
|
||||
}
|
||||
|
||||
// Use the correct route format: /forms/{slug}/submissions/{submission_id}
|
||||
return opnFetch(`/forms/${slug}/submissions/${submissionIdValue}`)
|
||||
.then(submissionData => {
|
||||
if (submissionData.data) {
|
||||
form.resetAndFill(submissionData.data)
|
||||
return true
|
||||
} else {
|
||||
console.warn(`Submission ${submissionIdValue} for form ${slug} loaded but returned no data.`)
|
||||
form.reset()
|
||||
return false
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Error loading submission ${submissionIdValue} for form ${slug}:`, error)
|
||||
form.reset()
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to load form data from pendingSubmission in localStorage.
|
||||
* @returns {Boolean} - Whether loading was successful
|
||||
*/
|
||||
const tryLoadFromPendingSubmission = () => {
|
||||
// Skip on server or if pendingSubmission is not available
|
||||
if (import.meta.server || !pendingSubmission) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if auto-save is enabled for this form
|
||||
if (!pendingSubmission.enabled?.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get the saved data
|
||||
const pendingData = pendingSubmission.get()
|
||||
|
||||
if (!pendingData || Object.keys(pendingData).length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
form.resetAndFill(pendingData)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Main method to initialize the form data.
|
||||
* Follows a clear priority order:
|
||||
* 1. Load from submission ID (if provided)
|
||||
* 2. Load from pendingSubmission (localStorage) - client-side only
|
||||
* 3. Apply URL parameters
|
||||
* 4. Apply default values for fields
|
||||
*
|
||||
* @param {Object} options - Initialization options
|
||||
* @param {String} [options.submissionId] - ID of submission to load
|
||||
* @param {URLSearchParams} [options.urlParams] - URL parameters
|
||||
* @param {Object} [options.defaultData] - Default data to apply
|
||||
* @param {Array} [options.fields] - Form fields for special handling
|
||||
*/
|
||||
const initialize = async (options = {}) => {
|
||||
const config = toValue(formConfig)
|
||||
|
||||
// 1. Reset form state
|
||||
form.reset()
|
||||
form.errors.clear()
|
||||
|
||||
// 2. Try loading from submission ID
|
||||
if (options.submissionId) {
|
||||
const loaded = await tryLoadFromSubmissionId(options.submissionId)
|
||||
if (loaded) return // Exit if loaded successfully
|
||||
}
|
||||
|
||||
// 3. Try loading from pendingSubmission
|
||||
if (tryLoadFromPendingSubmission()) {
|
||||
updateSpecialFields()
|
||||
return // Exit if loaded successfully
|
||||
}
|
||||
|
||||
// 4. Start with empty form data
|
||||
const formData = {}
|
||||
|
||||
// 5. Apply URL parameters
|
||||
if (options.urlParams) {
|
||||
applyUrlParameters(options.urlParams)
|
||||
}
|
||||
|
||||
// 6. Apply special field handling
|
||||
updateSpecialFields()
|
||||
|
||||
// 7. Apply default data from config or options
|
||||
const defaultData = options.defaultData || config?.default_data
|
||||
if (defaultData) {
|
||||
for (const key in defaultData) {
|
||||
if (!formData[key]) { // Only if not already set
|
||||
formData[key] = defaultData[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Fill the form with the collected data
|
||||
if (Object.keys(formData).length > 0) {
|
||||
form.resetAndFill(formData)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
initialize,
|
||||
applyUrlParameters,
|
||||
applyDefaultValues
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
import { reactive, computed, ref, toValue, onBeforeUnmount } from 'vue'
|
||||
import { useForm } from '~/composables/useForm.js' // Assuming useForm handles vForm setup
|
||||
import { FormMode, createFormModeStrategy } from '../FormModeStrategy'
|
||||
import { useFormStructure } from './useFormStructure'
|
||||
import { useFormInitialization } from './useFormInitialization'
|
||||
import { useFormValidation } from './useFormValidation'
|
||||
import { useFormSubmission } from './useFormSubmission'
|
||||
import { useFormPayment } from './useFormPayment'
|
||||
import { useFormTimer } from './useFormTimer'
|
||||
import { usePendingSubmission } from '~/lib/forms/composables/usePendingSubmission.js'
|
||||
import { usePartialSubmission } from '~/composables/forms/usePartialSubmission.js'
|
||||
import { useIsIframe } from '~/composables/useIsIframe'
|
||||
import { useAmplitude } from '~/composables/useAmplitude'
|
||||
import { useConfetti } from '~/composables/useConfetti'
|
||||
import { cloneDeep } from 'lodash'
|
||||
|
||||
/**
|
||||
* @fileoverview Main orchestrator composable for form operations.
|
||||
* Initializes and coordinates various form composables (Structure, Init, Validation, etc.)
|
||||
* based on the provided form configuration and mode.
|
||||
*/
|
||||
export function useFormManager(initialFormConfig, mode = FormMode.LIVE, options = {}) {
|
||||
// --- Reactive State ---
|
||||
const config = ref(initialFormConfig) // Use ref for potentially replaceable config
|
||||
const form = useForm() // Core vForm instance
|
||||
const strategy = computed(() => createFormModeStrategy(mode)) // Strategy based on mode
|
||||
|
||||
// Use the passed darkMode ref if it's a ref, otherwise create a new ref
|
||||
const darkMode = options.darkMode && typeof options.darkMode === 'object' && 'value' in options.darkMode
|
||||
? options.darkMode
|
||||
: ref(options.darkMode || false)
|
||||
|
||||
const state = reactive({
|
||||
currentPage: 0,
|
||||
isSubmitted: false,
|
||||
isProcessing: false, // Unified flag for async ops
|
||||
})
|
||||
|
||||
// --- Initialize services that depend on config and form data ---
|
||||
// Create a reactive reference to the form data for dependent composables to watch
|
||||
const formDataRef = computed(() => form.data())
|
||||
|
||||
// Instantiate pending submission service (handles localStorage saving)
|
||||
const pendingSubmissionService = usePendingSubmission(config, formDataRef)
|
||||
|
||||
// Instantiate partial submission service (handles server auto-sync)
|
||||
const partialSubmissionService = usePartialSubmission(config, formDataRef, pendingSubmissionService)
|
||||
|
||||
// --- Instantiate Other Composables (Services) ---
|
||||
const timer = useFormTimer(pendingSubmissionService)
|
||||
const initialization = useFormInitialization(config, form, pendingSubmissionService)
|
||||
const structure = useFormStructure(config, state, form)
|
||||
const validation = useFormValidation(config, form, state)
|
||||
const payment = useFormPayment(config, form)
|
||||
const submission = useFormSubmission(config, form)
|
||||
|
||||
/**
|
||||
* Initializes the form: loads data, resets state, starts timer.
|
||||
* @param {Object} options - Initialization options (passed to useFormInitialization).
|
||||
*/
|
||||
const initialize = async (options = {}) => {
|
||||
state.isProcessing = true
|
||||
state.isSubmitted = false
|
||||
state.currentPage = 0
|
||||
|
||||
await initialization.initialize({
|
||||
...options
|
||||
})
|
||||
|
||||
timer.reset()
|
||||
timer.start()
|
||||
|
||||
// Start partial submission sync if enabled
|
||||
if (import.meta.client && config.value.enable_partial_submissions) {
|
||||
partialSubmissionService.startSync()
|
||||
}
|
||||
|
||||
state.isProcessing = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the next page, handling validation and payment intent creation.
|
||||
*/
|
||||
const nextPage = async () => {
|
||||
if (state.isProcessing) return false
|
||||
state.isProcessing = true
|
||||
|
||||
try {
|
||||
const currentPageFields = structure.getPageFields(state.currentPage)
|
||||
// Use computed isLastPage directly from structure composable
|
||||
const isCurrentlyLastPage = structure.isLastPage.value
|
||||
|
||||
// 1. Validate current page
|
||||
await validation.validateCurrentPage(currentPageFields, strategy.value)
|
||||
|
||||
// 2. Process payment (Create Payment Intent if applicable)
|
||||
const paymentBlock = structure.currentPagePaymentBlock.value
|
||||
if (paymentBlock) {
|
||||
// In editor/test mode (not LIVE), skip payment validation
|
||||
const isPaymentRequired = mode === FormMode.LIVE ? !!paymentBlock.required : false
|
||||
|
||||
// Pass required refs if Stripe needs them now (unlikely for just intent creation)
|
||||
const paymentResult = await payment.processPayment(paymentBlock, isPaymentRequired)
|
||||
if (!paymentResult.success) {
|
||||
throw paymentResult.error || new Error('Payment intent creation failed')
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Move to the next page if not the last
|
||||
if (!isCurrentlyLastPage) {
|
||||
state.currentPage++
|
||||
}
|
||||
state.isProcessing = false
|
||||
return true
|
||||
|
||||
} catch {
|
||||
// Use validation composable's failure handler
|
||||
validation.onValidationFailure({
|
||||
fieldGroups: structure.fieldGroups.value, // Pass reactive groups
|
||||
setPageIndexCallback: (index) => { state.currentPage = index },
|
||||
timerService: timer // Pass the timer composable instance
|
||||
})
|
||||
state.isProcessing = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Navigates to the previous page */
|
||||
const previousPage = () => {
|
||||
if (state.currentPage > 0 && !state.isProcessing) {
|
||||
state.currentPage--
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts the final form submission.
|
||||
* Handles timer stop, final validation, captcha, and submission call.
|
||||
* @param {Object} submitOptions - Optional extra data for submission.
|
||||
* @returns {Promise<Object>} Result from submission composable.
|
||||
*/
|
||||
const submit = async (submitOptions = {}) => {
|
||||
if (state.isProcessing) {
|
||||
return Promise.reject('Processing')
|
||||
}
|
||||
state.isProcessing = true
|
||||
|
||||
try {
|
||||
// Stop partial submission sync during submission if enabled
|
||||
if (!import.meta.server && toValue(config).enable_partial_submissions) {
|
||||
partialSubmissionService.stopSync() // This will sync immediately before stopping
|
||||
}
|
||||
|
||||
// 1. Stop Timer & Get Time
|
||||
timer.stop()
|
||||
const completionTime = timer.getCompletionTime()
|
||||
|
||||
// 2. Process payment if applicable
|
||||
const paymentBlock = structure.currentPagePaymentBlock.value
|
||||
if (paymentBlock) {
|
||||
|
||||
// In editor/test mode (not LIVE), skip payment validation
|
||||
const isPaymentRequired = mode === FormMode.LIVE ? !!paymentBlock.required : false
|
||||
|
||||
const paymentResult = await payment.processPayment(paymentBlock, isPaymentRequired)
|
||||
|
||||
if (!paymentResult.success) {
|
||||
// If payment was skipped because it's not required, we continue
|
||||
if (!paymentResult.skipped) {
|
||||
// Payment error - don't proceed with submission
|
||||
state.isProcessing = false
|
||||
throw new Error(paymentResult.error || 'Payment failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Get submission hash from partialSubmission if enabled
|
||||
let submissionHash = null
|
||||
if (!import.meta.server && toValue(config).enable_partial_submissions) {
|
||||
submissionHash = partialSubmissionService.getSubmissionHash()
|
||||
}
|
||||
|
||||
// 4. Perform Submission (using submission composable)
|
||||
const submissionResult = await submission.submit({
|
||||
formModeStrategy: strategy.value,
|
||||
completionTime: completionTime,
|
||||
submissionHash: submissionHash,
|
||||
...submitOptions
|
||||
})
|
||||
|
||||
// 5. Update State on Success
|
||||
state.isSubmitted = true
|
||||
state.isProcessing = false
|
||||
|
||||
// 6. Play confetti if enabled in config
|
||||
if (import.meta.client && toValue(config).confetti_on_submission) {
|
||||
useConfetti().play()
|
||||
}
|
||||
|
||||
// 7. Clear pending submission data on successful submit
|
||||
pendingSubmissionService?.clear()
|
||||
|
||||
// 8. Handle amplitude logging
|
||||
if (import.meta.client) {
|
||||
const amplitude = useAmplitude()
|
||||
amplitude.logEvent('form_submission', {
|
||||
workspace_id: toValue(config).workspace_id,
|
||||
form_id: toValue(config).id
|
||||
})
|
||||
}
|
||||
|
||||
// 9. Handle postMessage communication for iframe integration
|
||||
if (import.meta.client) {
|
||||
const isIframe = useIsIframe()
|
||||
const formConfig = toValue(config)
|
||||
const payload = cloneDeep({
|
||||
type: 'form-submitted',
|
||||
form: {
|
||||
slug: formConfig.slug,
|
||||
id: formConfig.id,
|
||||
redirect_target_url: (formConfig.is_pro && submissionResult?.redirect && submissionResult?.redirect_url)
|
||||
? submissionResult.redirect_url
|
||||
: null
|
||||
},
|
||||
submission_data: form.data(),
|
||||
completion_time: completionTime
|
||||
})
|
||||
|
||||
// Send message to parent if in iframe
|
||||
if (isIframe) {
|
||||
window.parent.postMessage(payload, '*')
|
||||
}
|
||||
// Also send to current window for potential internal listeners
|
||||
window.postMessage(payload, '*')
|
||||
}
|
||||
|
||||
// 10. Handle redirect if server response includes redirect info
|
||||
if (import.meta.client && submissionResult?.redirect && submissionResult?.redirect_url) {
|
||||
window.location.href = submissionResult.redirect_url
|
||||
}
|
||||
|
||||
return submissionResult // Return result from submission composable
|
||||
|
||||
} catch (error) {
|
||||
// Restart partial submission sync if there was an error and it's enabled
|
||||
if (!import.meta.server && toValue(config).enable_partial_submissions) {
|
||||
partialSubmissionService.startSync()
|
||||
}
|
||||
|
||||
// Handle validation or submission errors using validation composable's handler
|
||||
validation.onValidationFailure({
|
||||
fieldGroups: structure.fieldGroups.value,
|
||||
setPageIndexCallback: (index) => { state.currentPage = index },
|
||||
timerService: timer
|
||||
})
|
||||
state.isProcessing = false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Resets the form to its initial state for refilling. */
|
||||
const restart = async () => {
|
||||
state.isSubmitted = false
|
||||
state.currentPage = 0
|
||||
state.isProcessing = false
|
||||
form.reset() // Reset vForm data
|
||||
form.errors.clear() // Clear vForm errors
|
||||
timer.reset() // Reset timer via composable
|
||||
timer.start() // Restart timer
|
||||
|
||||
// Restart partial submission if enabled
|
||||
if (!import.meta.server && toValue(config).enable_partial_submissions) {
|
||||
partialSubmissionService.stopSync() // This will sync immediately before stopping
|
||||
partialSubmissionService.startSync() // Start fresh sync
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up when component using the manager is unmounted
|
||||
if (import.meta.client) {
|
||||
onBeforeUnmount(() => {
|
||||
if (toValue(config).enable_partial_submissions) {
|
||||
partialSubmissionService.stopSync()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Exposed API ---
|
||||
return {
|
||||
// Reactive State & Config
|
||||
state, // Core state (currentPage, isProcessing, isSubmitted)
|
||||
config, // Form configuration (ref)
|
||||
form, // The vForm instance (from useForm)
|
||||
strategy, // Current mode strategy (computed)
|
||||
pendingSubmission: pendingSubmissionService, // Expose pendingSubmission service
|
||||
partialSubmission: partialSubmissionService, // Expose partialSubmission service with debounced sync
|
||||
|
||||
// UI-related properties
|
||||
darkMode, // Dark mode setting
|
||||
setDarkMode: (isDark) => { darkMode.value = isDark }, // Method to update dark mode
|
||||
|
||||
// Composables (Expose if direct access needed, often not necessary)
|
||||
structure,
|
||||
payment, // Expose payment service
|
||||
|
||||
// Core Methods
|
||||
initialize,
|
||||
nextPage,
|
||||
previousPage,
|
||||
submit,
|
||||
restart,
|
||||
|
||||
// Convenience Computed Getters for vForm state
|
||||
data: computed(() => form.data()),
|
||||
errors: computed(() => form.errors),
|
||||
isDirty: computed(() => form.isDirty),
|
||||
busy: computed(() => form.busy), // Or potentially combine with state.isProcessing
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
import { toValue, ref } from 'vue'
|
||||
import { createStripeElements } from '~/composables/useStripeElements'
|
||||
import { opnFetch } from '~/composables/useOpnApi.js'
|
||||
// Assume Stripe is loaded globally or via another mechanism if needed client-side
|
||||
// For server-side/Nuxt API routes, import Stripe library properly.
|
||||
|
||||
/**
|
||||
* @fileoverview Composable for handling payment processing, currently focused on Stripe.
|
||||
*/
|
||||
export function useFormPayment(formConfig, form) {
|
||||
const stripeElements = ref(null)
|
||||
|
||||
/**
|
||||
* Gets payment-related data for a specific payment block
|
||||
* @param {Object} paymentBlock - The payment field configuration
|
||||
* @returns {Object|null} Payment data including stripeElements or null if not applicable
|
||||
*/
|
||||
const getPaymentData = (paymentBlock) => {
|
||||
if (!import.meta.client || !paymentBlock || paymentBlock.type !== 'payment') return null
|
||||
|
||||
// Create Stripe elements if needed and this is a Stripe payment
|
||||
if (paymentBlock.provider === 'stripe' || !paymentBlock.provider) {
|
||||
// Ensure account ID is a string (Stripe.js requires a string)
|
||||
const accountId = paymentBlock.stripe_account_id ? String(paymentBlock.stripe_account_id) : null
|
||||
|
||||
if (!stripeElements.value) {
|
||||
// Create the Stripe elements with the account ID
|
||||
stripeElements.value = createStripeElements(accountId)
|
||||
} else if (stripeElements.value && accountId) {
|
||||
// Update the account ID if the instance already exists
|
||||
// Use the proper setter method to avoid readonly errors
|
||||
if (typeof stripeElements.value.setAccountId === 'function') {
|
||||
stripeElements.value.setAccountId(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stripeElements: stripeElements.value,
|
||||
oauthProviderId: accountId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a payment intent with the Stripe API using a POST request.
|
||||
* @param {Number} amount - The amount to charge in cents.
|
||||
* @param {String} currency - The currency code (e.g., 'usd').
|
||||
* @param {String} description - A description for the payment.
|
||||
* @returns {Promise<Object>} The result of creating the payment intent.
|
||||
*/
|
||||
const _createPaymentIntent = async (_amount, _currency, _description) => {
|
||||
if (!import.meta.client) {
|
||||
return { success: false, error: 'Client-side only operation' }
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
// Get form slug from config
|
||||
const config = toValue(formConfig)
|
||||
const formSlug = config.slug
|
||||
|
||||
if (!formSlug) {
|
||||
console.error('Missing form slug in config')
|
||||
return { success: false, error: 'Invalid form configuration' }
|
||||
}
|
||||
|
||||
// Construct the URL (no query params needed for POST)
|
||||
const url = `/forms/${formSlug}/stripe-connect/payment-intent`
|
||||
|
||||
// Use opnFetch with POST method and an empty body
|
||||
const response = await opnFetch(url, {
|
||||
method: 'POST',
|
||||
body: {}
|
||||
})
|
||||
|
||||
// Handle response structure with type and intent fields
|
||||
if (response?.type === 'success' && response?.intent?.secret) {
|
||||
return {
|
||||
success: true,
|
||||
client_secret: response.intent.secret,
|
||||
intentId: response.intent.id
|
||||
}
|
||||
}
|
||||
|
||||
// Handle error response
|
||||
return {
|
||||
success: false,
|
||||
error: response?.message || 'Could not create payment: Invalid response from server'
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Failed to create payment'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms the Stripe payment on the client-side using Stripe.js.
|
||||
* @param {String} clientSecret - The Stripe client secret.
|
||||
* @param {String} paymentBlockId - The ID of the payment block for setting errors.
|
||||
* @returns {Promise<Object>} The result of the payment confirmation.
|
||||
*/
|
||||
const _confirmStripePayment = async (clientSecret, paymentBlockId) => {
|
||||
if (!import.meta.client) {
|
||||
return { success: false, error: 'Client-side only operation' }
|
||||
}
|
||||
|
||||
if (!stripeElements.value || !stripeElements.value.state) {
|
||||
return { success: false, error: 'Stripe elements not initialized' }
|
||||
}
|
||||
|
||||
const state = stripeElements.value.state
|
||||
const { stripe, card } = state
|
||||
|
||||
if (!stripe || !card) {
|
||||
const error = 'Stripe or card element not available'
|
||||
if (paymentBlockId) form.errors.set(paymentBlockId, error)
|
||||
return { success: false, error }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await stripe.confirmCardPayment(clientSecret, {
|
||||
payment_method: {
|
||||
card: card,
|
||||
billing_details: {
|
||||
name: state.cardHolderName || '',
|
||||
email: state.cardHolderEmail || ''
|
||||
}
|
||||
},
|
||||
receipt_email: state.cardHolderEmail
|
||||
})
|
||||
|
||||
// Check for errors
|
||||
if (result.error) {
|
||||
console.error('Payment confirmation error:', result.error)
|
||||
const errorMessage = result.error.message || 'Payment failed. Please try again.'
|
||||
|
||||
if (paymentBlockId) {
|
||||
form.errors.set(paymentBlockId, errorMessage)
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
code: result.error.code,
|
||||
type: result.error.type
|
||||
}
|
||||
}
|
||||
|
||||
// Handle payment intent status
|
||||
if (result.paymentIntent) {
|
||||
const status = result.paymentIntent.status
|
||||
const intentId = result.paymentIntent.id
|
||||
|
||||
// Store successful payment intent ID
|
||||
if (status === 'succeeded' || status === 'processing') {
|
||||
// Update form data with payment information
|
||||
const updateData = {}
|
||||
updateData['stripe_payment_intent_id'] = intentId
|
||||
updateData['payment_status'] = status
|
||||
|
||||
// Update payment block field with intent ID
|
||||
if (paymentBlockId) {
|
||||
updateData[paymentBlockId] = intentId
|
||||
}
|
||||
|
||||
// Update form data
|
||||
form.update(updateData)
|
||||
|
||||
// Also update the Stripe state
|
||||
if (state.intentId !== intentId && stripeElements.value.setIntentId) {
|
||||
stripeElements.value.setIntentId(intentId)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
paymentIntent: result.paymentIntent,
|
||||
status: status,
|
||||
intentId: intentId
|
||||
}
|
||||
} else {
|
||||
// Payment intent exists but status is not successful
|
||||
const failMessage = `Payment failed with status: ${status}`
|
||||
console.error(failMessage)
|
||||
|
||||
if (paymentBlockId) {
|
||||
form.errors.set(paymentBlockId, failMessage)
|
||||
}
|
||||
|
||||
return { success: false, error: failMessage }
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, something unexpected happened
|
||||
const unexpectedError = 'Payment failed with an unexpected error'
|
||||
if (paymentBlockId) {
|
||||
form.errors.set(paymentBlockId, unexpectedError)
|
||||
}
|
||||
|
||||
return { success: false, error: unexpectedError }
|
||||
|
||||
} catch (error) {
|
||||
console.error('Payment confirmation error:', error)
|
||||
|
||||
const errorMessage = error.message || 'Payment confirmation failed'
|
||||
|
||||
if (paymentBlockId) {
|
||||
form.errors.set(paymentBlockId, errorMessage)
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
code: error.code,
|
||||
type: error.type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a payment for the form
|
||||
* @param {Object} paymentBlock - The payment block from the form config
|
||||
* @param {Boolean} isRequired - Whether payment is required
|
||||
* @returns {Promise<Object>} The result of the payment processing
|
||||
*/
|
||||
const processPayment = async (paymentBlock, isRequired = true) => {
|
||||
// Only process payments on the client side
|
||||
if (!import.meta.client) {
|
||||
console.warn('Payment processing attempted on server')
|
||||
return { success: false, error: 'Payment can only be processed in the browser' }
|
||||
}
|
||||
|
||||
// Validate the payment block
|
||||
if (!paymentBlock || paymentBlock.type !== 'payment') {
|
||||
console.error('Invalid payment block provided:', paymentBlock)
|
||||
return { success: false, error: 'Invalid payment block' }
|
||||
}
|
||||
|
||||
const paymentBlockId = paymentBlock.id
|
||||
|
||||
// First check for existing payment in form data
|
||||
const existingPaymentId = form[paymentBlockId]
|
||||
if (existingPaymentId && isPaymentIntentId(existingPaymentId)) {
|
||||
return { success: true, intentId: existingPaymentId }
|
||||
}
|
||||
|
||||
// Check if Stripe elements are initialized
|
||||
if (!stripeElements.value || !stripeElements.value.state) {
|
||||
console.error('Stripe elements not initialized')
|
||||
if (paymentBlockId) form.errors.set(paymentBlockId, 'Payment system not ready')
|
||||
return { success: false, error: 'Stripe elements not initialized' }
|
||||
}
|
||||
|
||||
const state = stripeElements.value.state
|
||||
const { stripe, card } = state
|
||||
|
||||
// Check if Stripe is loaded
|
||||
if (!stripe) {
|
||||
const error = 'Stripe.js not initialized'
|
||||
console.error(error)
|
||||
if (paymentBlockId) form.errors.set(paymentBlockId, error)
|
||||
return { success: false, error }
|
||||
}
|
||||
|
||||
// Check if card is complete
|
||||
const cardComplete = card && !card._empty
|
||||
if (!cardComplete) {
|
||||
// If payment is not required and card is empty, just skip payment
|
||||
if (!isRequired) {
|
||||
return { success: true, skipped: true }
|
||||
}
|
||||
|
||||
const error = 'Please enter your card details'
|
||||
if (paymentBlockId) form.errors.set(paymentBlockId, error)
|
||||
return { success: false, error }
|
||||
}
|
||||
|
||||
// Validate billing details
|
||||
if (!state.cardHolderName) {
|
||||
const error = 'Please enter the name on your card'
|
||||
if (paymentBlockId) form.errors.set(paymentBlockId, error)
|
||||
return { success: false, error }
|
||||
}
|
||||
|
||||
if (!state.cardHolderEmail) {
|
||||
const error = 'Please enter your billing email'
|
||||
if (paymentBlockId) form.errors.set(paymentBlockId, error)
|
||||
return { success: false, error }
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Create payment intent
|
||||
const config = toValue(formConfig)
|
||||
const formSlug = config.slug
|
||||
|
||||
if (!formSlug) {
|
||||
const error = 'Missing form slug'
|
||||
if (paymentBlockId) form.errors.set(paymentBlockId, error)
|
||||
return { success: false, error }
|
||||
}
|
||||
|
||||
// Create payment intent
|
||||
const intentResult = await _createPaymentIntent(
|
||||
paymentBlock.amount,
|
||||
paymentBlock.currency || 'usd',
|
||||
paymentBlock.description || ''
|
||||
)
|
||||
|
||||
if (!intentResult.success || !intentResult.client_secret) {
|
||||
const error = intentResult.error || 'Failed to create payment intent'
|
||||
console.error('Payment intent creation failed:', error)
|
||||
if (paymentBlockId) form.errors.set(paymentBlockId, error)
|
||||
return { success: false, error }
|
||||
}
|
||||
|
||||
// Step 2: Confirm payment with Stripe
|
||||
const confirmResult = await _confirmStripePayment(intentResult.client_secret, paymentBlockId)
|
||||
|
||||
// Return the result from confirmation
|
||||
return confirmResult
|
||||
|
||||
} catch (error) {
|
||||
console.error('Payment processing error:', error)
|
||||
const errorMessage = error.message || 'Payment processing failed'
|
||||
if (paymentBlockId) form.errors.set(paymentBlockId, errorMessage)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms a Stripe payment with given client secret.
|
||||
* This method is available for direct usage if needed.
|
||||
* @param {String} clientSecret - The client secret from a payment intent
|
||||
* @param {String} [paymentBlockId] - Optional ID of payment block for error display
|
||||
* @returns {Promise<Object>} The result of payment confirmation
|
||||
*/
|
||||
const confirmStripePayment = async (clientSecret, paymentBlockId) => {
|
||||
if (!clientSecret) {
|
||||
console.error('Missing client secret for payment confirmation')
|
||||
return { success: false, error: 'Invalid payment data' }
|
||||
}
|
||||
|
||||
return await _confirmStripePayment(clientSecret, paymentBlockId)
|
||||
}
|
||||
|
||||
// Helper function to check if a value looks like a Stripe payment intent ID
|
||||
const isPaymentIntentId = (value) => {
|
||||
return typeof value === 'string' && value.startsWith('pi_')
|
||||
}
|
||||
|
||||
// Expose the main payment processing function
|
||||
return {
|
||||
processPayment,
|
||||
getPaymentData,
|
||||
createPaymentIntent: _createPaymentIntent,
|
||||
confirmStripePayment
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,366 @@
|
|||
import { computed, toValue } from 'vue'
|
||||
import FormLogicPropertyResolver from '~/lib/forms/FormLogicPropertyResolver.js'
|
||||
|
||||
/**
|
||||
* @fileoverview Composable responsible for analyzing and managing the structural aspects of a form,
|
||||
* including page breaks, field grouping, page boundaries, and determining field locations.
|
||||
*/
|
||||
export function useFormStructure(formConfig, managerState, formData) {
|
||||
const form = computed(() => toValue(formConfig) || { properties: [] })
|
||||
|
||||
/**
|
||||
* Checks if a field is hidden based on form logic.
|
||||
* Uses FormLogicPropertyResolver.
|
||||
* @param {Object} field - The field configuration object.
|
||||
* @returns {Boolean} True if the field is hidden, false otherwise.
|
||||
*/
|
||||
const isFieldHidden = (field) => {
|
||||
try {
|
||||
// Use the formData ref passed into the composable
|
||||
const currentFormData = toValue(formData) || {}
|
||||
return new FormLogicPropertyResolver(field, currentFormData).isHidden()
|
||||
} catch (e) {
|
||||
console.error("Error checking if field is hidden:", field?.id, e)
|
||||
return field?.hidden || false // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the groups of fields based on non-hidden page breaks.
|
||||
* @returns {Array<Array<Object>>} Nested array where each inner array represents a page.
|
||||
*/
|
||||
const calculateFieldGroups = () => {
|
||||
const properties = form.value.properties || []
|
||||
if (properties.length === 0) return [[]]
|
||||
|
||||
const groups = []
|
||||
let currentGroup = []
|
||||
|
||||
properties.forEach((field, index) => {
|
||||
currentGroup.push(field)
|
||||
|
||||
// Check if the field is a page break AND it's not hidden
|
||||
if (field.type === 'nf-page-break' && !isFieldHidden(field)) {
|
||||
groups.push([...currentGroup])
|
||||
if (index < properties.length - 1) {
|
||||
currentGroup = []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Add the last group if it's not empty AND the last field wasn't a non-hidden page break
|
||||
const lastProperty = properties[properties.length - 1]
|
||||
if (currentGroup.length > 0 && (!lastProperty || lastProperty.type !== 'nf-page-break' || isFieldHidden(lastProperty))) {
|
||||
groups.push(currentGroup)
|
||||
}
|
||||
|
||||
// Ensure at least one group exists
|
||||
if (groups.length === 0) {
|
||||
groups.push([])
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive computed property holding the field groups.
|
||||
*/
|
||||
const fieldGroups = computed(calculateFieldGroups)
|
||||
|
||||
/**
|
||||
* Reactive computed property holding the total number of pages.
|
||||
*/
|
||||
const pageCount = computed(() => {
|
||||
const groups = fieldGroups.value
|
||||
const count = groups ? groups.length : 0
|
||||
return count
|
||||
})
|
||||
|
||||
/**
|
||||
* Calculates the start and end indices for each page.
|
||||
* @returns {Array<Object>} Array of boundary objects { start, end }.
|
||||
*/
|
||||
const calculatePageBoundaries = () => {
|
||||
const properties = form.value.properties || []
|
||||
if (properties.length === 0) {
|
||||
return [{ start: 0, end: -1 }] // Empty form
|
||||
}
|
||||
|
||||
const boundaries = []
|
||||
let startIndex = 0
|
||||
let visibleBreakFound = false
|
||||
|
||||
properties.forEach((field, index) => {
|
||||
// Check if the field is a page break AND it's not hidden
|
||||
if (field.type === 'nf-page-break' && !isFieldHidden(field)) {
|
||||
visibleBreakFound = true
|
||||
// The page ends *at* the page break field
|
||||
boundaries.push({ start: startIndex, end: index })
|
||||
// The next page starts *after* the page break field
|
||||
startIndex = index + 1
|
||||
}
|
||||
})
|
||||
|
||||
// If no visible page breaks were found, the entire form is a single page
|
||||
if (!visibleBreakFound) {
|
||||
return [{ start: 0, end: properties.length - 1 }]
|
||||
}
|
||||
|
||||
// Add the boundary for the last page if there are fields after the last visible break
|
||||
if (startIndex < properties.length) {
|
||||
boundaries.push({ start: startIndex, end: properties.length - 1 })
|
||||
}
|
||||
// If the last field was a visible page break, startIndex will equal properties.length,
|
||||
// and we don't add an extra empty page boundary.
|
||||
|
||||
// Safety check: Ensure at least one boundary exists if properties are not empty
|
||||
if (boundaries.length === 0 && properties.length > 0) {
|
||||
return [{ start: 0, end: properties.length - 1 }]
|
||||
}
|
||||
|
||||
return boundaries
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reactive computed property holding the page boundaries.
|
||||
*/
|
||||
const pageBoundaries = computed(calculatePageBoundaries)
|
||||
|
||||
/**
|
||||
* Reactive computed property for the page break field ending the current page.
|
||||
*/
|
||||
const currentPageBreak = computed(() => {
|
||||
const groups = fieldGroups.value
|
||||
if (!managerState || !groups) return null
|
||||
|
||||
const currentPageIndex = managerState.currentPage
|
||||
if (currentPageIndex < 0 || currentPageIndex >= groups.length) return null
|
||||
|
||||
const currentPageFields = groups[currentPageIndex] || []
|
||||
if (currentPageFields.length === 0) return null
|
||||
|
||||
const lastField = currentPageFields[currentPageFields.length - 1]
|
||||
// It's the current page break only if it's a page break and *not hidden*
|
||||
return (lastField && lastField.type === 'nf-page-break' && !isFieldHidden(lastField)) ? lastField : null
|
||||
})
|
||||
|
||||
/**
|
||||
* Reactive computed property for the page break field ending the previous page.
|
||||
*/
|
||||
const previousPageBreak = computed(() => {
|
||||
const groups = fieldGroups.value
|
||||
if (!managerState || !groups || managerState.currentPage <= 0) return null
|
||||
|
||||
const previousPageIndex = managerState.currentPage - 1
|
||||
if (previousPageIndex < 0 || previousPageIndex >= groups.length) return null
|
||||
|
||||
const previousPageFields = groups[previousPageIndex] || []
|
||||
if (previousPageFields.length === 0) return null
|
||||
|
||||
const lastField = previousPageFields[previousPageFields.length - 1]
|
||||
// It's the previous page break only if it's a page break and *not hidden*
|
||||
return (lastField && lastField.type === 'nf-page-break' && !isFieldHidden(lastField)) ? lastField : null
|
||||
})
|
||||
|
||||
/**
|
||||
* Reactive computed property indicating if the current page is the last one.
|
||||
*/
|
||||
const isLastPage = computed(() => {
|
||||
if (managerState?.currentPage === undefined || managerState?.currentPage === null || pageCount.value === undefined) {
|
||||
// console.warn('[useFormStructure] isLastPage: Invalid state, returning default true.');
|
||||
return true // Default true for safety in simple forms
|
||||
}
|
||||
const result = managerState.currentPage === pageCount.value - 1
|
||||
return result
|
||||
})
|
||||
|
||||
/**
|
||||
* Reactive computed property checking if current page has a payment block
|
||||
*/
|
||||
const currentPageHasPaymentBlock = computed(() => {
|
||||
if (managerState?.currentPage === undefined || managerState?.currentPage === null) return false
|
||||
|
||||
return hasPaymentBlock(managerState.currentPage)
|
||||
})
|
||||
|
||||
/**
|
||||
* Reactive computed property returning the payment block from the current page, if any
|
||||
*/
|
||||
const currentPagePaymentBlock = computed(() => {
|
||||
if (managerState?.currentPage === undefined || managerState?.currentPage === null) return undefined
|
||||
|
||||
return getPaymentBlock(managerState.currentPage)
|
||||
})
|
||||
|
||||
/**
|
||||
* Gets the fields for a specific page index.
|
||||
* @param {Number} pageIndex - The index of the page.
|
||||
* @returns {Array<Object>} Array of field objects for the page.
|
||||
*/
|
||||
const getPageFields = (pageIndex) => {
|
||||
const groups = fieldGroups.value
|
||||
if (!groups) {
|
||||
console.warn("useFormStructure: getPageFields called but fieldGroups is undefined.")
|
||||
return []
|
||||
}
|
||||
return groups[pageIndex] || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the page index for a given field index within the form properties array.
|
||||
* @param {Number} fieldIndex - The index of the field in the form.properties array.
|
||||
* @returns {Number} The page index (0-based).
|
||||
*/
|
||||
const getPageForField = (fieldIndex) => {
|
||||
// Basic validation for field index
|
||||
if (fieldIndex === null || fieldIndex === undefined ||
|
||||
typeof fieldIndex !== 'number' || isNaN(fieldIndex) || fieldIndex < 0) {
|
||||
console.warn(`Invalid field index passed to getPageForField: ${fieldIndex}`)
|
||||
return 0 // Default to first page for invalid indexes
|
||||
}
|
||||
|
||||
const properties = form.value.properties || []
|
||||
if (properties.length === 0 || fieldIndex >= properties.length) {
|
||||
return 0 // Default to first page
|
||||
}
|
||||
|
||||
const boundaries = pageBoundaries.value
|
||||
if (!boundaries || boundaries.length === 0) return 0
|
||||
|
||||
// If only one boundary (covers all), it's page 0
|
||||
if (boundaries.length === 1 && boundaries[0].start === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
for (let i = 0; i < boundaries.length; i++) {
|
||||
const { start, end } = boundaries[i]
|
||||
if (fieldIndex >= start && fieldIndex <= end) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Should technically not be reached with correct boundaries
|
||||
console.warn(`[useFormStructure] getPageForField: Field index ${fieldIndex} not found within calculated boundaries. Returning last page index.`)
|
||||
return Math.max(0, boundaries.length - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given page contains a payment block.
|
||||
* @param {Number} pageIndex - The page index.
|
||||
* @returns {Boolean} True if a payment block exists on the page.
|
||||
*/
|
||||
const hasPaymentBlock = (pageIndex) => {
|
||||
return getPageFields(pageIndex).some(field => field?.type === 'payment')
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the payment block field object from a given page.
|
||||
* @param {Number} pageIndex - The page index.
|
||||
* @returns {Object|undefined} The payment field object or undefined if not found.
|
||||
*/
|
||||
const getPaymentBlock = (pageIndex) => {
|
||||
return getPageFields(pageIndex).find(field => field?.type === 'payment')
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the target index in the flat form.properties array for drag/drop operations.
|
||||
* @param {number} currentFieldPageIndex - The relative index of the field within the current page's field list.
|
||||
* @param {number} selectedFieldIndex - The original flat index of the field being dragged (often not needed here).
|
||||
* @param {number} currentPageIndex - The index of the page where the drop occurs.
|
||||
* @returns {number} The absolute index in the form.properties array.
|
||||
*/
|
||||
const getTargetDropIndex = (relativeDropIndex, targetPageIndex) => {
|
||||
const groups = fieldGroups.value
|
||||
if (!groups) return relativeDropIndex // Fallback
|
||||
|
||||
let precedingFields = 0
|
||||
for(let i = 0; i < targetPageIndex; i++) {
|
||||
precedingFields += groups[i]?.length || 0
|
||||
}
|
||||
return precedingFields + relativeDropIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current page to the page containing the specified field.
|
||||
* @param {Number} fieldIndex - The index of the field in the form.properties array.
|
||||
* @returns {Number} The page index that was set.
|
||||
*/
|
||||
const setPageForField = (fieldIndex) => {
|
||||
// Get the page index, with additional validation
|
||||
const pageIndex = getPageForField(fieldIndex)
|
||||
|
||||
// Ensure we have a valid numeric page index
|
||||
if (typeof pageIndex !== 'number' || isNaN(pageIndex) || pageIndex < 0) {
|
||||
console.warn('[useFormStructure] setPageForField: Invalid page index', pageIndex)
|
||||
return
|
||||
}
|
||||
|
||||
// Update the manager state with validated page index - DON'T USE toValue HERE
|
||||
if (managerState && managerState.currentPage !== undefined) {
|
||||
managerState.currentPage = pageIndex
|
||||
}
|
||||
|
||||
return pageIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the correct index to insert a new field.
|
||||
* Considers selected field index and current page boundaries.
|
||||
* @param {Number|null} selectedFieldIndex - The index of the currently selected field in form.properties.
|
||||
* @param {Number} currentPageIndex - The current page index.
|
||||
* @param {Number|null} [explicitIndex=null] - An explicitly provided insert index.
|
||||
* @returns {Number} The calculated index for insertion.
|
||||
*/
|
||||
const determineInsertIndex = (selectedFieldIndex, currentPageIndex, explicitIndex = null) => {
|
||||
if (explicitIndex !== null && typeof explicitIndex === 'number') {
|
||||
return explicitIndex
|
||||
}
|
||||
|
||||
if (selectedFieldIndex !== null && selectedFieldIndex !== undefined && selectedFieldIndex >= 0) {
|
||||
return selectedFieldIndex + 1
|
||||
}
|
||||
|
||||
const properties = form.value.properties || []
|
||||
if (properties.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const boundaries = pageBoundaries.value
|
||||
// Use managerState directly without toValue
|
||||
const pageIdx = currentPageIndex ?? managerState?.currentPage ?? 0 // Use provided or state page index
|
||||
|
||||
if (!boundaries || boundaries.length === 0 || pageIdx >= boundaries.length || pageIdx < 0) {
|
||||
// Fallback to end of the form if boundaries/page index is invalid
|
||||
return properties.length
|
||||
}
|
||||
|
||||
const currentBoundary = boundaries[pageIdx]
|
||||
// Insert at the end of the current page (index after the last field of that page)
|
||||
return currentBoundary.end + 1
|
||||
}
|
||||
|
||||
// --- Exposed API ---
|
||||
return {
|
||||
// Reactive Computed Properties
|
||||
fieldGroups,
|
||||
pageCount,
|
||||
pageBoundaries,
|
||||
currentPageBreak,
|
||||
previousPageBreak,
|
||||
isLastPage,
|
||||
currentPage: computed(() => managerState?.currentPage ?? 0),
|
||||
currentPageHasPaymentBlock,
|
||||
currentPagePaymentBlock,
|
||||
|
||||
// Methods
|
||||
getPageFields, // Get fields for a specific page
|
||||
getPageForField, // Find which page a field index belongs to
|
||||
hasPaymentBlock, // Check if a page has a payment block
|
||||
getPaymentBlock, // Get the payment block from a page
|
||||
isFieldHidden, // Check if a specific field is hidden by logic
|
||||
getTargetDropIndex, // Calculate absolute index for drag/drop
|
||||
determineInsertIndex, // Calculate where to insert a new field
|
||||
setPageForField // Set the current page to the page containing the specified field
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { toValue } from 'vue'
|
||||
|
||||
/**
|
||||
* @fileoverview Composable for handling the final form submission process.
|
||||
*/
|
||||
export function useFormSubmission(formConfig, form) {
|
||||
|
||||
/**
|
||||
* Prepares additional metadata for the submission payload.
|
||||
* Focuses only on metadata fields, not the form data itself.
|
||||
* @param {Object} options - Options containing completionTime, captchaToken, etc.
|
||||
* @returns {Object} Metadata to be included in the submission request.
|
||||
*/
|
||||
const _prepareMetadata = (options = {}) => {
|
||||
const metadata = {}
|
||||
|
||||
// Add completion time if provided
|
||||
if (options.completionTime !== undefined) {
|
||||
metadata.completion_time = options.completionTime
|
||||
}
|
||||
|
||||
// Add captcha token if provided
|
||||
if (options.captchaToken) {
|
||||
metadata.captcha_token = options.captchaToken
|
||||
}
|
||||
|
||||
// Add submission hash if provided (for partial submissions)
|
||||
if (options.submissionHash) {
|
||||
metadata.submission_hash = options.submissionHash
|
||||
}
|
||||
|
||||
// Add submission ID if provided (for editable submissions)
|
||||
if (options.submissionId) {
|
||||
metadata.submission_id = options.submissionId
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the form submission.
|
||||
* @param {Object} options - Submission options.
|
||||
* @param {Object} options.formModeStrategy - The strategy object.
|
||||
* @param {Number} [options.completionTime] - Form completion time in seconds.
|
||||
* @param {String} [options.captchaToken] - Captcha verification token.
|
||||
* @param {String} [options.submissionHash] - Hash for partial submissions.
|
||||
* @param {String} [options.submissionId] - ID for editable submissions.
|
||||
* @returns {Promise<Object>} The response data from the submission endpoint.
|
||||
* @throws {Error} If submission fails.
|
||||
*/
|
||||
const submit = async (options = {}) => {
|
||||
// Get the form slug from config
|
||||
const formSlug = formConfig.value.slug
|
||||
// Get the URL for form submission
|
||||
const url = `/forms/${formSlug}/answer`
|
||||
// Prepare metadata only (form data will be auto-merged by Form.js)
|
||||
const metadata = _prepareMetadata(options)
|
||||
|
||||
// Use the vForm post method, which will automatically merge form data with metadata
|
||||
const response = await toValue(form).post(url, {
|
||||
data: metadata
|
||||
})
|
||||
|
||||
// Optionally reset form after successful submission based on strategy
|
||||
const formModeStrategy = options.formModeStrategy
|
||||
if (formModeStrategy?.submission?.resetAfterSubmit) {
|
||||
toValue(form).reset()
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Expose the main submission function
|
||||
return {
|
||||
submit,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* @fileoverview Composable for managing form completion time.
|
||||
* Includes persistence in localStorage via pendingSubmission.
|
||||
*/
|
||||
export function useFormTimer(pendingSubmission) {
|
||||
// Reactive state for timer
|
||||
const startTime = ref(null)
|
||||
const completionTime = ref(null)
|
||||
const isTimerActive = ref(false)
|
||||
const isInitialized = ref(false)
|
||||
|
||||
/**
|
||||
* Loads timer data from localStorage if available.
|
||||
* @returns {Boolean} Whether data was loaded successfully
|
||||
*/
|
||||
const loadFromLocalStorage = () => {
|
||||
if (import.meta.server || !pendingSubmission) return false
|
||||
|
||||
const savedTimer = pendingSubmission.getTimer()
|
||||
if (savedTimer) {
|
||||
startTime.value = parseInt(savedTimer)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current start time to localStorage.
|
||||
*/
|
||||
const saveToLocalStorage = () => {
|
||||
if (import.meta.server || !pendingSubmission) return
|
||||
|
||||
if (startTime.value) {
|
||||
pendingSubmission.setTimer(startTime.value.toString())
|
||||
} else {
|
||||
pendingSubmission.removeTimer()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the timer if not already active.
|
||||
* Will load from localStorage on first call if available.
|
||||
*/
|
||||
const start = () => {
|
||||
if (isTimerActive.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// If this is the first time starting, try to load from localStorage
|
||||
if (!isInitialized.value) {
|
||||
isInitialized.value = true
|
||||
loadFromLocalStorage()
|
||||
}
|
||||
|
||||
// Only set a new start time if one doesn't exist already
|
||||
if (!startTime.value) {
|
||||
startTime.value = Date.now()
|
||||
}
|
||||
|
||||
isTimerActive.value = true
|
||||
saveToLocalStorage()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the timer if active and calculates completion time.
|
||||
*/
|
||||
const stop = () => {
|
||||
if (!isTimerActive.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (startTime.value) {
|
||||
completionTime.value = Math.round((Date.now() - startTime.value) / 1000) // Time in seconds
|
||||
} else {
|
||||
completionTime.value = null
|
||||
}
|
||||
|
||||
isTimerActive.value = false
|
||||
|
||||
// Don't clear local storage on stop - only on reset or explicit removal
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the timer state and clears localStorage.
|
||||
*/
|
||||
const reset = () => {
|
||||
startTime.value = null
|
||||
completionTime.value = null
|
||||
isTimerActive.value = false
|
||||
|
||||
if (pendingSubmission) {
|
||||
pendingSubmission.removeTimer()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the calculated completion time.
|
||||
* @returns {Number|null} Completion time in seconds, or null if not available.
|
||||
*/
|
||||
const getCompletionTime = () => {
|
||||
return completionTime.value
|
||||
}
|
||||
|
||||
// Expose reactive state and methods
|
||||
return {
|
||||
isTimerActive, // Expose reactive status
|
||||
completionTime, // Expose reactive completion time
|
||||
start,
|
||||
stop,
|
||||
reset,
|
||||
getCompletionTime,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import { computed, toValue } from 'vue'
|
||||
|
||||
/**
|
||||
* @fileoverview Composable for handling form validation logic.
|
||||
*/
|
||||
export function useFormValidation(formConfig, form, managerState) {
|
||||
|
||||
const formRef = computed(() => toValue(form)) // Ensure reactivity with the form instance
|
||||
const configRef = computed(() => toValue(formConfig)) // Ensure reactivity with config
|
||||
|
||||
/**
|
||||
* Validates specific fields using the vForm instance's validate method.
|
||||
* @param {Array<String>} fieldIds - Array of field IDs to validate.
|
||||
* @param {String} [httpMethod='POST'] - HTTP method for the validation request.
|
||||
* @returns {Promise<boolean>} Resolves true if validation passes, rejects with errors if fails.
|
||||
*/
|
||||
const validateFields = async (fieldIds, httpMethod = 'POST') => {
|
||||
if (!fieldIds || fieldIds.length === 0) {
|
||||
return true // Nothing to validate
|
||||
}
|
||||
|
||||
const config = configRef.value
|
||||
const validationUrl = `/forms/${config.slug}/answer` // Use reactive config
|
||||
|
||||
// Use the reactive formRef
|
||||
await formRef.value.validate(httpMethod, validationUrl, {}, fieldIds)
|
||||
return true // Validation passed
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates fields on the current page based on the form mode strategy.
|
||||
* Excludes payment fields.
|
||||
* @param {Array<Object>} currentPageFields - Array of field objects for the current page.
|
||||
* @param {Object} formModeStrategy - The strategy object for the current mode.
|
||||
* @returns {Promise<boolean>} Resolves true if validation passes or isn't required, rejects otherwise.
|
||||
*/
|
||||
const validateCurrentPage = async (currentPageFields, formModeStrategy) => {
|
||||
if (!formModeStrategy?.validation?.validateOnNextPage) {
|
||||
return true
|
||||
}
|
||||
|
||||
const fieldIdsToValidate = currentPageFields
|
||||
.filter(field => field && field.id && field.type !== 'payment')
|
||||
.map(field => field.id)
|
||||
|
||||
if (fieldIdsToValidate.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
await validateFields(fieldIdsToValidate)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates all form fields before final submission, *unless* currently on the last page.
|
||||
* @param {Array<Object>} allFields - Array of all field objects in the form.
|
||||
* @param {Object} formModeStrategy - The strategy object for the current mode.
|
||||
* @param {boolean} isCurrentlyLastPage - Boolean indicating if the *current* state is the last page.
|
||||
* @returns {Promise<boolean>} Resolves true if validation passes or isn't required/skipped, rejects otherwise.
|
||||
*/
|
||||
const validateSubmissionIfNotLastPage = async (allFields, formModeStrategy, isCurrentlyLastPage) => {
|
||||
// Skip validation if we are already determined to be on the last page
|
||||
if (isCurrentlyLastPage) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Skip validation if strategy doesn't require it
|
||||
if (!formModeStrategy?.validation?.validateOnSubmit) {
|
||||
return true
|
||||
}
|
||||
|
||||
const fieldIdsToValidate = allFields
|
||||
.filter(field => field && field.id && field.type !== 'payment')
|
||||
.map(field => field.id)
|
||||
|
||||
if (fieldIdsToValidate.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
await validateFields(fieldIdsToValidate)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the index of the first page containing validation errors.
|
||||
* @param {Array<Array<Object>>} fieldGroups - Nested array of fields per page (from useFormStructure).
|
||||
* @returns {number} Index of the first page with errors, or -1 if no errors found.
|
||||
*/
|
||||
const findFirstPageWithError = (fieldGroups) => {
|
||||
const errors = formRef.value.errors
|
||||
if (!errors || !errors.any()) {
|
||||
return -1 // No errors exist
|
||||
}
|
||||
if (!fieldGroups) return -1 // No groups to check
|
||||
|
||||
for (let i = 0; i < fieldGroups.length; i++) {
|
||||
const pageHasError = fieldGroups[i]?.some(field => field && field.id && errors.has(field.id))
|
||||
if (pageHasError) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions to perform on validation failure (e.g., during submit or page change).
|
||||
* @param {Object} context - Object containing { fieldGroups, timerService, setPageIndexCallback }.
|
||||
*/
|
||||
const onValidationFailure = (context) => {
|
||||
const { fieldGroups, timerService, setPageIndexCallback } = context
|
||||
|
||||
// Restart timer using the timer composable instance
|
||||
if (timerService && typeof timerService.start === 'function') {
|
||||
timerService.start()
|
||||
}
|
||||
|
||||
// Find and navigate to the first page with an error
|
||||
if (fieldGroups && fieldGroups.length > 1 && typeof setPageIndexCallback === 'function') {
|
||||
const errorPageIndex = findFirstPageWithError(fieldGroups)
|
||||
if (errorPageIndex !== -1 && errorPageIndex !== managerState?.currentPage) {
|
||||
setPageIndexCallback(errorPageIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Exposed API ---
|
||||
return {
|
||||
// Methods
|
||||
validateFields,
|
||||
validateCurrentPage,
|
||||
validateSubmissionIfNotLastPage,
|
||||
findFirstPageWithError,
|
||||
onValidationFailure,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { hash } from "~/lib/utils.js"
|
||||
import { useStorage, watchThrottled } from "@vueuse/core"
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
/**
|
||||
* Composable for managing pending form submission data and timer in localStorage.
|
||||
* Includes throttled auto-saving of form data.
|
||||
*
|
||||
* @param {import('vue').Ref<Object>} formConfig - Reactive reference to the form configuration object.
|
||||
* @param {import('vue').ComputedRef<Object>} formDataRef - Computed reference to the reactive form data object.
|
||||
*/
|
||||
export function usePendingSubmission(formConfig, formDataRef) {
|
||||
const config = computed(() => toValue(formConfig)) // Ensure reactivity to config changes
|
||||
|
||||
const formPendingSubmissionKey = computed(() => {
|
||||
const currentConfig = config.value
|
||||
return currentConfig && typeof window !== 'undefined'
|
||||
? currentConfig.form_pending_submission_key + "-" + hash(window.location.href)
|
||||
: ""
|
||||
})
|
||||
|
||||
const formPendingSubmissionTimerKey = computed(() => {
|
||||
return formPendingSubmissionKey.value ? formPendingSubmissionKey.value + "-timer" : ""
|
||||
})
|
||||
|
||||
const enabled = computed(() => {
|
||||
// Auto-save is enabled if the feature is turned on in the config
|
||||
return config.value?.auto_save ?? false
|
||||
})
|
||||
|
||||
// Internal function to save data to localStorage
|
||||
const saveData = (value) => {
|
||||
if (import.meta.server || !enabled.value || !formPendingSubmissionKey.value) return
|
||||
try {
|
||||
useStorage(formPendingSubmissionKey.value).value =
|
||||
value === null ? null : JSON.stringify(value)
|
||||
} catch (e) {
|
||||
console.error("Error saving pending submission to localStorage:", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Internal function to retrieve data from localStorage
|
||||
const retrieveData = (defaultValue = {}) => {
|
||||
if (import.meta.server || !enabled.value || !formPendingSubmissionKey.value) return defaultValue
|
||||
try {
|
||||
const storageValue = useStorage(formPendingSubmissionKey.value).value
|
||||
return storageValue ? JSON.parse(storageValue) : defaultValue
|
||||
} catch (e) {
|
||||
console.error("Error reading pending submission from localStorage:", e)
|
||||
// Attempt to clear corrupted data
|
||||
remove()
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
// Watch formDataRef with throttling
|
||||
watchThrottled(
|
||||
formDataRef,
|
||||
(newData) => {
|
||||
// Only save if enabled and on client
|
||||
if (import.meta.client && enabled.value) {
|
||||
saveData(newData)
|
||||
}
|
||||
},
|
||||
{ deep: true, throttle: 1000 } // Throttle saving to once per second
|
||||
)
|
||||
|
||||
// --- Exposed Methods ---
|
||||
|
||||
const remove = () => {
|
||||
// Clear the data from storage
|
||||
saveData(null)
|
||||
}
|
||||
|
||||
const get = (defaultValue = {}) => {
|
||||
// Retrieve the currently stored data
|
||||
return retrieveData(defaultValue)
|
||||
}
|
||||
|
||||
const setSubmissionHash = (hash) => {
|
||||
if (!enabled.value) return
|
||||
const currentData = retrieveData()
|
||||
saveData({
|
||||
...currentData,
|
||||
submission_hash: hash
|
||||
})
|
||||
}
|
||||
|
||||
const getSubmissionHash = () => {
|
||||
return retrieveData()?.submission_hash ?? null
|
||||
}
|
||||
|
||||
const setTimer = (value) => {
|
||||
if (import.meta.server || !formPendingSubmissionTimerKey.value) return
|
||||
useStorage(formPendingSubmissionTimerKey.value).value = value
|
||||
}
|
||||
|
||||
const removeTimer = () => {
|
||||
setTimer(null)
|
||||
}
|
||||
|
||||
const getTimer = (defaultValue = null) => {
|
||||
if (import.meta.server || !formPendingSubmissionTimerKey.value) return defaultValue
|
||||
return useStorage(formPendingSubmissionTimerKey.value, defaultValue).value
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
remove()
|
||||
removeTimer()
|
||||
}
|
||||
|
||||
return {
|
||||
formPendingSubmissionKey, // Keep for potential external use (e.g., partial submission hash map key)
|
||||
enabled,
|
||||
get, // Method to retrieve stored data
|
||||
remove, // Method to clear stored data
|
||||
setSubmissionHash, // Method to specifically set the submission hash
|
||||
getSubmissionHash, // Method to specifically get the submission hash
|
||||
setTimer, // Method to set the timer value
|
||||
removeTimer, // Method to clear the timer value
|
||||
getTimer, // Method to get the timer value
|
||||
clear, // Method to clear both data and timer
|
||||
}
|
||||
}
|
||||
|
|
@ -92,7 +92,7 @@ export const getDomain = function (url) {
|
|||
try {
|
||||
if (!url.startsWith("http")) url = "https://" + url
|
||||
return new URL(url).hostname
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -25,6 +25,7 @@
|
|||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-vue": "^10.1.0",
|
||||
"nuxt": "^3.17.1",
|
||||
"nuxt-utm": "^0.2.2",
|
||||
"postcss": "^8.4.47",
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ function isDirty() {
|
|||
formInitialHash.value !==
|
||||
hash(JSON.stringify(updatedForm?.value?.data() ?? null))
|
||||
)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,13 +43,7 @@
|
|||
</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="recordLoading">
|
||||
<p class="text-center mt-6 p-4">
|
||||
<loader class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
</p>
|
||||
</div>
|
||||
<OpenCompleteForm
|
||||
v-show="!recordLoading"
|
||||
ref="openCompleteForm"
|
||||
:form="form"
|
||||
class="mb-10"
|
||||
|
|
@ -77,14 +71,12 @@ import { FormMode } from "~/lib/forms/FormModeStrategy.js"
|
|||
|
||||
const crisp = useCrisp()
|
||||
const formsStore = useFormsStore()
|
||||
const recordsStore = useRecordsStore()
|
||||
const darkMode = useDarkMode()
|
||||
const isIframe = useIsIframe()
|
||||
const formLoading = computed(() => formsStore.loading)
|
||||
const recordLoading = computed(() => recordsStore.loading)
|
||||
const slug = useRoute().params.slug
|
||||
const form = computed(() => formsStore.getByKey(slug))
|
||||
const $t = useI18n()
|
||||
const { t } = useI18n()
|
||||
|
||||
const openCompleteForm = ref(null)
|
||||
|
||||
|
|
@ -98,7 +90,7 @@ const passwordEntered = function (password) {
|
|||
nextTick(() => {
|
||||
loadForm().then(() => {
|
||||
if (form.value?.is_password_protected) {
|
||||
openCompleteForm.value.addPasswordError($t('forms.invalid_password'))
|
||||
openCompleteForm.value.addPasswordError(t('forms.invalid_password'))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -121,7 +113,7 @@ const loadForm = async (setup=false) => {
|
|||
try {
|
||||
const data = await formsStore.publicFetch(slug)
|
||||
formsStore.save(data)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
formsStore.stopLoading()
|
||||
setResponseStatus(event, 404, 'Page Not Found')
|
||||
return
|
||||
|
|
@ -143,11 +135,6 @@ const loadForm = async (setup=false) => {
|
|||
|
||||
await loadForm(true)
|
||||
|
||||
// Start loader if record needs to be loaded
|
||||
if (useRoute().query?.submission_id) {
|
||||
recordsStore.startLoading()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
crisp.hideChat()
|
||||
document.body.classList.add('public-page')
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ onMounted(async () => {
|
|||
throw new Error('No portal URL returned')
|
||||
}
|
||||
window.location.href = portal_url
|
||||
} catch (error) {
|
||||
} catch {
|
||||
useAlert().error('Unable to access billing portal. Please try again or contact support.')
|
||||
setTimeout(() => {
|
||||
navigateTo({ name: 'settings-billing' })
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ onMounted(async () => {
|
|||
method: 'PUT',
|
||||
body: { name, email }
|
||||
})
|
||||
} catch (error) {
|
||||
} catch {
|
||||
useAlert().error('Failed to update customer details, but proceeding with checkout')
|
||||
}
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ onMounted(async () => {
|
|||
}
|
||||
|
||||
window.location.href = checkout_url
|
||||
} catch (error) {
|
||||
} catch {
|
||||
useAlert().error('Unable to start checkout process. Please try again or contact support.')
|
||||
setTimeout(() => {
|
||||
navigateTo({ name: 'pricing' })
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ async function handleCallback() {
|
|||
waitForAcknowledgment: false,
|
||||
targetOrigin: window.location.origin
|
||||
})
|
||||
} catch (sendError) {
|
||||
} catch {
|
||||
// Silently handle error when sending window message - continue flow regardless
|
||||
}
|
||||
}
|
||||
|
|
@ -84,7 +84,7 @@ async function handleCallback() {
|
|||
try {
|
||||
errorMessage.value = error?.data?.message || "An error occurred while connecting the account."
|
||||
alert.error(errorMessage.value)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
errorMessage.value = "An unknown error occurred while connecting the account."
|
||||
alert.error(errorMessage.value)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export const useOAuthProvidersStore = defineStore("oauth_providers", () => {
|
|||
.catch((error) => {
|
||||
try {
|
||||
alert.error(error.data.message)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
alert.error("An error occurred while connecting an account")
|
||||
}
|
||||
})
|
||||
|
|
@ -109,7 +109,7 @@ export const useOAuthProvidersStore = defineStore("oauth_providers", () => {
|
|||
.catch((error) => {
|
||||
try {
|
||||
alert.error(error.data.message)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
alert.error("An error occurred while connecting an account")
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,138 +4,92 @@ import { generateUUID } from "~/lib/utils.js"
|
|||
import blocksTypes from "~/data/blocks_types.json"
|
||||
import { useAlert } from '~/composables/useAlert'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useForm } from '~/composables/useForm'
|
||||
|
||||
export const useWorkingFormStore = defineStore("working_form", {
|
||||
state: () => ({
|
||||
content: null,
|
||||
activeTab: 0,
|
||||
formPageIndex: 0,
|
||||
|
||||
|
||||
// Field being edited
|
||||
selectedFieldIndex: null,
|
||||
showEditFieldSidebar: null,
|
||||
showAddFieldSidebar: null,
|
||||
blockForm: null,
|
||||
draggingNewBlock: false,
|
||||
|
||||
// Structure service instance - will be set from useFormManager
|
||||
structureService: null,
|
||||
}),
|
||||
getters: {
|
||||
// Get all blocks/properties in the form
|
||||
formBlocks() {
|
||||
return this.content?.properties || []
|
||||
},
|
||||
|
||||
// Get page break indices to determine page boundaries
|
||||
pageBreakIndices() {
|
||||
if (!this.content?.properties || this.content.properties.length === 0) return []
|
||||
|
||||
// Find all indices of page break blocks
|
||||
const indices = []
|
||||
this.content.properties.forEach((prop, index) => {
|
||||
if (prop.type === 'nf-page-break' && !prop.hidden) {
|
||||
indices.push(index)
|
||||
}
|
||||
})
|
||||
|
||||
return indices
|
||||
|
||||
// Get page count using structure service
|
||||
simplePageCount() {
|
||||
if (!this.structureService) return 1
|
||||
return this.structureService.pageCount.value
|
||||
},
|
||||
|
||||
// Calculate page boundaries (start/end indices for each page)
|
||||
pageBoundaries() {
|
||||
if (!this.content?.properties || this.content.properties.length === 0) {
|
||||
return [{ start: 0, end: 0 }]
|
||||
}
|
||||
|
||||
const boundaries = []
|
||||
const breaks = this.pageBreakIndices
|
||||
|
||||
// If no page breaks, return a single page boundary
|
||||
if (breaks.length === 0) {
|
||||
return [{
|
||||
start: 0,
|
||||
end: this.formBlocks.length - 1
|
||||
}]
|
||||
}
|
||||
|
||||
// First page starts at 0
|
||||
let startIndex = 0
|
||||
|
||||
// For each page break, create a boundary
|
||||
breaks.forEach(breakIndex => {
|
||||
boundaries.push({
|
||||
start: startIndex,
|
||||
end: breakIndex
|
||||
})
|
||||
startIndex = breakIndex + 1
|
||||
})
|
||||
|
||||
// Add the last page
|
||||
boundaries.push({
|
||||
start: startIndex,
|
||||
end: this.formBlocks.length - 1
|
||||
})
|
||||
|
||||
return boundaries
|
||||
},
|
||||
|
||||
// Get the current page's boundary
|
||||
currentPageBoundary() {
|
||||
return this.pageBoundaries[this.formPageIndex] || { start: 0, end: this.formBlocks.length - 1 }
|
||||
},
|
||||
|
||||
// Count total pages
|
||||
pageCount() {
|
||||
return this.pageBoundaries.length
|
||||
},
|
||||
|
||||
// Whether this is the last page
|
||||
isLastPage() {
|
||||
return this.formPageIndex === this.pageCount - 1
|
||||
// Current page index from structure service
|
||||
formPageIndex() {
|
||||
if (!this.structureService) return 0
|
||||
return this.structureService.currentPage
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
set(form) {
|
||||
this.content = form
|
||||
// Don't reset structure service here - it's externally managed now
|
||||
},
|
||||
setStructureService(service) {
|
||||
this.structureService = service
|
||||
},
|
||||
setProperties(properties) {
|
||||
if (!this.content) return
|
||||
this.content.properties = [...properties]
|
||||
// No need to reset structure service as it's externally managed
|
||||
},
|
||||
objectToIndex(field) {
|
||||
if (typeof field === 'object') {
|
||||
if (!this.content?.properties) return -1
|
||||
if (typeof field === 'object' && field !== null && field.id !== undefined) {
|
||||
return this.content.properties.findIndex(
|
||||
prop => prop.id === field.id
|
||||
prop => prop && prop.id === field.id
|
||||
)
|
||||
}
|
||||
return field
|
||||
if (typeof field === 'number') {
|
||||
return field
|
||||
}
|
||||
return -1
|
||||
},
|
||||
setEditingField(field) {
|
||||
this.selectedFieldIndex = this.objectToIndex(field)
|
||||
},
|
||||
openSettingsForField(field) {
|
||||
this.setEditingField(field)
|
||||
const targetIndex = this.objectToIndex(field)
|
||||
const previousIndex = this.selectedFieldIndex
|
||||
|
||||
this.selectedFieldIndex = targetIndex
|
||||
this.showEditFieldSidebar = true
|
||||
this.showAddFieldSidebar = false
|
||||
|
||||
// Set the page to the one containing this field
|
||||
// But only do this when initially opening settings, not during editing
|
||||
if (typeof field === 'number' || (typeof field === 'object' && field !== null)) {
|
||||
// Only navigate to the field's page if we're newly selecting it
|
||||
// Not if we're just updating an already selected field
|
||||
const previousIndex = this.selectedFieldIndex
|
||||
const currentIndex = this.objectToIndex(field)
|
||||
|
||||
if (previousIndex !== currentIndex) {
|
||||
this.setPageForField(this.selectedFieldIndex)
|
||||
if (this.selectedFieldIndex !== -1 && previousIndex !== this.selectedFieldIndex) {
|
||||
// Find which page contains the selected field and set to that page
|
||||
if (this.structureService) {
|
||||
this.structureService.setPageForField(this.selectedFieldIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
closeEditFieldSidebar() {
|
||||
this.selectedFieldIndex = null
|
||||
this.showEditFieldSidebar = false
|
||||
this.showAddFieldSidebar = false
|
||||
},
|
||||
openAddFieldSidebar(field) {
|
||||
openAddFieldSidebar(field = null) {
|
||||
if (field !== null) {
|
||||
this.setEditingField(field)
|
||||
} else {
|
||||
this.selectedFieldIndex = null
|
||||
}
|
||||
this.showAddFieldSidebar = true
|
||||
this.showEditFieldSidebar = false
|
||||
|
|
@ -143,13 +97,14 @@ export const useWorkingFormStore = defineStore("working_form", {
|
|||
closeAddFieldSidebar() {
|
||||
this.selectedFieldIndex = null
|
||||
this.showAddFieldSidebar = false
|
||||
this.showEditFieldSidebar = false
|
||||
},
|
||||
reset() {
|
||||
this.content = null
|
||||
this.selectedFieldIndex = null
|
||||
this.showEditFieldSidebar = null
|
||||
this.showAddFieldSidebar = null
|
||||
this.showEditFieldSidebar = false
|
||||
this.showAddFieldSidebar = false
|
||||
this.blockForm = null
|
||||
this.structureService = null
|
||||
},
|
||||
|
||||
resetBlockForm() {
|
||||
|
|
@ -160,61 +115,45 @@ export const useWorkingFormStore = defineStore("working_form", {
|
|||
},
|
||||
|
||||
/**
|
||||
* Determine where to insert a new block
|
||||
* Determine where to insert a new block based on current page index and selection.
|
||||
* Uses the FormStructureService if available.
|
||||
* @param {number|null} explicitIndex - Optional explicit index to insert at
|
||||
* @returns {number} The index where the block should be inserted
|
||||
* @returns {number} The index where the block should be inserted relative to all properties
|
||||
*/
|
||||
determineInsertIndex(explicitIndex) {
|
||||
// If an explicit index is provided, use that
|
||||
// If we have a structure service, use its method
|
||||
if (this.structureService) {
|
||||
return this.structureService.determineInsertIndex(
|
||||
this.selectedFieldIndex,
|
||||
this.formPageIndex,
|
||||
explicitIndex
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback to old logic
|
||||
if (explicitIndex !== null && typeof explicitIndex === 'number') {
|
||||
return explicitIndex
|
||||
}
|
||||
|
||||
// If a field is selected, insert after it
|
||||
// This handles the case when adding from a field's "Add new field" button
|
||||
if (this.selectedFieldIndex !== null && this.selectedFieldIndex !== undefined) {
|
||||
|
||||
if (this.selectedFieldIndex !== null && this.selectedFieldIndex >= 0) {
|
||||
return this.selectedFieldIndex + 1
|
||||
}
|
||||
|
||||
// Early validation
|
||||
if (!this.content?.properties || this.content.properties.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Get the current page's boundaries
|
||||
const pageBreaks = this.pageBreakIndices
|
||||
|
||||
// If no page breaks, insert at the end of the form
|
||||
if (pageBreaks.length === 0) {
|
||||
return this.content.properties.length
|
||||
}
|
||||
|
||||
// For first page
|
||||
if (this.formPageIndex === 0) {
|
||||
return pageBreaks[0]
|
||||
}
|
||||
|
||||
// For pages after the first one
|
||||
// Find the end of the current page (the page break index)
|
||||
const nextPageBreakIndex = pageBreaks[this.formPageIndex] || this.content.properties.length
|
||||
|
||||
// Insert at the end of the current page, right before the next page break
|
||||
// If this is the last page, insert at the very end
|
||||
if (this.formPageIndex >= pageBreaks.length) {
|
||||
return this.content.properties.length
|
||||
}
|
||||
|
||||
return nextPageBreakIndex
|
||||
// Default: end of properties array
|
||||
return this.content?.properties?.length || 0
|
||||
},
|
||||
|
||||
prefillDefault(data) {
|
||||
// If a field already has this name, we need to make it unique with a number at the end
|
||||
if (!this.content?.properties) return data
|
||||
|
||||
let baseName = data.name
|
||||
let counter = 1
|
||||
while (this.content.properties.some(prop => prop.name === data.name)) {
|
||||
let uniqueName = data.name
|
||||
while (this.content.properties.some(prop => prop && prop.name === uniqueName)) {
|
||||
counter++
|
||||
data.name = `${baseName} ${counter}`
|
||||
uniqueName = `${baseName} ${counter}`
|
||||
}
|
||||
data.name = uniqueName
|
||||
|
||||
if (data.type === "nf-text") {
|
||||
data.content = "<p>This is a text block.</p>"
|
||||
|
|
@ -231,48 +170,48 @@ export const useWorkingFormStore = defineStore("working_form", {
|
|||
},
|
||||
|
||||
addBlock(type, index = null, openSettings = true) {
|
||||
if (!this.blockForm) {
|
||||
this.resetBlockForm()
|
||||
}
|
||||
if (!this.content) return
|
||||
|
||||
const block = blocksTypes[type]
|
||||
if (block?.self_hosted !== undefined && !block.self_hosted && useFeatureFlag('self_hosted')) {
|
||||
useAlert().error(block?.title + ' is not allowed on self hosted. Please use our hosted version.')
|
||||
return
|
||||
}
|
||||
|
||||
// Check if authentication is required for this block type
|
||||
if (block?.auth_required && !useAuthStore().check) {
|
||||
useAlert().error('Please login first to add this block')
|
||||
return
|
||||
}
|
||||
|
||||
// Check if block type has a maximum count defined
|
||||
if (block?.max_count !== undefined) {
|
||||
const currentCount = this.content.properties.filter(prop => prop.type === type).length
|
||||
const currentCount = this.content.properties.filter(prop => prop && prop.type === type).length
|
||||
if (currentCount >= block.max_count) {
|
||||
useAlert().error(`Only ${block.max_count} '${block.title}' block(s) allowed per form.`)
|
||||
return
|
||||
}
|
||||
// If a max_count is defined, always open settings like we did for payment
|
||||
openSettings = true
|
||||
}
|
||||
|
||||
this.blockForm.type = type
|
||||
this.blockForm.name = blocksTypes[type].default_block_name
|
||||
const newBlock = this.prefillDefault(this.blockForm.data())
|
||||
this.blockForm.name = blocksTypes[type]?.default_block_name || 'New Block'
|
||||
const newBlock = this.prefillDefault({ ...this.blockForm.data() })
|
||||
newBlock.id = generateUUID()
|
||||
newBlock.hidden = false
|
||||
newBlock.help_position = "below_input"
|
||||
|
||||
// Apply default values from blocks_types.json if they exist
|
||||
if (blocksTypes[type]?.default_values) {
|
||||
Object.assign(newBlock, blocksTypes[type].default_values)
|
||||
}
|
||||
|
||||
// Determine the insert index
|
||||
const insertIndex = this.determineInsertIndex(index)
|
||||
|
||||
// Insert at the determined position
|
||||
const newFields = clonedeep(this.content.properties)
|
||||
const newFields = clonedeep(this.content.properties || [])
|
||||
newFields.splice(insertIndex, 0, newBlock)
|
||||
this.content.properties = newFields
|
||||
// Use setProperties to ensure content update triggers computed service update
|
||||
this.setProperties(newFields)
|
||||
|
||||
if (openSettings) {
|
||||
this.openSettingsForField(insertIndex)
|
||||
|
|
@ -284,7 +223,7 @@ export const useWorkingFormStore = defineStore("working_form", {
|
|||
internalRemoveField(field) {
|
||||
const index = this.objectToIndex(field)
|
||||
|
||||
if (index !== -1) {
|
||||
if (index !== -1 && this.content?.properties) {
|
||||
useAlert().success('Ctrl + Z to undo',10000,{
|
||||
title: 'Field removed',
|
||||
actions: [{
|
||||
|
|
@ -295,53 +234,23 @@ export const useWorkingFormStore = defineStore("working_form", {
|
|||
}
|
||||
}]
|
||||
})
|
||||
this.content.properties.splice(index, 1)
|
||||
const newProps = [...this.content.properties]
|
||||
newProps.splice(index, 1)
|
||||
this.setProperties(newProps)
|
||||
}
|
||||
},
|
||||
moveField(oldIndex, newIndex) {
|
||||
if (!this.content?.properties || oldIndex === newIndex) return
|
||||
|
||||
const newFields = clonedeep(this.content.properties)
|
||||
if (oldIndex < 0 || oldIndex >= newFields.length) return
|
||||
|
||||
const field = newFields.splice(oldIndex, 1)[0]
|
||||
newFields.splice(newIndex, 0, field)
|
||||
this.content.properties = newFields
|
||||
},
|
||||
|
||||
/**
|
||||
* Find which page a field belongs to and navigate to it
|
||||
* @param {number} fieldIndex - The index of the field to navigate to
|
||||
*/
|
||||
setPageForField(fieldIndex) {
|
||||
if (fieldIndex === -1 || fieldIndex === null) return
|
||||
|
||||
// Early return if no fields or field is out of range
|
||||
if (!this.content?.properties ||
|
||||
this.content.properties.length === 0 ||
|
||||
fieldIndex >= this.content.properties.length) {
|
||||
return
|
||||
}
|
||||
const validNewIndex = Math.max(0, Math.min(newIndex, newFields.length))
|
||||
|
||||
// If there are no page breaks, everything is on page 0
|
||||
if (this.pageBreakIndices.length === 0) {
|
||||
this.formPageIndex = 0
|
||||
return
|
||||
}
|
||||
|
||||
// Find which page contains this field
|
||||
for (let i = 0; i < this.pageBoundaries.length; i++) {
|
||||
const { start, end } = this.pageBoundaries[i]
|
||||
if (fieldIndex >= start && fieldIndex <= end) {
|
||||
// Only set page if it's different to avoid unnecessary rerenders
|
||||
if (this.formPageIndex !== i) {
|
||||
this.formPageIndex = i
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to last page if field not found in any boundaries
|
||||
const lastPageIndex = this.pageBoundaries.length - 1
|
||||
if (this.formPageIndex !== lastPageIndex) {
|
||||
this.formPageIndex = lastPageIndex
|
||||
}
|
||||
newFields.splice(validNewIndex, 0, field)
|
||||
this.setProperties(newFields)
|
||||
}
|
||||
},
|
||||
history: {}
|
||||
|
|
|
|||
Loading…
Reference in New Issue