Skip to content

Sync master from upstream#82

Merged
adi3890 merged 4 commits into
masterfrom
sync/master
May 5, 2026
Merged

Sync master from upstream#82
adi3890 merged 4 commits into
masterfrom
sync/master

Conversation

@vanshk141999

@vanshk141999 vanshk141999 commented May 5, 2026

Copy link
Copy Markdown
Collaborator
  • New: Added "Both" payment type so a single form can collect one-time and subscription payments together.
  • New: Added Hidden field support for the payment block's dynamic amount.
  • New: Added minimum character limit support for the Textarea field.
  • Improvement: Added extension hooks for windowed entry caps in form restriction.
  • Fix: Fixed AI Form Builder silent failures and replaced generic error messages with clearer feedback.
  • Fix: Resolved an issue where the Confirm Email field could silently block form submission when the Email field was not marked as required.

vanshk141999 and others added 4 commits April 27, 2026 19:39
Adds a Claude Code slash command that syncs private master to the
brainstormforce/sureforms-public mirror, stripping internal-only paths
in a single commit on the sync branch before opening / updating the
sync PR.

The skill is the single source of truth for which paths are filtered
when going from private to public — co-located with the other release
skills (sync-release-branches, sureforms:version-bump, etc.) so the
team has one place to maintain release-time tooling.

After the matching public-mirror cleanup PR (sureforms-public#80) lands,
all future syncs should use this skill instead of raw git push +
gh pr create — that's the only way to keep .claude/, internal-docs/,
SVN release workflows, and AI-generated analysis docs from re-leaking
to the public repo.
…kill

chore: add /sureforms:sync-public skill for filtered mirror sync
* chore: remove unused @surecart/components-react dependency (#2447)

* chore: remove unused @surecart/components-react dependency

The package was never imported anywhere in the codebase. Removing it
eliminates 24 npm audit vulnerabilities (101 → 77) and resolves the
React 17/18 peer dependency conflict that blocked npm audit fix.

Added @babel/plugin-syntax-dynamic-import as an explicit devDependency
since the build relied on it being installed transitively via surecart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: patch dompurify and react-router-dom production vulnerabilities

- dompurify 3.3.0 → 3.3.3 (fixes XSS in 3.1.3–3.3.1, GHSA-v2wj-7wpq-c8vv)
- react-router-dom 6.30.0 → 6.30.3 (fixes @remix-run/router XSS via open redirect, GHSA-2w69-qvjg-hvjx)

Both are semver-compatible patch bumps within existing ^ranges in package.json.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate lock file without --legacy-peer-deps

The peer dep conflicts were caused by @surecart/components-react (removed
in prior commit). Regenerating the lock file without --legacy-peer-deps
resolves 17 previously-skipped peer deps and eliminates the need for
the flag in npm ci and npm install going forward.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: upgrade Volta Node pin from 18.15.0 to 20.20.1

Node 20.x LTS is fully compatible — npm ci, build, and lint all pass
with no lock file changes. This also resolves engine warnings from
mute-stream (>=18.17.0) and lighthouse (>=18.20).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update CI workflows to Node 20.20.1

Update node-version in playwright, release-tag-draft, and
update-translations workflows to match the Volta pin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: enable webpack filesystem cache for faster rebuilds

Repeat builds drop from ~26s to ~1.6s for webpack (34s to 6s total).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update caniuse-lite browserslist database

Silences "browsers data is 10 months old" warning during build and lint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: silence build warnings and deprecation notices

- Webpack: suppress performance hints (expected for WP plugin bundles)
- Webpack: silence Sass deprecation warnings (legacy-js-api, import, global-builtin)
- Grunt: silence Sass deprecations via SASS_SILENCE_DEPRECATION env var
- Grunt: fix autoprefixer browsers → overrideBrowserslist deprecation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review — bump dompurify, update CI Node versions

- Bump dompurify ^3.3.0 → ^3.3.3 in package.json (pin above vulnerable range)
- Update code-analysis.yml setup-node@v2 → @v4
- Update release-tag-draft.yml setup-node@v2 → @v4

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Vansh Kapoor <kvansh297@gmail.com>

* feat(form-restriction): add extension hooks for windowed entry caps (#2683)

* feat(form-restriction): add extension hooks for windowed entry caps

Adds two narrowly-scoped extension points so an extension (SureForms Pro's
Recurring Entry Limit feature) can swap the lifetime entry count used by
the Maximum Number of Entries cap with a window-scoped count (Per Day /
Week / Month / Year), without owning the cap UI itself.

PHP — `Form_Restriction::has_entries_limit_reached()`:
- New `srfm_form_restriction_entries_count` filter wraps the count read
  from `Entries::get_total_entries_by_status()`. The filter receives the
  lifetime count, the form id, and the parsed restriction array; returning
  a smaller integer defers the cap, returning a larger one trips it sooner.
  Non-int returns are coerced to a non-negative int.
- Default behaviour is unchanged: with no extension hooked in, the lifetime
  count flows through untouched and the cap fires exactly as before.

JS — `FormRestriction.js`:
- Adds an `srfm_form_restriction_max_entries_after` slot filter rendered
  right after the Maximum Entries number input (inside the same flex row,
  aligned to the bottom). The filter is given a `null` initial value so
  an extension can return a JSX element directly.
- Wraps the input in an `items-end` flex container so an injected control
  baselines with the input rather than its label.

Both are additive and back-compat. Companion change in
brainstormforce/sureforms-pro#1189 wires the Pro-side dropdown into these
hooks.

* fix(form-restriction): always floor entries-count filter return; document slot contract

Address two findings from review of #2683:

1) Inverted floor on the new srfm_form_restriction_entries_count filter
   (high-severity blocker).

   The previous code only coerced non-int returns:
       if ( ! is_int( $entries_count ) ) {
           $entries_count = max( 0, (int) $entries_count );
       }
   That left a negative-int return path uncovered — an extension that
   returned, say, -5 would silently disable the cap (-5 >= 100 is
   false). Move max( 0, (int) … ) outside the is_int gate so it always
   runs. The docblock already promised a non-negative integer; code
   now matches the documented contract on a newly-public extension
   hook.

2) Slot-filter ownership ambiguity on
   srfm_form_restriction_max_entries_after (high-severity).

   Document the filter contract directly above the applyFilters() call:
   callbacks receive whatever the previous callback returned, and
   should compose with prev rather than replace it. Includes the
   recommended idiom for would-be future extensions:

       addFilter(
         'srfm_form_restriction_max_entries_after',
         'my-plugin/extra',
         ( prev ) => <>{ prev }<MyControl /></>
       );

   Companion fix in sureforms-pro#1189 makes Pro's own callback follow
   this pattern.

* fix: warn when Never Store entries conflicts with entry cap

When GDPR compliance is active with "Never store entry data after form
submission", form-submit.php returns before inserting an entry, so the
entries-table count never increments and the Maximum Number of Entries
cap silently does nothing.

Surfaces a warning banner inside the Max Entries panel whenever both
settings are simultaneously active, so the admin knows the cap will not
be enforced until one of them is disabled.

* Dev to Next-release 2.8.2 (#2696)

* Fix: Confirm email field blocks submission without error when not required

The confirm email block lacked the `srfm-block` class, so existing CSS
error rules (red border, error message display) never applied when
`srfm-error` was toggled. Additionally, the error message element was
not rendered when the field was not required, causing a JS TypeError
that silently blocked submission.

- Add `srfm-block` class to confirm email block for CSS error styles
- Always render error message element via override when confirm is enabled
- Add null check on confirmError before setting textContent

Fixes #2230

* Fix: Use targeted CSS for confirm email error styles instead of adding srfm-block class

Adding srfm-block to the confirm block would break closest('.srfm-block')
lookups in real-time validation and other JS code. Instead, add dedicated
CSS rules for .srfm-email-confirm-block.srfm-error to show red border
and error message — matching the existing .srfm-block.srfm-error pattern
without introducing nested .srfm-block issues.

Also adds changelog entry in readme.txt.

* fix: surface AI Form Builder silent failures (#2452)

Every layer of the AI Form Builder had at least one silent-failure
path: the React catch only console.log'd, the PHP validator shipped
the raw response as the error payload, the JSON-decode checks treated
falsy JSON as "valid", Field_Mapping swallowed failures by returning
an empty string, and wp_insert_post errors were never checked. Any
one of these left the user on a frozen progress screen at 50/75/100%
with no error popup.

Frontend
- AiFormBuilder: split both apiFetch calls into their own try/catch,
  reset isBuildingForm + percentBuild on any failure, and replace the
  boolean error flag with a message-carrying string so every path
  surfaces a specific reason to the user.
- ErrorPopup: accept errorMessage + onRetry props; default retry no
  longer reloads the page when triggered by the AI flow, preserving
  the user's prompt.
- Helpers.handleAddNewPost: add an onError callback so permission /
  response.id / catch failures bubble up instead of only logging.

Backend
- ai-form-builder.php: fix the empty() parentheses bug, split the
  monolithic form validator into three guards with specific messages,
  and stop shipping the raw middleware response as the error payload.
- ai-helper.php: replace the falsy json_decode check with explicit
  json_last_error / is_array validation, share the new
  decode_json_response helper between both middleware calls, and log
  malformed responses (endpoint, HTTP status, truncated body) behind
  WP_DEBUG.
- field-mapping.php: fix the is_array($form) typo (was meant to guard
  $form_fields) and return typed WP_Error instead of empty strings,
  so the REST layer serialises a real message for the frontend.
- create-new-form.php: pass true to wp_insert_post so WP_Error is
  returned on failure, and respond with a 500 carrying the real
  message instead of treating WP_Error objects as success.
- abilities/forms/create-form.php and update-form.php: propagate the
  new Field_Mapping WP_Error contract.

Tests
- Update test_generate_gutenberg_fields_empty_request to match the
  new WP_Error contract.

Verified: npm run lint-js, npm run build:script, composer lint,
composer phpstan level 9 all clean. composer test requires local WP
test env (install-wp-tests.sh) to run.

Refs: FreeScout ticket #1389163

* fix: satisfy PHP Insights ordering + add missing test coverage

- field-mapping.php: switch two isset() ternaries to `??` null coalescing
  (PHP Insights "Ternary to null coalescing").
- ai-helper.php: move decode_json_response + log_ai_response_failure below
  the final public static method so protected visibility groups correctly
  (PHP Insights "Ordered class elements").
- test-ai-helper.php: add coverage for decode_json_response (valid,
  empty, invalid JSON, non-array JSON, custom fallback),
  log_ai_response_failure (WP_DEBUG noop + non-string body), and the
  two middleware callers (get_chat_completions_response,
  get_usage_response) using pre_http_request mocks.
- test-create-new-form.php: add test_create_form covering the new
  WP_REST_Response(500) error path when wp_insert_post fails.

* test: cover is_pro_license_active no-pro branch

* fix: address review findings from PR #2658 review

Addresses all 9 items in @rahulvarma722's review.

#1 (CRITICAL) — revert hardcoded foreign URL
ai-helper.php::get_user_token() was returning the literal
'http://www.republiquedesmangues.fr/' as the fallback X-Token for
non-licensed installs. dev had site_url(); the URL slipped in as debug
code. Restored site_url() so middleware token verification works and
no third-party domain leaks via X-Token.

#2 — tighten AI debug logging
log_ai_response_failure() now also gates on WP_DEBUG_LOG (so the body
no longer falls back to the host's PHP error log on sites that run
WP_DEBUG=true with WP_DEBUG_LOG=false), collapses whitespace before
formatting (prevents log injection via crafted CR/LF in response
bodies), and redacts the values of known sensitive JSON keys (email,
token, license_key, prompt, query) before writing.

#3 — sanitize upstream error messages before returning to client
Added AI_Helper::sanitize_ai_error_message() that strips URLs,
OpenAI-shape opaque IDs (org-/user-/key-/sess-/req-/file-/chatcmpl-/
asst-/run-/thread-), 'request id: …' trailers, sk-* / Bearer * tokens,
and 'gpt-*' model identifiers. The raw message is still preserved
server-side via log_ai_response_failure() (gated on WP_DEBUG[_LOG]).
Wired into ai-form-builder.php (response['error'] surfacing) and
create-new-form.php (wp_insert_post WP_Error surfacing).

#4 — drop dead invalid_json branch in AiFormBuilder.js
decode_json_response() returns ['error' => …], never
['code' => 'invalid_json'], so the frontend invalid_json check was
unreachable. The error already routes through the success===false /
empty-response branches.

#5 — drop unreachable inner ErrorPopup
handleFormCreationError() sets isBuildingForm=false in the same batch
as setFormCreationErr, so the early-return inside the
isBuildingForm block never coincides with a non-empty
formCreationErr. Only the outer popup is reachable; inner one removed.

#6 — ErrorPopup accessibility
Wrapped the popup in role='alertdialog' with aria-modal, aria-labelledby,
aria-describedby, and tabIndex=-1. Focus moves into the dialog on mount
(primary action button if available, else the wrapper). Escape now
dismisses by invoking the retry/close handler.

#7 — drop dead is_string checks post-narrowing
After is_wp_error early-return, generate_gutenberg_fields_from_questions
narrows to string. Removed !is_string in create-form.php and is_string
in update-form.php; kept the empty() guards which still serve a purpose.

#8 — defense-in-depth fieldOptions sanitization
Added Field_Mapping::sanitize_field_options() that runs label / value /
optionTitle through sanitize_text_field before the options array is
merged into Gutenberg block markup. Capability-gated trusted middleware
already, but cheap belt-and-suspenders if upstream ever returns
reflected user content.

#9 — test coverage on the new WP_Error contract
Added 5 cases to test-field-mapping.php covering invalid_form_data,
missing_form, missing_form_fields, invalid_field, plus a generic
status=400 assertion. Locks in the contract introduced by this PR.

* fix: address PHP Insights ordering and add sanitize_ai_error_message coverage

CI failures from the previous commit:

1. PHP Insights — code quality / style scores below the project's
   --min-quality=100 / --min-style=100 thresholds. Two findings:

   a) Ordered class elements: sanitize_ai_error_message (public static)
      was inserted after the protected static helpers, violating the
      public-before-protected ordering rule. Moved the method up to sit
      with the other public statics, immediately after
      is_pro_license_active().

   b) Return assignment: the trailing
        $cleaned = trim( $cleaned, … );
        return $cleaned;
      pair was flagged. Collapsed to a direct
        return trim( $cleaned, … );

2. check-test-coverage — sanitize_ai_error_message() had no matching
   test_sanitize_ai_error_message() in test-ai-helper.php. Added 5 cases:
   non-string / empty input → empty, clean pass-through, the full
   strip-pattern matrix (URL / OpenAI org-/user-/req_ IDs / request-id
   trailers / sk- keys / Bearer tokens / gpt model names), all-infra
   inputs collapse to empty, no-endpoint argument path.

Side fix while writing the strip tests: broadened the OpenAI-ID regex
to accept either '-' or '_' as the separator (real OpenAI request IDs
use req_…), and tightened the request-id trailer pattern to require a
separator and a 4+ char id so it cannot eat surrounding plain words.

* fix: bump confirm-email error CSS specificity above frontend hide rule

The display:block rule for .srfm-email-confirm-block.srfm-error
.srfm-error-message had specificity (0,4,0), tying with the base
hide rule .srfm-form .srfm-block .srfm-error-message { display:none }
in frontend/form.scss. Because common.css enqueues before frontend/
form.css (per inc/frontend-assets.php css_assets order), the hide
rule won and the error message stayed invisible on the frontend even
when the JS correctly added the .srfm-error class to the confirm block.

Wrap the entire confirm-email block under a .srfm-block ancestor (the
outer email wrapper carries that class via Base::get_field_classes())
so the show rule compiles to specificity (0,5,0) and consistently wins
regardless of file load order.

Fixes the two unchecked test plan items in PR review #2571:
- Non-required email + filled main + empty confirm now shows the
  'Confirmation email does not match' mismatch error with red border
- Required email + filled main + empty confirm now shows the
  'This field is required' message on the confirm field

The JS validation logic in assets/js/unminified/validation.js was
already correct — both branches (the !confirmValue && confirmError &&
isRequired === 'true' required-empty branch and the
confirmValue !== inputValue mismatch branch) fire as intended; the
visible regression was purely a CSS cascade bug.

* fix(abilities): allow null user in get-entry/bulk-get-entries output schema

The entry parser returns user as null for anonymous submissions, but the
output schema declared user as type "object" only. The MCP output
validator rejected null, causing every anonymous-submission entry fetch
to fail with "output[user] is not of type object". This blocked any
analytics dashboard that needs per-entry field values across all
submissions.

Schema now accepts both object (authenticated) and null (anonymous),
matching what entry-parser.php has always returned.

* fix: address WP 7.0/7.1 deprecation warnings and React 18 compat (#2660) (#2664)

* fix: address WP 7.0/7.1 deprecation warnings and React 18 compat

Migrates admin screen mounts to createRoot (React 18 API) and opts
in to the new @wordpress/components defaults so SureForms stops
emitting deprecation warnings in the admin console.

- Replace legacy `render()` from @wordpress/element with `createRoot`
  in Settings, PageHeader (shared across admin), TemplatePicker, and
  the ProPanel mount inside Editor.js (MutationObserver-driven).
- Add `__next40pxDefaultSize` to TextControl, SelectControl, RangeControl,
  and NumberControl across wrappers and raw usages (WP 6.8 → removed 7.1).
- Add `__nextHasNoMarginBottom` to ToggleControl, TextControl, and
  SelectControl usages (WP 6.7 → removed 7.0).

Scope covers `src/` and `modules/gutenberg/src/`. Wrappers
(SRFMTextControl, SRFMSelectControl, Range, SRFMNumberControl) are
updated once to propagate the props to every downstream consumer.

Refs brainstormforce/sureforms#2660

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: trim out-of-scope deprecation fixes

Drop changes that go beyond what #2660 explicitly asks for:

- Remove __nextHasNoMarginBottom additions on ToggleControl /
  TextControl / SelectControl. That's a separate WP 6.7 → 7.0
  deprecation track, not the 36px / React 18 issues called out
  in #2660. Can be addressed in a follow-up PR.

Keeps everything the issue requires:
- React 18 createRoot migration on admin screens
- __next40pxDefaultSize on TextControl / SelectControl /
  RangeControl / NumberControl

Refs #2660

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Feat: add "Both" payment type — editor + frontend (#2121) (#2565)

* Feat: add "Both" payment type option in payment block editor (Part 1 of #2121)

Adds a third "Both" option to the Payment Type toggle in the payment
block editor, allowing admins to configure rules for both one-time and
subscription payments in a single block. Introduces separate amount
settings per payment type so pricing can differ (e.g., $99 one-time vs
$9.99/month).

This is Part 1 of two — editor-side only. Frontend rendering, JS flow,
and backend AJAX handling will follow in Part 2. Existing "One Time"
and "Subscription" behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Feat: multi-select Billing Interval & Stop Subscription After

Converts the subscription plan's Billing Interval and Stop Subscription
After fields from single-select dropdowns to multi-select checkbox
groups so admins can configure the allowed options. End user will pick
one from the allowed set on the frontend (Part 2).

- New BillingIntervalControl: 5 checkboxes (Daily/Weekly/Monthly/Quarterly/Yearly)
  writes to subscriptionPlan.intervals (array)
- Rewritten BillingCyclesControl: checkboxes for presets (2, 3, 4, 5, Never)
  plus comma-separated custom payment counts input, writes to
  subscriptionPlan.billingCyclesOptions (array)
- Backward compatibility: legacy string `interval` / scalar `billingCycles`
  auto-migrate to single-item arrays on first render; legacy fields are
  kept in sync with first selected value so the current frontend
  renderer continues working until Part 2 wires up multi-select output
- Applies to both paymentType === "subscription" and paymentType === "both"
  modes for consistency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Refactor: use react-select multi-select for Billing Interval & Stop Subscription After

Aligns the two subscription multi-select controls with the pattern used
by the existing "Payment Methods" multi-select in sureforms-pro so the
UI is visually consistent across payment block settings.

- Replaces checkbox groups with react-select Select (isMulti)
- Uses classNamePrefix="srfm-select" and the same label/help markup as
  the Payment Methods control
- BillingCyclesControl: drops the separate custom-numbers text input in
  favor of a cleaner preset-only dropdown (2, 3, 4, 5, 6, 12, 24, Never).
  Arbitrary custom payment counts can be added later if required.
- Backward compatibility logic preserved: legacy `interval` / scalar
  `billingCycles` auto-migrate; legacy fields kept in sync with first
  selected array value

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Refactor: keep "subscription" mode single-select; isolate multi-select to "both" mode

Reverts the shared BillingCyclesControl to its original single-select
behavior and introduces two dedicated multi-select components used ONLY
in paymentType === "both" mode. This guarantees zero behavioral change
for existing subscription-mode forms.

- billing-cycles-control.js: restored to original SelectControl + custom
  numeric input (powers paymentType === "subscription" unchanged)
- billing-interval-multi-control.js: renamed from billing-interval-control.js;
  only used in paymentType === "both" mode
- billing-cycles-multi-control.js: new dedicated multi-select for
  paymentType === "both" mode (presets: 2, 3, 4, 5, 6, 12, 24, Never)
- edit.js: subscription block uses original SelectControl for interval
  and original BillingCyclesControl; both block uses the two new
  MultiControl components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Feat: frontend "both" payment mode — chooser + sub-choosers + styling

Part 2 of #2121. Renders the end-user payment-type chooser, dual
amount displays, Stripe element reinit on type switch, and (when the
admin allowed >1 option) radio-pill sub-choosers for Billing Interval
and Stop Subscription After.

- payment-markup.php: emits both-mode data attrs, renders chooser +
  sub-choosers via shared render_radio_pill helper that mirrors the
  multi-choice block (circle-checked / circle-unchecked SVGs,
  .srfm-block-content-wrap). Generates a pre-translated format map
  so JS can rebuild the visible amount text on sub-chooser change.
- stripe-payment.js: initPaymentTypeChoosers wires top-level +
  sub-chooser radios. switchActivePaymentType syncs all live
  data-* attrs and calls reinitForBlock to re-mount the Stripe
  Element in the new mode. switchSubscriptionInterval /
  switchSubscriptionBillingCycles update attrs and rebuild the
  amount text without re-mounting Stripe. Dispatches
  srfm_payment_type_changed and srfm_subscription_plan_changed
  events for gateway-agnostic listeners.
- payment-manager.js: re-broadcasts payment-method-changed when the
  payment type flips so accordion gateways re-render.
- front-end.php: add 'quarter' to the valid-intervals allow-list;
  was a pre-existing gap that would have rejected Quarterly
  subscriptions at submit time.
- SCSS: new components/payment-type-chooser.scss reuses the
  multi-choice mixins so the pills inherit the same border,
  padding, font-size, circle radio icons, primary-tinted checked
  state, focus ring, and mobile stacking as the rest of the form.

All new code is flagged with // BOTH MODE: start / end markers to
keep the follow-up rollout / rollback easy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: resolve PHPCS + PHPStan lint issues in payment-markup.php

- Add full-stops to inline comments (Squiz.Commenting.InlineComment).
- Replace single-line phpcs:ignore with phpcs:disable/enable blocks
  for multi-line echo calls (WordPress.Security.EscapeOutput).
- Fix equals sign alignment (Generic.Formatting.MultipleStatementAlignment).
- Change @var from strict array shape to array<mixed> so empty default
  is accepted by PHPStan level 9.

No functional changes — comments, whitespace, and annotations only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: server-side amount validation for "both" payment mode

In "both" mode one-time and subscription paths can have different
amount types and values (e.g. fixed $99 vs variable min $5). The
server-side validation was reading the legacy scalar config which
only holds the default choice's values — causing the non-default
path to fail with "Payment amount must be exactly $X".

- field-validation.php: process_payment_block() now stores per-type
  configs (one_time_*, subscription_*) when paymentType === 'both'.
- payment-helper.php: new resolve_payment_config_for_active_type()
  helper remaps per-type keys into the scalar positions the existing
  validation functions expect. validate_payment_amount() and
  validate_payment_intent_amount() accept $active_type param.
- front-end.php: all 4 Stripe callers pass 'one-time' or
  'subscription' to the validation functions.

Backward compatible — $active_type defaults to '' which returns
the original config unchanged for legacy (non-both) forms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: make entire payment chooser pill clickable

Changed render_radio_pill outer wrapper from <div> to <label> so
clicking anywhere on the card (icon, padding, whitespace) checks
the radio — not just the text label. Inner <label> changed to
<span> to avoid invalid nested labels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Refactor: simplify "both" mode to single-select for interval/cycles

Replace multi-select Billing Interval and Stop Subscription After
controls with the same single-select dropdowns used in subscription
mode. Admin picks one interval and one billing-cycle option; end
user sees only the top-level one-time/subscription chooser with no
sub-chooser pills.

Removed (-616 lines):
- billing-interval-multi-control.js and billing-cycles-multi-control.js
- Frontend sub-chooser rendering (render_subscription_sub_choosers,
  build_subscription_format_map, get_interval_display_label,
  get_billing_cycles_label) and related properties/data-attrs
- JS sub-chooser wiring (switchSubscriptionInterval,
  switchSubscriptionBillingCycles, updateSubscriptionAmountText,
  dispatchSubscriptionPlanChanged, srfm_subscription_plan_changed event)
- Sub-chooser SCSS rules

Kept unchanged: top-level chooser, dual amount displays, Stripe
reinit, PayPal rerender, server-side per-type validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: variable (dynamic) amount not working in "both" payment mode

Three bugs causing variable amounts to break in both mode:

1. PHP: data-variable-amount-field not emitted when the default
   choice is fixed but the other type is variable. listenAmountChanges
   runs once at init and queries [data-variable-amount-field] — without
   this attr, no listener is ever wired.

2. JS updatePaymentBlockAmount: querySelector('.srfm-payment-value')
   always returns the first span (one-time's) in both mode since two
   exist. Changed to query the VISIBLE amount block first:
   .srfm-payment-amount-block:not([hidden]) .srfm-payment-value

3. JS switchActivePaymentType: when flipping to a variable type, the
   amount span is empty (PHP renders it blank for variable). Added
   re-wire of listenAmountChanges + immediate read of the mapped
   field's current value to populate the display on flip.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: AI form-builder mapping for "both" payment mode + PHPStan/PHPCS/Insights

- Field_Mapping now accepts paymentType="both" and all 11 new dual-mode attributes returned by the AI middleware; prevents the middleware's "both" response from being silently downgraded to "one-time".
- Add "quarter" to the allowed subscription intervals (matches block.json).
- Payment_Markup: PHPStan level 9 — coerce mixed config values through Helper::get_string_value() + is_numeric float guards before passing to render_amount_display(); drop stray blank lines.
- Payment_Helper::validate_dynamic_amount_field() — add missing doc comment with typed params (PHPCS + PHPStan).

* Test: cover modified payment verification functions for "both" mode

Adds unit tests for the three functions touched by the "both" payment-mode
change — exercising the input-validation / error paths (empty IDs, missing
form config, missing payment-intent metadata) so the coverage gate passes:

- Payment_Helper::validate_payment_amount()
- Payment_Helper::verify_payment_intent()
- Front_End::verify_stripe_subscription_intent_and_save()

* Fix: harden server-side payment validation against client-type tampering (#2121) (#2671)

* Fix: harden server-side payment validation against client-type tampering (#2121)

Two tampering vectors in the new "both" payment-type work were exploitable
because the server trusted client-submitted fields that the server itself
had rendered. Closes both via the existing transient + block_config
verification mechanism — no new infrastructure.

1. Route ↔ form-config mismatch
   validate_payment_amount() did not enforce that the requested active_type
   ('one-time' | 'subscription') matched the form's stored payment_type.
   On a pure-subscription form an attacker could call create_payment_intent
   with $1 and pay once for what should be a recurring charge. Added a
   route-guard: when payment_type is not 'both', the active_type must match.

2. Cross-route intent_id replay
   The transient stored at intent-creation time did not bind the active_type,
   so a one-time intent_id could be replayed through the subscription submit
   path (or vice versa) on "both" mode forms. Persist active_type in the
   transient and reject in verify_payment_intent() when the submit handler's
   active_type does not match the stored value.

3. Interval / billing-cycles tampering
   The frontend echoed admin-configured subscriptionPlan.interval and
   subscriptionPlan.billingCycles via data-* attributes that the user could
   DevTools-flip before submit. End user has no chooser — these are pure
   passthroughs. Persist them to _srfm_block_config in process_payment_block(),
   reject mismatched interval in create_subscription_intent(), and override
   billing-cycles + interval at cancel_at calculation time so the server is
   the source of truth.

Companion: rationale comments added to PayPal dual-namespace SDK loader and
AI payment field mapping to prevent reviewers from re-flagging false-positive
collision / cents-vs-dollars / default-amount concerns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix: prevent Stripe Element re-init race during in-flight type flip (#2121)

When the user clicks submit on a "both" payment-type form, the flow runs
createPaymentIntent + elements.submit() + stripe.confirmPayment which can
take 3-10s end-to-end. Within that window the user could flip the type
radio, triggering switchActivePaymentType -> reinitForBlock which unmounts
the Stripe Element and deletes the cached intent the in-flight confirm is
still using. Outcomes ranged from "Stripe errors and user retries -> double
charge" to "Stripe completes the original (now wrong-type) charge silently".

Fix is a pre-emptive guard:

- handleFormPayment in payment.js sets form.dataset.srfmPaymentInFlight at
  function start and clears it in a finally block (covers all return paths
  and exceptions).
- The radio change handler in stripe-payment.js early-returns when the
  flag is set and reverts the radio to match the current data-payment-type
  so the UI does not lie about the user's choice.

PayPal-side companion fix lives in sureforms-pro #1176.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix: tear down stale variable-amount listeners on type flip (#2121)

PAYMENT_UTILITY.listenAmountChanges() is invoked at init AND every time
switchActivePaymentType() applies a new active type with a variable
amount. Each call walked all payment inputs with [data-variable-amount-field]
and called addEventListener() on the source field's input — without
removing the previous listener. Round-trip flips between two variable-
amount sources (e.g. one-time → field A, subscription → field B) leaked
listeners on both fields. Each subsequent input event then fired multiple
updatePaymentBlockAmount() calls, the last of which could overwrite the
displayed amount with a stale source field's value.

Fix: store an AbortController on the payment input. Each call aborts the
prior controller before binding new listeners with `{ signal }`. Pure
single-mode forms (no flips) see no behavior change — the controller is
created once and never aborted.

Also: extended the reviewer-facing comment block in field-mapping.php
with two more "do not flag this" notes:

- Update-flow caveat: generate_gutenberg_fields_from_questions() does
  full regeneration, not merge — every field's default-on-omit behavior
  applies. Pre-existing behavior, not a "both"-mode regression.
- Schema `required` scope: the flat `required` array in payment.json
  is a constraint of OpenAI structured output strict mode (when
  `additionalProperties: false` is set, every property must be in
  `required`). Per-property descriptions tell the model to emit empty
  values for inapplicable modes; the mapping correctly drops them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix: handle numeric-string billingCycles in editor control and AI mapping

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix: address PR #2565 review findings (#1, #2, #3, #4, #5, #6, #8, #10, #12, #13, #14, #15, #17)

- #1 High (payment-helper.php): use $resolved_config for variable-amount
  minimum check at verify time. The unresolved $payment_config falls
  back to 0 in 'both' mode because per-type keys are stored under the
  active-type prefix. Aligns with the rest of the function which
  already uses $resolved_config. Defense-in-depth — create-time
  validation already enforces the minimum on resolved config.

- #2 (payment-markup.php): wrap 'hidden'/'' ternary in esc_attr() for
  WPCS strict-mode consistency.

- #3 (stripe-payment.js): add per-block reinitInProgress Set so rapid
  type-flips don't race two Stripe.elements() instances on the same
  DOM container. Cleared in the new element's 'ready' event. Radio
  change handler reverts the UI when the guard trips so the visible
  state stays consistent.

- #4 (stripe-payment.js): read paymentType fresh from data-payment-type
  in confirmStripePayment instead of the cached paymentData. Prevents
  stale-type confirmation if the user flips during a captured-but-not-
  yet-confirmed flow.

- #5 (payment-manager.js): scope the document-level
  srfm_payment_type_changed listener to an AbortController and tear it
  down via destroy() before replacing on form re-init. Mirrors the
  PAYMENT_UTILITY.listenAmountChanges pattern in stripe-payment.js.

- #6 (stripe-payment.js): replace `new StripePayment(form)` in
  reinitForBlock with an Object.create stub. The constructor walks
  every payment block on the form; we only need to re-init the one
  that flipped.

- #8 (edit.js): fix useEffect stale-closure — depend on
  [isSelected, currentFormId] and drop the length-guard. The previous
  guard hid a stale-closure bug where the effect didn't react to form
  switches.

- #10 (payment-markup.php): add panel id + aria-labelledby on each
  amount-block; add aria_controls arg to render_radio_pill so each
  radio points to its panel. role=radiogroup + aria-label were
  already in place.

- #12 (stripe-payment.js): document the srfm_payment_type_changed and
  srfm_payment_method_changed event payload contracts so cross-PR
  listeners (e.g. PayPal in sureforms-pro) don't silently break on
  shape drift.

- #13 (stripe-payment.js): remove empty paymentElement.on('change')
  no-op. The 'ready' handler now does real work (clears the reinit
  flag) so it stays.

- #14 (payment-helper.php): cast (float) on fixed_amount and
  minimum_amount inside resolve_payment_config_for_active_type.
  Tightens the array shape for PHPStan and makes downstream floatval()
  redundant in the happy path.

- #15 (payment-markup.php): coerce subscription_plan name and
  interval to (string) before output. Defense-in-depth against a
  malformed REST save that stored these as arrays.

- #17 (edit.js): align the subscriptionPlan?.name fallback — value
  reads `subscriptionPlan?.name || ''` so the input stays controlled,
  while data.value keeps its 'Subscription Plan' display fallback.

Skipped:
- #7 (Medium): UI duplication refactor in edit.js — better as a
  follow-up PR; this commit is large enough already.
- #16 (Low): reviewer flagged but explicitly marked as
  "Acceptable, just flagging" — no action requested.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Resolved linting

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add editor nudge for contact/form pages (#2657)

* feat: add editor nudge for contact/form pages (closes #2655)

Marketing nudge shown in the block editor when a user is creating a
post/page whose title hints at a form ("contact", "form", "forms"),
the current post does not already contain a srfm/form block, and the
user has not dismissed the nudge before.

- Uses the native Gutenberg notice API (core/notices) — no DOM
  injection into the editor iframe, no polling for the canvas.
- Gated in PHP at enqueue time on capability, screen, and dismissal
  so the script is never shipped when it will no-op.
- Dismissal persisted per-user via nonce-protected AJAX.
- Auto-hides when a srfm/form block is inserted (without persisting
  dismissal) and reappears if the block is removed.

* style: adjust nudge content spacing

Add right margin on the notice content so the dismiss X does not
sit flush against the CTA button.

* test: add unit tests for Editor_Nudge class

Adds tests/unit/inc/admin/test-editor-nudge.php covering allow_load(),
enqueue_scripts(), and handle_dismiss() — required by the
check-test-coverage workflow for new/modified public methods.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: harden editor nudge — FSE title, perf, dismissal expiry, AJAX failure

- Normalize FSE entity-record title `{raw, rendered}` to a string so the
  Site Editor "Contact" pages match the keyword regex.
- Skip the recursive `srfm/form` block walk on editor ticks where the
  descendant client-id count is unchanged (cheap dirty marker), so
  typing in the title no longer pays for a full tree traversal.
- Replace permanent dismissal with a timestamp + 90-day expiry via
  `Editor_Nudge::is_dismissal_active()` so a mis-click is recoverable.
- `persistDismissal()` now returns a Promise; only flip in-memory
  `hasDismissed` after a confirmed `success: true` response, keeping
  client state aligned with server state on AJAX failure.
- Expand PHPUnit coverage: positive `allow_load()` path, SureForms CPT
  screen guard, expired-dismissal rollover, invalid-nonce branch, and
  timestamp-based persistence assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Resolved linting issue

* fix: gate editor nudge on manage_options and tidy CTA copy

Tighten the cap check in Editor_Nudge::allow_load() and handle_dismiss()
from edit_posts to manage_options (Helper::current_user_can() with no
argument), matching the cap the sureforms_form CPT requires for
create/edit. Otherwise the "Create Form" CTA would surface to roles
that cannot reach the form-creation flow. Also drops an em dash from
the nudge message so the second clause stands as its own sentence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: scope editor nudge dismissal to the post being edited

Switch the dismissal record from user meta to post meta so dismissing on
one page does not silence the nudge across every other post for that
user. Editor_Nudge::allow_load() now resolves the editing post via the
$post global and checks post meta; handle_dismiss() reads a validated
post_id from the request and writes update_post_meta on that post.
JS sends the current post ID (resolved through core/edit-site or
core/editor) with the dismissal AJAX.

Drops the block-tree dirty-marker that compared
getClientIdsWithDescendants().length — same-arity replaceBlocks
transforms (slash-command paragraph -> srfm/form) left the count
unchanged and the toggle never fired. The block walk now runs every
tick so insert/remove of the form block correctly hides and restores
the notice.

Updates the in-editor copy to match the agreed wording and migrates
existing tests to post meta. Adds coverage for the "once dismissed,
won't show again on this post" flow, per-post isolation, and the new
post_id validation in the AJAX handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: cover Editor_Nudge::get_current_post_id() directly

Coverage tool flagged the protected helper as untested. Adds a
reflection-based test that exercises the three branches: no $post
global, non-WP_Post value, and a valid WP_Post instance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: move get_current_post_id helper to end of class

Pure reorder — keeps the public hook surface (allow_load,
is_dismissal_active, enqueue_scripts, handle_dismiss) grouped at the
top and the protected helper at the bottom. No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: harden editor nudge dismiss authorization and tree-walk perf

Address PR #2657 review feedback:

- handle_dismiss(): reject SRFM_FORMS_POST_TYPE post IDs and require
  edit_post on the targeted post (in addition to manage_options) so
  the endpoint cannot dismiss against arbitrary post IDs.
- allow_load() / handle_dismiss(): pass 'manage_options' explicitly
  to Helper::current_user_can() so the rule survives any future
  change to the helper default. Editor / Author / Subscriber roles
  do not have manage_options on a stock WP install.
- watchEditorState(): reference-equality fast path on getBlocks() so
  the recursive tree walk only runs when the block list reference
  has actually changed.
- markDismissed() helper: centralises hasDismissed = true and the
  notice watcher tear-down so future callers cannot leave the
  watcher subscribed indefinitely.
- editor-nudge.scss: remove duplicate color declaration on the CTA.
- Tests: cover the new SRFM_FORMS_POST_TYPE rejection and the
  edit_post denial path (via map_meta_cap filter).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Feature: minimum character limit for Textarea field (#2686)

* Feature: add minimum character limit for Textarea field

- Add `minLength` block attribute to textarea block.json schema
- Add "Minimum Characters" number control in block editor sidebar
- Render `minlength` HTML attribute on the frontend textarea element
- Add client-side validation in validation.js that blocks submission
  and shows "Please enter at least N characters." error message
- Add server-side validation in Field_Validation::validate_form_data()
  using mb_strlen for multibyte character safety
- Store minLength in block config via new process_textarea_block() method
- Add srfm_textarea_min_chars translatable message with sprintf placeholder
- Register srfm_textarea_min_chars in global-settings and settings update allowlist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: use data-minlength instead of minlength to prevent browser native validation tooltip

Replace the HTML5 `minlength` attribute with `data-minlength` on the textarea element
so the browser does not show its native validation popup. SureForms custom inline error
message (displayed below the field) is now the only validation feedback shown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: always render error element when minLength is configured

The .srfm-error-message element is only rendered when a field is marked
required. If a non-required textarea has a minLength set, the error div
was absent from the DOM, so JS had nowhere to write the message.

Pass override=true to set_markup_properties() when min_length is set,
ensuring the error element is always present for JS to populate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: address review feedback on Textarea minLength feature (#2688)

- Use customizable srfm_textarea_min_chars message in server-side
  validation instead of a hardcoded English string, so admins can
  customize the message via Settings -> Messages.
- Align format specifier with translatable.php (%s instead of %d) to
  avoid duplicate POT entries.
- Skip min-length tracking and rendering when isRichText is enabled,
  since the submitted value contains HTML markup that would skew
  mb_strlen counts.
- Drop minLength when it exceeds maxLength so misconfigured forms
  remain submittable; surface a warning in the editor.
- Resolve a pre-existing PHPStan level-9 violation on
  process_textarea_block by replacing the empty-string guard with
  is_numeric() type narrowing.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Rahul Verma <rahulvarma722@gmail.com>

* feat: add Hidden field support for payment dynamic amount (#2690)

* feat: add Hidden field support for payment dynamic amount

Extends the Payment block's variable-amount source list to include
Hidden fields alongside Number, Dropdown, and Multi-choice. The
configured defaultValue acts as the server-side source of truth for
the charged amount; submitted values that drift from it are rejected.

- Payment block UI lists Hidden fields in "Choose Amount Field"
- Form validation registers Hidden block config (default_value)
- Payment intent validation pins Stripe-charged amount to the
  configured Hidden default_value with 0.01 tolerance
- Stripe frontend reads .srfm-hidden-input value into
  data-current-amount and listens for input/change updates

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: trust runtime hidden value for payment dynamic amount

Hidden field values can be set dynamically at runtime (URL params,
cookies, JS) — pinning the expected payment amount to the configured
defaultValue blocks legitimate dynamic-amount flows.

Switches the validator to the same trust model the Number field uses:
the value submitted with the form is the expected amount; the Stripe-
charged amount must match it (0.01 tolerance) and clear the existing
minimum_amount floor.

- payment-helper: replace default_value pinning with submitted-value
  comparison; add srfm-hidden mapping in form-data lookup helper
- field-validation: drop srfm/hidden switch case and process_hidden_block
  (no server-side hidden config needed; also resolves PHPStan failure on
  sanitize_text_field with mixed input)
- stripe-payment.js: remove leftover debug console.log in hidden sync

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: align hidden-field payment validation with reviewer feedback

- stripe-payment.js: tighten the runtime hidden value parser so it
  matches PHP's strict numeric semantics. parseFloat alone accepts
  malformed inputs like "99abc" (returns 99) which the server then
  rejects via is_numeric(), leading to silent "amount required"
  failures. Reject anything that isn't a clean positive decimal up
  front so client and server agree on what counts as numeric.

- payment-helper.php: refresh stale comment above the minimum-amount
  floor check; hidden-field amount sources are now also subject to
  the floor, so the comment now names them explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: add /sureforms:sync-public skill (#2695)

Adds a Claude Code slash command that syncs private master to the
brainstormforce/sureforms-public mirror, stripping internal-only paths
in a single commit on the sync branch before opening / updating the
sync PR.

The skill is the single source of truth for which paths are filtered
when going from private to public — co-located with the other release
skills (sync-release-branches, sureforms:version-bump, etc.) so the
team has one place to maintain release-time tooling.

After the matching public-mirror cleanup PR (sureforms-public#80) lands,
all future syncs should use this skill instead of raw git push +
gh pr create — that's the only way to keep .claude/, internal-docs/,
SVN release workflows, and AI-generated analysis docs from re-leaking
to the public repo.

Co-authored-by: Aditya Jain <adi3890@gmail.com>

---------

Co-authored-by: Aditya Jain <adi3890@gmail.com>
Co-authored-by: Rahul Verma <rahulvarma722@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Avinash Kumar Sharma <avinashs@bsf.io>

* Version Bump 2.8.2 (#2697)

* Version Bump 2.8.2

* Update changelog for 2.8.2

* Trim 2.8.2 changelog to user-facing highlights

* chore(2.8.2): clarify Both payment changelog & bump SRFM_PRO_RECOMMENDED_VER to 2.8.3 (#2699)

* Clarify Both payment type description (one-time + subscription)

* Bump SRFM_PRO_RECOMMENDED_VER to 2.8.3

* fix(textarea): clear stale min_length config on save (#2700)

When a textarea's minLength was cleared (or was non-numeric/missing),
process_textarea_block returned an empty config and the block was
dropped from $block_config. If no other validating blocks existed on
the form, add_block_config skipped update_post_meta entirely, leaving
the previously saved min_length in _srfm_block_config — so the
validator kept enforcing the old value.

- process_textarea_block: always emit a min_length key (0 when invalid,
  cleared, or rich-text) so the new save overwrites stale data.
- add_block_config: delete_post_meta when the resulting config is empty
  as a defensive backstop for the same class of bug.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(payment): preserve initial amount on type switch when hidden field is mapped (#2701)

In Both mode with a hidden field mapped as the dynamic-amount source,
flipping One Time ↔ Subscription reset the displayed amount to 0.
listenAmountChanges() correctly read the hidden input's value via
syncAmount(), but the immediate-update block in switchActivePaymentType()
only branched on number/dropdown/multi-choice — so currentValue fell
through as 0 and overwrote what syncAmount() had just set.

Add the missing srfm-hidden-input branch using the same parsing rules
syncAmount() uses (numeric strings only, negatives clamped to 0).

* chore: update i18n translations (#2702)

Auto-generated by /i18n command on PR #2698

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

* fix(security): allowlist tag-name attributes in heading & image blocks (CVE-2026-7623) (#2705)

* fix(security): allowlist tag-name attributes in advanced-heading and image blocks

CVE-2026-7623: the `headingWrapper` and `headingTag` attributes were echoed in
HTML tag-name position with only `esc_attr()`/`esc_html()`, neither of which
strips spaces or `=`. A Contributor could plant `headingWrapper="img src=x
onerror=..."` and inject script execution on the rendered frontend.

Validate both attributes against the editor UI option lists before output:
  - headingWrapper: ['div', 'header'] -> fallback 'div'
  - headingTag (advanced-heading): ['h1'..'h6', 'p', 'div'] -> fallback 'h2'
  - headingTag (image): ['h1'..'h6'] -> fallback 'h2'

Also fixes a pre-existing bug in image block where the entire `id="..."`
fragment was wrapped in `esc_attr()`, encoding the structural quotes and
breaking the id attribute. Now escapes only the value inside the fragment.

`esc_attr()` / `esc_html()` calls are preserved as defense-in-depth.

Verified end-to-end on the local site with the published PoC payload:
the malicious wrapper renders as `<div>` (fallback), no `<img>` tag, no
script execution, no console errors.

* test: cover render_html tag-name allowlist (CVE-2026-7623)

---------

Co-authored-by: Aditya Jain <adi3890@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Rahul Verma <rahulvarma722@gmail.com>
Co-authored-by: Avinash Kumar Sharma <avinashs@bsf.io>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
@adi3890 adi3890 merged commit 5713511 into master May 5, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants