Stripe Payment (#679)

* oAuth for Stripe

* Stripe Payment Frontend - WIP

* Payment block backend validation and new package for stripe

* change stripe scopes

* update PaymentBlockConfigurationRule

* Set loader on provider modal

* stripe oauth

* PaymentFieldOptions as seprate component

* validate Stripe account

* Payment intent

* Stripe Payment as composable

* confirmCardPayment working

* Set payment errors on form.errors

* Validate card other fields

* Store payment id to database and on submission add link for view payment on stripe

* FormPaymentController no need auth middleware

* paymentinput error display on field

* Make payment block as input
change 'nf-payment' to 'payment'

* Refactor payment processing and error handling

* Multi lang & direction support on payment

* reset card on change direction or local

* use connected account for loadstripe

* validate OAuthProvider before delete it

* payment improvements

* display payment by stripe

* use stripe_currencies.json

* Form Payment testcase

* Enhance form auto-save behavior for payment forms

* Restrict payment block in self-hosted environments

* validate form before process payment

* Refactor Nuxt Configuration for Improved Development Server Settings

- Removed the existing Vite server configuration for hot module replacement (HMR) as it was no longer necessary.
- Introduced a new `devServer` configuration to specify the host and port for the development server, allowing for more flexible environment setup based on environment variables.

These changes aim to streamline the development process by enhancing server configuration and ensuring better adaptability to different environments.

* Enhance Payment Handling and User Experience in Forms

- Refactored `FormPaymentController` to improve handling of Stripe account retrieval, including better error messages for both editor preview and public forms.
- Updated `OAuthProviderController` to utilize caching for OAuth connection context, enhancing performance and user experience during account connections.
- Improved `PaymentInput.client.vue` to display a loading state and a preview message for users, guiding them to save the form for payment activation.
- Modified various components to standardize payment-related messages and improve localization support across multiple languages.
- Removed the deprecated `connections.vue` page to streamline the codebase.

These changes aim to enhance the overall user experience when handling payments and improve the maintainability of the payment-related components.

* Refactor Payment Handling and Enhance User Experience in Forms

- Updated `FormPaymentController` to utilize a new method for checking if the Stripe provider belongs to any workspace user, improving security and error logging.
- Modified `OAuthProviderController` to streamline the OAuth provider creation process by directly using the service object.
- Enhanced `Workspace` model with a new method to verify provider ownership, improving code clarity and maintainability.
- Improved `PaymentInput.client.vue` to handle loading states and error messages more effectively, enhancing user feedback during payment processing.
- Refactored `useFormInput.js` to include an `isAdminPreview` prop for better context handling in form components.

These changes aim to improve the overall user experience when handling payments and enhance the maintainability of the payment-related components.

* Enhance Payment Validation and User Experience in Forms

- Updated `UserFormRequest` to improve workspace handling during form submissions, allowing for better context in validation rules.
- Modified `PaymentBlockConfigurationRule` to include workspace validation, ensuring that payment providers are associated with the correct workspace, enhancing security and error logging.
- Improved `PaymentInput.client.vue` to dynamically determine the success state of payment processing, providing clearer user feedback.
- Updated various localization files to include a payment disclaimer, ensuring users are informed about credit card charges during payment processing.

These changes aim to enhance the overall user experience when handling payments and improve the maintainability of payment-related components.

* Enhance Payment Features and User Experience in Forms

- Added checks in `FormPaymentController` to disable payment features for self-hosted instances, improving clarity for users regarding feature availability.
- Updated `PaymentBlockConfigurationRule` to change the minimum amount validation from 0.5 to 1, ensuring stricter payment requirements.
- Enhanced `PaymentInput.client.vue` with dark mode support for various UI elements, improving accessibility and user experience in different themes.
- Modified `useFormInput.js` to include an `isDark` prop, allowing for better theme handling in form components.
- Updated error messages in `useStripeElements.js` to include periods for consistency and improved user feedback.

These changes aim to enhance the overall user experience when handling payments and improve the maintainability of payment-related components.

* Enhance Payment Input Component with Focus Handling and Theme Support

- Updated `PaymentInput.client.vue` to include focus and blur event handlers, improving user interaction by visually indicating when the card input is focused.
- Enhanced theme support by adding new properties in `form-themes.js` for `PaymentInput`, allowing for better styling and transitions based on focus state.
- Introduced a new `isCardFocused` reactive reference to manage the focus state of the card input, enhancing the overall user experience.

These changes aim to improve the usability and visual feedback of the payment input component, aligning with recent enhancements to user experience in payment forms.

* Refactor Payment Handling and Improve Code Consistency

- Updated various files to enhance code consistency by adding spaces in arrow function definitions, improving readability and adhering to coding standards.
- Modified `PaymentBlockConfigurationRule.php`, `FormPaymentController.php`, and `Workspace.php` to ensure uniformity in the use of arrow functions.
- Enhanced `PaymentInput.client.vue` and other components by improving the formatting of template elements for better visual structure.
- Updated `useStripeElements.js` to streamline state management and improve clarity in the handling of Stripe elements.

These changes aim to improve code maintainability and readability across the payment handling components, ensuring a more consistent coding style throughout the codebase.

* Enhance Form Model and Logging Configuration

- Added a new 'auto_save' boolean property to the Form model, allowing for automatic saving of form data.
- Updated the logging configuration to include a 'combined' channel that stacks multiple log channels, improving logging flexibility and error tracking.
- Modified the FormFactory to set a default value for 'auto_save' to false, ensuring consistent behavior across form instances.
- Improved error message structure in FormPaymentTest to provide clearer feedback when a payment block is missing.

These changes aim to enhance the functionality of forms and improve logging capabilities, contributing to better maintainability and user experience.

* Update api/config/logging.php

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Refactor Payment Error Handling and Localization Updates

- Updated `FormPaymentTest.php` to improve code consistency by adding spaces in arrow function definitions, enhancing readability.
- Modified `useStripeElements.js` to replace hardcoded error messages with localized strings, improving user experience and maintainability.
- Enhanced various localization files (e.g., `en.json`, `fr.json`, `de.json`, etc.) to include new error messages related to payment processing, ensuring users receive clear and consistent feedback across different languages.

These changes aim to enhance the clarity of error handling in payment processes and improve the overall user experience through better localization support.

* Enhance AddFormBlock and FieldOptions with Authentication Checks

- Added an icon to indicate authentication requirements for blocks in `AddFormBlock.vue`, improving user awareness of access restrictions.
- Implemented alert notifications using `useAlert()` for unauthorized block additions and input cloning, enhancing user feedback.
- Updated `FieldOptions.vue` to conditionally render payment field options, ensuring relevant options are displayed based on the field type.
- Modified `blocks_types.json` to include an `auth_required` property for specific block types, facilitating authentication checks.

These changes aim to improve user experience by providing clear indications of authentication requirements and enhancing form functionality.

* Enhance Authentication Checks in AddFormBlock Component

- Updated `AddFormBlock.vue` to conditionally render authentication-required icons based on user authentication status, improving user awareness of access restrictions.
- Implemented additional authentication checks in the `addBlock` and `handleInputClone` functions, utilizing `useAlert()` for notifying users when login is required to add blocks or clone inputs.
- Introduced a computed property to manage the authenticated state, streamlining the authentication logic within the component.

These changes aim to enhance user experience by providing clear indications of authentication requirements and improving the functionality of the form component.

* Enhance PaymentInput Component with Disabled State Support

- Updated `PaymentInput.client.vue` to include a `disabled` prop for the card holder name and email inputs, improving form accessibility and user experience by preventing interaction when necessary.
- Modified the card options to respect the `disabled` state, ensuring consistent behavior across the payment input fields.

These changes aim to enhance the usability of the payment input component by providing better control over user interactions.

* Add Payment Condition Logic and Update Filters

- Introduced a new payment condition in `FormLogicConditionChecker.php` to handle 'paid' and 'not_paid' states, enhancing form logic capabilities.
- Added corresponding payment comparators in `open_filters.json` for both API and client, ensuring consistent validation and expected types for payment conditions.
- Updated the JavaScript logic in `FormLogicConditionChecker.js` to include the new payment condition checks, improving the overall functionality of form conditions.

These changes aim to enhance the form logic related to payment states, providing better validation and user experience in payment-related forms.

* Refactor Authentication Checks in AddFormBlock and Working Form Store

- Removed redundant authentication checks from `AddFormBlock.vue` for adding blocks and cloning inputs, streamlining the logic.
- Centralized authentication validation in `working_form.js` to ensure consistent user feedback when authentication is required for specific block types.
- Enhanced user experience by utilizing `useAlert()` for notifying users about login requirements, improving clarity and interaction.

These changes aim to simplify the authentication logic and improve user notifications regarding access restrictions in form components.

* Refactor Feature Flags and Update Payment Input Logic

- Updated `FeatureFlagsController.php` to utilize the `Cache` facade directly, improving code clarity and consistency.
- Modified `PaymentInput.client.vue` to enhance the display logic for payment previews, ensuring a better user experience by conditionally showing messages based on the state of the Stripe account.
- Removed the `STRIPE_PUBLISHABLE_KEY` from `runtimeConfig.js` to streamline the configuration and replaced it with a computed property that retrieves the key from feature flags, improving maintainability.
- Adjusted the `.env.example` file to maintain consistency in environment variable definitions.

These changes aim to enhance the clarity of feature flag management and improve the user experience in payment interactions by refining the logic and configuration handling.

* Update Stripe Configuration in Services

- Modified the `services.php` configuration file to enhance the Stripe integration by providing default values for `client_secret` and `redirect` URI. This change ensures that the application can fallback to a predefined secret and a specific callback URL, improving the robustness of the payment service setup.

These changes aim to streamline the configuration process for Stripe, ensuring that necessary values are always available for the application to function correctly.

---------

Co-authored-by: Chirag Chhatrala <chirag.chhatrala@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Julien Nahum 2025-04-10 12:04:25 +02:00 committed by GitHub
parent e14d1003ee
commit 5cb07191db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 5228 additions and 764 deletions

View File

@ -98,3 +98,7 @@ TELEGRAM_BOT_ID=
TELEGRAM_BOT_TOKEN=
ZAPIER_ENABLED=false
STRIPE_CLIENT_ID=
STRIPE_CLIENT_SECRET=
STRIPE_REDIRECT_URI=http://localhost:3000/settings/connections/callback/stripe

View File

@ -18,6 +18,7 @@ class FeatureFlagsController extends Controller
'billing' => [
'enabled' => !empty(config('cashier.key')) && !empty(config('cashier.secret')),
'appsumo' => !empty(config('services.appsumo.api_key')) && !empty(config('services.appsumo.api_secret')),
'stripe_publishable_key' => config('cashier.key'),
],
'storage' => [
'local' => config('filesystems.default') === 'local',

View File

@ -0,0 +1,164 @@
<?php
namespace App\Http\Controllers\Forms;
use App\Http\Controllers\Controller;
use App\Models\OAuthProvider;
use App\Http\Requests\Forms\GetStripeAccountRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Stripe\Stripe;
use Stripe\PaymentIntent;
class FormPaymentController extends Controller
{
/**
* Get the Stripe Connect Account ID for the form's payment block.
*
* Handles two cases:
* 1. Editor Preview: `oauth_provider_id` is provided in the request by an authenticated user.
* 2. Public Form/Saved: Loads the ID from the form's saved payment block properties.
*
* @param GetStripeAccountRequest $request
* @return \Illuminate\Http\JsonResponse
*/
public function getAccount(GetStripeAccountRequest $request)
{
// Disable payment features on self-hosted instances
if (config('app.self_hosted')) {
return $this->error(['message' => 'Payment features are not available in the self-hosted version.'], 403);
}
$form = $request->form;
$provider = null;
// Case 1: Editor Preview (Validated by GetStripeAccountRequest)
if ($request->isPreview()) {
$provider = $request->getPreviewProvider();
if ($provider === null) {
// Should not happen if validation passes, but defensively handle
return $this->error(['message' => 'Invalid Stripe account selection.'], 400);
}
}
// Case 2: Public Form / Loading from Saved Form Data
else {
$paymentBlock = collect($form->properties)->first(fn ($prop) => $prop['type'] === 'payment');
if (!$paymentBlock || !isset($paymentBlock['stripe_account_id'])) {
// Allow preview by returning a specific, non-blocking error message
if (Auth::check()) { // Only return this hint to authenticated users (in editor)
return $this->error(['message' => 'Please save the form and try again.'], 400);
} else {
return $this->error(['message' => 'Payment configuration not found.'], 404); // Public error
}
}
// Load provider based on saved ID
$provider = OAuthProvider::find($paymentBlock['stripe_account_id']);
if ($provider === null) {
return $this->error(['message' => 'Configured Stripe account not found.'], 404);
}
// Use the new workspace method to check if the provider belongs to *any* workspace user
if (!$form->workspace->hasProvider($provider->id)) {
Log::error('User attempted to use Stripe account not associated with the workspace', [
'form_id' => $form->id,
'stripe_account_id' => $paymentBlock['stripe_account_id'],
'provider_id' => $provider->id,
'workspace_id' => $form->workspace_id,
'auth_user_id' => Auth::id(), // Keep auth user for logging context
]);
return $this->error(['message' => 'The configured Stripe account is not associated with this workspace.'], 403);
}
}
// Return the Stripe Connect Account ID
return $this->success(['stripeAccount' => $provider->provider_user_id]);
}
public function createIntent(Request $request)
{
// Disable payment features on self-hosted instances
if (config('app.self_hosted')) {
return $this->error(['message' => 'Payment features are not available in the self-hosted version.'], 403);
}
$form = $request->form;
// Verify form exists and is accessible
if ($form->workspace === null || $form->visibility !== 'public') {
Log::warning('Attempt to create payment for invalid form', [
'form_id' => $form->id
]);
return $this->error(['message' => 'Form not found.'], 404);
}
// Get payment block (only one allowed)
$paymentBlock = collect($form->properties)->first(fn ($prop) => $prop['type'] === 'payment');
if (!$paymentBlock) {
Log::warning('Attempt to create payment for form without payment block', [
'form_id' => $form->id
]);
return $this->error(['message' => 'Form does not have a payment block. If you just added a payment block, please save the form and try again.']);
}
// Get provider
$provider = OAuthProvider::find($paymentBlock['stripe_account_id']);
if ($provider === null) {
Log::error('Failed to find Stripe account', [
'stripe_account_id' => $paymentBlock['stripe_account_id']
]);
return $this->error(['message' => 'Failed to find Stripe account']);
}
try {
Log::info('Creating payment intent', [
'form_id' => $form->id,
'amount' => $paymentBlock['amount'],
'currency' => $paymentBlock['currency']
]);
Stripe::setApiKey(config('cashier.secret'));
$intent = PaymentIntent::create([
'description' => 'Form - ' . $form->title,
'amount' => (int) ($paymentBlock['amount'] * 100), // Stripe requires amount in cents
'currency' => strtolower($paymentBlock['currency']),
'payment_method_types' => ['card'],
'metadata' => [
'form_id' => $form->id,
'workspace_id' => $form->workspace_id,
'form_name' => $form->title,
],
], [
'stripe_account' => $provider->provider_user_id
]);
Log::info('Payment intent created', [
'form_id' => $form->id,
'intent' => $intent
]);
if ($intent->id) {
return $this->success([
'intent' => ['id' => $intent->id, 'secret' => $intent->client_secret]
]);
} else {
return $this->error(['message' => 'Failed to create payment intent']);
}
} catch (\Stripe\Exception\CardException $e) {
Log::warning('Failed to create payment intent', [
'form_id' => $form->id,
'message' => $e->getMessage()
]);
return $this->error(['message' => $e->getMessage()]);
} catch (\Exception $e) {
Log::error('Failed to create payment intent', [
'form_id' => $form->id,
'error' => $e->getMessage()
]);
return $this->error(['message' => 'Failed to initialize payment.']);
}
}
}

View File

@ -8,6 +8,7 @@ use App\Integrations\OAuth\OAuthProviderService;
use App\Models\OAuthProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
class OAuthProviderController extends Controller
{
@ -24,7 +25,11 @@ class OAuthProviderController extends Controller
public function connect(Request $request, OAuthProviderService $service)
{
$userId = Auth::id();
cache()->put("oauth-intention:{$userId}", $request->input('intention'), 60 * 5);
$context = [
'intention' => $request->input('intention'),
'autoClose' => $request->boolean('autoClose', false),
];
Cache::put("oauth-context:{$userId}", $context, now()->addMinutes(5));
// Connecting an account for integrations purposes
// Adding full scopes to the driver
@ -33,27 +38,44 @@ class OAuthProviderController extends Controller
]);
}
public function handleRedirect(OAuthProviderService $service)
public function handleRedirect(Request $request, OAuthProviderService $service)
{
$userId = Auth::id();
$context = Cache::pull("oauth-context:{$userId}", [
'intention' => null,
'autoClose' => false
]);
$autoClose = $context['autoClose'];
$intention = $context['intention'];
try {
$driverUser = $service->getDriver()->getUser();
$provider = OAuthProvider::query()
->updateOrCreate(
[
'user_id' => Auth::id(),
'user_id' => $userId,
'provider' => $service,
'provider_user_id' => $driverUser->getId(),
],
[
'access_token' => $driverUser->token,
'refresh_token' => $driverUser->refreshToken,
'name' => $driverUser->getName(),
'name' => $driverUser->getName() ?? $driverUser->getNickname(),
'email' => $driverUser->getEmail(),
'scopes' => $driverUser->approvedScopes
'scopes' => $driverUser->approvedScopes ?? [],
]
);
return OAuthProviderResource::make($provider);
return response()->json([
'provider' => OAuthProviderResource::make($provider),
'autoClose' => $autoClose,
'intention' => $intention,
]);
} catch (\Exception $e) {
report($e);
return response()->json(['message' => 'Failed to connect the account. Please try again.'], 400);
}
}
public function handleWidgetRedirect(OAuthProviderService $service, Request $request)

View File

@ -0,0 +1,86 @@
<?php
namespace App\Http\Requests\Forms;
use App\Models\OAuthProvider;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
class GetStripeAccountRequest extends FormRequest
{
protected ?OAuthProvider $previewProvider = null;
/**
* Determine if the user is authorized to make this request.
*
* Public forms can access this (without oauth_provider_id).
* Authenticated users can access with oauth_provider_id.
*
* @return bool
*/
public function authorize(): bool
{
// If oauth_provider_id is present, user must be authenticated
if ($this->has('oauth_provider_id')) {
return Auth::check();
}
// Otherwise, allow public access (for loading from saved form)
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
// Validate oauth_provider_id only if it's present
'oauth_provider_id' => [
'sometimes',
'required',
'integer',
Rule::exists('oauth_providers', 'id')->where(function ($query) {
// Ensure the provider belongs to the authenticated user if provided
if (Auth::check()) {
$query->where('user_id', Auth::id());
}
}),
],
];
}
/**
* Check if the request is for an editor preview.
*
* @return bool
*/
public function isPreview(): bool
{
// It's a preview if the provider ID is present and the user is authenticated
// The main validation rules will handle invalid/unowned IDs.
return $this->has('oauth_provider_id') && Auth::check();
}
/**
* Get the validated OAuthProvider for the preview.
*
* @return OAuthProvider|null
*/
public function getPreviewProvider(): ?OAuthProvider
{
if (!$this->isPreview()) {
return null;
}
// Cache the loaded provider to avoid redundant queries
if ($this->previewProvider === null) {
$this->previewProvider = OAuthProvider::find($this->input('oauth_provider_id'));
}
return $this->previewProvider;
}
}

View File

@ -5,6 +5,7 @@ namespace App\Http\Requests;
use App\Http\Requests\Workspace\CustomDomainRequest;
use App\Models\Forms\Form;
use App\Rules\FormPropertyLogicRule;
use App\Rules\PaymentBlockConfigurationRule;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Illuminate\Contracts\Validation\Validator;
@ -71,6 +72,22 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest
*/
public function rules()
{
// Get the workspace from the form being updated or the current user's workspace
$workspace = null;
// For update requests, try to get the workspace from the form
if ($this->route('form')) {
$workspace = $this->route('form')->workspace;
}
// For create requests, get the workspace from the workspace parameter
elseif ($this->route('workspace')) {
$workspace = $this->route('workspace');
}
// Otherwise, try to get from the request attribute
elseif ($this->get('workspace_id')) {
$workspace = \App\Models\Workspace::find($this->get('workspace_id'));
}
return [
// Form Info
'title' => 'required|string|max:60',
@ -119,7 +136,7 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest
'properties' => 'required|array',
'properties.*.id' => 'required',
'properties.*.name' => 'required',
'properties.*.type' => 'required',
'properties.*.type' => ['required', new PaymentBlockConfigurationRule($this->properties, $workspace)],
'properties.*.placeholder' => 'sometimes|nullable',
'properties.*.prefill' => 'sometimes|nullable',
'properties.*.help' => 'sometimes|nullable',

View File

@ -49,6 +49,7 @@ class FormResource extends JsonResource
'max_number_of_submissions_reached' => $this->max_number_of_submissions_reached,
'form_pending_submission_key' => $this->form_pending_submission_key,
'max_file_size' => $this->max_file_size / 1000000,
'auto_save' => $this->getAutoSave(),
]);
}
@ -112,4 +113,16 @@ class FormResource extends JsonResource
{
return $this->extra?->cleanings ?? $this->cleanings;
}
private function hasPaymentBlock()
{
return array_filter($this->properties, function ($property) {
return $property['type'] === 'payment';
});
}
private function getAutoSave()
{
return $this->hasPaymentBlock() ? true : $this->auto_save;
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Integrations\OAuth\Drivers;
use App\Integrations\OAuth\Drivers\Contracts\OAuthDriver;
use Laravel\Socialite\Contracts\User;
use Laravel\Socialite\Facades\Socialite;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use SocialiteProviders\Stripe\Provider as StripeProvider;
class OAuthStripeDriver implements OAuthDriver
{
private ?string $redirectUrl = null;
private ?array $scopes = [];
protected StripeProvider $provider;
public function __construct()
{
$this->provider = Socialite::driver('stripe');
}
public function getRedirectUrl(): string
{
$user = Auth::user();
$params = [
'stripe_user[email]' => $user->email,
'stripe_user[url]' => config('app.url'),
'stripe_user[business_name]' => $user->name,
];
Log::info('Initiating Stripe Connect flow', [
'user_id' => $user->id
]);
return $this->provider
->scopes($this->scopes ?? [])
->stateless()
->redirectUrl($this->redirectUrl ?? config('services.stripe.redirect'))
->with($params)
->redirect()
->getTargetUrl();
}
public function getUser(): User
{
return $this->provider
->stateless()
->redirectUrl($this->redirectUrl ?? config('services.stripe.redirect'))
->user();
}
public function canCreateUser(): bool
{
return true;
}
public function setRedirectUrl(string $url): OAuthDriver
{
$this->redirectUrl = $url;
return $this;
}
public function setScopes(array $scopes): OAuthDriver
{
$this->scopes = $scopes;
return $this;
}
public function fullScopes(): OAuthDriver
{
return $this->setScopes([
'read_write',
]);
}
}

View File

@ -4,17 +4,20 @@ namespace App\Integrations\OAuth;
use App\Integrations\OAuth\Drivers\Contracts\OAuthDriver;
use App\Integrations\OAuth\Drivers\OAuthGoogleDriver;
use App\Integrations\OAuth\Drivers\OAuthStripeDriver;
use App\Integrations\OAuth\Drivers\OAuthTelegramDriver;
enum OAuthProviderService: string
{
case Google = 'google';
case Stripe = 'stripe';
case Telegram = 'telegram';
public function getDriver(): OAuthDriver
{
return match ($this) {
self::Google => new OAuthGoogleDriver(),
self::Stripe => new OAuthStripeDriver(),
self::Telegram => new OAuthTelegramDriver(),
};
}

View File

@ -109,7 +109,8 @@ class Form extends Model implements CachableAttributes
'closes_at' => 'datetime',
'tags' => 'array',
'removed_properties' => 'array',
'seo_meta' => 'object'
'seo_meta' => 'object',
'auto_save' => 'boolean',
];
}

View File

@ -204,6 +204,20 @@ class Workspace extends Model implements CachableAttributes
return $this->hasMany(Form::class);
}
/**
* Check if the given OAuthProvider ID belongs to any user in this workspace.
*
* @param int $providerId
* @return bool
*/
public function hasProvider(int $providerId): bool
{
// Check if there's an intersection between workspace users and the provider owner
return $this->users()->whereHas('oauthProviders', function ($query) use ($providerId) {
$query->where('id', $providerId);
})->exists();
}
public function isReadonlyUser(?User $user)
{
return $user ? $this->users()

View File

@ -2,6 +2,7 @@
namespace App\Policies;
use App\Integrations\OAuth\OAuthProviderService;
use App\Models\Integration\FormIntegration;
use App\Models\OAuthProvider;
use App\Models\User;
@ -62,6 +63,20 @@ class OAuthProviderPolicy
if ($integrations->count() > 0) {
return $this->denyWithStatus(400, 'This connection cannot be removed because there is already an integration using it.');
}
if ($provider->provider->value === OAuthProviderService::Stripe->value) {
$formsUsingStripe = $user->forms()
->get()
->filter(function ($form) use ($provider) {
return collect($form->properties)
->some(fn ($prop) => ($prop['stripe_account_id'] ?? null) === $provider->id);
})
->isNotEmpty();
if ($formsUsingStripe) {
return $this->denyWithStatus(400, 'This Stripe connection cannot be removed because it is being used in a form payment field.');
}
}
return $provider->user()->is($user);
}

View File

@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Laravel\Cashier\Cashier;
use Laravel\Dusk\DuskServiceProvider;
@ -40,6 +41,10 @@ class AppServiceProvider extends ServiceProvider
}
Validator::includeUnvalidatedArrayKeys();
Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
$event->extendSocialite('stripe', \SocialiteProviders\Stripe\Provider::class);
});
}
/**

View File

@ -0,0 +1,94 @@
<?php
namespace App\Rules;
use App\Models\OAuthProvider;
use App\Models\Workspace;
use Closure;
use Illuminate\Support\Facades\Log;
use Illuminate\Contracts\Validation\ValidationRule;
class PaymentBlockConfigurationRule implements ValidationRule
{
protected array $properties;
protected array $field;
protected ?Workspace $workspace;
public function __construct(array $properties, ?Workspace $workspace = null)
{
$this->properties = $properties;
$this->workspace = $workspace;
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// Set the field
$fieldIndex = explode('.', $attribute)[1];
$this->field = $this->properties[$fieldIndex];
if ($this->field['type'] !== 'payment') {
return; // If not a payment block, validation passes
}
// Payment block not allowed if self hosted
if (config('app.self_hosted')) {
$fail('Payment block is not allowed on self hosted. Please use our hosted version.');
return;
}
// Only one payment block allowed
$paymentBlocks = collect($this->properties)
->filter(fn ($prop) => $prop['type'] === 'payment')
->count();
if ($paymentBlocks > 1) {
$fail('Only one payment block allowed');
return;
}
// Amount validation
if (!isset($this->field['amount']) || !is_numeric($this->field['amount']) || $this->field['amount'] < 1) {
$fail('Amount must be a number greater than 1');
return;
}
// Currency validation
$stripeCurrencies = json_decode(file_get_contents(resource_path('data/stripe_currencies.json')), true);
if (!isset($this->field['currency']) || !in_array(strtoupper($this->field['currency']), array_column($stripeCurrencies, 'code'))) {
$fail('Currency must be a valid currency');
return;
}
// Stripe account validation
if (!isset($this->field['stripe_account_id']) || empty($this->field['stripe_account_id'])) {
$fail('Stripe account is required');
return;
}
try {
$provider = OAuthProvider::find($this->field['stripe_account_id']);
if ($provider === null) {
$fail('Failed to validate Stripe account');
return;
}
// Check if the provider is associated with the workspace (if workspace is provided)
if ($this->workspace && !$this->workspace->hasProvider($provider->id)) {
Log::error('Attempted to use Stripe account not associated with the workspace', [
'stripe_account_id' => $this->field['stripe_account_id'],
'provider_id' => $provider->id,
'workspace_id' => $this->workspace->id,
]);
$fail('The configured Stripe account is not associated with this workspace');
return;
}
} catch (\Exception $e) {
Log::error('Failed to validate Stripe account', [
'error' => $e->getMessage(),
'account_id' => $this->field['stripe_account_id']
]);
$fail('Failed to validate Stripe account');
return;
}
}
}

View File

@ -76,6 +76,8 @@ class FormLogicConditionChecker
return $this->filesConditionMet($propertyCondition, $value);
case 'matrix':
return $this->matrixConditionMet($propertyCondition, $value);
case 'payment':
return $this->paymentConditionMet($propertyCondition, $value);
}
return false;
@ -554,4 +556,21 @@ class FormLogicConditionChecker
return false;
}
private function paymentConditionMet(array $propertyCondition, $value): bool
{
switch ($propertyCondition['operator']) {
case 'paid':
return $this->checkPaid($propertyCondition, $value);
case 'not_paid':
return !$this->checkPaid($propertyCondition, $value);
}
return false;
}
private function checkPaid($propertyCondition, $value): bool
{
return ($value) ? str_starts_with($value, 'pi_') : false;
}
}

View File

@ -16,6 +16,7 @@
"ext-json": "*",
"aws/aws-sdk-php": "*",
"doctrine/dbal": "*",
"fakerphp/faker": "^1.23",
"giggsey/libphonenumber-for-php": "*",
"google/apiclient": "^2.16",
"guzzlehttp/guzzle": "*",
@ -33,14 +34,14 @@
"openai-php/client": "*",
"propaganistas/laravel-disposable-email": "*",
"sentry/sentry-laravel": "*",
"socialiteproviders/stripe": "^4.1",
"spatie/laravel-data": "^4.6",
"spatie/laravel-ray": "*",
"spatie/laravel-sitemap": "*",
"spatie/laravel-sluggable": "*",
"stevebauman/purify": "*",
"tymon/jwt-auth": "*",
"vinkla/hashids": "*",
"fakerphp/faker": "^1.23",
"spatie/laravel-ray": "*"
"vinkla/hashids": "*"
},
"require-dev": {
"barryvdh/laravel-ide-helper": "^3.0.0",

117
api/composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "37a321177c832a3f7c1639ad9ae1583d",
"content-hash": "344980a97a80d1594bc4679b28bd6209",
"packages": [
{
"name": "amphp/amp",
@ -8225,6 +8225,121 @@
],
"time": "2025-01-23T12:35:37+00:00"
},
{
"name": "socialiteproviders/manager",
"version": "v4.8.0",
"source": {
"type": "git",
"url": "https://github.com/SocialiteProviders/Manager.git",
"reference": "e93acc38f8464cc775a2b8bf09df311d1fdfefcb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/e93acc38f8464cc775a2b8bf09df311d1fdfefcb",
"reference": "e93acc38f8464cc775a2b8bf09df311d1fdfefcb",
"shasum": ""
},
"require": {
"illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0",
"laravel/socialite": "^5.5",
"php": "^8.1"
},
"require-dev": {
"mockery/mockery": "^1.2",
"phpunit/phpunit": "^9.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"SocialiteProviders\\Manager\\ServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"SocialiteProviders\\Manager\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Andy Wendt",
"email": "andy@awendt.com"
},
{
"name": "Anton Komarev",
"email": "a.komarev@cybercog.su"
},
{
"name": "Miguel Piedrafita",
"email": "soy@miguelpiedrafita.com"
},
{
"name": "atymic",
"email": "atymicq@gmail.com",
"homepage": "https://atymic.dev"
}
],
"description": "Easily add new or override built-in providers in Laravel Socialite.",
"homepage": "https://socialiteproviders.com",
"keywords": [
"laravel",
"manager",
"oauth",
"providers",
"socialite"
],
"support": {
"issues": "https://github.com/socialiteproviders/manager/issues",
"source": "https://github.com/socialiteproviders/manager"
},
"time": "2025-01-03T09:40:37+00:00"
},
{
"name": "socialiteproviders/stripe",
"version": "4.1.1",
"source": {
"type": "git",
"url": "https://github.com/SocialiteProviders/Stripe.git",
"reference": "6ba1e2ec1841db090827e034f7e659c3068e4234"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SocialiteProviders/Stripe/zipball/6ba1e2ec1841db090827e034f7e659c3068e4234",
"reference": "6ba1e2ec1841db090827e034f7e659c3068e4234",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^7.2 || ^8.0",
"socialiteproviders/manager": "~4.0"
},
"type": "library",
"autoload": {
"psr-4": {
"SocialiteProviders\\Stripe\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Brian Faust",
"email": "hello@brianfaust.de"
}
],
"description": "Stripe OAuth2 Provider for Laravel Socialite",
"support": {
"source": "https://github.com/SocialiteProviders/Stripe/tree/4.1.1"
},
"time": "2021-05-31T07:30:21+00:00"
},
{
"name": "spatie/backtrace",
"version": "1.7.1",

View File

@ -41,6 +41,12 @@ return [
'ignore_exceptions' => false,
],
'combined' => [
'driver' => 'stack',
'channels' => [env('LOG_CHANNEL') === 'combined' ? 'stack' : env('LOG_CHANNEL', 'stack'), 'slack'],
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),

View File

@ -88,4 +88,9 @@ return [
'enabled' => env('ZAPIER_ENABLED', false),
],
'stripe' => [
'client_id' => env('STRIPE_CLIENT_ID'),
'client_secret' => env('STRIPE_CLIENT_SECRET', env('STRIPE_SECRET')),
'redirect' => env('STRIPE_REDIRECT_URI', front_url('/settings/connections/callback/stripe')),
]
];

View File

@ -71,6 +71,7 @@ class FormFactory extends Factory
'tags' => [],
'editable_submissions_button_text' => 'Edit submission',
'confetti_on_submission' => false,
'auto_save' => false,
'seo_meta' => [],
];
}

View File

@ -884,5 +884,23 @@
"expected_type": "number"
}
}
},
"payment": {
"comparators": {
"paid": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [true]
}
},
"not_paid": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [true]
}
}
}
}
}

View File

@ -0,0 +1,197 @@
[
{
"name": "AED - UAE Dirham",
"code": "AED",
"symbol": "د.إ"
},
{
"name": "AUD - Australian Dollar",
"code": "AUD",
"symbol": "A$"
},
{
"name": "BGN - Bulgarian Lev",
"code": "BGN",
"symbol": "лв"
},
{
"name": "BRL - Brazilian Real",
"code": "BRL",
"symbol": "R$"
},
{
"name": "CAD - Canadian Dollar",
"code": "CAD",
"symbol": "C$"
},
{
"name": "CHF - Swiss Franc",
"code": "CHF",
"symbol": "CHF"
},
{
"name": "CNY - Yuan Renminbi",
"code": "CNY",
"symbol": "¥"
},
{
"name": "CZK - Czech Koruna",
"code": "CZK",
"symbol": "Kč"
},
{
"name": "DKK - Danish Krone",
"code": "DKK",
"symbol": "kr"
},
{
"name": "EUR - Euro",
"code": "EUR",
"symbol": "€"
},
{
"name": "GBP - Pound Sterling",
"code": "GBP",
"symbol": "£"
},
{
"name": "HKD - Hong Kong Dollar",
"code": "HKD",
"symbol": "HK$"
},
{
"name": "HRK - Croatian Kuna",
"code": "HRK",
"symbol": "kn"
},
{
"name": "HUF - Hungarian Forint",
"code": "HUF",
"symbol": "Ft"
},
{
"name": "IDR - Indonesian Rupiah",
"code": "IDR",
"symbol": "Rp"
},
{
"name": "ILS - Israeli Shekel",
"code": "ILS",
"symbol": "₪"
},
{
"name": "INR - Indian Rupee",
"code": "INR",
"symbol": "₹"
},
{
"name": "ISK - Icelandic Króna",
"code": "ISK",
"symbol": "kr"
},
{
"name": "JPY - Japanese Yen",
"code": "JPY",
"symbol": "¥"
},
{
"name": "KRW - South Korean Won",
"code": "KRW",
"symbol": "₩"
},
{
"name": "MAD - Moroccan Dirham",
"code": "MAD",
"symbol": "د.م."
},
{
"name": "MXN - Mexican Peso",
"code": "MXN",
"symbol": "$"
},
{
"name": "MYR - Malaysian Ringgit",
"code": "MYR",
"symbol": "RM"
},
{
"name": "NOK - Norwegian Krone",
"code": "NOK",
"symbol": "kr"
},
{
"name": "NZD - New Zealand Dollar",
"code": "NZD",
"symbol": "NZ$"
},
{
"name": "PHP - Philippine Peso",
"code": "PHP",
"symbol": "₱"
},
{
"name": "PLN - Polish Złoty",
"code": "PLN",
"symbol": "zł"
},
{
"name": "RON - Romanian Leu",
"code": "RON",
"symbol": "lei"
},
{
"name": "RSD - Serbian Dinar",
"code": "RSD",
"symbol": "дин."
},
{
"name": "RUB - Russian Rouble",
"code": "RUB",
"symbol": "₽"
},
{
"name": "SAR - Saudi Riyal",
"code": "SAR",
"symbol": "﷼"
},
{
"name": "SEK - Swedish Krona",
"code": "SEK",
"symbol": "kr"
},
{
"name": "SGD - Singapore Dollar",
"code": "SGD",
"symbol": "S$"
},
{
"name": "THB - Thai Baht",
"code": "THB",
"symbol": "฿"
},
{
"name": "TWD - New Taiwan Dollar",
"code": "TWD",
"symbol": "NT$"
},
{
"name": "UAH - Ukrainian Hryvnia",
"code": "UAH",
"symbol": "₴"
},
{
"name": "USD - United States Dollar",
"code": "USD",
"symbol": "$"
},
{
"name": "VND - Vietnamese Dong",
"code": "VND",
"symbol": "₫"
},
{
"name": "ZAR - South African Rand",
"code": "ZAR",
"symbol": "R"
}
]

View File

@ -22,6 +22,7 @@ use App\Http\Controllers\Settings\TokenController;
use App\Http\Controllers\SubscriptionController;
use App\Http\Controllers\Forms\TemplateController;
use App\Http\Controllers\Auth\UserInviteController;
use App\Http\Controllers\Forms\FormPaymentController;
use App\Http\Controllers\WorkspaceController;
use App\Http\Controllers\WorkspaceUserController;
use App\Http\Middleware\Form\ResolveFormMiddleware;
@ -296,6 +297,8 @@ Route::group(['prefix' => 'appsumo'], function () {
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);
// Form content endpoints (user lists, relation lists etc.)
Route::get(

View File

@ -0,0 +1,83 @@
<?php
use Illuminate\Testing\Fluent\AssertableJson;
use App\Models\OAuthProvider;
beforeEach(function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
// Create OAuth provider for Stripe
$this->stripeAccount = OAuthProvider::factory()->for($user)->create([
'provider' => 'stripe',
'provider_user_id' => 'acct_1LhEwZCragdZygxE'
]);
// Create form with payment block
$this->form = $this->createForm($user, $workspace);
$this->form->properties = array_merge($this->form->properties, [
[
'type' => 'payment',
'stripe_account_id' => $this->stripeAccount->id,
'amount' => 99.99,
'currency' => 'USD'
]
]);
$this->form->update();
});
it('can get stripe account for form', function () {
$this->getJson(route('forms.stripe-connect.get-account', $this->form->slug))
->assertSuccessful()
->assertJson(function (AssertableJson $json) {
return $json->has('stripeAccount')
->where('stripeAccount', fn ($id) => str_starts_with($id, 'acct_'))
->etc();
});
});
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))
->assertStatus(404)
->assertJson([
'message' => 'Form not found.'
]);
});
it('cannot create payment intent for form without payment block', function () {
// Remove payment block entirely
$properties = collect($this->form->properties)
->reject(fn ($block) => $block['type'] === 'payment')
->values()
->all();
$this->form->update(['properties' => $properties]);
$this->getJson(route('forms.stripe-connect.create-intent', $this->form->slug))
->assertStatus(400)
->assertJson([
'type' => 'error',
'message' => 'Form does not have a payment block. If you just added a payment block, please save the form and try again.'
]);
});
it('cannot create payment intent with invalid stripe account', function () {
// Update payment block with non-existent stripe account
$properties = collect($this->form->properties)->map(function ($block) {
if ($block['type'] === 'payment') {
$block['stripe_account_id'] = 999999;
}
return $block;
})->all();
$this->form->update(['properties' => $properties]);
$this->getJson(route('forms.stripe-connect.create-intent', $this->form->slug))
->assertStatus(400)
->assertJson([
'message' => 'Failed to find Stripe account'
]);
});

View File

@ -0,0 +1,416 @@
<template>
<InputWrapper v-bind="inputWrapperProps">
<template #label>
<slot name="label" />
</template>
<div
:class="[
theme.default.input,
theme.default.borderRadius,
theme.default.spacing.horizontal,
theme.default.spacing.vertical,
theme.default.fontSize,
{
'!ring-red-500 !ring-2 !border-transparent': hasError,
'!cursor-not-allowed !bg-gray-200 dark:!bg-gray-800': disabled,
},
'dark:bg-gray-900 dark:border-gray-700 dark:text-gray-200'
]"
>
<div v-if="!oauthProviderId">
<div class="space-y-4 mt-3">
<div class="animate-pulse flex flex-col gap-3">
<div class="h-6 bg-gray-200 dark:bg-gray-800 rounded-md" />
<div class="h-6 bg-gray-200 dark:bg-gray-800 rounded-md" />
<div class="h-6 bg-gray-200 dark:bg-gray-800 rounded-md" />
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">
Connect Stripe account to continue
</p>
</div>
</div>
<div
v-else-if="showSuccessState"
class="my-4 p-4 text-center text-sm text-green-700 bg-green-100 dark:bg-green-900/50 dark:text-green-300 rounded-md"
>
<div class="flex items-center justify-center gap-2">
<Icon
name="heroicons:check-circle"
class="w-5 h-5"
/>
<p>{{ $t('forms.payment.success') }}.</p>
</div>
</div>
<template v-else>
<div
v-if="shouldShowPreviewMessage"
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>
</div>
<div
v-else-if="stripeState && stripeState.isLoadingAccount"
class="my-4 flex justify-center"
>
<Loader class="mx-auto h-6 w-6" />
</div>
<div
v-else-if="stripeState && stripeState.hasAccountLoadingError"
class="my-4 p-4 text-center text-sm text-red-700 bg-red-100 dark:bg-red-900/50 dark:text-red-300 rounded-md"
>
<p>{{ stripeState.errorMessage || 'Failed to load payment configuration' }}</p>
</div>
<div
v-else-if="stripeState && stripeState.stripeAccountId && isStripeJsLoaded && publishableKey"
class="my-2"
>
<div
:class="[
theme.default.borderRadius,
theme.default.spacing.horizontal,
theme.default.spacing.vertical,
theme.default.fontSize,
]"
class="mb-4 flex border border-gray-300 dark:border-gray-600 items-center justify-between bg-gray-50 dark:bg-gray-800"
>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('forms.payment.amount_to_pay') }}</span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ currencySymbol }}{{ amount }}</span>
</div>
<StripeElements
ref="stripeElementsRef"
:stripe-key="publishableKey"
:stripe-account="stripeState.stripeAccountId"
:instance-options="{ stripeAccount: stripeState.stripeAccountId }"
:elements-options="{ locale: props.locale }"
@ready="onStripeReady"
@error="onStripeError"
>
<template #default="{ elements }">
<div class="space-y-4">
<div
:class="[
theme.default.input,
theme.default.borderRadius,
theme.default.spacing.horizontal,
theme.default.spacing.vertical,
theme.default.fontSize,
theme.PaymentInput?.cardContainer || '',
{
[theme.PaymentInput?.focusRing || 'ring-2 ring-primary-500 border-transparent']: isCardFocused && !hasError,
'!ring-red-500 !ring-2 !border-transparent': hasError
},
'dark:bg-gray-800 dark:border-gray-700'
]"
>
<StripeElement
v-if="elements"
ref="card"
type="card"
:elements="elements"
:options="cardOptions"
@ready="onCardReady"
@focus="onCardFocus"
@blur="onCardBlur"
/>
</div>
<TextInput
v-model="cardHolderName"
name="cardholder_name"
:placeholder="$t('forms.payment.name_on_card')"
class="w-full"
:theme="theme"
:disabled="disabled"
/>
<TextInput
v-model="cardHolderEmail"
name="billing_email"
:placeholder="$t('forms.payment.billing_email')"
class="w-full"
:theme="theme"
:disabled="disabled"
/>
</div>
</template>
</StripeElements>
</div>
<div v-else>
<Loader class="mx-auto h-6 w-6" />
</div>
</template>
</div>
<template #help>
<slot name="help" />
</template>
<template #error>
<slot name="error" />
</template>
</InputWrapper>
</template>
<script setup>
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { inputProps, useFormInput } from './useFormInput.js'
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'
const props = defineProps({
...inputProps,
direction: { type: String, default: 'ltr' },
currency: { type: String, default: 'USD' },
amount: { type: Number, default: 0 },
oauthProviderId: { type: [String, Number], default: null },
isAdminPreview: { type: Boolean, default: false },
color: { type: String, default: '#000000' },
isDark: { type: Boolean, default: false }
})
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()
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)
// Computed to determine if we should show success state
const showSuccessState = computed(() => {
return stripeState?.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)
})
// Helper function to check if a string looks like a Stripe payment intent ID
const isPaymentIntentId = (value) => {
return typeof value === 'string' && value.startsWith('pi_')
}
// 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
}
// We'll check if Stripe is already available globally
if (typeof window !== 'undefined' && !window.Stripe) {
await loadStripe(publishableKey.value)
isStripeJsLoaded.value = true
} else {
isStripeJsLoaded.value = true
}
// 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.')
return
}
// If compVal already contains a payment intent ID, sync it to stripeState
if (compVal.value && isPaymentIntentId(compVal.value) && stripeState) {
stripeState.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
const slug = formSlug.value
if (slug && props.oauthProviderId && prepareStripeState) {
const result = await prepareStripeState(slug, props.oauthProviderId, props.isAdminPreview)
if (!result.success && result.message && !result.requiresSave) {
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) {
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) {
const slug = formSlug.value
if (slug) {
await prepareStripeState(slug, newVal, props.isAdminPreview)
}
}
})
// Update onStripeReady to always use the stripe instance from the component
const onStripeReady = ({ stripe, elements }) => {
if (!stripe) {
return
}
if (setStripeInstance) {
setStripeInstance(stripe)
}
if (elements && setElementsInstance) {
setElementsInstance(elements)
}
}
const onStripeError = (_error) => {
alert.error('Failed to load payment component. Please check configuration or refresh.')
}
// Card focus/blur event handlers
const onCardFocus = () => {
isCardFocused.value = true
}
const onCardBlur = () => {
isCardFocused.value = false
}
const onCardReady = (_element) => {
if (card.value?.stripeElement) {
if (setCardElement) {
setCardElement(card.value.stripeElement)
}
}
}
// Billing details
watch(cardHolderName, (newValue) => {
setBillingDetails({ name: newValue })
})
watch(cardHolderEmail, (newValue) => {
setBillingDetails({ email: newValue })
})
// Payment intent sync
watch(() => stripeState?.intentId, (newValue) => {
if (newValue) compVal.value = newValue
})
watch(compVal, (newValue) => {
if (newValue && stripeState && newValue !== stripeState.intentId) {
stripeState.intentId = newValue
}
}, { immediate: true })
// Direction and locale handling
watch(() => props.direction, async () => {
await resetCard()
})
watch(() => props.locale, async () => {
await resetCard()
})
// Computed properties
const currencySymbol = computed(() => {
return stripeCurrencies.find(item => item.code === props.currency)?.symbol
})
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'
return {
hidePostalCode: true,
disableLink: true,
disabled: props.disabled || false,
style: {
base: {
iconColor: props.color,
color: props.isDark ? '#D1D5DB' : '#374151',
fontSmoothing: 'antialiased',
fontSize: '16px',
'::placeholder': {
color: props.isDark ? darkPlaceholderColor : lightPlaceholderColor
}
},
invalid: {
iconColor: '#df1b41',
color: '#df1b41'
}
}
}
})
const formSlug = computed(() => {
// Return the slug from route params regardless of route name
if (route.params && route.params.slug) {
return route.params.slug
}
return null
})
// Reset card element
const resetCard = async () => {
if (card.value?.stripeElement) {
card.value.stripeElement.unmount()
await nextTick()
if (stripeElementsRef.value?.elements) {
card.value.stripeElement.mount(stripeElementsRef.value.elements)
setCardElement(card.value.stripeElement)
} else {
console.error('Cannot remount card, Stripe Elements instance not found.')
}
}
}
// Add watcher to check when stripeElementsRef becomes available for fallback access
watch(() => stripeElementsRef.value, async (newRef) => {
if (newRef) {
// If @ready event hasn't fired, try accessing the instance directly
if (newRef.instance && setStripeInstance && !stripeState.isStripeInstanceReady) {
setStripeInstance(newRef.instance)
}
if (newRef.elements && setElementsInstance) {
setElementsInstance(newRef.elements)
}
}
}, { immediate: true })
</script>

View File

@ -29,7 +29,9 @@ export const inputProps = {
helpPosition: {type: String, default: "below_input"},
color: {type: String, default: "#3B82F6"},
wrapperClass: { type: String, default: "" },
isDark: { type: Boolean, default: false },
locale: { type: String, default: "en" },
isAdminPreview: { type: Boolean, default: false }
}
export function useFormInput(props, context, options = {}) {

View File

@ -1,3 +1,5 @@
<template></template>
<script setup>
import { pendingSubmission as pendingSubmissionFun } from "~/composables/forms/pendingSubmission.js"

View File

@ -298,7 +298,6 @@ export default {
if (form.busy) return
this.loading = true
form.post('/forms/' + this.form.slug + '/answer').then((data) => {
this.submittedData = form.data()
useAmplitude().logEvent('form_submission', {

View File

@ -31,7 +31,9 @@
group="form-elements"
item-key="id"
class="grid grid-cols-12 relative transition-all w-full"
:class="{'rounded-md bg-blue-50 dark:bg-gray-800':draggingNewBlock}"
:class="[
draggingNewBlock ? 'rounded-md bg-blue-50 dark:bg-gray-800' : '',
]"
ghost-class="ghost-item"
filter=".not-draggable"
:animation="200"
@ -83,6 +85,7 @@
v-if="isLastPage"
name="submit-btn"
:submit-form="submitForm"
:loading="dataForm.busy"
/>
<open-form-button
v-else-if="currentFieldsPageBreak"
@ -98,6 +101,14 @@
<div v-if="!currentFieldsPageBreak && !isLastPage">
{{ $t('forms.wrong_form_structure') }}
</div>
<div
v-if="paymentBlock"
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">
{{ $t('forms.payment.payment_disclaimer') }}
</p>
</div>
</div>
</form>
</template>
@ -115,6 +126,7 @@ 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'
export default {
name: 'OpenForm',
@ -159,6 +171,9 @@ export default {
const dataForm = ref(useForm())
const config = useRuntimeConfig()
// Provide Stripe elements to be used by child components
const stripeElements = provideStripeElements()
const hasCaptchaProviders = computed(() => {
return config.public.hCaptchaSiteKey || config.public.recaptchaSiteKey
})
@ -167,9 +182,10 @@ export default {
dataForm,
recordsStore,
workingFormStore,
stripeElements,
isIframe: useIsIframe(),
draggingNewBlock: computed(() => workingFormStore.draggingNewBlock),
pendingSubmission: pendingSubmission(props.form),
pendingSubmission: import.meta.client ? pendingSubmission(props.form) : { get: () => ({}), set: () => {} },
formPageIndex: storeToRefs(workingFormStore).formPageIndex,
// Used for admin previews
@ -296,6 +312,9 @@ export default {
'--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') {
@ -369,8 +388,19 @@ export default {
},
methods: {
submitForm() {
if (!this.isAutoSubmit && this.formPageIndex !== this.fieldGroups.length - 1) return
async submitForm() {
this.dataForm.busy = true
try {
if (!await this.nextPage()) {
this.dataForm.busy = false
return
}
if (!this.isAutoSubmit && this.formPageIndex !== this.fieldGroups.length - 1) {
this.dataForm.busy = false
return
}
if (this.form.use_captcha && import.meta.client) {
this.$refs.captcha?.reset()
@ -390,6 +420,10 @@ export default {
}
this.$emit('submit', this.dataForm, this.onSubmissionFailure)
} catch (error) {
this.handleValidationError(error)
this.dataForm.busy = false
}
},
/**
* Handle form submission failure
@ -397,6 +431,8 @@ export default {
onSubmissionFailure() {
this.$refs['form-timer'].startTimer()
this.isAutoSubmit = false
this.dataForm.busy = false
if (this.fieldGroups.length > 1) {
this.showFirstPageWithError()
}
@ -501,14 +537,17 @@ export default {
},
tryInitFormFromPendingSubmission() {
if (this.isPublicFormPage && this.form.auto_save) {
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
},
@ -582,22 +621,146 @@ export default {
this.formPageIndex--
this.scrollToTop()
},
nextPage() {
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() {
// Use the stripeElements from setup instead of calling useStripeElements
const { state: stripeState, processPayment, isCardPopulated, isReadyForPayment } = this.stripeElements
// Check if there's a payment block in the current step
if (!this.paymentBlock) {
return true // No payment needed for this step
}
// 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
}
const fieldsToValidate = this.currentFields.map(f => f.id)
this.dataForm.busy = true
this.dataForm.validate('POST', '/forms/' + this.form.slug + '/answer', {}, fieldsToValidate)
.then(() => {
this.formPageIndex++
this.dataForm.busy = false
this.scrollToTop()
}).catch(this.handleValidationError)
// 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' })

View File

@ -19,7 +19,7 @@
>
<div
v-if="isAdminPreview"
class="absolute translate-y-full lg:translate-y-0 -bottom-1 left-1/2 -translate-x-1/2 lg:-translate-x-full lg:-left-1 lg:top-1 lg:bottom-0 hidden group-hover/nffield:block"
class="absolute translate-y-full lg:translate-y-0 -bottom-1 left-1/2 -translate-x-1/2 lg:-translate-x-full lg:-left-1 lg:top-1 lg:bottom-0 hidden group-hover/nffield:block z-10"
>
<div
class="flex lg:flex-col bg-white !bg-white dark:!bg-white border rounded-md shadow-sm z-50 p-[1px] relative"
@ -32,7 +32,7 @@
<UTooltip
text="Add new field"
:popper="{ placement: 'right' }"
class="z-[100]"
:ui="{ container: 'z-50' }"
>
<Icon
name="i-heroicons-plus-circle-20-solid"
@ -48,7 +48,7 @@
<UTooltip
text="Edit field settings"
:popper="{ placement: 'right' }"
class="z-[100]"
:ui="{ container: 'z-50' }"
>
<Icon
name="heroicons:cog-8-tooth-20-solid"
@ -64,7 +64,7 @@
<UTooltip
text="Delete field"
:popper="{ placement: 'right' }"
class="z-[100]"
:ui="{ container: 'z-50' }"
>
<Icon
name="heroicons:trash-20-solid"
@ -80,6 +80,7 @@
v-bind="inputProperties(field)"
:required="isFieldRequired"
:disabled="isFieldDisabled ? true : null"
:is-admin-preview="isAdminPreview"
/>
<template v-else>
<div
@ -232,6 +233,7 @@ export default {
if (field.type === 'phone_number' && !field.use_simple_text_input) {
return 'PhoneInput'
}
return {
text: 'TextInput',
rich_text: 'RichTextAreaInput',
@ -248,7 +250,8 @@ export default {
email: 'TextInput',
phone_number: 'TextInput',
matrix: 'MatrixInput',
barcode: 'BarcodeInput'
barcode: 'BarcodeInput',
payment: 'PaymentInput'
}[field.type]
},
isPublicFormPage() {
@ -422,6 +425,11 @@ export default {
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

View File

@ -42,6 +42,11 @@
>
{{ element.title }}
</p>
<Icon
v-if="element.auth_required && !authenticated"
name="heroicons:lock-closed"
class="text-gray-400 w-4 h-4"
/>
</div>
</template>
</draggable>
@ -73,6 +78,11 @@
>
{{ element.title }}
</p>
<Icon
v-if="element.auth_required && !authenticated"
name="heroicons:lock-closed"
class="text-gray-400 w-4 h-4"
/>
</div>
</template>
</draggable>
@ -86,6 +96,8 @@ import blocksTypes from '~/data/blocks_types.json'
import BlockTypeIcon from '../BlockTypeIcon.vue'
const workingFormStore = useWorkingFormStore()
const authStore = useAuthStore()
const authenticated = computed(() => authStore.check)
const inputBlocks = computed(() => Object.values(blocksTypes).filter(block => !block.name.startsWith('nf-')))
const layoutBlocks = computed(() => Object.values(blocksTypes).filter(block => block.name.startsWith('nf-')))
@ -95,10 +107,12 @@ const closeSidebar = () => {
}
const addBlock = (type) => {
workingFormStore.addBlock(type)
}
const handleInputClone = (item) => {
return item.name
}

View File

@ -24,12 +24,21 @@
:form="form"
label="Auto save form response"
help="Saves form progress, allowing respondents to resume later."
class="mt-4"
:disabled="hasPaymentBlock"
/>
<UAlert
v-if="hasPaymentBlock"
color="primary"
variant="subtle"
title="You have a payment block in your form. so can't disable auto save"
class="max-w-md"
/>
<flat-select-input
:form="submissionOptions"
name="databaseAction"
class="max-w-xs"
class="mt-4 max-w-xs"
label="Database Submission Action"
:options="[
{ name: 'Create new record', value: 'create' },
@ -133,7 +142,7 @@
enable-mentions
:mentions="form.properties"
name="submitted_text"
class="w-full"
class="w-full mt-4"
:form="form"
label="Success page text"
:required="false"
@ -231,4 +240,8 @@ watch(submissionOptions, (val) => {
if (val.submissionMode === 'default') form.value.redirect_url = null
if (val.databaseAction === 'create') form.value.database_fields_update = null
}, { deep: true })
const hasPaymentBlock = computed(() => {
return form.value.properties.some(property => property.type === 'payment')
})
</script>

View File

@ -194,6 +194,11 @@
@update:model-value="field = $event"
/>
<PaymentFieldOptions
v-if="field.type === 'payment'"
:field="field"
/>
<!-- Text Options -->
<div
v-if="field.type === 'text' && displayBasedOnAdvanced"
@ -477,7 +482,7 @@
label="Pre-filled value"
/>
<text-input
v-else-if="!['files', 'signature', 'rich_text'].includes(field.type)"
v-else-if="!['files', 'signature', 'rich_text', 'payment'].includes(field.type)"
name="prefill"
class="mt-3"
:form="field"
@ -605,6 +610,7 @@ import timezones from '~/data/timezones.json'
import countryCodes from '~/data/country_codes.json'
import CountryFlag from 'vue-country-flag-next'
import MatrixFieldOptions from './MatrixFieldOptions.vue'
import PaymentFieldOptions from './PaymentFieldOptions.vue'
import HiddenRequiredDisabled from './HiddenRequiredDisabled.vue'
import EditorSectionHeader from '~/components/open/forms/components/form-components/EditorSectionHeader.vue'
import { format } from 'date-fns'
@ -613,7 +619,7 @@ import blocksTypes from '~/data/blocks_types.json'
export default {
name: 'FieldOptions',
components: { CountryFlag, MatrixFieldOptions, HiddenRequiredDisabled, EditorSectionHeader },
components: { CountryFlag, MatrixFieldOptions, HiddenRequiredDisabled, EditorSectionHeader, PaymentFieldOptions },
props: {
field: {
type: Object,
@ -629,7 +635,7 @@ export default {
},
data() {
return {
typesWithoutPlaceholder: ['date', 'checkbox', 'files'],
typesWithoutPlaceholder: ['date', 'checkbox', 'files', 'payment'],
editorToolbarCustom: [
['bold', 'italic', 'underline', 'link']
],

View File

@ -0,0 +1,139 @@
<template>
<div
v-if="field.type === 'payment'"
class="px-4"
>
<EditorSectionHeader
icon="i-heroicons-credit-card-20-solid"
title="Payment"
/>
<select-input
name="currency"
label="Currency"
:options="currencyList"
:form="field"
:required="true"
:searchable="true"
:disabled="stripeAccounts.length === 0"
/>
<text-input
name="amount"
label="Amount"
native-type="number"
:form="field"
:required="true"
:disabled="stripeAccounts.length === 0"
/>
<div v-if="stripeAccounts.length > 0">
<select-input
name="stripe_account_id"
label="Stripe Account"
:options="stripeAccounts"
:form="field"
:required="true"
/>
<p class="mt-4 text-sm text-center text-bold">
OR
</p>
</div>
<UButton
class="mt-4"
icon="i-heroicons-arrow-right"
block
trailing
:loading="stripeLoading"
@click.prevent="connectStripe"
>
Connect Stripe Account
</UButton>
<p class="text-sm text-gray-500 mt-3">
<a
target="#"
class="text-gray-500 cursor-pointer text-sm"
@click.prevent="crisp.openHelpdeskArticle('how-to-collect-payment-svig30')"
>
<Icon
name="heroicons:information-circle-16-solid"
class="h-3 w-3 mt-1"
/>
Learn how to accept payments
</a>
</p>
</div>
</template>
<script setup>
import EditorSectionHeader from '~/components/open/forms/components/form-components/EditorSectionHeader.vue'
import stripeCurrencies from "~/data/stripe_currencies.json"
import { useWindowMessage, WindowMessageTypes } from '~/composables/useWindowMessage'
const props = defineProps({
field: {
type: Object,
required: true
}
})
const crisp = useCrisp()
const providersStore = useOAuthProvidersStore()
const stripeLoading = ref(false)
// Setup window message listener for Stripe connection
const { listen, cleanup } = useWindowMessage()
onMounted(async () => {
await providersStore.fetchOAuthProviders()
if(props.field?.currency === undefined || props.field?.currency === null) {
props.field.currency = 'USD'
}
if(props.field?.amount === undefined || props.field?.amount === null) {
props.field.amount = 10
}
// Auto-select first Stripe account if none is selected
if (!props.field.stripe_account_id && stripeAccounts.value.length > 0) {
props.field.stripe_account_id = stripeAccounts.value[0].value
}
// Listen for Stripe connection message
listen(async () => {
await providersStore.fetchOAuthProviders()
// Auto-select first Stripe account after refresh if one isn't already selected (or maybe always select the newest? for now, first)
if (stripeAccounts.value.length > 0) {
props.field.stripe_account_id = stripeAccounts.value[0].value
}
useAlert().success('Stripe accounts updated.')
}, {
useMessageChannel: false,
acknowledge: false
}, `${WindowMessageTypes.OAUTH_PROVIDER_CONNECTED}:stripe`)
})
onUnmounted(() => {
// Cleanup listener (optional, as useWindowMessage handles it)
cleanup()
})
const stripeAccounts = computed(() => providersStore.getAll.filter((item) => item.provider === 'stripe').map((item) => ({
name: item.name + (item.email ? ' (' + item.email + ')' : ''),
value: item.id
})))
const currencyList = computed(() => {
return stripeCurrencies.map((item) => ({
name: item.name,
value: item.code
}))
})
const connectStripe = () => {
stripeLoading.value = true
providersStore.connect('stripe', false, true, true)
setTimeout(() => {
stripeLoading.value = false
}, 10000)
}
</script>

View File

@ -148,6 +148,7 @@ import OpenMatrix from "./components/OpenMatrix.vue"
import OpenDate from "./components/OpenDate.vue"
import OpenFile from "./components/OpenFile.vue"
import OpenCheckbox from "./components/OpenCheckbox.vue"
import OpenPayment from "./components/OpenPayment.vue"
import ResizableTh from "./components/ResizableTh.vue"
import RecordOperations from "../components/RecordOperations.vue"
import clonedeep from "clone-deep"
@ -218,6 +219,7 @@ export default {
email: shallowRef(OpenText),
phone_number: shallowRef(OpenText),
signature: shallowRef(OpenFile),
payment: shallowRef(OpenPayment),
barcode: shallowRef(OpenText),
},
}

View File

@ -0,0 +1,39 @@
<template>
<span
v-if="value"
class="-mb-2"
>
<UButton
:to="paymentUrl"
icon="i-heroicons-arrow-top-right-on-square"
size="xs"
variant="link"
label="View Payment"
target="_blank"
trailing
/>
</span>
</template>
<script>
export default {
components: { },
props: {
value: {
type: Object,
},
},
data() {
return {}
},
computed: {
paymentUrl() {
if (!this.value) return null
const isLocal = useRuntimeConfig().public.env === 'local'
return `https://dashboard.stripe.com${isLocal ? '/test' : ''}/payments/${this.value}`
},
},
}
</script>

View File

@ -80,7 +80,6 @@ const emit = defineEmits(['close'])
const providersStore = useOAuthProvidersStore()
const services = computed(() => providersStore.services)
const loading = ref(false)
const showWidgetModal = ref(false)
const selectedService = ref(null)

View File

@ -1,3 +1,5 @@
import { WindowMessageTypes, useWindowMessage } from "~/composables/useWindowMessage"
export const useAuth = () => {
const authStore = useAuthStore()
const workspaceStore = useWorkspacesStore()
@ -117,6 +119,22 @@ 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,
source: provider,

337
client/composables/useStripeElements.js vendored Normal file
View File

@ -0,0 +1,337 @@
import { computed, provide, inject, reactive } from 'vue'
import { useI18n } from '#imports'
// Symbol for injection key
export const STRIPE_ELEMENTS_KEY = Symbol('stripe-elements')
export const createStripeElements = () => {
// Get the translation function
const { t } = useI18n()
// Use reactive for the state to ensure changes propagate
const state = reactive({
// Loading states
isLoadingAccount: false,
hasAccountLoadingError: false,
isStripeInstanceReady: false,
isCardElementReady: false,
// Core Stripe objects
stripe: null,
elements: null,
card: null,
// Form data
cardHolderName: '',
cardHolderEmail: '',
// Account & payment state
stripeAccountId: null,
intentId: null,
showPreviewMessage: false,
errorMessage: ''
})
// Computed properties
const isReadyForPayment = computed(() => {
return state.isStripeInstanceReady &&
state.isCardElementReady &&
state.stripeAccountId
})
const isCardPopulated = computed(() => {
return state.card && !state.card._empty
})
/**
* Resets the Stripe state to its initial values
*/
const resetStripeState = () => {
state.isLoadingAccount = false
state.hasAccountLoadingError = false
state.isStripeInstanceReady = false
state.isCardElementReady = false
state.stripe = null
state.elements = null
state.card = null
state.intentId = null
state.showPreviewMessage = false
state.stripeAccountId = null
state.errorMessage = ''
}
/**
* 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
*/
const prepareStripeState = async (formSlug, providerId, isEditorPreview = false) => {
if (!formSlug || !providerId) {
resetStripeState()
return { success: false, message: t('forms.payment.errors.missingFormOrProvider') }
}
resetStripeState()
state.isLoadingAccount = true
try {
const fetchOptions = {}
if (isEditorPreview) {
fetchOptions.query = { oauth_provider_id: providerId }
}
const response = await opnFetch(`/forms/${formSlug}/stripe-connect/get-account`, fetchOptions)
if (response?.type === 'success' && response?.stripeAccount) {
// Explicitly set the account ID in state
state.stripeAccountId = response.stripeAccount
state.isLoadingAccount = false
// We'll rely on the StripeElements component to create the Stripe instance
// Don't try to create it here
return { success: true, accountId: response.stripeAccount }
} else {
state.hasAccountLoadingError = true
state.isLoadingAccount = false
if (response?.message?.includes('save the form and try again')) {
state.showPreviewMessage = true
}
state.errorMessage = response?.message || t('forms.payment.errors.failedAccountDetails')
return {
success: false,
message: state.errorMessage,
requiresSave: state.showPreviewMessage
}
}
} catch (error) {
state.hasAccountLoadingError = true
state.isLoadingAccount = false
const message = error?.data?.message || t('forms.payment.errors.setupError')
if (message.includes('save the form and try again')) {
state.showPreviewMessage = true
}
state.errorMessage = message
return {
success: false,
message: state.errorMessage,
requiresSave: state.showPreviewMessage
}
}
}
/**
* 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) {
state.stripe = instance
state.isStripeInstanceReady = true
}
} else {
state.stripe = null
state.isStripeInstanceReady = false
}
}
/**
* Sets the Elements instance in the state
* @param {Object} elementsInstance - The Elements instance from vue-stripe-js
*/
const setElementsInstance = (elementsInstance) => {
if (elementsInstance) {
state.elements = elementsInstance
}
}
/**
* Sets the Card Element in the state
* @param {Object} cardElement - The Card Element instance
*/
const setCardElement = (cardElement) => {
if (cardElement) {
state.card = cardElement
state.isCardElementReady = true
} else {
state.card = null
state.isCardElementReady = false
}
}
/**
* 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
if (email !== undefined) state.cardHolderEmail = email
}
/**
* 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,
error: { message: t('forms.payment.errors.systemNotReady') }
}
}
// Check if the stripe instance exists
if (!state.stripe) {
return {
success: false,
error: { message: t('forms.payment.errors.misconfigured') }
}
}
// Additional validation for card
if (!state.card) {
return {
success: false,
error: { message: t('forms.payment.errors.notFullyReady') }
}
}
// Check if payment is required but card is empty
if (isRequired && state.card._empty) {
return {
success: false,
error: { message: t('forms.payment.errors.paymentRequired') }
}
}
// 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,
error: { message: t('forms.payment.errors.nameRequired') }
}
}
// Validate billing email
if (!state.cardHolderEmail) {
return {
success: false,
error: { message: t('forms.payment.errors.emailRequired') }
}
}
// Validate email format
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(state.cardHolderEmail)) {
return {
success: false,
error: { message: t('forms.payment.errors.invalidEmail') }
}
}
}
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,
billing_details: {
name: state.cardHolderName,
email: state.cardHolderEmail
},
},
receipt_email: state.cardHolderEmail,
})
// Store payment intent ID on success
if (result?.paymentIntent?.status === 'succeeded') {
state.intentId = result.paymentIntent.id
}
return {
success: result?.paymentIntent?.status === 'succeeded',
...result
}
} else {
return {
success: false,
error: { message: responseIntent?.message || t('forms.payment.errors.failedIntent') }
}
}
} 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'
return {
success: false,
error: {
message: errorMessage,
type: errorType,
code: errorCode
}
}
}
}
const stripeElements = {
state,
isReadyForPayment,
isCardPopulated,
processPayment,
resetStripeState,
prepareStripeState,
setStripeInstance,
setElementsInstance,
setCardElement,
setBillingDetails
}
// 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
}

View File

@ -7,7 +7,8 @@
// Define common message types as constants
export const WindowMessageTypes = {
LOGIN_COMPLETE: 'login-complete',
AFTER_LOGIN: 'after-login'
AFTER_LOGIN: 'after-login',
OAUTH_PROVIDER_CONNECTED: 'oauth-provider-connected'
}
export const useWindowMessage = (messageType = null) => {

View File

@ -210,6 +210,18 @@
"decoders": ["ean_reader", "ean_8_reader"]
}
},
"payment": {
"name": "payment",
"title": "Payment",
"icon": "i-heroicons-credit-card",
"default_block_name": "Payment",
"bg_class": "bg-green-100",
"text_class": "text-green-900",
"is_input": true,
"self_hosted": false,
"max_count": 1,
"auth_required": true
},
"nf-text": {
"name": "nf-text",
"title": "Text",

View File

@ -884,5 +884,23 @@
"expected_type": "number"
}
}
},
"payment": {
"comparators": {
"paid": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [true]
}
},
"not_paid": {
"expected_type": "boolean",
"format": {
"type": "enum",
"values": [true]
}
}
}
}
}

View File

@ -0,0 +1,197 @@
[
{
"name": "AED - UAE Dirham",
"code": "AED",
"symbol": "د.إ"
},
{
"name": "AUD - Australian Dollar",
"code": "AUD",
"symbol": "A$"
},
{
"name": "BGN - Bulgarian Lev",
"code": "BGN",
"symbol": "лв"
},
{
"name": "BRL - Brazilian Real",
"code": "BRL",
"symbol": "R$"
},
{
"name": "CAD - Canadian Dollar",
"code": "CAD",
"symbol": "C$"
},
{
"name": "CHF - Swiss Franc",
"code": "CHF",
"symbol": "CHF"
},
{
"name": "CNY - Yuan Renminbi",
"code": "CNY",
"symbol": "¥"
},
{
"name": "CZK - Czech Koruna",
"code": "CZK",
"symbol": "Kč"
},
{
"name": "DKK - Danish Krone",
"code": "DKK",
"symbol": "kr"
},
{
"name": "EUR - Euro",
"code": "EUR",
"symbol": "€"
},
{
"name": "GBP - Pound Sterling",
"code": "GBP",
"symbol": "£"
},
{
"name": "HKD - Hong Kong Dollar",
"code": "HKD",
"symbol": "HK$"
},
{
"name": "HRK - Croatian Kuna",
"code": "HRK",
"symbol": "kn"
},
{
"name": "HUF - Hungarian Forint",
"code": "HUF",
"symbol": "Ft"
},
{
"name": "IDR - Indonesian Rupiah",
"code": "IDR",
"symbol": "Rp"
},
{
"name": "ILS - Israeli Shekel",
"code": "ILS",
"symbol": "₪"
},
{
"name": "INR - Indian Rupee",
"code": "INR",
"symbol": "₹"
},
{
"name": "ISK - Icelandic Króna",
"code": "ISK",
"symbol": "kr"
},
{
"name": "JPY - Japanese Yen",
"code": "JPY",
"symbol": "¥"
},
{
"name": "KRW - South Korean Won",
"code": "KRW",
"symbol": "₩"
},
{
"name": "MAD - Moroccan Dirham",
"code": "MAD",
"symbol": "د.م."
},
{
"name": "MXN - Mexican Peso",
"code": "MXN",
"symbol": "$"
},
{
"name": "MYR - Malaysian Ringgit",
"code": "MYR",
"symbol": "RM"
},
{
"name": "NOK - Norwegian Krone",
"code": "NOK",
"symbol": "kr"
},
{
"name": "NZD - New Zealand Dollar",
"code": "NZD",
"symbol": "NZ$"
},
{
"name": "PHP - Philippine Peso",
"code": "PHP",
"symbol": "₱"
},
{
"name": "PLN - Polish Złoty",
"code": "PLN",
"symbol": "zł"
},
{
"name": "RON - Romanian Leu",
"code": "RON",
"symbol": "lei"
},
{
"name": "RSD - Serbian Dinar",
"code": "RSD",
"symbol": "дин."
},
{
"name": "RUB - Russian Rouble",
"code": "RUB",
"symbol": "₽"
},
{
"name": "SAR - Saudi Riyal",
"code": "SAR",
"symbol": "﷼"
},
{
"name": "SEK - Swedish Krona",
"code": "SEK",
"symbol": "kr"
},
{
"name": "SGD - Singapore Dollar",
"code": "SGD",
"symbol": "S$"
},
{
"name": "THB - Thai Baht",
"code": "THB",
"symbol": "฿"
},
{
"name": "TWD - New Taiwan Dollar",
"code": "TWD",
"symbol": "NT$"
},
{
"name": "UAH - Ukrainian Hryvnia",
"code": "UAH",
"symbol": "₴"
},
{
"name": "USD - United States Dollar",
"code": "USD",
"symbol": "$"
},
{
"name": "VND - Vietnamese Dong",
"code": "VND",
"symbol": "₫"
},
{
"name": "ZAR - South African Rand",
"code": "ZAR",
"symbol": "R"
}
]

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "انقر لفتح كاميرا"
},
"payment": {
"amount_to_pay": "المبلغ للدفع",
"success": "تم الدفع بنجاح",
"name_on_card": "الاسم على البطاقة",
"billing_email": "عنوان البريد الإلكتروني للفوترة",
"payment_disclaimer": "سيتم خصم المبلغ من بطاقتك الائتمانية عند النقر على هذا الزر. تتم معالجة المدفوعات بشكل آمن من خلال Stripe.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -10,7 +10,7 @@
"create_form_free": "OpnForm দিয়ে বিনামূল্যে আপনার ফর্ম তৈরি করুন",
"submit": "জমা দিন",
"wrong_form_structure": "এই ফর্মের কাঠামোতে কিছু ভুল আছে। আপনি যদি ফর্মের মালিক হন তবে আমাদের সাথে যোগাযোগ করুন।",
"cannot_access_here": "এই বিষয়বস্তু এখানে অ্যাক্সেস করা যাবে না। এটি অ্যাক্সেস করতে নীচের লিঙ্কে ক্ল<EFBFBD><EFBFBD>ক করুন।",
"cannot_access_here": "এই বিষয়বস্তু এখানে অ্যাক্সেস করা যাবে না। এটি অ্যাক্সেস করতে নীচের লিঙ্কে ক্লিক করুন।",
"open_form": "ফর্ম খুলুন",
"restricted_to_workspace": "Notion ওয়ার্কস্পেসের সদস্যদের জন্য সীমাবদ্ধ। এই বিষয়বস্তু অ্যাক্সেস করতে লগইন করুন।",
"select": {
@ -23,7 +23,7 @@
"fileInput": {
"chooseFiles": "ফাইল(গুলি) বেছে নিতে ক্লিক করুন বা এখানে টেনে আনুন | একটি ফাইল বেছে নিতে ক্লিক করুন বা এখানে টেনে আনুন",
"sizeLimit": "সাইজ সীমা: প্রতি ফাইলে {count}MB",
"uploadingFile": "আপনার ফাইল <EFBFBD><EFBFBD><EFBFBD>পলোড হচ্ছে..."
"uploadingFile": "আপনার ফাইল পলোড হচ্ছে..."
},
"cameraUpload": {
"allowCameraPermission": "ক্যামেরা অনুমতি দিন",
@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "ক্লিক করুন ক্যামেরা খোলা করতে"
},
"payment": {
"amount_to_pay": "পরিশোধের পরিমাণ",
"success": "পেমেন্ট সফল হয়েছে",
"name_on_card": "কার্ডে নাম",
"billing_email": "বিলিং ইমেইল ঠিকানা",
"payment_disclaimer": "এই বোতামে ক্লিক করলে আপনার ক্রেডিট কার্ড থেকে চার্জ করা হবে। পেমেন্টগুলি নিরাপদে স্ট্রাইপের মাধ্যমে প্রসেস করা হয়।",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "Fes clic per obrir la càmera"
},
"payment": {
"amount_to_pay": "Quantitat a pagar",
"success": "Pagament realitzat amb èxit",
"name_on_card": "Nom a la targeta",
"billing_email": "Adreça de correu electrònic de facturació",
"payment_disclaimer": "Se us cobrarà a la targeta de crèdit en fer clic en aquest botó. Els pagaments es processen de forma segura a través de Stripe.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "Klikněte pro otevření kamery"
},
"payment": {
"amount_to_pay": "Částka k zaplacení",
"success": "Platba úspěšná",
"name_on_card": "Jméno na kartě",
"billing_email": "Fakturační e-mailová adresa",
"payment_disclaimer": "Vaše kreditní karta bude zatížena kliknutím na toto tlačítko. Platby jsou bezpečně zpracovány prostřednictvím Stripe.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -38,7 +38,28 @@
"clear": "Löschen"
},
"barcodeInput": {
"clickToOpenCamera": "Klicken Sie, um eine Kamera zu öffnen"
"clickToOpenCamera": "Klicken Sie hier, um die Kamera zu öffnen"
},
"payment": {
"amount_to_pay": "Zu zahlender Betrag",
"success": "Zahlung erfolgreich",
"name_on_card": "Name auf Karte",
"billing_email": "Rechnungs-E-Mail-Adresse",
"payment_disclaimer": "Ihre Kreditkarte wird belastet, wenn Sie auf diesen Button klicken. Zahlungen werden sicher über Stripe abgewickelt.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "Click to open a camera"
},
"payment": {
"amount_to_pay": "Amount to pay",
"success": "Payment successful",
"name_on_card": "Name on card",
"billing_email": "Billing email address",
"payment_disclaimer": "Your credit card will be charged upon clicking this button. Payments are securely processed through Stripe.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -38,7 +38,28 @@
"clear": "Borrar"
},
"barcodeInput": {
"clickToOpenCamera": "Haga clic para abrir una cámara"
"clickToOpenCamera": "Haga clic para abrir la cámara"
},
"payment": {
"amount_to_pay": "Cantidad a pagar",
"success": "Pago realizado con éxito",
"name_on_card": "Nombre en la tarjeta",
"billing_email": "Correo electrónico de facturación",
"payment_disclaimer": "Se cargará a su tarjeta de crédito al hacer clic en este botón. Los pagos se procesan de forma segura a través de Stripe.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "Egin klik kamera irekitzeko"
},
"payment": {
"amount_to_pay": "Ordaindu beharreko zenbatekoa",
"success": "Ordainketa arrakastatsua",
"name_on_card": "Txartelaren izena",
"billing_email": "Fakturazio-helbide elektronikoa",
"payment_disclaimer": "Zure kreditu txartela kargatuko da botoi honetan klik egitean. Ordainketak Stripe bidez prozesatzen dira modu seguruan.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -38,7 +38,28 @@
"clear": "Effacer"
},
"barcodeInput": {
"clickToOpenCamera": "Cliquez pour ouvrir une caméra"
"clickToOpenCamera": "Cliquez pour ouvrir la caméra"
},
"payment": {
"amount_to_pay": "Montant à payer",
"success": "Paiement réussi",
"name_on_card": "Nom sur la carte",
"billing_email": "Adresse e-mail de facturation",
"payment_disclaimer": "Votre carte de crédit sera débitée lorsque vous cliquerez sur ce bouton. Les paiements sont traités en toute sécurité via Stripe.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "Fai clic para abrir a cámara"
},
"payment": {
"amount_to_pay": "Cantidade a pagar",
"success": "Pago realizado con éxito",
"name_on_card": "Nome na tarxeta",
"billing_email": "Enderezo de correo electrónico de facturación",
"payment_disclaimer": "Cobrarase na túa tarxeta de crédito ao premer neste botón. Os pagos procésanse de forma segura a través de Stripe.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "क्लिक करें कैमरा खोलने के लिए"
},
"payment": {
"amount_to_pay": "भुगतान करने की राशि",
"success": "भुगतान सफल",
"name_on_card": "कार्ड पर नाम",
"billing_email": "बिलिंग ईमेल पता",
"payment_disclaimer": "इस बटन पर क्लिक करने पर आपके क्रेडिट कार्ड से शुल्क लिया जाएगा। भुगतान स्ट्राइप के माध्यम से सुरक्षित रूप से संसाधित किए जाते हैं।",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "クリックしてカメラを開く"
},
"payment": {
"amount_to_pay": "支払額",
"success": "支払い成功",
"name_on_card": "カード名義",
"billing_email": "請求先メールアドレス",
"payment_disclaimer": "このボタンをクリックすると、クレジットカードに請求されます。支払いはStripeを通じて安全に処理されます。",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "Klik kanggo nggawe kamera"
},
"payment": {
"amount_to_pay": "Jumlah sing kudu dibayar",
"success": "Pembayaran kasil",
"name_on_card": "Jeneng ing kertu",
"billing_email": "Alamat email tagihan",
"payment_disclaimer": "Kertu kredit sampeyan bakal dikenani biaya nalika ngeklik tombol iki. Pembayaran diproses kanthi aman liwat Stripe.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "클릭하여 카메라 열기"
},
"payment": {
"amount_to_pay": "결제 금액",
"success": "결제 성공",
"name_on_card": "카드 소유자 이름",
"billing_email": "청구 이메일 주소",
"payment_disclaimer": "이 버튼을 클릭하면 신용카드로 청구됩니다. 결제는 Stripe를 통해 안전하게 처리됩니다.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "क्लिक करा कैमरा खोलण्यासाठी"
},
"payment": {
"amount_to_pay": "देय रक्कम",
"success": "पेमेंट यशस्वी",
"name_on_card": "कार्डवरील नाव",
"billing_email": "बिलिंग ईमेल पत्ता",
"payment_disclaimer": "या बटणावर क्लिक केल्यावर तुमच्या क्रेडिट कार्डमधून शुल्क आकारले जाईल. पेमेंट्स स्ट्राइपद्वारे सुरक्षितपणे प्रक्रिया केली जातात.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "ਕਲਿੱਕ ਕਰੋ ਕੈਮਰਾ ਖੋਲਣ ਲਈ"
},
"payment": {
"amount_to_pay": "ਭੁਗਤਾਨ ਕਰਨ ਦੀ ਰਕਮ",
"success": "ਭੁਗਤਾਨ ਸਫਲ",
"name_on_card": "ਕਾਰਡ 'ਤੇ ਨਾਮ",
"billing_email": "ਬਿਲਿੰਗ ਈਮੇਲ ਪਤਾ",
"payment_disclaimer": "ਇਸ ਬਟਨ 'ਤੇ ਕਲਿੱਕ ਕਰਨ 'ਤੇ ਤੁਹਾਡੇ ਕ੍ਰੈਡਿਟ ਕਾਰਡ ਤੋਂ ਚਾਰਜ ਲਿਆ ਜਾਵੇਗਾ। ਭੁਗਤਾਨ Stripe ਰਾਹੀਂ ਸੁਰੱਖਿਅਤ ਢੰਗ ਨਾਲ ਪ੍ਰੋਸੈਸ ਕੀਤੇ ਜਾਂਦੇ ਹਨ।",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "Kliknij, aby otworzyć kamerę"
},
"payment": {
"amount_to_pay": "Kwota do zapłaty",
"success": "Płatność zakończona sukcesem",
"name_on_card": "Imię i nazwisko na karcie",
"billing_email": "Adres e-mail do faktury",
"payment_disclaimer": "Twoja karta kredytowa zostanie obciążona po kliknięciu tego przycisku. Płatności są bezpiecznie przetwarzane przez Stripe.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "Clique para abrir a câmera"
},
"payment": {
"amount_to_pay": "Valor a pagar",
"success": "Pagamento bem-sucedido",
"name_on_card": "Nome no cartão",
"billing_email": "Endereço de e-mail para faturação",
"payment_disclaimer": "Seu cartão de crédito será cobrado ao clicar neste botão. Os pagamentos são processados com segurança através do Stripe.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -12,7 +12,7 @@
"wrong_form_structure": "Что-то не так со структурой этой формы. Если вы владелец формы, пожалуйста, свяжитесь с нами.",
"cannot_access_here": "Этот контент недоступен здесь. Нажмите на ссылку ниже, чтобы получить доступ.",
"open_form": "Открыть форму",
"restricted_to_workspace": "Доступ ограничен для участников рабочего пространства Notion. Войдите, чтобы получить доступ к этому кон<EFBFBD><EFBFBD>енту.",
"restricted_to_workspace": "Доступ ограничен для участников рабочего пространства Notion. Войдите, чтобы получить доступ к этому контенту.",
"select": {
"search": "Поиск",
"searchOrTypeToCreateNew": "Поиск или ввод для создания нового",
@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "Нажмите, чтобы открыть камеру"
},
"payment": {
"amount_to_pay": "Сумма к оплате",
"success": "Оплата прошла успешно",
"name_on_card": "Имя на карте",
"billing_email": "Адрес электронной почты для выставления счетов",
"payment_disclaimer": "С вашей кредитной карты будет снята сумма при нажатии на эту кнопку. Платежи безопасно обрабатываются через Stripe.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "Kliknite pre otvorenie kamery"
},
"payment": {
"amount_to_pay": "Suma na zaplatenie",
"success": "Platba úspešná",
"name_on_card": "Meno na karte",
"billing_email": "Fakturačná e-mailová adresa",
"payment_disclaimer": "Vaša kreditná karta bude zaťažená kliknutím na toto tlačidlo. Platby sú bezpečne spracované prostredníctvom Stripe.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "Klicka för att öppna kameran"
},
"payment": {
"amount_to_pay": "Belopp att betala",
"success": "Betalning lyckades",
"name_on_card": "Namn på kort",
"billing_email": "Fakturerings-e-postadress",
"payment_disclaimer": "Ditt kreditkort kommer att debiteras när du klickar på den här knappen. Betalningar behandlas säkert via Stripe.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -21,7 +21,7 @@
"create": "உருவாக்கு"
},
"fileInput": {
"chooseFiles": "கோப்புகளைத் தேர்ந்தெடுக்க கிளிக் செய்யவும் அல்லது இ<EFBFBD><EFBFBD><EFBFBD>்கே இழுக்கவும் | கோப்பைத் தேர்ந்தெடுக்க கிளிக் செய்யவும் அல்லது இங்கே இழுக்கவும்",
"chooseFiles": "கோப்புகளைத் தேர்ந்தெடுக்க கிளிக் செய்யவும் அல்லது இ்கே இழுக்கவும் | கோப்பைத் தேர்ந்தெடுக்க கிளிக் செய்யவும் அல்லது இங்கே இழுக்கவும்",
"sizeLimit": "அளவு வரம்பு: ஒரு கோப்புக்கு {count}MB",
"uploadingFile": "உங்கள் கோப்பு பதிவேற்றப்படுகிறது..."
},
@ -30,7 +30,7 @@
"allowCameraPermissionDescription": "புகைப்படங்களை எடுக்கும் முன் நீங்கள் கேமரா அணுகலை அனுமதிக்க வேண்டும். இந்தப் பக்கத்தில் கேமரா அனுமதியை இயக்க உலாவி அமைப்புகளுக்குச் செல்லவும்.",
"gotIt": "புரிந்தது!",
"cameraDeviceError": "கேமரா சாதனப் பிழை",
"cameraDeviceErrorDescription": "வெப்கேம் சாதனத்தைத் தொடங்க முயற்<EFBFBD><EFBFBD><EFBFBD>ிக்கும்போது அறியப்படாத பிழை ஏற்பட்டது.",
"cameraDeviceErrorDescription": "வெப்கேம் சாதனத்தைத் தொடங்க முயற்ிக்கும்போது அறியப்படாத பிழை ஏற்பட்டது.",
"goBack": "திரும்பிச் செல்"
},
"signatureInput": {
@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "கிளிக் செய்யவும் கேமரா திறக்கவும்"
},
"payment": {
"amount_to_pay": "செலுத்த வேண்டிய தொகை",
"success": "பணம் செலுத்துதல் வெற்றி",
"name_on_card": "கார்டில் உள்ள பெயர்",
"billing_email": "பில்லிங் மின்னஞ்சல் முகவரி",
"payment_disclaimer": "இந்த பொத்தானைக் கிளிக் செய்யும் போது உங்கள் கிரெடிட் கார்டில் கட்டணம் வசூலிக்கப்படும். பணம் செலுத்துதல்கள் பாதுகாப்பாக Stripe மூலம் செயலாக்கப்படுகின்றன.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -10,7 +10,7 @@
"create_form_free": "OpnForm తో మీ ఫారమ్‌ను ఉచితంగా సృష్టించండి",
"submit": "సమర్పించండి",
"wrong_form_structure": "ఈ ఫారమ్ నిర్మాణంలో ఏదో తప్పు ఉంది. మీరు ఫారమ్ యజమాని అయితే, దయచేసి మమ్మల్ని సంప్రదించండి.",
"cannot_access_here": "ఈ కంటెంట్‌ను ఇక్కడ యాక్సెస్ చేయలేరు. దీన్ని యాక్సెస్ చేయడానికి దిగువ లింక<EFBFBD><EFBFBD><EFBFBD>‌పై క్లిక్ చేయండి.",
"cannot_access_here": "ఈ కంటెంట్‌ను ఇక్కడ యాక్సెస్ చేయలేరు. దీన్ని యాక్సెస్ చేయడానికి దిగువ లింక‌పై క్లిక్ చేయండి.",
"open_form": "ఫారమ్ తెరవండి",
"restricted_to_workspace": "Notion వర్క్‌స్పేస్ సభ్యులకు మాత్రమే పరిమితం. ఈ కంటెంట్‌ను యాక్సెస్ చేయడానికి లాగిన్ చేయండి.",
"select": {
@ -21,7 +21,7 @@
"create": "సృష్టించండి"
},
"fileInput": {
"chooseFiles": "ఫైల్(లు) ఎంచుకోవడానికి క్లిక్ చేయండి లేదా ఇక్కడ డ్రాగ్ చేయండి | ఫైల్ ఎంచుకోవడానికి క్లిక్ చేయండి లేదా ఇక్కడ డ్రాగ్ <EFBFBD><EFBFBD>ేయండి",
"chooseFiles": "ఫైల్(లు) ఎంచుకోవడానికి క్లిక్ చేయండి లేదా ఇక్కడ డ్రాగ్ చేయండి | ఫైల్ ఎంచుకోవడానికి క్లిక్ చేయండి లేదా ఇక్కడ డ్రాగ్ ేయండి",
"sizeLimit": "పరిమాణ పరిమితి: ప్రతి ఫైల్‌కు {count}MB",
"uploadingFile": "మీ ఫైల్ అప్‌లోడ్ అవుతోంది..."
},
@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "క్లిక్ చేయండి కెమెరా తెరవండి"
},
"payment": {
"amount_to_pay": "చెల్లించవలసిన మొత్తం",
"success": "చెల్లింపు విజయవంతమైంది",
"name_on_card": "కార్డుపై పేరు",
"billing_email": "బిల్లింగ్ ఇమెయిల్ చిరునామా",
"payment_disclaimer": "ఈ బటన్‌ను క్లిక్ చేసినప్పుడు మీ క్రెడిట్ కార్డ్‌కు ఛార్జీ విధించబడుతుంది. చెల్లింపులు Stripe ద్వారా సురక్షితంగా ప్రాసెస్ చేయబడతాయి.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "Kamera açmak için tıklayın"
},
"payment": {
"amount_to_pay": "Ödenecek tutar",
"success": "Ödeme başarılı",
"name_on_card": "Kart üzerindeki isim",
"billing_email": "Fatura e-posta adresi",
"payment_disclaimer": "Bu düğmeye tıkladığınızda kredi kartınızdan ücret alınacaktır. Ödemeler Stripe aracılığıyla güvenli bir şekilde işlenir.",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "کیمرے کھولنے کے لیے کلک کریں"
},
"payment": {
"amount_to_pay": "ادا کرنے کی رقم",
"success": "ادائیگی کامیاب",
"name_on_card": "کارڈ پر نام",
"billing_email": "بلنگ ای میل ایڈریس",
"payment_disclaimer": "اس بٹن پر کلک کرنے پر آپ کے کریڈٹ کارڈ سے چارج کیا جائے گا۔ ادائیگیوں پر Stripe کے ذریعے محفوظ طریقے سے کارروائی کی جاتی ہے۔",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -39,6 +39,13 @@
},
"barcodeInput": {
"clickToOpenCamera": "Nhấp để mở Camera"
},
"payment": {
"amount_to_pay": "Số tiền cần thanh toán",
"success": "Thanh toán thành công",
"name_on_card": "Tên trên thẻ",
"billing_email": "Email thanh toán",
"payment_disclaimer": "Thẻ tín dụng của bạn sẽ bị tính phí khi nhấp vào nút này. Các khoản thanh toán được xử lý an toàn qua Stripe."
}
}
}

View File

@ -39,6 +39,27 @@
},
"barcodeInput": {
"clickToOpenCamera": "点击打开相机"
},
"payment": {
"amount_to_pay": "支付金额",
"success": "支付成功",
"name_on_card": "卡上姓名",
"billing_email": "账单邮箱地址",
"payment_disclaimer": "点击此按钮后,将从您的信用卡中扣款。付款通过 Stripe 安全处理。",
"errors": {
"missingFormOrProvider": "Missing form slug or provider ID.",
"failedAccountDetails": "Failed to get Stripe account details.",
"setupError": "An error occurred during Stripe setup.",
"systemNotReady": "Payment system not ready. Please wait a moment and try again.",
"misconfigured": "Payment system misconfigured. Please refresh and try again.",
"notFullyReady": "Payment system not fully ready. Please refresh and try again.",
"paymentRequired": "Complete the payment before you can proceed.",
"nameRequired": "Card holder name is required.",
"emailRequired": "Billing email address is required.",
"invalidEmail": "Invalid billing email address.",
"failedIntent": "Failed to create payment intent.",
"processingFailed": "Payment processing failed."
}
}
}
}

View File

@ -63,6 +63,8 @@ function propertyConditionMet(propertyCondition, value) {
return filesConditionMet(propertyCondition, value)
case "matrix":
return matrixConditionMet(propertyCondition, value)
case "payment":
return paymentConditionMet(propertyCondition, value)
}
return false
}
@ -432,3 +434,17 @@ function matrixConditionMet(propertyCondition, value) {
}
return false
}
function paymentConditionMet(propertyCondition, value) {
switch (propertyCondition.operator) {
case "paid":
return checkPaid(propertyCondition, value)
case "not_paid":
return !checkPaid(propertyCondition, value)
}
return false
}
function checkPaid(propertyCondition, value) {
return (value) ? value.startsWith('pi_') : false
}

View File

@ -101,6 +101,10 @@
input:
'flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100'
},
PaymentInput: {
cardContainer: 'transition-all duration-200',
focusRing: 'ring-2 ring-primary-500 border-transparent ring-opacity-100'
},
CheckboxInput:{
size: {
sm: 'w-4 h-4',
@ -256,7 +260,11 @@
}
},
DateInput: {
input: 'flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 placeholder-gray-400 text-base focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100'
input: 'flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 text-base focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100'
},
PaymentInput: {
cardContainer: 'transition-all duration-200',
focusRing: 'ring-2 ring-primary-500 border-transparent ring-opacity-100'
},
CheckboxInput:{
size: {
@ -410,6 +418,10 @@
DateInput: {
input: 'border border-notion-input-border dark:border-notion-input-borderDark flex-1 appearance-none w-full bg-notion-input-background dark:bg-notion-dark-light text-gray-900 dark:text-gray-100 placeholder-gray-400 text-base focus:outline-none focus:ring-2 focus:ring-opacity-40 focus:border-transparent'
},
PaymentInput: {
cardContainer: 'transition-all duration-200',
focusRing: 'ring-2 ring-primary-500 border-transparent ring-opacity-40'
},
CheckboxInput:{
size: {
sm: 'w-4 h-4',

View File

@ -114,14 +114,6 @@ export default defineNuxtConfig({
'~/components',
],
vite: {
server: {
hmr: {
clientPort: 3000
}
}
},
tailwindcss: {
cssPath: ['~/scss/app.scss']
},
@ -140,6 +132,11 @@ export default defineNuxtConfig({
},
},
devServer: {
host: process.env.NUXT_HOST || 'localhost',
port: Number(process.env.NUXT_PORT) || 3000,
},
sitemap,
runtimeConfig,
gtm,

2678
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -40,6 +40,7 @@
"@nuxt/ui": "^2.19.2",
"@pinia/nuxt": "^0.5.5",
"@popperjs/core": "^2.11.8",
"@stripe/stripe-js": "^5.5.0",
"@sentry/nuxt": "^9.8.0",
"@sentry/vite-plugin": "^3.2.2",
"@sentry/vue": "^9.8.0",
@ -76,6 +77,7 @@
"vue-json-pretty": "^2.4.0",
"vue-notion": "^3.0.0",
"vue-signature-pad": "^3.0.2",
"vue-stripe-js": "^1.0.4",
"vuedraggable": "next",
"webcam-easy": "^1.1.1"
},

View File

@ -0,0 +1,105 @@
<template>
<div class="flex flex-col items-center justify-center p-10">
<template v-if="loading">
<Loader class="h-6 w-6 mb-4" />
<p class="text-gray-600">
Processing your connection...
</p>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAlert } from '~/composables/useAlert'
import { useWindowMessage, WindowMessageTypes } from "~/composables/useWindowMessage"
// Define meta for auth middleware
definePageMeta({
middleware: "auth",
})
// Use composables
const route = useRoute()
const router = useRouter()
const alert = useAlert()
// State
const loading = ref(true)
const errorMessage = ref(null)
async function handleCallback() {
loading.value = true
errorMessage.value = null
const code = route.query.code
const service = route.params.service
if(!code || !service) {
errorMessage.value = "Missing code or service parameter."
alert.error(errorMessage.value)
router.push('/settings/connections')
return
}
try {
const data = await opnFetch(`/settings/providers/callback/${service}`, {
method: 'POST',
params: { code }
})
if (window.opener && !window.opener.closed) {
try {
await useWindowMessage(WindowMessageTypes.OAUTH_PROVIDER_CONNECTED).send(window.opener, {
eventType: `${WindowMessageTypes.OAUTH_PROVIDER_CONNECTED}:${service}`,
useMessageChannel: false,
waitForAcknowledgment: false,
targetOrigin: window.location.origin
})
} catch (sendError) {
// Silently handle error when sending window message - continue flow regardless
}
}
// Get autoClose preference from the API response data
const shouldAutoClose = data?.autoClose === true
console.log('[CallbackPage] Checking autoClose status from API data:', {
apiValue: data?.autoClose,
shouldAutoClose
})
alert.success('Account connected successfully.')
// Close window if autoClose is set from API data, otherwise redirect
if (shouldAutoClose) {
console.log('[CallbackPage] Attempting window.close() based on API data.')
window.close()
// Add a fallback check in case window.close is blocked
setTimeout(() => {
if (!window.closed) {
console.warn('[CallbackPage] window.close() did not execute or was blocked.')
// Optionally, redirect here as a fallback if close fails?
// router.push('/settings/connections');
}
}, 500) // Check after 500ms
} else {
console.log('[CallbackPage] autoClose is false or not detected in API data, redirecting.')
router.push('/settings/connections')
}
} catch (error) {
try {
errorMessage.value = error?.data?.message || "An error occurred while connecting the account."
alert.error(errorMessage.value)
} catch (e) {
errorMessage.value = "An unknown error occurred while connecting the account."
alert.error(errorMessage.value)
}
router.push('/settings/connections')
}
}
onMounted(() => {
handleCallback()
})
</script>

View File

@ -38,62 +38,24 @@
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
import { useOAuthProvidersStore } from '~/stores/oauth_providers'
import { useOpnSeoMeta } from '~/composables/useOpnSeoMeta'
useOpnSeoMeta({
title: "Connections",
})
definePageMeta({
middleware: "auth",
alias: '/settings/connections/callback/:service'
})
const router = useRouter()
const route = useRoute()
const alert = useAlert()
const providerModal = ref(false)
const providersStore = useOAuthProvidersStore()
const providers = computed(() => providersStore.getAll)
const loading = computed(() => providersStore.loading)
function handleCallback() {
const code = route.query.code
const service = route.params.service
if(!code || !service) {
router.push('/settings/connections')
return
}
opnFetch(`/settings/providers/callback/${service}`, {
method: 'POST',
params: {
code
}
})
.then((data) => {
if(!data.intention) {
router.push('/settings/connections')
}
else {
router.push(data.intention)
}
})
.catch((error) => {
try {
alert.error(error.data.message)
} catch (e) {
alert.error("An error occurred while connecting an account")
}
router.push('/settings/connections')
})
}
onMounted(() => {
handleCallback()
providersStore.fetchOAuthProviders()
})
</script>

View File

@ -20,6 +20,12 @@ export const useOAuthProvidersStore = defineStore("oauth_providers", () => {
enabled: featureFlagsStore.getFlag('services.google.auth', false),
auth_type: 'redirect'
},
{
name: 'stripe',
title: 'Stripe',
icon: 'cib:stripe',
enabled: true
},
{
name: 'telegram',
title: 'Telegram',
@ -47,25 +53,30 @@ export const useOAuthProvidersStore = defineStore("oauth_providers", () => {
)
}
const connect = (service, redirect = false) => {
const connect = (service, redirect = false, newtab = false, autoClose = false) => {
contentStore.resetState()
const serviceConfig = getService(service)
if (serviceConfig.auth_type !== 'redirect') {
if (serviceConfig && serviceConfig.auth_type !== 'redirect') {
return
}
contentStore.startLoading()
const intention = new URL(window.location.href).pathname
const intention = redirect ? new URL(window.location.href).pathname : undefined
opnFetch(`/settings/providers/connect/${service}`, {
method: 'POST',
body: {
...redirect ? { intention } : {},
...(intention && { intention }),
autoClose: autoClose
}
})
.then((data) => {
// Redirect flow
if (newtab) {
window.open(data.url, '_blank')
} else {
window.location.href = data.url
}
})
.catch((error) => {
try {

View File

@ -3,6 +3,7 @@ import clonedeep from "clone-deep"
import { generateUUID } from "~/lib/utils.js"
import blocksTypes from "~/data/blocks_types.json"
import { useAlert } from '~/composables/useAlert'
import { useAuthStore } from '~/stores/auth'
export const useWorkingFormStore = defineStore("working_form", {
state: () => ({
@ -230,6 +231,29 @@ export const useWorkingFormStore = defineStore("working_form", {
},
addBlock(type, index = null, openSettings = true) {
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
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())