diff --git a/api/app/Http/Controllers/Forms/FormPaymentController.php b/api/app/Http/Controllers/Forms/FormPaymentController.php index 18536c77..7cd374da 100644 --- a/api/app/Http/Controllers/Forms/FormPaymentController.php +++ b/api/app/Http/Controllers/Forms/FormPaymentController.php @@ -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'], diff --git a/api/app/Http/Controllers/Forms/PublicFormController.php b/api/app/Http/Controllers/Forms/PublicFormController.php index 3dd542a5..ae4a509d 100644 --- a/api/app/Http/Controllers/Forms/PublicFormController.php +++ b/api/app/Http/Controllers/Forms/PublicFormController.php @@ -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))); } diff --git a/api/app/Http/Requests/AnswerFormRequest.php b/api/app/Http/Requests/AnswerFormRequest.php index fb08658a..29d71252 100644 --- a/api/app/Http/Requests/AnswerFormRequest.php +++ b/api/app/Http/Requests/AnswerFormRequest.php @@ -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; } diff --git a/api/app/Http/Requests/Forms/CreatePaymentIntentRequest.php b/api/app/Http/Requests/Forms/CreatePaymentIntentRequest.php new file mode 100644 index 00000000..a011acdb --- /dev/null +++ b/api/app/Http/Requests/Forms/CreatePaymentIntentRequest.php @@ -0,0 +1,30 @@ +|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. + ]; + } +} diff --git a/api/app/Rules/StorageFile.php b/api/app/Rules/StorageFile.php index 189bd39e..e0ca624f 100644 --- a/api/app/Rules/StorageFile.php +++ b/api/app/Rules/StorageFile.php @@ -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)); diff --git a/api/app/Rules/ValidReCaptcha.php b/api/app/Rules/ValidReCaptcha.php index a889d423..b90260ed 100644 --- a/api/app/Rules/ValidReCaptcha.php +++ b/api/app/Rules/ValidReCaptcha.php @@ -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)) { diff --git a/api/ray.php b/api/ray.php new file mode 100644 index 00000000..e45f69ab --- /dev/null +++ b/api/ray.php @@ -0,0 +1,128 @@ + 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, +]; diff --git a/api/routes/api.php b/api/routes/api.php index c9facc38..aead655f 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -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( diff --git a/api/tests/Feature/Forms/FormPaymentTest.php b/api/tests/Feature/Forms/FormPaymentTest.php index 6b771d49..24a19933 100644 --- a/api/tests/Feature/Forms/FormPaymentTest.php +++ b/api/tests/Feature/Forms/FormPaymentTest.php @@ -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' diff --git a/api/tests/Feature/UserManagementTest.php b/api/tests/Feature/UserManagementTest.php index e3b5531b..59b42d32 100644 --- a/api/tests/Feature/UserManagementTest.php +++ b/api/tests/Feature/UserManagementTest.php @@ -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.', diff --git a/client/.eslintrc.cjs b/client/.eslintrc.cjs deleted file mode 100644 index e6e9b6af..00000000 --- a/client/.eslintrc.cjs +++ /dev/null @@ -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": "^_" }] - }, -}; diff --git a/client/components/forms/DateInput.vue b/client/components/forms/DateInput.vue index 52156168..d81de9c5 100644 --- a/client/components/forms/DateInput.vue +++ b/client/components/forms/DateInput.vue @@ -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 '' } } diff --git a/client/components/forms/FlatSelectInput.vue b/client/components/forms/FlatSelectInput.vue index 83e32839..15d7091f 100644 --- a/client/components/forms/FlatSelectInput.vue +++ b/client/components/forms/FlatSelectInput.vue @@ -24,6 +24,24 @@ @@ -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)] + }, }, } \ No newline at end of file diff --git a/client/components/forms/PaymentInput.client.vue b/client/components/forms/PaymentInput.client.vue index 0efe856c..68fa2c73 100644 --- a/client/components/forms/PaymentInput.client.vue +++ b/client/components/forms/PaymentInput.client.vue @@ -44,10 +44,17 @@ @@ -144,7 +160,12 @@ @@ -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 }) - \ No newline at end of file + diff --git a/client/components/forms/TextInput.vue b/client/components/forms/TextInput.vue index 159a1a0d..d5a50999 100644 --- a/client/components/forms/TextInput.vue +++ b/client/components/forms/TextInput.vue @@ -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 } diff --git a/client/components/forms/components/CaptchaInput.vue b/client/components/forms/components/CaptchaInput.vue index 7b2ab978..145c023f 100644 --- a/client/components/forms/components/CaptchaInput.vue +++ b/client/components/forms/components/CaptchaInput.vue @@ -138,7 +138,7 @@ const resizeIframe = (height) => { try { window.parentIFrame?.size(height) - } catch (e) { + } catch { // Silently handle error } } diff --git a/client/components/forms/components/CaptchaWrapper.vue b/client/components/forms/components/CaptchaWrapper.vue new file mode 100644 index 00000000..ab277887 --- /dev/null +++ b/client/components/forms/components/CaptchaWrapper.vue @@ -0,0 +1,83 @@ + + + \ No newline at end of file diff --git a/client/components/forms/components/HCaptchaV2.vue b/client/components/forms/components/HCaptchaV2.vue index 18d2d4bf..803245c4 100644 --- a/client/components/forms/components/HCaptchaV2.vue +++ b/client/components/forms/components/HCaptchaV2.vue @@ -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() } diff --git a/client/components/forms/components/RecaptchaV2.vue b/client/components/forms/components/RecaptchaV2.vue index 88507787..3f2adc40 100644 --- a/client/components/forms/components/RecaptchaV2.vue +++ b/client/components/forms/components/RecaptchaV2.vue @@ -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() } }) \ No newline at end of file diff --git a/client/components/open/forms/CaptchaWrapper.vue b/client/components/open/forms/CaptchaWrapper.vue new file mode 100644 index 00000000..4705ce1a --- /dev/null +++ b/client/components/open/forms/CaptchaWrapper.vue @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/client/components/open/forms/FormProgressbar.vue b/client/components/open/forms/FormProgressbar.vue index 3a58be22..602e1b53 100644 --- a/client/components/open/forms/FormProgressbar.vue +++ b/client/components/open/forms/FormProgressbar.vue @@ -1,5 +1,5 @@