Skip to content

Rock: Enable pricing fields in Lite#3006

Merged
Crabcyborg merged 90 commits intomasterfrom
rock-enable-pricing-fields
Mar 30, 2026
Merged

Rock: Enable pricing fields in Lite#3006
Crabcyborg merged 90 commits intomasterfrom
rock-enable-pricing-fields

Conversation

@truongwp
Copy link
Copy Markdown
Contributor

@truongwp truongwp commented Mar 9, 2026

Please check the Handoff for the details: https://linear.app/strategy11/document/feature-handoff-a33762f44d84

Summary by CodeRabbit

Release Notes

  • New Features

    • Added product, quantity, and total field types for pricing workflows.
    • Enhanced currency formatting and price localization.
    • Improved field organization with section-based grouping in the field builder.
    • Added setup prompts for payment gateway configuration.
  • Bug Fixes

    • Improved CSS selector specificity in accordion styling.
  • Chores

    • Updated database version.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 9, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds product pricing: new field types (product, quantity, total), currency helpers and front-end JS for price/quantity/total calculations, admin UI/modal updates, DB migration to version 105, templates/styles, and removes the repository .htaccess file.

Changes

Cohort / File(s) Summary
New Field Types
classes/models/fields/FrmFieldProduct.php, classes/models/fields/FrmFieldQuantity.php, classes/models/fields/FrmFieldTotal.php
Implements product/quantity/total field classes with builder views, front-end rendering, price extraction, quantity integration, and post-submit total validation.
Factory & Field Metadata
classes/factories/FrmFieldFactory.php, classes/models/FrmField.php
Registers new types in factory; adds pricing section to field registry and helpers field_section_labels() and remove_moved_field_types_from_pro().
Field Controllers / HTML attrs
classes/controllers/FrmFieldsController.php, classes/helpers/FrmFieldsHelper.php
Adds currency/price data attributes to field HTML and a builder helper show_add_field_link() for sectioned field rendering.
Currency Helpers
classes/helpers/FrmCurrencyHelper.php
Makes get_currency() optional, adds price parsing/formatting (prepare_price, format_price), decimal handling, and add_currency_to_global() for front-end currency data.
Forms Helper / JS injection
classes/controllers/FrmFormsController.php, classes/helpers/FrmFormsHelper.php
Adds conditional currency JS loader and load_currency_js() to inject currency data into frontend when present.
Back-end Views / Builder Partials
classes/views/frm-fields/back-end/...
Adds builder partials: product-options, product-single-option, quantity-options, separate-values, and field-total backend template.
Front-end Templates
classes/views/frm-fields/front-end/product-*.php
Adds front-end templates for product radios/checkboxes, select, and single-option hidden rendering with price data attributes.
Add-field UI Refactor
classes/views/frm-forms/add_field_links.php
Groups add-field links by section, defers rendering for sectioned fields, and uses new helpers for pro-field removal.
Frontend Logic (JS)
js/formidable.js
Large addition: quantity min/max clamping, product total aggregation, currency normalization/formatting, rounding helpers, event bindings for repeaters and field changes (~+430 lines).
Admin UI / Modal & Validation
js/src/admin/admin.js, classes/views/shared/info-overlay.php
Extends infoModal to accept richer payloads, adds price validation and dismissal handling, and restructures info-overlay markup (image/action buttons).
Styling & Utilities
resources/scss/..., css/frm_testing_mode.css
Selector refinements for direct-child spans, new .frmright utility, responsive modal image rule, and an added duplicate tooltip block in testing CSS.
DB Version & Migration
classes/helpers/FrmAppHelper.php, classes/models/FrmMigrate.php
Bumps DB version to 105, adds migration step to set frm_show_pricing_fields_modal. Note: migrate_to_105() appears duplicated in FrmMigrate. Localizes new admin script strings and clears the option after showing modal.
Stripe Lite helpers
stripe/controllers/FrmStrpLiteAppController.php
Adds is_payment_gateway_configured() and get_payments_settings_url() and uses them in redirect logic.
Repository config removal
.htaccess
Removes previously committed .htaccess that denied global access then allowed specific assets (file content deleted).

Sequence Diagram

sequenceDiagram
    participant User
    participant Frontend
    participant JS
    participant Backend
    participant DB

    User->>Frontend: interact (select product / change qty)
    Frontend->>JS: change event (data-frmprice, quantity)
    JS->>JS: calcProductsTotal(formId) → format currency
    JS->>Frontend: update total readout + hidden total field
    User->>Frontend: submit form
    Frontend->>Backend: POST form data (includes product details + total)
    Backend->>DB: read form / currency settings (if needed)
    Backend->>Backend: validate_total() recomputes expected sum from posted product data
    alt totals match
        Backend->>User: accept entry
    else mismatch
        Backend->>User: return validation error
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through fields of product and price,
Quantities counted, totals precise.
JS tallies sums, PHP checks the score,
Currencies sing, options galore—
A rabbit applauds this pricing device.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature being implemented: enabling pricing fields (product, quantity, total) in the Lite version of Formidable Forms.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch rock-enable-pricing-fields

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@deepsource-io
Copy link
Copy Markdown

deepsource-io bot commented Mar 9, 2026

DeepSource Code Review

We reviewed changes in 745b145...948977c on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

Code Review Summary

Analyzer Status Updated (UTC) Details
PHP Mar 9, 2026 7:11p.m. Review ↗
JavaScript Mar 9, 2026 7:11p.m. Review ↗

@deepsource-io
Copy link
Copy Markdown

deepsource-io bot commented Mar 9, 2026

DeepSource Code Review

We reviewed changes in 2d8b30e...ced42b5 on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

Important

Some issues found as part of this review are outside of the diff in this pull request and aren't shown in the inline review comments due to GitHub's API limitations. You can see those issues on the DeepSource dashboard.

PR Report Card

Overall Grade   Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
PHP Mar 27, 2026 6:25p.m. Review ↗
JavaScript Mar 27, 2026 6:25p.m. Review ↗

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (10)
js/src/admin/admin.js-9741-9754 (1)

9741-9754: ⚠️ Potential issue | 🟡 Minor

Use the dialogclose event instead of individual click listeners.

The current implementation only handles click dismissals, missing Escape key and dialog titlebar close button. When those non-click dismissals occur, the handler on #frm-info-click remains registered and can incorrectly affect subsequent unrelated info modals.

Suggested fix
 function handleModalDismiss( input ) {
-	const modalDismissers = document.querySelectorAll( '#frm_info_modal .dismiss, `#frm_info_modal` `#frm-info-click`, .ui-widget-overlay.ui-front' );
-	function onModalClose() {
+	jQuery( '#frm_info_modal' ).one( 'dialogclose', function() {
 		input.classList.add( 'frm_invalid_field' );
 		setTimeout( () => input.focus(), 0 );
-		modalDismissers.forEach( el => {
-			el.removeEventListener( 'click', onModalClose );
-		});
-	}
-
-	modalDismissers.forEach( el => {
-		el.addEventListener( 'click', onModalClose );
-	});
+	} );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/src/admin/admin.js` around lines 9741 - 9754, The handler in
handleModalDismiss currently adds click listeners to multiple elements and
misses non-click dismissals (Escape/titlebar), so replace that approach by
attaching a single 'dialogclose' event listener to the modal element
(`#frm_info_modal`) instead; in handleModalDismiss (and/or the onModalClose
function) add the 'frm_invalid_field' class to the input, focus it, and remove
any previously added click listeners and the 'dialogclose' listener itself to
fully clean up; keep references to the modal element and any click-listener
cleanup logic (the modalDismissers NodeList and their removeEventListener calls)
so they are invoked from the dialogclose handler rather than relying on
individual click events.
classes/views/frm-fields/back-end/quantity-options.php-8-10 (1)

8-10: ⚠️ Potential issue | 🟡 Minor

Use the Lite text domain for this new label.

This template lives in Lite, but the string is registered under formidable-pro, so it won't be translated here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/views/frm-fields/back-end/quantity-options.php` around lines 8 - 10,
The label uses the pro text domain; update the esc_html_e call for the "Product
Field" label to use the Lite text domain (replace 'formidable-pro' with the Lite
domain used by the plugin) so the string is translated in the Lite template—look
for the esc_html_e invocation for "Product Field" in this file and change its
text domain accordingly.
classes/views/frm-fields/back-end/product-single-option.php-11-12 (1)

11-12: ⚠️ Potential issue | 🟡 Minor

Use the Lite text domain for the new placeholders.

Product Name and Price are added in a Lite template, but both are registered under formidable-pro, so they won't be translated in this package.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/views/frm-fields/back-end/product-single-option.php` around lines 11
- 12, The placeholders for the two input fields use esc_attr_e with the pro text
domain ('formidable-pro') but these are in the Lite template; change the text
domain to the Lite package text domain (use the Lite domain, e.g. 'formidable')
for both placeholder calls so "Product Name" and "Price" are translatable in
this package—update the esc_attr_e invocation on the input with id built from
$html_id . '-' . $opt_key and on the input with class frm_product_price to use
the Lite text domain.
classes/views/frm-forms/modals/pricing-fields-modal.php-24-31 (1)

24-31: ⚠️ Potential issue | 🟡 Minor

Button labels and actions appear to be swapped.

The UX seems counterintuitive:

  • "I'll do it later" is styled as the primary button but links to the upgrade page
  • "Setup Payments Now" just closes the modal (.frm-cancel-modal)

Users clicking "Setup Payments Now" would expect to be directed to payment gateway settings, not have the modal close. Consider either:

  1. Swapping the button labels, or
  2. Making "Setup Payments Now" navigate to the settings page where payment gateways can be configured
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/views/frm-forms/modals/pricing-fields-modal.php` around lines 24 -
31, The primary and secondary actions are reversed: the anchor using
FrmAppHelper::admin_upgrade_link currently shows "I'll do it later" and the
cancel button (.frm-cancel-modal) shows "Setup Payments Now"; change the buttons
so the primary action (class frm-button-primary) is "Setup Payments Now" and
links to the payment/setup URL (use FrmAppHelper::admin_upgrade_link or the
correct payment settings URL), and make the secondary/cancel button read "I'll
do it later" and keep the .frm-cancel-modal behavior to close the modal; update
the text in esc_html_e calls to match the swapped roles.
classes/controllers/FrmAppController.php-867-881 (1)

867-881: ⚠️ Potential issue | 🟡 Minor

Race condition: Option deletion happens before modal is confirmed to display.

The delete_option('frm_show_pricing_fields_modal') is called during asset enqueueing (line 880), but the modal isn't actually shown until later when the page renders and JavaScript executes. If the page load fails or the user navigates away before seeing the modal, the option is already deleted and the user will never see the modal.

Consider moving the option deletion to JavaScript after the modal has been successfully displayed, or use an AJAX callback when the modal is closed/interacted with.

💡 Alternative approach using JavaScript
 		// Add modal to admin footer
 		add_action( 'admin_footer', array( __CLASS__, 'include_pricing_fields_modal' ) );
-
-		// Delete the option so modal only shows once
-		delete_option( 'frm_show_pricing_fields_modal' );
 	}

Then in JavaScript, make an AJAX call to delete the option when the modal is shown or dismissed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/controllers/FrmAppController.php` around lines 867 - 881, The option
frm_show_pricing_fields_modal is being deleted during enqueueing (delete_option
in FrmAppController block) which can race with the modal display; instead, keep
the option intact during PHP enqueue and remove the delete_option call, then
perform the deletion from client-side JS (in frm-pricing-fields-modal.js) after
the modal is successfully shown or dismissed by calling an AJAX endpoint (create
a small admin-ajax handler/method that calls
delete_option('frm_show_pricing_fields_modal') and reference it from the modal
script), or trigger deletion from the server via a dedicated
include_pricing_fields_modal flow only after confirmation.
classes/models/fields/FrmFieldProduct.php-155-157 (1)

155-157: ⚠️ Potential issue | 🟡 Minor

Text domain inconsistency.

Same issue - using 'formidable-pro' text domain in Lite code.

🐛 Proposed fix
 protected function get_bulk_edit_string() {
-    return __( 'Bulk Edit Products', 'formidable-pro' );
+    return __( 'Bulk Edit Products', 'formidable' );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/models/fields/FrmFieldProduct.php` around lines 155 - 157, The
get_bulk_edit_string() method is using the pro text domain 'formidable-pro' in
Lite code; update the translation call in get_bulk_edit_string to use the
correct Lite text domain (replace 'formidable-pro' with the Lite domain, e.g.,
'formidable') so the string __("Bulk Edit Products", ...) uses the proper text
domain for this code path.
classes/models/fields/FrmFieldProduct.php-28-37 (1)

28-37: ⚠️ Potential issue | 🟡 Minor

Text domain inconsistency: Using 'formidable-pro' in Lite code.

This code is part of Formidable Forms Lite, but uses the 'formidable-pro' text domain for the "Product 1" string. This will cause translation issues as the string won't be found in the Lite translation files.

🐛 Proposed fix
 protected function new_field_settings() {
     return array(
         'options' => serialize(
             array(
                 '',
-                __( 'Product 1', 'formidable-pro' ),
+                __( 'Product 1', 'formidable' ),
             )
         ),
     );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/models/fields/FrmFieldProduct.php` around lines 28 - 37, The string
in new_field_settings (FrmFieldProduct::new_field_settings) uses the
'formidable-pro' text domain in the serialized 'options' array; update the text
domain to the Lite domain (e.g., 'formidable') so translations are loaded
correctly, preserving the surrounding serialize(...) structure and array keys
exactly as-is.
classes/models/fields/FrmFieldProduct.php-276-289 (1)

276-289: ⚠️ Potential issue | 🟡 Minor

Text domain inconsistency.

Same issue - using 'formidable-pro' text domain for "New Product" string.

🐛 Proposed fix
 private static function hidden_field_option( $field ) {
     $opt_key    = '000';
-    $field_val  = __( 'New Product', 'formidable-pro' );
+    $field_val  = __( 'New Product', 'formidable' );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/models/fields/FrmFieldProduct.php` around lines 276 - 289, The string
__('New Product', 'formidable-pro') in hidden_field_option is using the wrong
text domain; update the text domain to the project standard (replace
'formidable-pro' with the correct domain used elsewhere, e.g., 'formidable') so
$field_val uses the consistent translation domain; modify the __('New Product',
...) call inside the hidden_field_option method accordingly (refer to $field_val
and hidden_field_option to locate the change).
js/formidable.js-2003-2007 (1)

2003-2007: ⚠️ Potential issue | 🟡 Minor

getCurrency returns undefined when currency is not found.

If __FRMCURR is defined but doesn't contain the formId, this function returns undefined. Callers like calcProductsTotal then access properties on undefined (e.g., currency.decimal_separator), which will throw a TypeError.

Consider adding a fallback or early return in calcProductsTotal:

🛡️ Proposed defensive fix in getCurrency
 function getCurrency( formId ) {
 	if ( typeof __FRMCURR  !== 'undefined' && typeof __FRMCURR[ formId ] !== 'undefined' ) {
 		return __FRMCURR[ formId ];
 	}
+	return null;
 }

Then in calcProductsTotal, add a check:

 currency = getCurrency( formId );
+if ( ! currency ) {
+	return;
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/formidable.js` around lines 2003 - 2007, getCurrency can return undefined
when __FRMCURR[formId] is missing which causes callers like calcProductsTotal to
access properties on undefined; update getCurrency to return a safe default
currency object when __FRMCURR or __FRMCURR[formId] is missing (e.g. include
keys decimal_separator, thousands_separator, decimal_digit, symbol with sensible
defaults) and also add a defensive check at the start of calcProductsTotal to
bail out or use the default when getCurrency(formId) returns falsy so you never
access properties on undefined; refer to the getCurrency and calcProductsTotal
functions and the __FRMCURR lookup when making these changes.
classes/models/fields/FrmFieldTotal.php-158-158 (1)

158-158: ⚠️ Potential issue | 🟡 Minor

Potential type error with trim() on non-string value.

$price_quantity['parent_field_id'] might be null, an integer, or another non-string type. Calling trim() on a non-string in strict environments can cause issues. While PHP will coerce the value, it's safer to cast explicitly.

🛡️ Proposed fix
-} elseif ( ! empty( trim( $price_quantity['parent_field_id'] ) ) ) {
+} elseif ( ! empty( $price_quantity['parent_field_id'] ) ) {

The empty() check already handles null, empty string, and falsy values, making trim() unnecessary here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/models/fields/FrmFieldTotal.php` at line 158, The condition in
FrmFieldTotal.php uses trim() on $price_quantity['parent_field_id'], which can
be non-string and cause type issues; remove the trim() and change the check to
simply use ! empty( $price_quantity['parent_field_id'] ) (or explicitly cast to
string only if whitespace trimming is required) so the code uses the existing
empty() behavior safely when evaluating $price_quantity['parent_field_id'].
🧹 Nitpick comments (14)
classes/views/shared/info-overlay.php (1)

19-24: Good restructuring of action buttons.

The button container provides clean semantic grouping. One minor consideration: the empty anchor on line 23 could benefit from aria-hidden="true" until JavaScript populates it with content, to ensure screen readers don't encounter an empty interactive element.

🔧 Optional accessibility improvement
-						<a href="#" class="info-modal-action-link button button-primary frm-button-primary frm_hidden"></a>
+						<a href="#" class="info-modal-action-link button button-primary frm-button-primary frm_hidden" aria-hidden="true"></a>

Note: The JavaScript that shows this element should also set aria-hidden="false" when making it visible and adding content.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/views/shared/info-overlay.php` around lines 19 - 24, The empty
interactive anchor with class "info-modal-action-link" should be hidden from
assistive tech until populated: add aria-hidden="true" to the <a
class="info-modal-action-link"> element in info-overlay.php and update the JS
that reveals/populates this element to set aria-hidden="false" (and remove the
frm_hidden class) when content is inserted and it becomes visible; also ensure
any script that clears or hides it again resets aria-hidden back to "true".
stripe/controllers/FrmStrpLiteAppController.php (1)

60-69: Rename this helper or make it check real gateway configuration.

FrmTransLiteAppHelper::payments_table_exists() only tells us the payments schema is installed. It does not prove Stripe or any other gateway is configured, so this new public API/docblock can produce false positives if it gets reused for pricing/payment feature gating.

♻️ If the intent is only “payments tables installed”, rename it to match
-	 * Checks if any payment gateway is configured.
+	 * Checks if the payments tables are installed.
@@
-	public static function is_payment_gateway_configured() {
+	public static function payments_table_exists() {
 		return FrmTransLiteAppHelper::payments_table_exists();
 	}
@@
-		if ( self::is_payment_gateway_configured() ) {
+		if ( self::payments_table_exists() ) {
 			return;
 		}

Also applies to: 87-88

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@stripe/controllers/FrmStrpLiteAppController.php` around lines 60 - 69, The
method is_payment_gateway_configured currently returns
FrmTransLiteAppHelper::payments_table_exists(), which only checks schema
presence and can yield false positives; either rename the helper to reflect
"payments_table_exists" and update the docblock/public API accordingly, or
change is_payment_gateway_configured to perform a real gateway check (e.g.,
verify Stripe/other gateway settings, API keys, or enabled gateway flags) before
returning true; update any other uses (including the similar call around the
other occurrence) and adjust the docblock for
FrmStrpLiteAppController::is_payment_gateway_configured to accurately describe
the new semantics.
classes/models/FrmField.php (1)

403-415: Add type hint to resolve PHPStan error.

The static analysis tool flagged that the $pro_fields parameter lacks a type specification. Since this is a pass-by-reference parameter modifying an array, add the array type hint.

🔧 Proposed fix
 	/**
 	 * Remove field types that are moved from Pro to Lite.
 	 *
 	 * `@since` x.x
 	 *
+	 * `@param` array $pro_fields The Pro fields array to modify.
 	 * `@return` void
 	 */
-	public static function remove_moved_field_types_from_pro( &$pro_fields ) {
+	public static function remove_moved_field_types_from_pro( array &$pro_fields ) {
 		unset( $pro_fields['credit_card'] );
 		unset( $pro_fields['product'] );
 		unset( $pro_fields['quantity'] );
 		unset( $pro_fields['total'] );
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/models/FrmField.php` around lines 403 - 415, The static analyzer
flags that the pass-by-reference parameter in
FrmField::remove_moved_field_types_from_pro lacks a type, so update the method
signature to declare the parameter as an array by adding the array type hint to
&$pro_fields (keeping the reference) so PHPStan recognizes it as an array being
modified.
classes/views/frm-fields/front-end/product-single.php (2)

12-15: TODO: Currency formatting is missing for price display.

The comment on line 13 indicates this is incomplete. The price is displayed as a raw number without currency symbol/formatting, which may confuse users expecting to see "$10.00" instead of "10".

Would you like me to help implement the currency formatting using FrmCurrencyHelper::format_price()?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/views/frm-fields/front-end/product-single.php` around lines 12 - 15,
The price is rendered as a raw number; update the product label rendering to
format the price using the currency helper so users see a proper currency
string. Replace the direct echo of $price in the frm_single_product_label block
with a call to FrmCurrencyHelper::format_price($price) (fall back to esc_html on
failure), and ensure the formatted value is escaped before output; keep the
label text ($opt) escaped as-is.

7-20: Consider simplifying the single-option retrieval.

The foreach loop with an immediate break is inefficient for retrieving just the first option. Consider using array functions instead:

♻️ Suggested simplification
-foreach ( $field['options'] as $opt_key => $opt ) {
-	$field_val = FrmFieldsHelper::get_value_from_array( $opt, $opt_key, $field );
-	$price     = FrmFieldProduct::get_price_from_array( $opt, $opt_key, $field );
-	$opt       = FrmFieldsHelper::get_label_from_array( $opt, $opt_key, $field );
+$opt_key   = array_key_first( $field['options'] );
+$opt       = $field['options'][ $opt_key ];
+$field_val = FrmFieldsHelper::get_value_from_array( $opt, $opt_key, $field );
+$price     = FrmFieldProduct::get_price_from_array( $opt, $opt_key, $field );
+$opt       = FrmFieldsHelper::get_label_from_array( $opt, $opt_key, $field );
+if ( $opt_key !== null ) :
 	?>
 	<p class="frm_single_product_label">
-		<?php // TODO: should show currency
-		echo esc_html( $opt ); ?>: <?php echo esc_html( $price ); ?>
+		<?php echo esc_html( $opt ); ?>: <?php echo esc_html( $price ); ?>
 	</p>
 
 	<input type="hidden" name="<?php echo esc_attr( $field_name ); ?>" id="<?php echo esc_attr( $html_id ); ?>" value="<?php echo esc_attr( $field_val ); ?>" data-frmprice="<?php echo esc_attr( $price ); ?>" <?php do_action( 'frm_field_input_html', $field ); ?> />
 	<?php
-	break; // We want just the first
-}
+endif;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/views/frm-fields/front-end/product-single.php` around lines 7 - 20,
The foreach loop is only used to grab the first option then break; replace it by
directly retrieving the first option (e.g., via reset/current) from
$field['options'] and then call FrmFieldsHelper::get_value_from_array,
FrmFieldProduct::get_price_from_array, and FrmFieldsHelper::get_label_from_array
on that single option; keep the same output/escaping for the <p> and hidden
input using $field_name and $html_id and preserve the data-frmprice attribute
and the do_action call.
classes/views/frm-fields/front-end/product-select.php (1)

17-20: Use strict comparison for empty string check.

The loose comparison $opt == '' on line 17 could incorrectly match other falsy values like 0 or false. Use strict comparison for reliability.

🔧 Proposed fix
-	if ( ! empty( $placeholder ) && $opt == '' && ! $skipped ) {
+	if ( ! empty( $placeholder ) && $opt === '' && ! $skipped ) {
 		$skipped = true;
 		continue;
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/views/frm-fields/front-end/product-select.php` around lines 17 - 20,
Change the loose comparison $opt == '' to a strict comparison $opt === '' so
falsy values like 0 or false won't be treated as an empty string; update the
conditional in the block that checks $placeholder, $opt and $skipped (the if
using $placeholder, $opt and $skipped) to use $opt === '' and keep the rest of
the logic ($skipped = true; continue;) unchanged.
js/pricing-fields-modal.js (2)

70-77: Escape key handling may conflict with jQuery UI's built-in behavior.

jQuery UI Dialog already handles the Escape key by default (unless closeOnEscape: false is set). This custom handler may cause the close logic to execute twice or conflict with the dialog's internal state.

💡 Consider removing duplicate escape handler

Since closeOnEscape defaults to true in jQuery UI Dialog, you can remove this custom handler or explicitly disable jQuery UI's handler if you need custom behavior:

 		$modal.dialog({
 			autoOpen: false,
 			modal: true,
 			width: 500,
 			height: 'auto',
 			resizable: false,
 			draggable: false,
+			closeOnEscape: true, // This is already the default
 			position: { my: 'center', at: 'center', of: window },

Then remove lines 70-77 as they're redundant.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/pricing-fields-modal.js` around lines 70 - 77, The global keydown handler
that listens for Escape and manually calls
$('#frm-pricing-fields-modal').dialog('close') is redundant with jQuery UI
Dialog's built-in closeOnEscape behavior and may double-close or conflict;
remove the entire keydown handler block that checks e.keyCode === 27 and calls
dialog('isOpen')/dialog('close'), or alternatively disable the dialog's built-in
handler by setting closeOnEscape: false on the
$('#frm-pricing-fields-modal').dialog(...) initialization and then keep a single
delegated handler — ensure you modify the dialog initialization (closeOnEscape)
or remove the keydown block to avoid duplicate escape handling.

27-30: Selector may move multiple dialogs unintentionally.

The selector $('.ui-dialog') will match all jQuery UI dialogs on the page, not just the pricing modal's dialog. This could cause unintended side effects if other dialogs exist.

🔧 Use specific dialog reference
 			create: function() {
 				// Move dialog to the end of body to ensure proper stacking
-				$('.ui-dialog').appendTo('body');
+				$(this).closest('.ui-dialog').appendTo('body');
 			},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/pricing-fields-modal.js` around lines 27 - 30, The create callback
currently moves all dialogs by using the global selector $('.ui-dialog'); narrow
this to the pricing modal's dialog by selecting the specific dialog widget for
the current instance (use the create callback's this context and obtain the
dialog widget or the element's closest .ui-dialog) and append only that element
to body instead of the global selector; update the create function in
pricing-fields-modal.js to use the instance-specific dialog reference rather
than $('.ui-dialog').
classes/controllers/FrmFieldsController.php (1)

1054-1061: Add @since tag to new method.

The new add_currency_field_attributes() method should include a @since x.x tag for documentation consistency.

📝 Add documentation
 	/**
 	 * Add product attributes to fields in multi-paged forms.
 	 *
+	 * `@since` x.x
+	 *
 	 * `@param` array $field
 	 * `@param` array $add_html
 	 *
 	 * `@return` void
 	 */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/controllers/FrmFieldsController.php` around lines 1054 - 1061, The
docblock for the new method add_currency_field_attributes() is missing a `@since`
tag; update the method's PHPDoc block (the comment immediately above the
add_currency_field_attributes function) to include a `@since` x.x entry matching
your release version to keep documentation consistent, e.g., add a line like
"@since x.x" within the existing docblock alongside the `@param/`@return tags.
classes/models/fields/FrmFieldTotal.php (2)

178-180: Unused variable and commented-out code.

Static analysis correctly identifies $form_id is assigned but never used. Additionally, line 180 contains commented-out code that should be removed.

♻️ Proposed fix
 protected function prepare_display_value( $value, $atts ) {
     if ( ! empty( $atts['format'] ) && 'number' === $atts['format'] ) {
         return $value;
     }

-    $form_id  = $this->get_field_column( 'form_id' );
     $currency = FrmCurrencyHelper::get_currency();
-    // $currency = FrmProCurrencyHelper::apply_shortcode_atts( $currency, $atts );

     return FrmCurrencyHelper::format_price( $value, $currency );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/models/fields/FrmFieldTotal.php` around lines 178 - 180, Remove the
dead assignment and the commented-out line: delete the unused $form_id =
$this->get_field_column( 'form_id' ); and remove the commented-out $currency =
FrmProCurrencyHelper::apply_shortcode_atts( $currency, $atts ); line in
FrmFieldTotal (the code around get_field_column and
FrmCurrencyHelper::get_currency), leaving only the active $currency assignment
to avoid unused variable warnings and stale commented code.

56-69: Validation hook pattern is sound but consider cleanup.

The pattern of deferring validation via frm_entries_before_create hook ensures product/quantity data is gathered first. However, the hook is added but never removed. If validate() is called multiple times for different fields, multiple callbacks could accumulate.

Consider whether the hook should be removed after execution in validate_total():

♻️ Possible cleanup
 public function validate_total( $errors, $form ) {
+    // Remove this callback to prevent multiple executions
+    remove_filter( 'frm_entries_before_create', array( $this, 'validate_total' ), 10 );
+
     $value = $this->posted_value_args['value'];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/models/fields/FrmFieldTotal.php` around lines 56 - 69, The validate()
method adds a frm_entries_before_create hook (via add_filter) to defer
validation but never removes it; update the validate_total() method to call
remove_filter('frm_entries_before_create', array($this, 'validate_total'), 10,
2) immediately after running so the callback is cleaned up and posted_value_args
is cleared (or reset) to prevent accumulation when validate() is invoked
multiple times for different fields; ensure the remove_filter uses the same
priority and accepted args as the original add_filter.
classes/models/fields/FrmFieldProduct.php (2)

137-137: Non-static method called with self::.

get_data_type_settings() is a private instance method (not static), but it's being called with self::get_data_type_settings(). While this works in PHP, it's misleading. Use $this-> for instance methods.

♻️ Proposed fix
-} elseif ( array_key_exists( $product_type, self::get_data_type_settings() ) ) {
+} elseif ( array_key_exists( $product_type, $this->get_data_type_settings() ) ) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/models/fields/FrmFieldProduct.php` at line 137, The conditional uses
self::get_data_type_settings() but get_data_type_settings() is a private
instance method of FrmFieldProduct; change the call to
$this->get_data_type_settings() (and any other occurrences in the class) so
instance method invocation is clear and correct, keeping the method non-static
and ensuring the surrounding conditional (the elseif in the method where this
appears) calls $this->get_data_type_settings().

72-80: Missing return type declarations (PHPStan).

PHPStan correctly identifies that get_data_type_settings() and get_posted_price() lack return type declarations. Adding these improves code clarity and enables better static analysis.

♻️ Proposed fix
-private function get_data_type_settings() {
+private function get_data_type_settings(): array {
     return array(
-public function get_posted_price( $posted_value ) {
+public function get_posted_price( $posted_value ): array|int {
     $price   = 0;

Also applies to: 203-223

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/models/fields/FrmFieldProduct.php` around lines 72 - 80, Add explicit
return type declarations to the methods flagged by PHPStan: update
get_data_type_settings() to declare it returns array (e.g.,
get_data_type_settings(): array) and update get_posted_price() to declare a
precise numeric return type that matches its implementation (e.g., float or
?float if it may return null); ensure the declared types reflect the actual
values returned by get_posted_price() and adjust any callers or internal
conversions if needed.
classes/models/fields/FrmFieldQuantity.php (1)

41-48: Potential issue with empty array check.

Line 44 uses a truthiness check $field['product_field'] ? (array) $field['product_field'] : array(). If product_field is already an empty array [], this will evaluate to false and unnecessarily recreate an empty array. While functionally correct, the intent might be clearer with an explicit check.

Consider simplifying:

-$field['product_field'] = $field['product_field'] ? (array) $field['product_field'] : array();
+$field['product_field'] = (array) $field['product_field'];

Casting to (array) handles both null and scalar values correctly, making the ternary unnecessary.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/models/fields/FrmFieldQuantity.php` around lines 41 - 48, In
show_primary_options, simplify the product_field handling: instead of the
ternary `$field['product_field'] ? (array) $field['product_field'] : array()`,
directly cast with `(array) $field['product_field']` so null/scalar/array are
handled consistently; update the assignment to `$field['product_field'] =
(array) $field['product_field'];` and leave the include and
parent::show_primary_options($args) call unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@classes/controllers/FrmFieldsController.php`:
- Around line 1084-1098: The get_product_price function currently assumes
FrmFieldFactory::get_field_object($field['id']) returns an object with
get_posted_price; update it to guard against that by checking the object's type
or method first (e.g., use instanceof FrmFieldProduct or
method_exists($field_obj, 'get_posted_price')) and only call get_posted_price
when available; if the check fails, return a safe default (e.g., 0 or null) or
handle gracefully so no fatal error occurs. Ensure you update get_product_price
to reference FrmFieldFactory::get_field_object, FrmFieldProduct, and
get_posted_price in the fix.

In `@classes/helpers/FrmCurrencyHelper.php`:
- Around line 89-95: The regex in FrmCurrencyHelper that extracts numeric chunks
using preg_match_all incorrectly excludes space and apostrophe thousand
separators, so update the pattern used where preg_match_all is called (the block
that sets $price and then calls self::maybe_use_decimal and str_replace) to
allow the configured thousand separator(s) (including space and apostrophe) and
the currency decimal separator; specifically include \s and ' (and also
dynamically include $currency['thousand_separator'] and
$currency['decimal_separator'] when building the pattern), ensure proper
escaping of those separators in the regex, and keep the existing fallback logic
($matches ? end($matches[0]) : 0) and subsequent maybe_use_decimal/str_replace
flow.
- Around line 58-129: Update the method signatures to include PHP type hints and
return types to satisfy PHPStan: change format_price to accept float|string
$amount and array $currency and return string; change prepare_price to accept
string $price and array $currency and return string; change maybe_use_decimal to
accept string $amount and array $currency and return string; change
add_currency_to_global to accept int|string $form_id and return void. Also
update the corresponding docblocks to match these types (or remove redundant
`@param/`@return types) for functions format_price, prepare_price,
maybe_use_decimal, and add_currency_to_global.

In `@classes/helpers/FrmFieldsHelper.php`:
- Around line 2373-2381: Normalize composite field keys by replacing pipe
characters before building the class/id strings: create a normalized key (e.g.,
$norm_key = str_replace('|', '-', $field_type['key'])) and use $norm_key when
composing $classes ('frmbutton frm6 frm_t' . $norm_key) and the li id attribute;
this matches the normalization used in show_add_field_buttons() so composite
fields get the correct styling/JS hooks.

In `@classes/helpers/FrmFormsHelper.php`:
- Around line 2153-2163: In load_currency_js() replace the use of PHP's
json_encode when serializing $frm_vars['currency'] for inline output with
WordPress's wp_json_encode to safely escape sequences like "</script>" and other
problematic data; locate the json_encode call that builds 'var frmcurrency=' and
swap it to wp_json_encode($frm_vars['currency']) so the generated frmcurrency,
__FRMCURR assignment/merge code, and existing output logic remain the same but
safe for translatable strings.

In `@classes/models/fields/FrmFieldTotal.php`:
- Around line 83-87: The code calls FrmProFieldsHelper::is_field_hidden()
directly which will fatally fail when the Pro class is absent; update the
conditional in FrmFieldTotal (where $this->field and $errors and
get_field_column('id') are used) to first verify the Pro helper exists and the
method is callable (e.g., class_exists('FrmProFieldsHelper') &&
is_callable(['FrmProFieldsHelper','is_field_hidden'])) before invoking
is_field_hidden, and if the check fails treat the field as not hidden (skip the
hidden-logic branch) so Lite installations won't error.

In `@classes/views/frm-fields/back-end/product-single-option.php`:
- Around line 15-20: The add-option trigger ID is not unique because
id="frm_add_opt_<?php echo esc_attr( $field['id'] ); ?>" is rendered for every
option row; change the id to include the option key (use $opt_key or similar) so
each row gets a unique id (e.g. frm_add_opt_{field_id}-{opt_key}) and update the
related data-showlast selector to reference that unique id
(data-showlast="#frm_add_opt_{field_id}-{opt_key}"), ensuring the elements
referenced by frm_add_opt, frm_remove_tag, and data-showlast remain consistent
with the new id naming.

In `@classes/views/frm-fields/back-end/separate-values.php`:
- Around line 14-18: The current upsell block uses a non-interactive <p> with a
disabled checkbox (class names like frm6 frm_form_field frm6_followed and
data-content="separate-values") which is not focusable; replace the <p> with a
real interactive element (e.g., a <button> or <a> styled as the same block) that
preserves the existing data- attributes (data-upgrade, data-message,
data-medium, data-content) and visual markup around the label and span, make the
element keyboard-accessible (native button or add role="button" and
tabindex="0"), wire its click and keypress handlers to open the upgrade/upsell
flow, and keep the presentational checkbox input but mark it aria-hidden="true"
or remove its focusability so screen readers and keyboard users interact with
the actionable control instead; ensure aria-label/aria-describedby uses the
existing esc_html_e text for accessibility.

In `@css/frm_testing_mode.css`:
- Line 3: The global Bootstrap tooltip rules starting at the selector
".tooltip{...}" are currently unscoped and affect all tooltips; scope them to
Formidable by prefixing those selectors with the Formidable tooltip class (e.g.,
change ".tooltip", ".tooltip.show", ".tooltip .arrow", ".tooltip
.arrow::before", ".bs-tooltip-top", ".bs-tooltip-auto[x-placement^=top]",
".bs-tooltip-right", ".bs-tooltip-auto[x-placement^=right]",
".bs-tooltip-bottom", ".bs-tooltip-auto[x-placement^=bottom]",
".bs-tooltip-left", ".bs-tooltip-auto[x-placement^=left]", and ".tooltip-inner"
to target only the Formidable tooltip variant such as ".tooltip.frm_tooltip",
".tooltip.frm_tooltip.show", ".tooltip.frm_tooltip .arrow",
".tooltip.frm_tooltip .arrow::before", ".bs-tooltip-top.frm_tooltip",
".bs-tooltip-auto.frm_tooltip[x-placement^=top]", etc.), ensuring all placement
selectors get the same .frm_tooltip prefix so unrelated admin tooltips are
unaffected.

In `@js/formidable.js`:
- Line 1872: Remove the stray debug console.log call that prints formTotals;
locate the console.log(formTotals) statement (referenced in the diff) and delete
it so production code no longer outputs form totals to the browser console. If
you need to preserve the value for future debugging, replace it with a
debug-only logger guarded by a feature flag or development check (e.g., only log
when NODE_ENV !== 'production') referencing the same formTotals variable.
- Line 1910: Remove the leftover debug statement console.log( total ) from
js/formidable.js; locate the occurrence of the console.log( total ) call (the
debug print referencing the variable total) and delete it so the production
browser console is not polluted, ensuring no other logic or side-effects are
affected by its removal.

In `@js/frm-pricing-fields-modal.js`:
- Around line 31-44: The callbacks are mutating every dialog and overlay via
selectors '.ui-dialog' and '.ui-widget-overlay'; instead scope changes to this
modal by using the dialog widget and the overlay instance for this open call: in
create/open use jQuery(this).dialog('widget') (or store it in a variable like
$widget) to move/style only that dialog, and target only the overlay for this
modal with jQuery('.ui-widget-overlay').last() (or the overlay element
associated with this dialog) when changing z-index/opacity so unrelated dialogs
are not affected.
- Around line 95-101: The document click handler should first check that the
dialog is actually open via $modal.dialog('isOpen') and then use
$modal.dialog('widget') to test whether the click was inside the dialog widget;
also explicitly detect clicks on the jQuery UI overlay ('.ui-widget-overlay') so
overlay clicks close the modal, and ignore the original triggering click that
opened the dialog by ensuring the event target is not the element that opened it
(or by checking that the event occurred after the dialog was opened). Update the
listener around the '#frm-pricing-fields-modal' reference and use
dialog('isOpen'), dialog('widget'), and '.ui-widget-overlay' checks before
calling hidePricingFieldsModal(), so clicks inside the widget do nothing, clicks
on the overlay close, and the opening click does not immediately close the
modal.

In `@js/src/admin/admin.js`:
- Around line 2245-2255: The pricing-field gate that shows infoModal for
['product','quantity','total'] is only in addFieldClick(); replicate the same
guard and modal logic inside insertNewFieldByDragging() (or call a shared helper
like the existing field-guard function) so drag/drop cannot add those fields
without a gateway, and replace the hard-coded actionUrl 'http://truongwp.com'
with the correct dynamic admin/payment-settings URL used elsewhere in the app
(e.g., the existing payment settings route or function that builds admin URLs)
so the modal's action links to the current site's payment settings.
- Around line 410-435: The modal leaves CTA-specific state set after a previous
infoModal call; before applying msg.actionUrl you must reset the modal to its
default non-CTA state: ensure the close button (element id 'frm-info-click') has
primary classes ('button-primary' and 'frm-button-primary') and does not have
secondary classes ('button-secondary' or 'frm-button-secondary'), remove the
'frmright' class from the buttons wrapper (querySelector '.info-modal-buttons'),
hide the action button (querySelector '.info-modal-action-link') by adding
'frm_hidden', clear or reset its href/textContent, and only then apply the CTA
changes when msg.actionUrl is present so subsequent plain infoModal(...) calls
don’t show stale CTA UI.
- Around line 390-400: The code is incorrectly checking the type of msg using
the literal 'string' === msg so string arguments never normalize; update the
type check to use typeof msg === 'string' (or equivalent) before converting msg
to an object in the modal normalization block (the branch that currently does
"if ( 'string' === msg ) { msg = { msg }; }") so that calls like
infoModal('...') are converted to { msg: '...' } and then merged with
Object.assign defaults (title, msg, closeText, actionUrl, actionText).

---

Minor comments:
In `@classes/controllers/FrmAppController.php`:
- Around line 867-881: The option frm_show_pricing_fields_modal is being deleted
during enqueueing (delete_option in FrmAppController block) which can race with
the modal display; instead, keep the option intact during PHP enqueue and remove
the delete_option call, then perform the deletion from client-side JS (in
frm-pricing-fields-modal.js) after the modal is successfully shown or dismissed
by calling an AJAX endpoint (create a small admin-ajax handler/method that calls
delete_option('frm_show_pricing_fields_modal') and reference it from the modal
script), or trigger deletion from the server via a dedicated
include_pricing_fields_modal flow only after confirmation.

In `@classes/models/fields/FrmFieldProduct.php`:
- Around line 155-157: The get_bulk_edit_string() method is using the pro text
domain 'formidable-pro' in Lite code; update the translation call in
get_bulk_edit_string to use the correct Lite text domain (replace
'formidable-pro' with the Lite domain, e.g., 'formidable') so the string
__("Bulk Edit Products", ...) uses the proper text domain for this code path.
- Around line 28-37: The string in new_field_settings
(FrmFieldProduct::new_field_settings) uses the 'formidable-pro' text domain in
the serialized 'options' array; update the text domain to the Lite domain (e.g.,
'formidable') so translations are loaded correctly, preserving the surrounding
serialize(...) structure and array keys exactly as-is.
- Around line 276-289: The string __('New Product', 'formidable-pro') in
hidden_field_option is using the wrong text domain; update the text domain to
the project standard (replace 'formidable-pro' with the correct domain used
elsewhere, e.g., 'formidable') so $field_val uses the consistent translation
domain; modify the __('New Product', ...) call inside the hidden_field_option
method accordingly (refer to $field_val and hidden_field_option to locate the
change).

In `@classes/models/fields/FrmFieldTotal.php`:
- Line 158: The condition in FrmFieldTotal.php uses trim() on
$price_quantity['parent_field_id'], which can be non-string and cause type
issues; remove the trim() and change the check to simply use ! empty(
$price_quantity['parent_field_id'] ) (or explicitly cast to string only if
whitespace trimming is required) so the code uses the existing empty() behavior
safely when evaluating $price_quantity['parent_field_id'].

In `@classes/views/frm-fields/back-end/product-single-option.php`:
- Around line 11-12: The placeholders for the two input fields use esc_attr_e
with the pro text domain ('formidable-pro') but these are in the Lite template;
change the text domain to the Lite package text domain (use the Lite domain,
e.g. 'formidable') for both placeholder calls so "Product Name" and "Price" are
translatable in this package—update the esc_attr_e invocation on the input with
id built from $html_id . '-' . $opt_key and on the input with class
frm_product_price to use the Lite text domain.

In `@classes/views/frm-fields/back-end/quantity-options.php`:
- Around line 8-10: The label uses the pro text domain; update the esc_html_e
call for the "Product Field" label to use the Lite text domain (replace
'formidable-pro' with the Lite domain used by the plugin) so the string is
translated in the Lite template—look for the esc_html_e invocation for "Product
Field" in this file and change its text domain accordingly.

In `@classes/views/frm-forms/modals/pricing-fields-modal.php`:
- Around line 24-31: The primary and secondary actions are reversed: the anchor
using FrmAppHelper::admin_upgrade_link currently shows "I'll do it later" and
the cancel button (.frm-cancel-modal) shows "Setup Payments Now"; change the
buttons so the primary action (class frm-button-primary) is "Setup Payments Now"
and links to the payment/setup URL (use FrmAppHelper::admin_upgrade_link or the
correct payment settings URL), and make the secondary/cancel button read "I'll
do it later" and keep the .frm-cancel-modal behavior to close the modal; update
the text in esc_html_e calls to match the swapped roles.

In `@js/formidable.js`:
- Around line 2003-2007: getCurrency can return undefined when __FRMCURR[formId]
is missing which causes callers like calcProductsTotal to access properties on
undefined; update getCurrency to return a safe default currency object when
__FRMCURR or __FRMCURR[formId] is missing (e.g. include keys decimal_separator,
thousands_separator, decimal_digit, symbol with sensible defaults) and also add
a defensive check at the start of calcProductsTotal to bail out or use the
default when getCurrency(formId) returns falsy so you never access properties on
undefined; refer to the getCurrency and calcProductsTotal functions and the
__FRMCURR lookup when making these changes.

In `@js/src/admin/admin.js`:
- Around line 9741-9754: The handler in handleModalDismiss currently adds click
listeners to multiple elements and misses non-click dismissals
(Escape/titlebar), so replace that approach by attaching a single 'dialogclose'
event listener to the modal element (`#frm_info_modal`) instead; in
handleModalDismiss (and/or the onModalClose function) add the
'frm_invalid_field' class to the input, focus it, and remove any previously
added click listeners and the 'dialogclose' listener itself to fully clean up;
keep references to the modal element and any click-listener cleanup logic (the
modalDismissers NodeList and their removeEventListener calls) so they are
invoked from the dialogclose handler rather than relying on individual click
events.

---

Nitpick comments:
In `@classes/controllers/FrmFieldsController.php`:
- Around line 1054-1061: The docblock for the new method
add_currency_field_attributes() is missing a `@since` tag; update the method's
PHPDoc block (the comment immediately above the add_currency_field_attributes
function) to include a `@since` x.x entry matching your release version to keep
documentation consistent, e.g., add a line like "@since x.x" within the existing
docblock alongside the `@param/`@return tags.

In `@classes/models/fields/FrmFieldProduct.php`:
- Line 137: The conditional uses self::get_data_type_settings() but
get_data_type_settings() is a private instance method of FrmFieldProduct; change
the call to $this->get_data_type_settings() (and any other occurrences in the
class) so instance method invocation is clear and correct, keeping the method
non-static and ensuring the surrounding conditional (the elseif in the method
where this appears) calls $this->get_data_type_settings().
- Around line 72-80: Add explicit return type declarations to the methods
flagged by PHPStan: update get_data_type_settings() to declare it returns array
(e.g., get_data_type_settings(): array) and update get_posted_price() to declare
a precise numeric return type that matches its implementation (e.g., float or
?float if it may return null); ensure the declared types reflect the actual
values returned by get_posted_price() and adjust any callers or internal
conversions if needed.

In `@classes/models/fields/FrmFieldQuantity.php`:
- Around line 41-48: In show_primary_options, simplify the product_field
handling: instead of the ternary `$field['product_field'] ? (array)
$field['product_field'] : array()`, directly cast with `(array)
$field['product_field']` so null/scalar/array are handled consistently; update
the assignment to `$field['product_field'] = (array) $field['product_field'];`
and leave the include and parent::show_primary_options($args) call unchanged.

In `@classes/models/fields/FrmFieldTotal.php`:
- Around line 178-180: Remove the dead assignment and the commented-out line:
delete the unused $form_id = $this->get_field_column( 'form_id' ); and remove
the commented-out $currency = FrmProCurrencyHelper::apply_shortcode_atts(
$currency, $atts ); line in FrmFieldTotal (the code around get_field_column and
FrmCurrencyHelper::get_currency), leaving only the active $currency assignment
to avoid unused variable warnings and stale commented code.
- Around line 56-69: The validate() method adds a frm_entries_before_create hook
(via add_filter) to defer validation but never removes it; update the
validate_total() method to call remove_filter('frm_entries_before_create',
array($this, 'validate_total'), 10, 2) immediately after running so the callback
is cleaned up and posted_value_args is cleared (or reset) to prevent
accumulation when validate() is invoked multiple times for different fields;
ensure the remove_filter uses the same priority and accepted args as the
original add_filter.

In `@classes/models/FrmField.php`:
- Around line 403-415: The static analyzer flags that the pass-by-reference
parameter in FrmField::remove_moved_field_types_from_pro lacks a type, so update
the method signature to declare the parameter as an array by adding the array
type hint to &$pro_fields (keeping the reference) so PHPStan recognizes it as an
array being modified.

In `@classes/views/frm-fields/front-end/product-select.php`:
- Around line 17-20: Change the loose comparison $opt == '' to a strict
comparison $opt === '' so falsy values like 0 or false won't be treated as an
empty string; update the conditional in the block that checks $placeholder, $opt
and $skipped (the if using $placeholder, $opt and $skipped) to use $opt === ''
and keep the rest of the logic ($skipped = true; continue;) unchanged.

In `@classes/views/frm-fields/front-end/product-single.php`:
- Around line 12-15: The price is rendered as a raw number; update the product
label rendering to format the price using the currency helper so users see a
proper currency string. Replace the direct echo of $price in the
frm_single_product_label block with a call to
FrmCurrencyHelper::format_price($price) (fall back to esc_html on failure), and
ensure the formatted value is escaped before output; keep the label text ($opt)
escaped as-is.
- Around line 7-20: The foreach loop is only used to grab the first option then
break; replace it by directly retrieving the first option (e.g., via
reset/current) from $field['options'] and then call
FrmFieldsHelper::get_value_from_array, FrmFieldProduct::get_price_from_array,
and FrmFieldsHelper::get_label_from_array on that single option; keep the same
output/escaping for the <p> and hidden input using $field_name and $html_id and
preserve the data-frmprice attribute and the do_action call.

In `@classes/views/shared/info-overlay.php`:
- Around line 19-24: The empty interactive anchor with class
"info-modal-action-link" should be hidden from assistive tech until populated:
add aria-hidden="true" to the <a class="info-modal-action-link"> element in
info-overlay.php and update the JS that reveals/populates this element to set
aria-hidden="false" (and remove the frm_hidden class) when content is inserted
and it becomes visible; also ensure any script that clears or hides it again
resets aria-hidden back to "true".

In `@js/pricing-fields-modal.js`:
- Around line 70-77: The global keydown handler that listens for Escape and
manually calls $('#frm-pricing-fields-modal').dialog('close') is redundant with
jQuery UI Dialog's built-in closeOnEscape behavior and may double-close or
conflict; remove the entire keydown handler block that checks e.keyCode === 27
and calls dialog('isOpen')/dialog('close'), or alternatively disable the
dialog's built-in handler by setting closeOnEscape: false on the
$('#frm-pricing-fields-modal').dialog(...) initialization and then keep a single
delegated handler — ensure you modify the dialog initialization (closeOnEscape)
or remove the keydown block to avoid duplicate escape handling.
- Around line 27-30: The create callback currently moves all dialogs by using
the global selector $('.ui-dialog'); narrow this to the pricing modal's dialog
by selecting the specific dialog widget for the current instance (use the create
callback's this context and obtain the dialog widget or the element's closest
.ui-dialog) and append only that element to body instead of the global selector;
update the create function in pricing-fields-modal.js to use the
instance-specific dialog reference rather than $('.ui-dialog').

In `@stripe/controllers/FrmStrpLiteAppController.php`:
- Around line 60-69: The method is_payment_gateway_configured currently returns
FrmTransLiteAppHelper::payments_table_exists(), which only checks schema
presence and can yield false positives; either rename the helper to reflect
"payments_table_exists" and update the docblock/public API accordingly, or
change is_payment_gateway_configured to perform a real gateway check (e.g.,
verify Stripe/other gateway settings, API keys, or enabled gateway flags) before
returning true; update any other uses (including the similar call around the
other occurrence) and adjust the docblock for
FrmStrpLiteAppController::is_payment_gateway_configured to accurately describe
the new semantics.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 115cb8a5-637b-4e7f-9970-650a1157a418

📥 Commits

Reviewing files that changed from the base of the PR and between 745b145 and 948977c.

⛔ Files ignored due to path filters (2)
  • images/upsell/pricing-fields.png is excluded by !**/*.png
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (36)
  • .htaccess
  • classes/controllers/FrmAppController.php
  • classes/controllers/FrmFieldsController.php
  • classes/controllers/FrmFormsController.php
  • classes/factories/FrmFieldFactory.php
  • classes/helpers/FrmAppHelper.php
  • classes/helpers/FrmCurrencyHelper.php
  • classes/helpers/FrmFieldsHelper.php
  • classes/helpers/FrmFormsHelper.php
  • classes/models/FrmField.php
  • classes/models/FrmMigrate.php
  • classes/models/fields/FrmFieldProduct.php
  • classes/models/fields/FrmFieldQuantity.php
  • classes/models/fields/FrmFieldTotal.php
  • classes/views/frm-fields/back-end/field-total.php
  • classes/views/frm-fields/back-end/product-options.php
  • classes/views/frm-fields/back-end/product-single-option.php
  • classes/views/frm-fields/back-end/quantity-options.php
  • classes/views/frm-fields/back-end/separate-values.php
  • classes/views/frm-fields/front-end/product-radio.php
  • classes/views/frm-fields/front-end/product-select.php
  • classes/views/frm-fields/front-end/product-single.php
  • classes/views/frm-forms/add_field_links.php
  • classes/views/frm-forms/modals/pricing-fields-modal.php
  • classes/views/shared/info-overlay.php
  • css/frm-pricing-fields-modal.css
  • css/frm_admin.css
  • css/frm_testing_mode.css
  • js/formidable.js
  • js/formidable_admin.js
  • js/frm-pricing-fields-modal.js
  • js/pricing-fields-modal.js
  • js/src/admin/admin.js
  • resources/scss/admin/components/accordion/_accordion.scss
  • resources/scss/admin/utilities/typography/_text-align-part2.scss
  • stripe/controllers/FrmStrpLiteAppController.php
💤 Files with no reviewable changes (1)
  • .htaccess

Comment on lines +89 to +95
preg_match_all( '/[\-]*[0-9,.]*\.?\,?[0-9]+/', $price, $matches );
$price = $matches ? end( $matches[0] ) : 0;

if ( $price ) {
$price = self::maybe_use_decimal( $price, $currency );
$price = str_replace( $currency['decimal_separator'], '.', str_replace( $currency['thousand_separator'], '', $price ) );
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix price parsing for currencies that use spaces or apostrophes as thousand separators.

Line 89 only matches digits, commas, and dots, so inputs like 1 234,56 or 1'234.56 get truncated to the last chunk before Line 94 ever strips the configured separator. That breaks several currencies this helper explicitly supports.

💡 One way to keep the configured separators intact
-		preg_match_all( '/[\-]*[0-9,.]*\.?\,?[0-9]+/', $price, $matches );
+		$allowed_separators = preg_quote( $currency['thousand_separator'] . $currency['decimal_separator'], '/' );
+		preg_match_all( '/-?[0-9' . $allowed_separators . ']+/u', $price, $matches );
 		$price = $matches ? end( $matches[0] ) : 0;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/helpers/FrmCurrencyHelper.php` around lines 89 - 95, The regex in
FrmCurrencyHelper that extracts numeric chunks using preg_match_all incorrectly
excludes space and apostrophe thousand separators, so update the pattern used
where preg_match_all is called (the block that sets $price and then calls
self::maybe_use_decimal and str_replace) to allow the configured thousand
separator(s) (including space and apostrophe) and the currency decimal
separator; specifically include \s and ' (and also dynamically include
$currency['thousand_separator'] and $currency['decimal_separator'] when building
the pattern), ensure proper escaping of those separators in the regex, and keep
the existing fallback logic ($matches ? end($matches[0]) : 0) and subsequent
maybe_use_decimal/str_replace flow.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (4)
js/src/admin/admin.js (3)

2246-2255: ⚠️ Potential issue | 🟠 Major

Gate pricing fields behind a shared “payments configured?” check.

This branch is unconditional, so click-add is blocked for product, quantity, and total on every site. insertNewFieldByDragging() can still bypass it, and the CTA still uses a hard-coded off-site URL. This should be a shared guard/helper so both add paths behave the same and the action points to the local payment settings screen.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/src/admin/admin.js` around lines 2246 - 2255, The click-add branch
unconditionally blocks adding product/quantity/total and uses a hard-coded
off-site URL; extract the payment-check into a shared helper (e.g.,
isPaymentGatewayConfigured) and use it wherever fields are gated (the current
click-add handler and insertNewFieldByDragging), so both add paths behave
identically; replace the inline infoModal actionUrl with the local payment
settings route (use your app’s payment settings URL constant or route) and
update the infoModal call to run only when isPaymentGatewayConfigured() is
false, returning false to block and otherwise allowing the add operation to
proceed.

387-405: ⚠️ Potential issue | 🟠 Major

Fix string normalization here, and don't pipe raw modal strings into .html().

This is still unresolved from the earlier review: plain infoModal( '...' ) callers never normalize, so they fall back to the default text. Once that check is corrected, callers like checkCalculationCreatedByUser() will flow straight into .html( msg.msg ), which currently accepts raw user-entered markup.

Suggested fix
-		if ( 'string' === msg ) {
+		if ( 'string' === typeof msg ) {
 			msg = { msg };
 		}
@@
 		if ( msg.msg ) {
-			$info.find( '.frm-info-msg' ).html( msg.msg );
+			$info.find( '.frm-info-msg' ).html( purifyHtml( msg.msg ) );
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/src/admin/admin.js` around lines 387 - 405, Fix the string normalization
and avoid injecting raw HTML: change the faulty type check to use typeof (i.e.
if (typeof msg === 'string') msg = { msg };) so string callers are properly
converted, and replace the unsafe HTML insertion
($info.find('.frm-info-msg').html( msg.msg )) with a safe text insertion (use
.text(...) or set textContent) so user-entered markup is not rendered.

10673-10673: ⚠️ Potential issue | 🔴 Critical

Pass the input element into validateProductPriceValue().

Every other frmDom.util.documentOn() usage in this file treats the callback argument as an event object, but validateProductPriceValue() immediately reads .value. As wired, the first .frm_product_price change will throw before any validation runs.

Suggested fix
-			frmDom.util.documentOn( 'change', '.frm_product_price', validateProductPriceValue );
+			frmDom.util.documentOn( 'change', '.frm_product_price', event => validateProductPriceValue( event.target ) );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/src/admin/admin.js` at line 10673, The current event hookup calls
validateProductPriceValue directly, but that function expects the input element
(reads .value) not the event object; update the frmDom.util.documentOn call for
'.frm_product_price' so the handler passes the element (e.g., event.target or
event.currentTarget / this) into validateProductPriceValue instead of the event
object, ensuring the function receives the input element that has the .value
property.
classes/controllers/FrmFieldsController.php (1)

1095-1096: ⚠️ Potential issue | 🟠 Major

Guard get_posted_price() on the factory result.

This is still the reason Psalm/PHPStan are failing: FrmFieldFactory::get_field_object() is not guaranteed to return a type that defines get_posted_price(). If a non-product object reaches this path, it will fatal. Narrow the object first (instanceof FrmFieldProduct or method_exists) and return a safe fallback when it is not a product field.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@classes/controllers/FrmFieldsController.php` around lines 1095 - 1096, Guard
the call to get_posted_price by ensuring the factory result is a product field:
retrieve the object via FrmFieldFactory::get_field_object( $field['id'] ), check
that it is an instance of FrmFieldProduct (or at minimum that
method_exists($field_obj, 'get_posted_price')), and only then call
$field_obj->get_posted_price( $value ); otherwise return a safe fallback (for
example the unmodified $value or null) to avoid a fatal when a non-product field
is encountered in FrmFieldsController.
🧹 Nitpick comments (1)
js/formidable.js (1)

1787-1788: Consider using an object instead of an array for formTotals.

formTotals is declared as an array but accessed with string keys (formTotals[formId]). While this works in JavaScript, using an object ({}) is semantically clearer and avoids confusion.

♻️ Suggested change
-		var formTotals = [],
+		var formTotals = {},
 			totalFields;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/formidable.js` around lines 1787 - 1788, formTotals is declared as an
array but used with string keys (e.g., formTotals[formId]); change its
declaration to an object to reflect intended use and avoid confusion: replace
the array declaration of formTotals with an object literal and ensure any later
references (places using formTotals and the formId key) continue to work with
the object-based map (look for the symbol formTotals and usages where formId is
used as the key).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@classes/models/FrmField.php`:
- Line 410: The method remove_moved_field_types_from_pro currently accepts an
untyped reference parameter &$pro_fields but only operates on array keys; add an
explicit array type hint to the signature (change &$pro_fields to array
&$pro_fields) so the contract is clear and static analysis passes, keeping the
parameter by reference and leaving the function body unchanged.

In `@classes/views/frm-forms/add_field_links.php`:
- Around line 133-135: The code calls FrmAppHelper::show_pill_text()
unconditionally in the section header, causing the Pricing section (and any
section added via frm_available_field_sections) to always display the pill;
update the rendering so the pill is shown only when the current section has a
specific flag (e.g., a property on the section array like 'show_pill' or a slug
check for 'pricing') by checking that flag before calling
FrmAppHelper::show_pill_text(); locate the section rendering block that outputs
the span and wrap the call to FrmAppHelper::show_pill_text() in a conditional
that reads the section's flag (or verifies the section key/slug) so only flagged
sections render the pill.

In `@js/formidable.js`:
- Around line 1982-1996: The loop over quantityFields calls JSON.parse on
this.getAttribute('data-frmproduct') which can throw on invalid JSON; wrap the
JSON.parse call in a try-catch inside the quantityFields.each callback (around
the parsing of data-frmproduct), and on catch treat it as empty/invalid (skip
this element, e.g., continue by returning true) so that parsing errors don’t
break the calculation; keep the rest of the logic using ids, fieldID and
assigning quantity as before.

In `@js/src/admin/admin.js`:
- Around line 9718-9719: Change the i18n text domain used for the validation
message so it matches the rest of the admin bundle: update the call that sets
validationFailMessage (the __('Please enter a valid number.', 'formidable-pro')
expression) to use 'formidable' instead of 'formidable-pro' so the message will
be translated in the core admin UI; keep the same variable name
(validationFailMessage) and invocation of frmAdminBuild.infoModal.

---

Duplicate comments:
In `@classes/controllers/FrmFieldsController.php`:
- Around line 1095-1096: Guard the call to get_posted_price by ensuring the
factory result is a product field: retrieve the object via
FrmFieldFactory::get_field_object( $field['id'] ), check that it is an instance
of FrmFieldProduct (or at minimum that method_exists($field_obj,
'get_posted_price')), and only then call $field_obj->get_posted_price( $value );
otherwise return a safe fallback (for example the unmodified $value or null) to
avoid a fatal when a non-product field is encountered in FrmFieldsController.

In `@js/src/admin/admin.js`:
- Around line 2246-2255: The click-add branch unconditionally blocks adding
product/quantity/total and uses a hard-coded off-site URL; extract the
payment-check into a shared helper (e.g., isPaymentGatewayConfigured) and use it
wherever fields are gated (the current click-add handler and
insertNewFieldByDragging), so both add paths behave identically; replace the
inline infoModal actionUrl with the local payment settings route (use your app’s
payment settings URL constant or route) and update the infoModal call to run
only when isPaymentGatewayConfigured() is false, returning false to block and
otherwise allowing the add operation to proceed.
- Around line 387-405: Fix the string normalization and avoid injecting raw
HTML: change the faulty type check to use typeof (i.e. if (typeof msg ===
'string') msg = { msg };) so string callers are properly converted, and replace
the unsafe HTML insertion ($info.find('.frm-info-msg').html( msg.msg )) with a
safe text insertion (use .text(...) or set textContent) so user-entered markup
is not rendered.
- Line 10673: The current event hookup calls validateProductPriceValue directly,
but that function expects the input element (reads .value) not the event object;
update the frmDom.util.documentOn call for '.frm_product_price' so the handler
passes the element (e.g., event.target or event.currentTarget / this) into
validateProductPriceValue instead of the event object, ensuring the function
receives the input element that has the .value property.

---

Nitpick comments:
In `@js/formidable.js`:
- Around line 1787-1788: formTotals is declared as an array but used with string
keys (e.g., formTotals[formId]); change its declaration to an object to reflect
intended use and avoid confusion: replace the array declaration of formTotals
with an object literal and ensure any later references (places using formTotals
and the formId key) continue to work with the object-based map (look for the
symbol formTotals and usages where formId is used as the key).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 39340069-1b5b-4cf1-a5cb-630b6ee70749

📥 Commits

Reviewing files that changed from the base of the PR and between 948977c and ae5e9af.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (14)
  • classes/controllers/FrmAppController.php
  • classes/controllers/FrmFieldsController.php
  • classes/controllers/FrmFormsController.php
  • classes/helpers/FrmAppHelper.php
  • classes/helpers/FrmFieldsHelper.php
  • classes/helpers/FrmFormsHelper.php
  • classes/models/FrmField.php
  • classes/models/FrmMigrate.php
  • classes/views/frm-forms/add_field_links.php
  • css/frm_admin.css
  • js/formidable-web-components.js
  • js/formidable.js
  • js/formidable_admin.js
  • js/src/admin/admin.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • classes/helpers/FrmFormsHelper.php
  • classes/helpers/FrmAppHelper.php
  • classes/controllers/FrmFormsController.php

@truongwp truongwp changed the title Rock enable pricing fields Rock: Enable pricing fields in Lite Mar 10, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (4)
css/frm_testing_mode.css (1)

3-3: ⚠️ Potential issue | 🟠 Major

Scope the Bootstrap tooltip reset to .frm_tooltip.

Line 3 still applies the base tooltip rules globally, so any tooltip on screens loading this stylesheet can pick up Formidable’s z-index, padding, and arrow overrides. Prefix the .tooltip, .tooltip-inner, and .bs-tooltip-* selectors with .frm_tooltip to keep this isolated to Formidable’s tooltip variant.

Suggested direction
-.tooltip{...}.tooltip.show{...}.tooltip .arrow{...}.tooltip-inner{...}
-.bs-tooltip-top,...,.bs-tooltip-left,...
+.tooltip.frm_tooltip{...}.tooltip.frm_tooltip.show{...}.tooltip.frm_tooltip .arrow{...}.tooltip.frm_tooltip .tooltip-inner{...}
+.bs-tooltip-top.frm_tooltip,...,.bs-tooltip-left.frm_tooltip,...
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@css/frm_testing_mode.css` at line 3, The global Bootstrap tooltip rules
(.tooltip, .tooltip-inner,
.bs-tooltip-top/.bs-tooltip-right/.bs-tooltip-bottom/.bs-tooltip-left and
related .arrow rules) are applied site-wide; scope them to Formidable by
prefixing those selectors with the Formidable container (use .frm_tooltip or
.frm_tooltip .tooltip as appropriate) so only Formidable’s tooltip variant
receives the z-index, padding, arrow and opacity overrides; update all
occurrences of .tooltip, .tooltip-inner, .bs-tooltip-* and .arrow selectors in
this diff to the prefixed versions to isolate the styles.
js/src/admin/admin.js (3)

9726-9727: ⚠️ Potential issue | 🟡 Minor

Use the formidable text domain here.

Keeping formidable-pro on Line 9726 will leave this validation message untranslated in the core/Lite admin UI.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/src/admin/admin.js` around lines 9726 - 9727, The translation domain used
for the validation message is incorrect; update the call that defines
validationFailMessage (the __(...) invocation that currently uses
'formidable-pro') to use the core 'formidable' text domain instead so the
message is translatable in the Lite admin UI—this change occurs where
validationFailMessage is created and passed to frmAdminBuild.infoModal.

387-389: ⚠️ Potential issue | 🔴 Critical

Use typeof when normalizing string modal input.

Line 387 compares msg to the literal 'string', so plain-string callers never become { msg } and the modal falls back to the default message instead of the passed text.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/src/admin/admin.js` around lines 387 - 389, The conditional that attempts
to normalize a string modal input is comparing the variable to the literal
'string' instead of using typeof; change the check in the modal normalization
(around the block that currently reads "if ( 'string' === msg ) { msg = { msg };
}") to use typeof msg === 'string' so plain-string callers are wrapped into the
object form (msg = { msg }) and the modal receives the passed text correctly.

2253-2264: ⚠️ Potential issue | 🔴 Critical

Gate pricing fields on drag/drop too.

This modal only blocks click-to-add. insertNewFieldByDragging() still inserts product, quantity, and total directly, so the payment-setup requirement is still bypassable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/src/admin/admin.js` around lines 2253 - 2264, The payment-setup check
currently shown when clicking to add fields is missing from the drag handler, so
update insertNewFieldByDragging to enforce the same restriction: when
frm_admin_js.shouldShowPaymentsSettingsModal is true and the fieldType is one of
'product','quantity','total', abort the insertion and call the same infoModal
configuration (using frm_admin_js.paymentsSettingsUrl and the same
title/msg/actionText/closeText/noCenter) and return false; ensure you reference
insertNewFieldByDragging and the existing modal logic to keep behavior identical
to the click-to-add path.
🧹 Nitpick comments (1)
stripe/controllers/FrmStrpLiteAppController.php (1)

61-68: Rename this helper or narrow the docblock.

is_payment_gateway_configured() currently just forwards FrmTransLiteAppHelper::payments_table_exists(), which reads more like an installation-state check than a gateway-configuration check. The current name/docblock overstate what this method guarantees and make future call sites easy to misuse.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@stripe/controllers/FrmStrpLiteAppController.php` around lines 61 - 68, The
method is_payment_gateway_configured() only proxies
FrmTransLiteAppHelper::payments_table_exists(), which checks for a payments
table (installation state) not actual gateway config; either rename this method
to reflect that (e.g., payments_table_exists or has_payments_table) or change
its docblock/behavior to accurately reflect what it checks: update the docblock
to say "Checks whether the payments table exists (installation/state check)"
and/or implement a true gateway configuration check inside
is_payment_gateway_configured() that queries gateway settings (e.g., examine
stored API keys/options) instead of calling
FrmTransLiteAppHelper::payments_table_exists(); update all call sites to use the
new name or rely on the new behavior accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@js/src/admin/admin.js`:
- Around line 405-406: The code injects untrusted HTML via
$info.find('.frm-info-msg').html(msg.msg) which creates an XSS sink; replace
this with a sanitized insertion by either using text() for plain text
($info.find('.frm-info-msg').text(msg.msg)) or, if HTML is required, run msg.msg
through a sanitizer (e.g. DOMPurify.sanitize(msg.msg) before passing to
.html()). Update the infoModal/handler that sets msg.msg to import/require
DOMPurify (or add a safe fallback) and use DOMPurify.sanitize(msg.msg) where
.html(...) is called so only allowed markup is rendered.
- Around line 9704-9716: In handleModalDismiss, the current cleanup only removes
click handlers from modalDismissers and misses non-click closes (Escape or
.dialog('close')); update it to attach a single dialogclose handler on
'#frm_info_modal' that calls the existing onModalClose logic (adds
'frm_invalid_field', sets focus) and removes all added listeners (both the
per-element click listeners and the dialogclose handler itself) so cleanup runs
for all close methods; reference the modalDismissers NodeList and the
onModalClose function and ensure you detach via the dialog's 'dialogclose' event
(and/or jQuery .off('dialogclose')) to prevent handlers persisting for
subsequent modals.

In `@stripe/controllers/FrmStrpLiteAppController.php`:
- Around line 91-95: The call site uses self:: which prevents overridden methods
from being invoked; update the call in FrmStrpLiteAppController (the block that
currently calls self::is_payment_gateway_configured() and
self::get_payments_settings_url()) to use late static binding
(static::is_payment_gateway_configured() and
static::get_payments_settings_url()) so subclasses overriding those public
static wrapper methods receive proper polymorphic behavior.

---

Duplicate comments:
In `@css/frm_testing_mode.css`:
- Line 3: The global Bootstrap tooltip rules (.tooltip, .tooltip-inner,
.bs-tooltip-top/.bs-tooltip-right/.bs-tooltip-bottom/.bs-tooltip-left and
related .arrow rules) are applied site-wide; scope them to Formidable by
prefixing those selectors with the Formidable container (use .frm_tooltip or
.frm_tooltip .tooltip as appropriate) so only Formidable’s tooltip variant
receives the z-index, padding, arrow and opacity overrides; update all
occurrences of .tooltip, .tooltip-inner, .bs-tooltip-* and .arrow selectors in
this diff to the prefixed versions to isolate the styles.

In `@js/src/admin/admin.js`:
- Around line 9726-9727: The translation domain used for the validation message
is incorrect; update the call that defines validationFailMessage (the __(...)
invocation that currently uses 'formidable-pro') to use the core 'formidable'
text domain instead so the message is translatable in the Lite admin UI—this
change occurs where validationFailMessage is created and passed to
frmAdminBuild.infoModal.
- Around line 387-389: The conditional that attempts to normalize a string modal
input is comparing the variable to the literal 'string' instead of using typeof;
change the check in the modal normalization (around the block that currently
reads "if ( 'string' === msg ) { msg = { msg }; }") to use typeof msg ===
'string' so plain-string callers are wrapped into the object form (msg = { msg
}) and the modal receives the passed text correctly.
- Around line 2253-2264: The payment-setup check currently shown when clicking
to add fields is missing from the drag handler, so update
insertNewFieldByDragging to enforce the same restriction: when
frm_admin_js.shouldShowPaymentsSettingsModal is true and the fieldType is one of
'product','quantity','total', abort the insertion and call the same infoModal
configuration (using frm_admin_js.paymentsSettingsUrl and the same
title/msg/actionText/closeText/noCenter) and return false; ensure you reference
insertNewFieldByDragging and the existing modal logic to keep behavior identical
to the click-to-add path.

---

Nitpick comments:
In `@stripe/controllers/FrmStrpLiteAppController.php`:
- Around line 61-68: The method is_payment_gateway_configured() only proxies
FrmTransLiteAppHelper::payments_table_exists(), which checks for a payments
table (installation state) not actual gateway config; either rename this method
to reflect that (e.g., payments_table_exists or has_payments_table) or change
its docblock/behavior to accurately reflect what it checks: update the docblock
to say "Checks whether the payments table exists (installation/state check)"
and/or implement a true gateway configuration check inside
is_payment_gateway_configured() that queries gateway settings (e.g., examine
stored API keys/options) instead of calling
FrmTransLiteAppHelper::payments_table_exists(); update all call sites to use the
new name or rely on the new behavior accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: aa77ccd3-0071-4014-9cf9-4fc25c24870e

📥 Commits

Reviewing files that changed from the base of the PR and between ae5e9af and eb6ec49.

⛔ Files ignored due to path filters (2)
  • images/upsell/pricing-fields.png is excluded by !**/*.png
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (8)
  • classes/helpers/FrmAppHelper.php
  • classes/views/shared/info-overlay.php
  • css/frm_admin.css
  • css/frm_testing_mode.css
  • js/formidable_admin.js
  • js/src/admin/admin.js
  • resources/scss/admin/components/modal/_info-modals.scss
  • stripe/controllers/FrmStrpLiteAppController.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • classes/views/shared/info-overlay.php

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
stripe/controllers/FrmStrpLiteAppController.php (1)

60-69: is_payment_gateway_configured() reads broader than what it actually checks.

FrmTransLiteAppHelper::payments_table_exists() in stripe/helpers/FrmTransLiteAppHelper.php:29-56 only checks installation state, not whether Stripe is actually configured with credentials. That makes this new public wrapper easy to misread and reuse incorrectly. I’d either rename it to reflect “payments installed” semantics, or make it validate real gateway configuration before returning true.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@stripe/controllers/FrmStrpLiteAppController.php` around lines 60 - 69, The
public method is_payment_gateway_configured() currently just proxies
FrmTransLiteAppHelper::payments_table_exists(), which only checks installation
state and is misleading; either rename is_payment_gateway_configured() to
something like is_payments_table_installed() (and update all callers) OR extend
is_payment_gateway_configured() to validate actual Stripe
credentials/configuration before returning true (e.g., call the helper that
reads stored Stripe keys or use get_option('frm_stripe_*') and verify they are
non-empty/valid), keeping the original payments_table_exists() call as part of
the new check; update method docblock and all usages accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@classes/controllers/FrmFieldsController.php`:
- Around line 1074-1079: The condition treating the selected product value as
empty incorrectly collapses valid string "0"; in the block handling $type ===
'product' and 'hidden' === $field['type'] (inside FrmFieldsController where
get_product_price is used), replace the empty($field['value']) check with a
strict-empty-string test (e.g., check that $field['value'] === '' or use
strlen($field['value']) === 0) or use isset($field['value']) and $field['value']
!== '' before calling self::get_product_price($field), so that a value of "0" is
preserved and correctly passed to get_product_price and used for data-frmprice.

In `@classes/helpers/FrmCurrencyHelper.php`:
- Around line 97-105: The preg_match_all branch can still let end($matches[0])
return false when there are no matches; update the extraction to explicitly
check that $matches[0] is non-empty before calling end and otherwise set $price
to 0 (e.g. replace the current `$matches ? end($matches[0]) : 0` logic with an
explicit `!empty($matches[0]) ? end($matches[0]) : 0`), then continue to call
self::maybe_use_decimal($price, $currency) and the subsequent str_replace only
when $price is truthy so the function prepare_price (and uses of preg_match_all,
$matches, maybe_use_decimal) never returns a false non-numeric value.

In `@classes/models/fields/FrmFieldProduct.php`:
- Around line 223-226: The current validation uses empty($args['value']) which
treats '0' as empty and will skip legitimate zero-like product selections; in
FrmFieldProduct::validate (and surrounding logic that builds $frm_products)
replace the empty() check with a strict existence/empty-string check such as (!
isset($args['value']) || $args['value'] === '') so that '0' (and other non-empty
zero-like values) are preserved during validation and not erroneously skipped
while still handling missing or truly empty values.

In `@js/formidable.js`:
- Around line 2061-2068: The decimal heuristic in maybeUseDecimal currently
treats a dot followed by exactly two digits as a decimal; update it to accept a
one- or two-digit suffix after '.' to match the PHP helper: in maybeUseDecimal,
change the check on amountParts[1].length so usedForDecimal becomes true when
the fractional part length is 1 or 2 (e.g., length === 1 || length === 2, or
length >=1 && length <=2), then proceed to replace the '.' with
currency.decimal_separator as before so inputs like "12.5" are parsed as "12.5"
for EUR/DKK/NOK-style locales.
- Around line 1824-1837: Remove the illegal reassignment of the const $this in
the non-SELECT branch: do not reassign $this = jQuery(this) inside the else
branch; instead rely on the already-declared const $this (and existing
isUserDef/isSingle variables) initialized before the if (this.tagName ===
'SELECT') check so radio/checkbox/hidden product fields can be processed without
throwing a TypeError in the quantity/price calculation logic.

---

Nitpick comments:
In `@stripe/controllers/FrmStrpLiteAppController.php`:
- Around line 60-69: The public method is_payment_gateway_configured() currently
just proxies FrmTransLiteAppHelper::payments_table_exists(), which only checks
installation state and is misleading; either rename
is_payment_gateway_configured() to something like is_payments_table_installed()
(and update all callers) OR extend is_payment_gateway_configured() to validate
actual Stripe credentials/configuration before returning true (e.g., call the
helper that reads stored Stripe keys or use get_option('frm_stripe_*') and
verify they are non-empty/valid), keeping the original payments_table_exists()
call as part of the new check; update method docblock and all usages
accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 188c3ee6-9919-4f6f-b4d1-88fb79b148c7

📥 Commits

Reviewing files that changed from the base of the PR and between eb6ec49 and c48d9fd.

📒 Files selected for processing (7)
  • classes/controllers/FrmFieldsController.php
  • classes/helpers/FrmCurrencyHelper.php
  • classes/models/FrmField.php
  • classes/models/fields/FrmFieldProduct.php
  • classes/models/fields/FrmFieldTotal.php
  • js/formidable.js
  • stripe/controllers/FrmStrpLiteAppController.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • classes/models/FrmField.php

@truongwp truongwp requested a review from lauramekaj1 March 10, 2026 19:21
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
js/formidable.js (2)

1977-1983: ⚠️ Potential issue | 🟠 Major

Guard data-frmproduct before parsing.

Line 1980 can throw for empty or invalid data-frmproduct, which breaks total calculation before the generic-quantity fallback runs.

🐛 Minimal fix
 		quantityFields.each( function() {
 			let ids;
-
-			ids = JSON.parse( this.getAttribute( 'data-frmproduct' ).trim() );
-			if ( '' === ids ) {
+			const rawIds = this.getAttribute( 'data-frmproduct' ).trim();
+			if ( '' === rawIds ) {
+				return true;
+			}
+			try {
+				ids = JSON.parse( rawIds );
+			} catch ( e ) {
 				return true;
 			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/formidable.js` around lines 1977 - 1983, In quantityFields.each (the loop
that reads the data-frmproduct attribute) guard the call to JSON.parse by first
reading the attribute into a variable, checking it's non-null/non-empty and
well-formed before parsing; if the attribute is missing, empty, or invalid, skip
parsing or fall back to the generic-quantity behavior. Use a try-catch around
JSON.parse of this.getAttribute('data-frmproduct') to catch malformed JSON and
treat it as an empty/absent value so the function (quantityFields.each / the
total calculation) continues without throwing.

2060-2067: ⚠️ Potential issue | 🟠 Major

Accept 1-digit decimals when . may be the decimal mark.

With the current check, inputs like 12.5 in .-thousands/,-decimal locales are parsed as 125, so totals are wrong for user-defined prices.

🐛 Minimal fix
 	if ( currency.thousand_separator == '.' ) {
 		amountParts = amount.split( '.' );
-		usedForDecimal = ( 2 == amountParts.length && 2 == amountParts[1].length );
+		usedForDecimal = amountParts.length === 2 && [ 1, 2 ].includes( amountParts[1].length );
 		if ( usedForDecimal ) {
 			amount = amount.replace( '.', currency.decimal_separator );
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/formidable.js` around lines 2060 - 2067, The function maybeUseDecimal
currently treats the dot as a thousands separator unless the fractional part has
exactly 2 digits, causing inputs like "12.5" to be mis-parsed; update the
usedForDecimal check in maybeUseDecimal to accept fractional parts of length 1
or 2 (e.g., change the condition from amountParts[1].length == 2 to
amountParts[1].length >= 1 && amountParts[1].length <= 2) so "12.5" will be
treated as a decimal and the replace( '.', currency.decimal_separator ) will
run.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@js/formidable.js`:
- Around line 1896-1899: The current branch in the isLargeTotal check always
uses Math.ceil for zero-decimal currencies which biases totals up; change the
zero-decimal branch to use nearest rounding (e.g., Math.round) instead of
Math.ceil so total is rounded to the nearest whole unit (keep using Math.round10
for decimals > 0 and apply Math.round to the total when currency.decimals === 0,
referencing the existing symbols total, currency.decimals, isLargeTotal,
Math.round10).

---

Duplicate comments:
In `@js/formidable.js`:
- Around line 1977-1983: In quantityFields.each (the loop that reads the
data-frmproduct attribute) guard the call to JSON.parse by first reading the
attribute into a variable, checking it's non-null/non-empty and well-formed
before parsing; if the attribute is missing, empty, or invalid, skip parsing or
fall back to the generic-quantity behavior. Use a try-catch around JSON.parse of
this.getAttribute('data-frmproduct') to catch malformed JSON and treat it as an
empty/absent value so the function (quantityFields.each / the total calculation)
continues without throwing.
- Around line 2060-2067: The function maybeUseDecimal currently treats the dot
as a thousands separator unless the fractional part has exactly 2 digits,
causing inputs like "12.5" to be mis-parsed; update the usedForDecimal check in
maybeUseDecimal to accept fractional parts of length 1 or 2 (e.g., change the
condition from amountParts[1].length == 2 to amountParts[1].length >= 1 &&
amountParts[1].length <= 2) so "12.5" will be treated as a decimal and the
replace( '.', currency.decimal_separator ) will run.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1382eb89-b727-4f1d-8df2-39afeda4cd05

📥 Commits

Reviewing files that changed from the base of the PR and between c48d9fd and a508db4.

📒 Files selected for processing (1)
  • js/formidable.js

@truongwp
Copy link
Copy Markdown
Contributor Author

@Crabcyborg I fixed all of them. There is a Psalm error, can you take a look at it? In the code, the $value is array before the loop.

@Crabcyborg
Copy link
Copy Markdown
Contributor

There is a Psalm error, can you take a look at it? In the code, the $value is array before the loop.

@truongwp I believe it's because it checks for something like if ( ! is_array( $var ) ) but not if ( ! $is_array ) ).

We can use @var comments when we're certain of the type.

I made this commit to get Psalm passing 2d98651

Copy link
Copy Markdown
Contributor

@Crabcyborg Crabcyborg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @truongwp!

I think we can merge this now.

I'll continue to try testing it out as I work on PayPal. Having this merged means I can try both updates together more easily.

🚀

@Crabcyborg Crabcyborg merged commit e0c1736 into master Mar 30, 2026
21 of 22 checks passed
@Crabcyborg Crabcyborg deleted the rock-enable-pricing-fields branch March 30, 2026 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants