From 2183e515008c46f3e61eae2b13835802685a2267 Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Mon, 27 Apr 2026 19:39:20 +0530 Subject: [PATCH 1/3] chore: add /sureforms:sync-public skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .claude/commands/sureforms:sync-public.md | 218 ++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 .claude/commands/sureforms:sync-public.md diff --git a/.claude/commands/sureforms:sync-public.md b/.claude/commands/sureforms:sync-public.md new file mode 100644 index 000000000..f69001d12 --- /dev/null +++ b/.claude/commands/sureforms:sync-public.md @@ -0,0 +1,218 @@ +--- +allowed-tools: Bash(git:*), Bash(gh:*), Bash(rm:*), Bash(rmdir:*), Bash(mktemp:*), Bash(echo:*), Bash(cd:*), Bash(test:*) +description: Sync private master to the sureforms-public mirror, stripping internal-only paths in one commit +--- + +# SureForms — Sync to Public Mirror + +Sync `brainstormforce/sureforms@master` (private, this repo) to `brainstormforce/sureforms-public@master` (public WordPress.org-facing mirror), stripping internal-only paths in a single follow-up commit on the sync branch. Replaces the previous manual flow (`git push mirror origin/master:sync/master` + `gh pr create`). + +This skill is the **only sanctioned way** to sync the public mirror. Running raw `git push mirror …` skips the strip and re-leaks internal artifacts. + +## Stripped paths (single source of truth) + +Edit this list when the leak surface changes — there is no other place to update. + +``` +.claude +.scripts/git-hooks +internal-docs +CLAUDE.md +ARCHITECTURE.md +COMPREHENSIVE_ANALYSIS.md +PRODUCT_ANALYSIS.md +TECHNICAL_OVERVIEW.md +.github/workflows/push-to-deploy.yml +.github/workflows/push-asset-readme-update.yml +.github/workflows/release-tag-draft.yml +.github/workflows/update-translations.yml +.github/workflows/release-pr-template.yml +bin/build-zip.sh +bin/checkout-and-build +bin/i18n.sh +``` + +These paths must NOT appear on the public mirror. They legitimately exist on private `master` and stay there (internal release CI, AI tooling, internal team wiki). + +## Preconditions + +Working directory: any worktree of this repo. + +Required remotes: +- `origin` → `https://github.com/brainstormforce/sureforms` (private) +- `mirror` → `https://github.com/brainstormforce/sureforms-public` (public) + +Verify with `git remote -v`. Abort if either remote is missing. + +## Instructions + +Follow steps sequentially. If any step fails, jump to **Error Recovery**. + +### Step 1: Record current state + +```bash +ORIGINAL_BRANCH=$(git rev-parse --abbrev-ref HEAD) +HAD_STASH=false +if [ -n "$(git status --porcelain)" ]; then + git stash push -m "sureforms-sync-public auto-stash" + HAD_STASH=true +fi +``` + +### Step 2: Fetch both remotes + +```bash +git fetch origin master +git fetch mirror master sync/master 2>/dev/null || git fetch mirror master +``` + +### Step 3: Detect no-op + +```bash +PRIVATE_TIP=$(git rev-parse origin/master) +PUBLIC_TIP=$(git rev-parse mirror/master) +``` + +If `PRIVATE_TIP` == `PUBLIC_TIP`, report "Public mirror is already up to date with private master — nothing to sync." Run **Step 8 cleanup** and exit. + +Capture the upstream commit range and count for reporting: + +```bash +COMMIT_COUNT=$(git rev-list --count "$PUBLIC_TIP..$PRIVATE_TIP") +``` + +### Step 4: Stage cleaned tree on a temp worktree + +Use a temp worktree so the developer's current working tree is never modified. + +```bash +WORKTREE=$(mktemp -d)/srfm-sync-public +git worktree add "$WORKTREE" "$PRIVATE_TIP" +cd "$WORKTREE" +``` + +### Step 5: Strip internal paths idempotently + +For each path in the **Stripped paths** list above, only act if it exists: + +```bash +for p in \ + .claude \ + .scripts/git-hooks \ + internal-docs \ + CLAUDE.md \ + ARCHITECTURE.md \ + COMPREHENSIVE_ANALYSIS.md \ + PRODUCT_ANALYSIS.md \ + TECHNICAL_OVERVIEW.md \ + .github/workflows/push-to-deploy.yml \ + .github/workflows/push-asset-readme-update.yml \ + .github/workflows/release-tag-draft.yml \ + .github/workflows/update-translations.yml \ + .github/workflows/release-pr-template.yml \ + bin/build-zip.sh \ + bin/checkout-and-build \ + bin/i18n.sh \ +; do + if [ -e "$p" ]; then + git rm -r --quiet "$p" + fi +done + +# Remove now-empty .scripts directory if applicable +if [ -d .scripts ] && [ -z "$(ls -A .scripts 2>/dev/null)" ]; then + rmdir .scripts +fi +``` + +### Step 6: Commit the strip if anything changed + +```bash +STRIPPED=false +if ! git diff --cached --quiet; then + STRIPPED=true + STRIPPED_PATHS=$(git diff --cached --name-only | tr '\n' ' ') + git -c user.name="sureforms-sync" \ + -c user.email="noreply@brainstormforce.com" \ + commit -m "chore: strip internal-only paths from public mirror sync" +fi +``` + +If `STRIPPED=false`, the upstream commits did not touch any internal-only paths — that's fine, just push the unmodified upstream tip. + +### Step 7: Push to `mirror sync/master` + +```bash +git push --force-with-lease mirror HEAD:sync/master +``` + +`--force-with-lease` is used because the strip commit is regenerated on top of each new upstream tip; previous strip commits on `sync/master` are discarded. The `--force-with-lease` flag still refuses if someone else has pushed to `sync/master` in the meantime. + +### Step 8: Open or update the sync PR + +Check whether an open PR already exists from `sync/master` → `master` on `brainstormforce/sureforms-public`: + +```bash +EXISTING_PR=$(gh pr list -R brainstormforce/sureforms-public \ + --base master --head sync/master --state open \ + --json number -q '.[0].number') +``` + +- **If `EXISTING_PR` is non-empty:** the existing PR auto-updates with the new commits (no action needed). Comment on it with a brief refresh note: + + ```bash + gh pr comment "$EXISTING_PR" -R brainstormforce/sureforms-public \ + --body "Refreshed: synced \`$COMMIT_COUNT\` commit(s) from private master ($PUBLIC_TIP..$PRIVATE_TIP). Strip applied: $STRIPPED." + ``` + +- **If `EXISTING_PR` is empty:** open a new PR. + + Title: `Sync master from upstream` + + Body: list the upstream commit range, the commit count, and the strip status. Paste the high-level summary of upstream commits (use `git log --oneline "$PUBLIC_TIP..$PRIVATE_TIP"`) into a "## Highlights" section. Note in the body that the strip commit on top of upstream content is the only public-only change. + + ```bash + gh pr create -R brainstormforce/sureforms-public \ + --base master --head sync/master \ + --title "Sync master from upstream" \ + --body "$PR_BODY" + ``` + +### Step 9: Tear down the temp worktree + +```bash +cd - +git worktree remove --force "$WORKTREE" +``` + +### Step 10: Restore developer state + +```bash +git checkout "$ORIGINAL_BRANCH" +if [ "$HAD_STASH" = "true" ]; then + git stash pop +fi +``` + +## Output to user + +Report at the end: + +- Upstream range: `..` (`$COMMIT_COUNT` commits) +- Whether the strip commit was created (`$STRIPPED`); if true, list the stripped paths +- PR URL — new or existing + +## Error Recovery + +If any step fails: + +1. **`cd` back to the original repo dir** if currently inside `$WORKTREE`. +2. **Remove temp worktree** if it was created: `git worktree remove --force "$WORKTREE"` (ignore errors). +3. **Restore branch and stash** as in Step 10. +4. Surface the failing command and its error to the user — do not retry blindly. + +Common failure modes: + +- **`git push --force-with-lease` rejected** — someone else pushed to `sync/master` since the last fetch. Re-run the skill from Step 2; their changes will be incorporated automatically. +- **`gh pr create` fails with "PR already exists"** — Step 8 detection missed a draft PR. Re-run Step 8 with `--state all` and reuse / reopen the existing PR. +- **No `mirror` remote** — add it and re-run: `git remote add mirror https://github.com/brainstormforce/sureforms-public`. From ea72c4c8a48c1c631fe5f95a2ba14dc58afa879d Mon Sep 17 00:00:00 2001 From: Vansh Kapoor <67928850+vanshk141999@users.noreply.github.com> Date: Tue, 5 May 2026 17:01:16 +0530 Subject: [PATCH 2/3] Version 2.8.2 (#2698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Vansh Kapoor * 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 } ); 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) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) * 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) * 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) * 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) * 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) * 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) * 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 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) * 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) * Fix: make entire payment chooser pill clickable Changed render_radio_pill outer wrapper from
to