diff --git a/.distignore b/.distignore index 7cbeb9c63..06d62896a 100644 --- a/.distignore +++ b/.distignore @@ -42,4 +42,5 @@ tsconfig.json /plugins /docs /build/pro/ +*.map AGENTS.md diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index a2aad4869..000000000 --- a/.eslintrc +++ /dev/null @@ -1,94 +0,0 @@ -{ - "env": { - "browser": true - }, - "extends": "plugin:@wordpress/eslint-plugin/recommended", - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaFeatures": { - "jsx": true - }, - "ecmaVersion": 2021, - "sourceType": "module" - }, - "ignorePatterns": [ "node_modules", "assets" ], - "rules": { - "indent": [ - "error", - "tab" - ], - "linebreak-style": [ - "error", - "unix" - ], - "quotes": [ - "error", - "single" - ], - "semi": [ - "error", - "always" - ], - "prefer-destructuring": [ - "warn", - { - "array": false, - "object": true - }, - { - "enforceForRenamedProperties": false - } - ], - "array-bracket-spacing": [ - "warn", - "always", - { - "arraysInArrays": false, - "objectsInArrays": false - } - ], - "key-spacing": [ - "warn", - { - "beforeColon": false, - "afterColon": true - } - ], - "object-curly-spacing": [ - "warn", - "always", - { - "arraysInObjects": true, - "objectsInObjects": false - } - ], - "prettier/prettier": ["off"], - "react-hooks/rules-of-hooks": ["warn"], - "dot-notation": ["off"], - "@wordpress/no-unsafe-wp-apis": ["warn"], - "no-undef": ["warn"], - "no-shadow": ["warn"], - "@typescript-eslint/no-shadow": "warn", - "@wordpress/no-base-control-with-label-without-id": ["warn"], - "no-unused-vars": ["warn"], - "@typescript-eslint/no-unused-vars": "warn", - "jsdoc/require-returns-description": ["warn"], - "no-unused-expressions": ["warn"], - "jsdoc/require-returns-type": ["warn"], - "no-nested-ternary": ["warn"], - "no-console": "warn", - "jsdoc/require-param-type": "warn", - "jsdoc/no-undefined-types": "warn", - "jsx-a11y/click-events-have-key-events": "warn", - "jsx-a11y/no-static-element-interactions": "warn", - "import/no-extraneous-dependencies": "warn", - "jsx-a11y/label-has-associated-control": "warn", - "jsx-a11y/alt-text": "warn", - "jsx-a11y/anchor-is-valid": "warn", - "jsx-a11y/no-noninteractive-element-interactions": "warn", - "jsx-a11y/no-autofocus": "warn", - "@wordpress/i18n-text-domain": ["error", { - "allowedTextDomain": ["otter-blocks", "otter-pro", "blocks-export-import", "blocks-css", "blocks-animation"] - }] - } -} diff --git a/.github/workflows/build-size.yml b/.github/workflows/build-size.yml index 5c771a3b2..213ed19e6 100644 --- a/.github/workflows/build-size.yml +++ b/.github/workflows/build-size.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out source files - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.base_ref }} - name: Install composer deps @@ -21,7 +21,7 @@ jobs: - name: Build files run: npm run analyzer - name: Upload base stats.json - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: base path: ./build/**/stats.json @@ -33,7 +33,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out source files - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install composer deps run: composer install --no-dev --prefer-dist --no-progress --no-suggest - name: Install npm deps @@ -41,7 +41,7 @@ jobs: - name: Build files run: npm run analyzer - name: Upload base stats.json - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: pr path: ./build/**/stats.json @@ -54,14 +54,14 @@ jobs: steps: - name: Checkout PR - uses: actions/checkout@v2 + uses: actions/checkout@v6 - name: Download base stats - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: base path: base - name: Download PR stats - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: pr path: pr diff --git a/.github/workflows/create-build-url.yml b/.github/workflows/create-build-url.yml index 9012e7566..69ef9497f 100644 --- a/.github/workflows/create-build-url.yml +++ b/.github/workflows/create-build-url.yml @@ -15,8 +15,8 @@ jobs: git-sha-8: ${{ steps.retrieve-git-sha-8.outputs.sha8 }} steps: - name: Check out source files - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: 20 cache: "npm" diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index 05a750a0b..6cfb48093 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -13,7 +13,7 @@ jobs: with: persist-credentials: false - name: Build files using Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 20 cache: "npm" diff --git a/.github/workflows/deploy-stagging.yml b/.github/workflows/deploy-stagging.yml index 83bc56ec5..509de77a5 100644 --- a/.github/workflows/deploy-stagging.yml +++ b/.github/workflows/deploy-stagging.yml @@ -17,8 +17,8 @@ jobs: SSH_PATH: ${{ secrets.SSH_PATH }} steps: - name: Check out source files - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: 20 cache: "npm" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4d4dac07a..b4c57326c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 20 cache: "npm" diff --git a/.github/workflows/e2e-js.yml b/.github/workflows/e2e-js.yml index 9211b0c2a..269c3677f 100644 --- a/.github/workflows/e2e-js.yml +++ b/.github/workflows/e2e-js.yml @@ -10,11 +10,28 @@ jobs: e2e: runs-on: ubuntu-latest if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name + name: ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - name: Playwright Blocks Serial + suite: e2e-serial + command: npm run test:e2e:playwright:serial + artifact: artifacts-e2e-serial + - name: Playwright Blocks Parallel + suite: e2e-parallel + command: npm run test:e2e:playwright:parallel + artifact: artifacts-e2e-parallel + - name: Performance + suite: performance + command: npm run test:performance + artifact: artifacts-performance steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: 20 cache: "npm" @@ -30,10 +47,6 @@ jobs: run: | npm run build-dev - - name: Create License File - run: | - printf "{\"key\":\"${{ secrets.OTTER_PRO_LICENSE }}\"}" > license.json - - name: Setup WP Env run: | npm run wp-env start @@ -41,35 +54,38 @@ jobs: - name: Install Playwright run: | npm install -g playwright-cli - npx playwright install + npx playwright install --with-deps chromium - - name: Playwright Blocks + - name: Run ${{ matrix.name }} + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: true run: | - npm run test:e2e:playwright && echo "PLAYWRIGHT_SUCCESS=true" >> $GITHUB_ENV || echo "PLAYWRIGHT_SUCCESS=false" >> $GITHUB_EN - continue-on-error: true + ${{ matrix.command }} - - name: Performance check + - name: Print performance results + if: matrix.suite == 'performance' run: | - npm run test:performance cat artifacts/post-editor.performance-results.json - name: Upload tests artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: - name: artifact + name: ${{ matrix.artifact }} path: ./artifacts retention-days: 1 - name: Print the results + if: matrix.suite == 'performance' run: | RESULTS=$(jq -r 'to_entries|map("\(.key): \(.value)")|join(", ")' artifacts/post-editor.performance-results.json) - FLAKY_TEST_STATUS="Playwright Test Status: $PLAYWRIGHT_SUCCESS" + FLAKY_TEST_STATUS="Playwright Test Status: See serial and parallel matrix jobs" echo "::set-output name=PERFORMANCE_OUTPUT::$RESULTS" echo "::set-output name=FLAKY_TEST_STATUS::$FLAKY_TEST_STATUS" id: summary - name: Comment + if: matrix.suite == 'performance' uses: NejcZdovc/comment-pr@v2 with: file: "e2e-summary.md" diff --git a/.github/workflows/plugin-check.yml b/.github/workflows/plugin-check.yml index c1fd81d70..f07af1cdc 100644 --- a/.github/workflows/plugin-check.yml +++ b/.github/workflows/plugin-check.yml @@ -8,19 +8,22 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: plugin-check: name: WordPress.org Guidelines Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Neutralize wp-env override run: | echo '{}' > .wp-env.override.json - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 20 cache: 'npm' diff --git a/.github/workflows/tastewp.yml b/.github/workflows/tastewp.yml index 281c01757..f914f4ab7 100644 --- a/.github/workflows/tastewp.yml +++ b/.github/workflows/tastewp.yml @@ -5,6 +5,9 @@ on: tags: - "*" +permissions: + contents: read + jobs: create-wp-for-testing: runs-on: ubuntu-latest diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml index 23142839e..f6d6c710e 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -4,6 +4,9 @@ on: pull_request: types: [opened, synchronize, ready_for_review] +permissions: + contents: read + jobs: run: runs-on: ubuntu-latest @@ -14,7 +17,7 @@ jobs: with: persist-credentials: false - name: Build files using 20 - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 20 cache: "npm" diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml index 48eec6dd8..56c329eff 100644 --- a/.github/workflows/test-php.yml +++ b/.github/workflows/test-php.yml @@ -4,6 +4,9 @@ on: pull_request: types: [opened, synchronize, ready_for_review] +permissions: + contents: read + jobs: phplint: name: Phplint @@ -16,7 +19,7 @@ jobs: php-version: "7.2" extensions: simplexml - name: Checkout source code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install composer run: composer install --prefer-dist --no-progress --no-suggest - name: Run PHPCS @@ -33,9 +36,19 @@ jobs: php-version: "7.4" extensions: simplexml, mysql - name: Checkout source code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install composer run: composer install --prefer-dist --no-progress --no-suggest + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: "npm" + - name: npm ci + run: | + npm ci + - name: npm run build + run: | + npm run build - name: PHPStan Static Analysis run: composer phpstan @@ -45,10 +58,10 @@ jobs: if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 cache: "npm" # run composer install - name: Install composer diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml index 37624fb85..35425c9ed 100644 --- a/.github/workflows/update-translations.yml +++ b/.github/workflows/update-translations.yml @@ -5,6 +5,9 @@ on: tags: - "*" +permissions: + contents: read + jobs: makepot: name: Build, make pot file and upload to S3 @@ -12,9 +15,9 @@ jobs: steps: - name: Check out source files - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 20 cache: "npm" diff --git a/.gitignore b/.gitignore index ac7e96720..966d63f92 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ trace.json license.json .phpunit.result.cache .fleet +.wp-env.override.json tests/assets/*_tmp .phpunit.result.cache languages diff --git a/.wp-env.json b/.wp-env.json new file mode 100644 index 000000000..ecc59bbe2 --- /dev/null +++ b/.wp-env.json @@ -0,0 +1,25 @@ +{ + "core": null, + "phpVersion": "7.4", + "testsEnvironment": false, + "plugins": [ + ".", + "https://downloads.wordpress.org/plugin/ai-provider-for-openai.zip" + ], + "themes": [ "./test/emptytheme" ], + "config": { + "WP_DEBUG": true, + "WP_DEBUG_LOG": true, + "WP_DEBUG_DISPLAY": false, + "FS_METHOD": "direct", + "WP_DEFAULT_THEME": "twentytwentythree", + "ENABLE_OTTER_PRO_DEV": true + }, + "mappings": { + "wp-content/mu-plugins": "./packages/e2e-tests/mu-plugins", + "wp-content/themes/raft": "https://downloads.wordpress.org/theme/raft.zip" + }, + "lifecycleScripts": { + "afterStart": "bash bin/e2e-tests.sh" + } +} diff --git a/.wp-env.override.json b/.wp-env.override.json deleted file mode 100644 index 8b187122a..000000000 --- a/.wp-env.override.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "core": null, - "plugins": [ - "." - ], - "config": { - "WP_DEBUG": true, - "WP_DEBUG_DISPLAY": true, - "FS_METHOD": "direct", - "WP_DEFAULT_THEME": "twentytwentythree" - }, - "phpVersion": "7.4", - "env": { - "development": { - "themes": [ "./test/emptytheme" ], - "mappings": { - "wp-content/themes/raft": "https://downloads.wordpress.org/theme/raft.zip" - } - }, - "tests": { - "config": { - "WP_DEBUG": true, - "WP_DEBUG_LOG": true, - "WP_DEBUG_DISPLAY": false, - "ENABLE_OTTER_PRO_DEV": true - }, - "plugins": [ - "." - ], - "themes": [ "./test/emptytheme" ], - "mappings": { - "wp-content/mu-plugins": "./packages/e2e-tests/mu-plugins", - "wp-content/plugins/gutenberg-test-plugins": "./packages/e2e-tests/plugins", - "wp-content/themes/gutenberg-test-themes": "./test/gutenberg-test-themes", - "wp-content/themes/gutenberg-test-themes/twentytwentyone": "https://downloads.wordpress.org/theme/twentytwentyone.zip", - "wp-content/themes/gutenberg-test-themes/twentytwentythree": "https://downloads.wordpress.org/theme/twentytwentythree.zip", - "wp-content/themes/raft": "https://downloads.wordpress.org/theme/raft.zip" - } - } - }, - "lifecycleScripts": { - "afterStart": "bash bin/e2e-tests.sh" - } -} diff --git a/AGENTS.md b/AGENTS.md index 4f304d5c6..e774de573 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,122 +1,137 @@ -# Agent Workflow +# Agent Workflow (Compressed) -## Project Overview +## Repo + Scope -Otter Blocks is a WordPress Gutenberg blocks page builder plugin. It's a monorepo containing: -- **Otter Blocks** (main free plugin) — `otter-blocks.php` -- **Otter Pro** (premium extension) — `plugins/otter-pro/` -- **Blocks Animation** — `plugins/blocks-animation/` -- **Blocks CSS** — `plugins/blocks-css/` -- **Blocks Export/Import** — `plugins/blocks-export-import/` +Otter Blocks monorepo: +- Free plugin: `otter-blocks.php` +- Pro: `plugins/otter-pro/` +- Sister plugins: `plugins/blocks-animation/`, `plugins/blocks-css/`, `plugins/blocks-export-import/` -Namespace: `ThemeIsle\GutenbergBlocks`. Requires WordPress 6.2+, PHP 7.4+ (platform target). +Namespace: `ThemeIsle\GutenbergBlocks` +Minimums: WordPress `6.2+`, PHP `7.4+` (Composer platform target). -## Build & Development Commands +## Read First + +- Use `./docs/` for feature workflow/context before edits. +- Prefer behavior-first changes + tests. + +## Core Commands ```bash # Setup npm ci && composer install -# Development (watch mode) -npm run start # All configs with watch -npm run dev:lite # Watch lite blocks only -npm run dev:pro # Watch pro blocks only - -# Production build -npm run build # Full production build (all configs in parallel) -npm run prod:lite # Lite blocks only -npm run prod:pro # Pro blocks only -npm run prod:grunt # SASS compilation via Grunt - -# Sister plugins build -npm run plugins # Build blocks-animation, blocks-css, blocks-export-import -``` - -## Testing - -```bash -# JavaScript unit tests (Jest) +# Dev/build +npm run start +npm run dev:lite +npm run dev:pro +npm run build +npm run prod:lite +npm run prod:pro +npm run prod:grunt +npm run plugins + +# JS tests npm run test:unit npm run test:unit:watch -# PHP unit tests (requires wp-env) -npm run test:unit:php # Starts wp-env + runs PHPUnit -npm run test:unit:php:base # PHPUnit only (if wp-env already running) -npm run test:unit:php:multisite # Multisite PHPUnit tests +# PHP tests +npm run test:unit:php +npm run test:unit:php:base +npm run test:unit:php:multisite -# E2E tests (Playwright) +# E2E/perf npm run test:e2e:playwright -npm run test:e2e:playwright-ui # With UI - -# Performance tests +npm run test:e2e:playwright:serial +npm run test:e2e:playwright:parallel +npm run test:e2e:playwright-ui npm run test:performance + +# Lint/format +npm run lint +npm run format +composer run lint +composer run format +composer run phpstan ``` -PHPUnit config: `phpunit.xml` (single-site), `phpunit/multisite.xml`. -Playwright config: `src/blocks/test/e2e/playwright.config.js`. +Configs: +- Playwright: `src/blocks/test/e2e/playwright.config.js` +- PHPUnit: `phpunit.xml`, `phpunit/multisite.xml` +- wp-env: `.wp-env.json` + gitignored `.wp-env.override.json` + +## wp-env Instance (Ports + Multi-Checkout) -## Linting & Formatting +Single environment (`"testsEnvironment": false`): one `wordpress`/`cli`/`mysql` container set serves dev and all test suites. Never use `tests-wordpress`/`tests-cli` in `wp-env run`, never add `testsPort`/`env.tests` config (deprecated in wp-env >= 11). ```bash -# JavaScript -npm run lint # ESLint check -npm run format # ESLint autofix - -# PHP -composer run lint # PHPCS -composer run format # PHPCBF -composer run phpstan # PHPStan static analysis (uses phpstan.neon + baseline) +npm run env:start # reuse running instance, or pick a free port + start +npm run env:stop +npm run env:cleanup # remove THIS checkout's containers/volumes (run before deleting a worktree) ``` -ESLint: WordPress preset with TypeScript (`.eslintrc`). PHPCS: WordPress-Core/Docs/Extra + VIP-Go (`phpcs.xml`). +`env:start` (`bin/wp-env-up.js`) handles everything: reuses a running instance, otherwise probes for a free port (default `8888`, scans upward) and pins it in the gitignored `.wp-env.override.json`. The Playwright configs read the same override file, so `npm run test:e2e:playwright` / `test:performance` follow the pinned port with no env var. Port precedence everywhere: `WP_BASE_URL` > `WP_ENV_PORT` > `.wp-env.override.json` > `8888`. -## Architecture +If `env:start` is unavailable, the manual steps it automates: -### Webpack Build Pipeline +1. `npx wp-env status --json` — `"status": "running"` means reuse it; do not start a second one. (`wp-env install-path` was removed in wp-env >= 11 and exits 0 silently — don't use it for detection.) +2. Pick a free port (`nc -z localhost `; exit 0 = taken), pin it in `.wp-env.override.json` as `{ "port": }`, then `npm run wp-env start`. -Three webpack configs, all extending `@wordpress/scripts`: -- `webpack.config.js` — Lite: dashboard, onboarding, animation frontend, and all free blocks -- `webpack.config.pro.js` — Pro blocks and features -- `webpack.config.plugins.js` — Sister plugins (animation, CSS, export-import) +Notes: `afterStart` (`bin/e2e-tests.sh`) seeds the AI key option. PHPUnit runs in the same instance (`wp-env run wordpress …`, PHPUnit lib mounted with `WP_TESTS_DIR` preset). Containers are namespaced per checkout — only host ports can conflict. Each worktree needs its own `npm ci && composer install && npm run build`. Login: `admin`/`password`. The root `docker-compose.yml` is legacy, not wp-env — ignore it. -Grunt handles SASS compilation and version bumping (`Gruntfile.js`). +## E2E Rules (Important) -### Block Metadata Registry +- Split: + - `chromium-serial`: specs from `SERIAL_SPECS`, `workers: 1` + - `chromium-parallel`: everything else, workers from `E2E_WORKERS` (`CI ? 4 : 2`) +- Add to `SERIAL_SPECS` only if shared WP state cannot be isolated (global options, auth/users, editor/theme global state, external state). +- Do **not** use `test.describe.configure({ mode: 'serial' })` to force file-level serial; project split handles this. +- Parallel-safe specs: + - no broad cleanup (`deleteAllPosts()`, `deleteAllMedia()`) + - create/clean only test-local resources + - do not depend on other specs + - prefer user-facing selectors (`getByRole`, labels, visible text) +- CI: install Chromium and set `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=true`. -`blocks.json` is the central manifest mapping every block to its `block.json` path and SCSS asset paths. Both webpack and Grunt read this file. Pro blocks are marked with `isPro: true`. +## TDD + Test Quality -### PHP Structure (`inc/`) +- Use red -> green -> refactor loops. +- Add one behavior test, fail first, implement minimum fix, repeat. +- Keep tests on public behavior (editor/frontend/REST/public helpers), not private internals. -- `class-main.php` — Singleton bootstrap, hooks, autoloading, SVG/MIME handling -- `class-registration.php` — Block registration (WordPress native), asset enqueue, block categories (`themeisle-blocks`, `themeisle-woocommerce-blocks`), lazy-load dependencies -- `class-pro.php` — Pro plugin loader -- `class-patterns.php` — Pattern library -- `css/` — CSS generation classes (`Block_Frontend`, `CSS_Handler`) -- `plugins/` — Feature modules: Block_Conditions, Dynamic_Content, Dashboard, Stripe_API, Template_Cloud -- `render/` — Dynamic block server-side renderers -- `server/` — REST API endpoints -- `integrations/` — Form provider integrations (email services) +## Architecture Quick Map -### JavaScript Structure (`src/`) +- Build: + - `webpack.config.js` (lite/main) + - `webpack.config.pro.js` (pro) + - `webpack.config.plugins.js` (sister plugins) + - `Gruntfile.js` (SASS + version tasks) +- Block registry: `blocks.json` (used by webpack + Grunt, includes `isPro`) -- `src/blocks/blocks/` — Individual block implementations (edit.js, index.js, inspector.js, block.json) -- `src/blocks/components/` — Shared React components -- `src/blocks/frontend/` — Block frontend scripts (loaded on visitor-facing pages) -- `src/blocks/plugins/` — Editor plugins (conditions, CSS, animations, copy-paste styles) -- `src/blocks/helpers/` — Utility functions -- `src/pro/` — Pro block implementations -- `src/dashboard/` — Otter settings dashboard -- `src/onboarding/` — Onboarding wizard -- `src/animation/`, `src/css/`, `src/export-import/` — Sister plugin sources +PHP (`inc/`): +- bootstrap/registration: `class-main.php`, `class-registration.php`, `class-pro.php`, `class-patterns.php` +- feature dirs: `css/`, `plugins/`, `render/`, `server/`, `integrations/` -### Development Environment +JS (`src/`): +- blocks: `src/blocks/blocks/` +- shared: `src/blocks/components/`, `src/blocks/helpers/` +- editor plugins: `src/blocks/plugins/` +- frontend scripts: `src/blocks/frontend/` +- pro: `src/pro/` +- dashboard/onboarding: `src/dashboard/`, `src/onboarding/` +- sister plugins: `src/animation/`, `src/css/`, `src/export-import/` -Uses `@wordpress/env` (wp-env). Config: `.wp-env.override.json`. Start with `npm run test:unit:php:setup` or `npx wp-env start`. - -## Key Conventions +## Conventions - Text domains: `otter-blocks`, `otter-pro`, `blocks-animation`, `blocks-css`, `blocks-export-import` -- Tab indentation (JS and PHP), single quotes, semicolons required (JS) -- Block namespace: `themeisle-blocks/` (e.g., `themeisle-blocks/accordion`) -- PHP autoloading follows class file naming: `class-.php` -- Distribution via `npm run dist` (runs `bin/dist.sh`, creates ZIP artifacts filtered by `.distignore`) +- JS/PHP: tabs; JS uses single quotes + semicolons. +- Block namespace: `themeisle-blocks/` +- PHP class files: `class-.php` +- Dist: `npm run dist` (`bin/dist.sh`, `.distignore` filters) + +## PHP Bug-Fix Checklist + +1) Write failing test (TDD) +2) Implement smallest fix +3) Run: `composer run lint`, `composer run format`, `composer run phpstan` +4) Run relevant tests again diff --git a/Gruntfile.js b/Gruntfile.js index ca0b7cc12..ae0433706 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,4 +1,4 @@ -/* eslint-disable camelcase */ + /* jshint node:true */ /* global require */ const fs = require( 'fs' ); diff --git a/assets/images/otter-mascot.svg b/assets/images/otter-mascot.svg new file mode 100644 index 000000000..6636d56b5 --- /dev/null +++ b/assets/images/otter-mascot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bin/e2e-tests.sh b/bin/e2e-tests.sh index 5699f81ec..687f9b6a0 100644 --- a/bin/e2e-tests.sh +++ b/bin/e2e-tests.sh @@ -1,4 +1,5 @@ #!/bin/sh # Set-up the `wp_env` environment. -npm run wp-env run tests-cli wp option set themeisle_open_ai_api_key "sk_XXXXXXXXXXXXXXXXXXXXXXXx" # Used by AI tools. \ No newline at end of file +npm run wp-env run cli wp option set themeisle_open_ai_api_key "sk_XXXXXXXXXXXXXXXXXXXXXXXx" # Used by AI tools. +npm run wp-env run cli wp rewrite structure '/%postname%/' # Pretty permalinks: the e2e global-setup reads /wp-json/. \ No newline at end of file diff --git a/bin/generate-icons-json.mjs b/bin/generate-icons-json.mjs index 66de867ad..586ee0e23 100755 --- a/bin/generate-icons-json.mjs +++ b/bin/generate-icons-json.mjs @@ -15,64 +15,71 @@ import { readFileSync, writeFileSync, readdirSync } from 'fs'; import { join, basename, dirname } from 'path'; import { fileURLToPath } from 'url'; -const ROOT = join( dirname( fileURLToPath( import.meta.url ) ), '..' ); +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); function parseArgs() { - const args = process.argv.slice( 2 ); + const args = process.argv.slice(2); const opts = { - iconsDir: join( ROOT, 'assets/atomic-wind/icons' ), - out: join( ROOT, 'assets/atomic-wind/icons.json' ), + iconsDir: join(ROOT, 'assets/atomic-wind/icons'), + out: join(ROOT, 'assets/atomic-wind/icons.json'), }; - for ( let i = 0; i < args.length; i++ ) { - if ( args[ i ] === '--icons-dir' ) { opts.iconsDir = args[ ++i ]; } - if ( args[ i ] === '--out' ) { opts.out = args[ ++i ]; } + for (let i = 0; i < args.length; i++) { + if (args[i] === '--icons-dir') { + opts.iconsDir = args[++i]; + } + if (args[i] === '--out') { + opts.out = args[++i]; + } } return opts; } -function extractInner( svgText ) { - const match = svgText.match( /]*>([\s\S]*?)<\/svg>/i ); - if ( ! match ) { return null; } +function extractInner(svgText) { + const match = svgText.match(/]*>([\s\S]*?)<\/svg>/i); + if (!match) { + return null; + } // Collapse all runs of whitespace (newlines, tabs, multiple spaces) to a single space, // then trim and remove spaces around > and <. - return match[ 1 ] - .replace( /\s+/g, ' ' ) - .replace( />\s+<' ) - .trim(); + return match[1].replace(/\s+/g, ' ').replace(/>\s+<').trim(); } function main() { const { iconsDir, out } = parseArgs(); - const files = readdirSync( iconsDir ) - .filter( ( f ) => f.endsWith( '.svg' ) ) + const files = readdirSync(iconsDir) + .filter((f) => f.endsWith('.svg')) .sort(); - if ( files.length === 0 ) { - console.error( `No SVG files found in ${ iconsDir }` ); - process.exit( 1 ); + if (files.length === 0) { + console.error(`No SVG files found in ${iconsDir}`); + process.exit(1); } const icons = {}; let skipped = 0; - for ( const file of files ) { - const name = basename( file, '.svg' ); - const svg = readFileSync( join( iconsDir, file ), 'utf8' ); - const inner = extractInner( svg ); - if ( inner === null ) { - console.warn( ` skipped (no found): ${ file }` ); + for (const file of files) { + const name = basename(file, '.svg'); + const svg = readFileSync(join(iconsDir, file), 'utf8'); + const inner = extractInner(svg); + if (inner === null) { + console.warn(` skipped (no found): ${file}`); skipped++; continue; } - icons[ name ] = inner; + icons[name] = inner; } - writeFileSync( out, JSON.stringify( icons ), 'utf8' ); + writeFileSync(out, JSON.stringify(icons), 'utf8'); - const sizeKB = ( Buffer.byteLength( JSON.stringify( icons ), 'utf8' ) / 1024 ).toFixed( 1 ); - console.log( `icons.json written: ${ Object.keys( icons ).length } icons, ${ sizeKB } KB${ skipped ? ` (${ skipped } skipped)` : '' }` ); - console.log( ` -> ${ out }` ); + const sizeKB = ( + Buffer.byteLength(JSON.stringify(icons), 'utf8') / 1024 + ).toFixed(1); + console.log( + `icons.json written: ${Object.keys(icons).length} icons, ${sizeKB} KB${skipped ? ` (${skipped} skipped)` : ''}` + ); + console.log(` -> ${out}`); } main(); diff --git a/bin/update-changelog.mjs b/bin/update-changelog.mjs index b467ef9cd..6108729aa 100644 --- a/bin/update-changelog.mjs +++ b/bin/update-changelog.mjs @@ -1,6 +1,5 @@ import { replaceInFile } from 'replace-in-file'; - async function updateChangelog() { const options = { files: 'readme.txt', @@ -18,4 +17,4 @@ async function updateChangelog() { } } -updateChangelog(); \ No newline at end of file +updateChangelog(); diff --git a/bin/wp-env-up.js b/bin/wp-env-up.js new file mode 100644 index 000000000..4aa9a7fb5 --- /dev/null +++ b/bin/wp-env-up.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node + +/** + * Start (or reuse) this checkout's wp-env instance without clashing with + * other checkouts/worktrees over the host port. + * + * 1. Reuses the instance if it is already running. + * 2. Otherwise picks a free port (override file > WP_ENV_PORT > 8888, scanning + * upward on conflict), pins it in the gitignored .wp-env.override.json, + * and runs `wp-env start`. + * + * The Playwright configs read the same override file, so the test suites + * follow the pinned port automatically. See AGENTS.md. + */ + +const fs = require( 'fs' ); +const net = require( 'net' ); +const path = require( 'path' ); +const { spawnSync } = require( 'child_process' ); + +const ROOT = path.resolve( __dirname, '..' ); +const OVERRIDE_PATH = path.join( ROOT, '.wp-env.override.json' ); +const DEFAULT_PORT = 8888; + +function getStatus() { + const result = spawnSync( 'npx', [ 'wp-env', 'status', '--json' ], { + cwd: ROOT, + encoding: 'utf8' + }); + + try { + return JSON.parse( result.stdout.trim() ); + } catch ( e ) { + return null; + } +} + +function isPortFree( port ) { + return new Promise( ( resolve ) => { + const socket = net.createConnection({ host: '127.0.0.1', port, timeout: 1000 }); + socket.once( 'connect', () => { + socket.destroy(); + resolve( false ); + }); + socket.once( 'error', () => resolve( true ) ); + socket.once( 'timeout', () => { + socket.destroy(); + resolve( true ); + }); + }); +} + +function readOverride() { + try { + return JSON.parse( fs.readFileSync( OVERRIDE_PATH, 'utf8' ) ); + } catch ( e ) { + return {}; + } +} + +async function main() { + const docker = spawnSync( 'docker', [ 'info' ], { stdio: 'ignore' }); + if ( 0 !== docker.status ) { + console.error( 'Docker is not running. Start Docker Desktop and retry.' ); + process.exit( 1 ); + } + + const status = getStatus(); + if ( 'running' === status?.status ) { + console.log( `wp-env is already running at ${ status.urls.development } — reusing it.` ); + return; + } + + const override = readOverride(); + let port = + parseInt( override.port, 10 ) || + parseInt( process.env.WP_ENV_PORT || '', 10 ) || + DEFAULT_PORT; + + while ( ! ( await isPortFree( port ) ) ) { + console.log( `Port ${ port } is taken, trying ${ port + 1 }.` ); + port += 1; + } + + if ( override.port !== port ) { + fs.writeFileSync( + OVERRIDE_PATH, + JSON.stringify({ ...override, port }, null, '\t' ) + '\n' + ); + console.log( `Pinned port ${ port } in .wp-env.override.json.` ); + } + + // Pass the resolved port explicitly so a stale WP_ENV_PORT in the caller's + // shell cannot override the pinned (verified-free) one. + const start = spawnSync( 'npx', [ 'wp-env', 'start' ], { + cwd: ROOT, + stdio: 'inherit', + env: { ...process.env, WP_ENV_PORT: String( port ) } + }); + + process.exit( start.status ?? 1 ); +} + +main(); diff --git a/blocks.json b/blocks.json index 5a0224215..2f2422d57 100644 --- a/blocks.json +++ b/blocks.json @@ -56,6 +56,13 @@ "circle-counter/style.css": "blocks/blocks/circle-counter/style.scss" } }, + "content-slider": { + "block": "blocks/blocks/content-slider/block.json", + "assets": { + "content-slider/editor.css": "blocks/blocks/content-slider/editor.scss", + "content-slider/style.css": "blocks/blocks/content-slider/style.scss" + } + }, "countdown": { "block": "blocks/blocks/countdown/block.json", "assets": { @@ -84,6 +91,9 @@ "form/style.css": "blocks/blocks/form/style.scss" } }, + "form-captcha": { + "block": "blocks/blocks/form/captcha/block.json" + }, "form-input": { "block": "blocks/blocks/form/input/block.json" }, diff --git a/composer.json b/composer.json index c0baa27e6..35703adb4 100644 --- a/composer.json +++ b/composer.json @@ -16,15 +16,15 @@ "squizlabs/php_codesniffer": "^3.3", "wp-coding-standards/wpcs": "^3.2", "phpcompatibility/php-compatibility": "^9", - "dealerdirect/phpcodesniffer-composer-installer": "0.7.1", + "dealerdirect/phpcodesniffer-composer-installer": "^1", "phpunit/phpunit": "9.6.5", "yoast/phpunit-polyfills": "^4.0", "phpstan/phpstan": "^2.1", - "php-stubs/woocommerce-stubs": "^9.1", - "php-stubs/acf-pro-stubs": "^6.0", + "php-stubs/woocommerce-stubs": "^10.8", + "php-stubs/acf-pro-stubs": "^6.8", "wpackagist-plugin/woocommerce": "*", - "szepeviktor/phpstan-wordpress": "^2.0", - "automattic/vipwpcs": "^3.0" + "automattic/vipwpcs": "^3.0", + "php-stubs/wordpress-stubs": "7.0.0 as 6.8.2" }, "license": "GPL-2.0+", "authors": [ diff --git a/composer.lock b/composer.lock index 52487fe94..0d8f0c84d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4fddabbc0ea7df1b3c8c1cfa0e3698b7", + "content-hash": "b41cfd1bb81a9d4f8ed32b2381a22d65", "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.51", + "version": "3.3.54", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "bb2a8414b0418b18c68c9ff1df3d7fb10467928d" + "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/bb2a8414b0418b18c68c9ff1df3d7fb10467928d", - "reference": "bb2a8414b0418b18c68c9ff1df3d7fb10467928d", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/095c2d0f1388af0b0196c492a7f79e2fd092dab1", + "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1", "shasum": "" }, "require-dev": { @@ -43,9 +43,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.51" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.54" }, - "time": "2026-03-30T07:58:49+00:00" + "time": "2026-06-23T13:43:47+00:00" }, { "name": "enshrined/svg-sanitize", @@ -590,35 +590,38 @@ }, { "name": "dealerdirect/phpcodesniffer-composer-installer", - "version": "v0.7.1", + "version": "v1.2.1", "source": { "type": "git", - "url": "https://github.com/Dealerdirect/phpcodesniffer-composer-installer.git", - "reference": "fe390591e0241955f22eb9ba327d137e501c771c" + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Dealerdirect/phpcodesniffer-composer-installer/zipball/fe390591e0241955f22eb9ba327d137e501c771c", - "reference": "fe390591e0241955f22eb9ba327d137e501c771c", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", "shasum": "" }, "require": { - "composer-plugin-api": "^1.0 || ^2.0", - "php": ">=5.3", - "squizlabs/php_codesniffer": "^2.0 || ^3.0 || ^4.0" + "composer-plugin-api": "^2.2", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" }, "require-dev": { - "composer/composer": "*", - "phpcompatibility/php-compatibility": "^9.0", - "sensiolabs/security-checker": "^4.1.0" + "composer/composer": "^2.2", + "ext-json": "*", + "ext-zip": "*", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", + "yoast/phpunit-polyfills": "^1.0" }, "type": "composer-plugin", "extra": { - "class": "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" }, "autoload": { "psr-4": { - "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -628,13 +631,16 @@ "authors": [ { "name": "Franck Nijhof", - "email": "franck.nijhof@dealerdirect.com", - "homepage": "http://www.frenck.nl", - "role": "Developer / IT Manager" + "email": "opensource@frenck.dev", + "homepage": "https://frenck.dev", + "role": "Open source developer" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" } ], "description": "PHP_CodeSniffer Standards Composer Installer Plugin", - "homepage": "http://www.dealerdirect.com", "keywords": [ "PHPCodeSniffer", "PHP_CodeSniffer", @@ -642,6 +648,7 @@ "codesniffer", "composer", "installer", + "phpcbf", "phpcs", "plugin", "qa", @@ -653,10 +660,29 @@ "tests" ], "support": { - "issues": "https://github.com/dealerdirect/phpcodesniffer-composer-installer/issues", - "source": "https://github.com/dealerdirect/phpcodesniffer-composer-installer" + "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", + "source": "https://github.com/PHPCSStandards/composer-installer" }, - "time": "2020-12-07T18:04:37+00:00" + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-05-06T08:26:05+00:00" }, { "name": "doctrine/instantiator", @@ -966,20 +992,20 @@ }, { "name": "php-stubs/acf-pro-stubs", - "version": "v6.5.0", + "version": "v6.8.2", "source": { "type": "git", "url": "https://github.com/php-stubs/acf-pro-stubs.git", - "reference": "e54ba80a945fd1f6c9ebe761e3f19992fbaedb39" + "reference": "b22454992d9651cd837439a2c1073a0955b68d94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/acf-pro-stubs/zipball/e54ba80a945fd1f6c9ebe761e3f19992fbaedb39", - "reference": "e54ba80a945fd1f6c9ebe761e3f19992fbaedb39", + "url": "https://api.github.com/repos/php-stubs/acf-pro-stubs/zipball/b22454992d9651cd837439a2c1073a0955b68d94", + "reference": "b22454992d9651cd837439a2c1073a0955b68d94", "shasum": "" }, "require": { - "php-stubs/wordpress-stubs": "^4.7 || ^5.0 || ^6.0" + "php-stubs/wordpress-stubs": "^4.7 || ^5.0 || ^6.0 || ^7.0" }, "require-dev": { "php": "~7.1 || ^8.0", @@ -1012,22 +1038,22 @@ ], "support": { "issues": "https://github.com/php-stubs/acf-pro-stubs/issues", - "source": "https://github.com/php-stubs/acf-pro-stubs/tree/v6.5.0" + "source": "https://github.com/php-stubs/acf-pro-stubs/tree/v6.8.2" }, - "time": "2025-09-05T00:47:01+00:00" + "time": "2026-06-01T12:08:34+00:00" }, { "name": "php-stubs/woocommerce-stubs", - "version": "v9.9.5", + "version": "v10.9.1", "source": { "type": "git", "url": "https://github.com/php-stubs/woocommerce-stubs.git", - "reference": "3f4d4e14afe6150569bd96cf14e8a17a84812447" + "reference": "e6d583b9fa05b3f5ff67f86c939b45a0c35b8444" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/3f4d4e14afe6150569bd96cf14e8a17a84812447", - "reference": "3f4d4e14afe6150569bd96cf14e8a17a84812447", + "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/e6d583b9fa05b3f5ff67f86c939b45a0c35b8444", + "reference": "e6d583b9fa05b3f5ff67f86c939b45a0c35b8444", "shasum": "" }, "require": { @@ -1056,22 +1082,22 @@ ], "support": { "issues": "https://github.com/php-stubs/woocommerce-stubs/issues", - "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v9.9.5" + "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v10.9.1" }, - "time": "2025-07-14T17:12:48+00:00" + "time": "2026-06-25T10:47:57+00:00" }, { "name": "php-stubs/wordpress-stubs", - "version": "v6.8.2", + "version": "v7.0.0", "source": { "type": "git", "url": "https://github.com/php-stubs/wordpress-stubs.git", - "reference": "9c8e22e437463197c1ec0d5eaa9ddd4a0eb6d7f8" + "reference": "d74b963ed4f47303859bf73c741c80f554c83dc6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/9c8e22e437463197c1ec0d5eaa9ddd4a0eb6d7f8", - "reference": "9c8e22e437463197c1ec0d5eaa9ddd4a0eb6d7f8", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/d74b963ed4f47303859bf73c741c80f554c83dc6", + "reference": "d74b963ed4f47303859bf73c741c80f554c83dc6", "shasum": "" }, "conflict": { @@ -1081,10 +1107,11 @@ "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "nikic/php-parser": "^5.5", "php": "^7.4 || ^8.0", - "php-stubs/generator": "^0.8.3", - "phpdocumentor/reflection-docblock": "^5.4.1", + "php-stubs/generator": "^0.8.6", + "phpdocumentor/reflection-docblock": "^6.0", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^9.5", + "symfony/polyfill-php80": "*", "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.1.1", "wp-coding-standards/wpcs": "3.1.0 as 2.3.0" }, @@ -1107,9 +1134,9 @@ ], "support": { "issues": "https://github.com/php-stubs/wordpress-stubs/issues", - "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.8.2" + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v7.0.0" }, - "time": "2025-07-16T06:41:00+00:00" + "time": "2026-05-21T21:41:34+00:00" }, { "name": "phpcompatibility/php-compatibility", @@ -1350,16 +1377,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.29", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan-phar-composer-source.git", - "reference": "git" - }, + "version": "2.2.0", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d618573eed4a1b6b75e37b2e0b65ac65c885d88e", - "reference": "d618573eed4a1b6b75e37b2e0b65ac65c885d88e", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b4cd98348c809924f62bb9cc8c047f5e73bc9a58", + "reference": "b4cd98348c809924f62bb9cc8c047f5e73bc9a58", "shasum": "" }, "require": { @@ -1382,6 +1404,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -1404,7 +1437,7 @@ "type": "github" } ], - "time": "2025-09-25T06:58:18+00:00" + "time": "2026-05-28T08:22:43+00:00" }, { "name": "phpunit/php-code-coverage", @@ -2885,16 +2918,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.13.2", + "version": "3.13.5", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "5b5e3821314f947dd040c70f7992a64eac89025c" + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/5b5e3821314f947dd040c70f7992a64eac89025c", - "reference": "5b5e3821314f947dd040c70f7992a64eac89025c", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", "shasum": "" }, "require": { @@ -2911,11 +2944,6 @@ "bin/phpcs" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" @@ -2965,69 +2993,7 @@ "type": "thanks_dev" } ], - "time": "2025-06-17T22:17:01+00:00" - }, - { - "name": "szepeviktor/phpstan-wordpress", - "version": "v2.0.2", - "source": { - "type": "git", - "url": "https://github.com/szepeviktor/phpstan-wordpress.git", - "reference": "963887b04c21fe7ac78e61c1351f8b00fff9f8f8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/szepeviktor/phpstan-wordpress/zipball/963887b04c21fe7ac78e61c1351f8b00fff9f8f8", - "reference": "963887b04c21fe7ac78e61c1351f8b00fff9f8f8", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0", - "php-stubs/wordpress-stubs": "^6.6.2", - "phpstan/phpstan": "^2.0" - }, - "require-dev": { - "composer/composer": "^2.1.14", - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "php-parallel-lint/php-parallel-lint": "^1.1", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.0", - "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.0", - "wp-coding-standards/wpcs": "3.1.0 as 2.3.0" - }, - "suggest": { - "swissspidy/phpstan-no-private": "Detect usage of internal core functions, classes and methods" - }, - "type": "phpstan-extension", - "extra": { - "phpstan": { - "includes": [ - "extension.neon" - ] - } - }, - "autoload": { - "psr-4": { - "SzepeViktor\\PHPStan\\WordPress\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "WordPress extensions for PHPStan", - "keywords": [ - "PHPStan", - "code analyse", - "code analysis", - "static analysis", - "wordpress" - ], - "support": { - "issues": "https://github.com/szepeviktor/phpstan-wordpress/issues", - "source": "https://github.com/szepeviktor/phpstan-wordpress/tree/v2.0.2" - }, - "time": "2025-02-12T18:43:37+00:00" + "time": "2025-11-04T16:30:35+00:00" }, { "name": "theseer/tokenizer", @@ -3227,7 +3193,14 @@ "time": "2025-02-09T18:58:54+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "php-stubs/wordpress-stubs", + "version": "7.0.0.0", + "alias": "6.8.2", + "alias_normalized": "6.8.2.0" + } + ], "minimum-stability": "dev", "stability-flags": {}, "prefer-stable": true, diff --git a/docs/adding-patterns.md b/docs/adding-patterns.md index 286dc184b..e837509e4 100644 --- a/docs/adding-patterns.md +++ b/docs/adding-patterns.md @@ -4,9 +4,9 @@ Patterns are big part of Gutenberg ecosystem. In the old days, plugin developer Adding a new pattern for Otter is straight forward. -- All the patterns are located in `./src/patterns` folder. +- All patterns are located in `./inc/patterns/`. - Every pattern is just an array with keys described by the [Gutenberg documentation](https://developer.wordpress.org/block-editor/developers/block-api/block-patterns/). -- After creating a file for it, register it on `./inc/patterns.php` file in `$block_patterns` array. +- After creating a file for it, register it in `./inc/class-patterns.php` in the `$block_patterns` array. ## Mentions diff --git a/docs/blocks/first-block.md b/docs/blocks/first-block.md index 5b9a84edb..698e49084 100644 --- a/docs/blocks/first-block.md +++ b/docs/blocks/first-block.md @@ -143,7 +143,7 @@ registerBlockType( 'themeisle-blocks/tutorial-1', { }); ``` -You can check the `index` file of the block in the `src/block` and see that they follow this pattern. +You can check the `index.js` files of blocks in `src/blocks/blocks/` and see that they follow this pattern. The `attributes.js` follow this tructure: ```javascript diff --git a/docs/blocks/save.md b/docs/blocks/save.md index 14c4d7cee..359a3778b 100644 --- a/docs/blocks/save.md +++ b/docs/blocks/save.md @@ -81,7 +81,7 @@ If we save and click on preview, we have: As we can see, now we have some HTML values in the `Code editor`. And as mentioned in the [first chapter](first-block.md), `Apple is good` appears two times. Since it is very short, we do not need to worry too much. -Save the page. Go back to your IDE and delete the `style={{ color: props.attributes?.color }}` from the `save` function. (I assume you are in the dev mode with `npm run dev`) +Save the page. Go back to your IDE and delete the `style={{ color: props.attributes?.color }}` from the `save` function. (I assume you are in development mode with `npm run start`, `npm run dev:lite`, or `npm run dev:pro`.) Refresh the page and a nice surprise appears before your eyes. @@ -91,7 +91,7 @@ The reason: ![eror-inspector](images/save-error-inspector.png) -The new structure of the `save` function does not match with the old one. To avoid this error you need to write a migration scheme that you need to put it on the `deprecated` attribute on the `registerBlockType`. You can check the `src/block/slider/deprecated.js`. +The new structure of the `save` function does not match with the old one. To avoid this error you need to write a migration scheme that you need to put it on the `deprecated` attribute on the `registerBlockType`. You can check `src/blocks/blocks/slider/deprecated.js`. :warning: *When developing the block, you could delete the block and insert a new one.* **But for the users, this is another story; they will not want to see this error, leading them to delete their work.** diff --git a/docs/copy-paste.md b/docs/copy-paste.md index f655dd2ae..ca8763500 100644 --- a/docs/copy-paste.md +++ b/docs/copy-paste.md @@ -22,11 +22,11 @@ When adding Copy/Paste for a block, you need to implement two adaptors: one for :warning: This feature heavily relies on Typescript for autocompletion, type compatibility, and modification. When you change the type of an attribute, Typescript will raise an error if there is a problem in the adaptor. -You can see the Common Interface declaration for `Storage` in [`src/block/plugins/models.d.ts`](../src/blocks/plugins/copy-paste/models.d.ts) +You can see the Common Interface declaration for `Storage` in [`src/blocks/plugins/copy-paste/models.d.ts`](../src/blocks/plugins/copy-paste/models.d.ts) -Otter adaptors in [`src/block/plugins/adaptors.ts`](../src/blocks/plugins/copy-paste/adaptors.ts) +Otter adaptors in [`src/blocks/plugins/copy-paste/adaptors.ts`](../src/blocks/plugins/copy-paste/adaptors.ts) -Core adaptors in [`src/block/plugins/core-adaptors.ts`](../src/blocks/plugins/copy-paste/core-adaptors.ts) +Core adaptors in [`src/blocks/plugins/copy-paste/core-adaptors.ts`](../src/blocks/plugins/copy-paste/core-adaptors.ts) ## How to add a block diff --git a/docs/creating-blocks.md b/docs/creating-blocks.md index a87a25a7b..63918382b 100644 --- a/docs/creating-blocks.md +++ b/docs/creating-blocks.md @@ -17,7 +17,7 @@ In the root folder, at `./blocks.json`, add another entry with the name of your Back to the `./src/blocks/blocks/my-card`. You can create the other files like: `edit.js`, `save.js`, `inspector.js`, `style.scss`, `editor.scss`. -If your block has functionality for end user (aka frontend), you need to add the script in new folder at `./src/blocks/frontend/`. Then you need to add as an entry point in `./webpack.config.json` file. Then building the project, you will that the script is added independently in `./build/blocks/` (you need to run `npm run build` or `npm run start` if you do not see the folder `./build`). Then register that independent script in `./inc/class-registration.php` on `enqueue_dependencies` function (this function load the script for the end user if the block is present in page). (Tip: Add a `console.log('Loaded')` at the beginning of your script to check if it is loaded). +If your block has functionality for end users (frontend), add the script in `./src/blocks/frontend/`. Then add it as an entry point in `./webpack.config.js` (or `./webpack.config.pro.js` for Pro-only code). After building the project, the script is added independently in `./build/blocks/` (run `npm run build` or `npm run start` if you do not see `./build`). Then register that independent script in `./inc/class-registration.php` in `enqueue_dependencies` (this function loads the script for the end user if the block is present on the page). (Tip: add a `console.log('Loaded')` at the beginning of your script to check if it is loaded.) ## Adding content @@ -37,7 +37,7 @@ To add content you will usually have those files: Pro feature need to be separated from the free version if it is possible. This is to make sure that the free version is not bloated with unnecessary code. The pro version will be loaded only if the user has the pro version of the plugin. -Pro block are added in `./src/blocks/pro/`. The folder structure is the same as the free version. +Pro blocks are added in `./src/pro/`. The folder structure is the same as the free version. Some blocks are hybrid, free to use but with limited functionality (e.g.: Sticky, Popup). The pro features are added via JS WP hooks (in a style similar to the one from PHP). Learn more about them [here](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-hooks/). diff --git a/docs/dynamic-conditions-and-content.md b/docs/dynamic-conditions-and-content.md index 36f35a204..8e7b41fbc 100644 --- a/docs/dynamic-conditions-and-content.md +++ b/docs/dynamic-conditions-and-content.md @@ -23,8 +23,8 @@ Extensions (Pro) | Feature | Editor | Backend | | :-- | :-- | :-- | -| Dynamic Block Conditions | `./src/blocks/plugins/conditions` | `./plugins/otter-pro/class-block-conditions.php` | -| Dynamic Content | `./src/blocks/pro/plugins/conditions` | `./plugins/otter-pro/class-dynamic-content.php` | +| Dynamic Block Conditions | `./src/pro/plugins/conditions` | `./plugins/otter-pro/inc/plugins/class-block-conditions.php` | +| Dynamic Content | `./src/pro/plugins/dynamic-content` | `./plugins/otter-pro/inc/plugins/class-dynamic-content.php` | ## How it works @@ -41,7 +41,7 @@ The concept and workflow is simplistic. The hard part of this is making the eval - performance. This function is called for each block on the page with the given condition, so it needs to be as fast as possible. - stability. You need to handle the errors and do not let the page crash. -To add a new condition outside Otter context (e.g.: making a custom plugin for extending functionality), you need to add a new function to filter `otter_blocks_evaluate_condition`. You can see an example in `./plugins/otter-pro/class-block-conditions.php`. +To add a new condition outside Otter context (e.g.: making a custom plugin for extending functionality), you need to add a new function to filter `otter_blocks_evaluate_condition`. You can see an example in `./plugins/otter-pro/inc/plugins/class-block-conditions.php`. ### Dynamic Content diff --git a/docs/environment-installation.md b/docs/environment-installation.md index 5141449eb..dc2c54fed 100644 --- a/docs/environment-installation.md +++ b/docs/environment-installation.md @@ -32,7 +32,7 @@ Resources: Mentions: - Working with composer should be done by using the terminal instance included in the app. Click on `Open Site Shell` in the app. - - Use PHP 7.3.X. This is required for composer commands (`composer run lint`, `composer run format`) to work. + - Use PHP 7.4+ (the Composer platform target is PHP 7.4). This is required for composer commands (`composer run lint`, `composer run format`) to work. - NodeJS is not included. You need to install it separately. - This is a WordPress only environment. - Making Visual Studio Code and PHPStorm to detect the PHP install file is tricky. In the terminal instance of the app, run `which php` and to see where is the PHP install file. Then in the IDE, set the PHP executable to that file. It looks something like this: `/Users/robert/Library/Application Support/Local/lightning-services/php-7.3.5+14/bin/darwin/bin/php` diff --git a/docs/form-block-workflow.md b/docs/form-block-workflow.md index 33865f2af..afc0877cd 100644 --- a/docs/form-block-workflow.md +++ b/docs/form-block-workflow.md @@ -14,7 +14,7 @@ When doing this we need to consider the following: ## How it works from the Submit click to the data being sent When the user clicks on the Submit button, the following happens: -- The data is validated with JS and collected. Then sended to the server via `wp-json/form/frontend` endpoint (definition in `./inc/server/class-form-server.php`). +- The data is validated with JS and collected. Then sent to the server via `/wp-json/otter/v1/form/frontend` endpoint (definition in `./inc/server/class-form-server.php`). - The data is validated with PHP via `otter_form_validate_form` filter hook. If the data is invalid, we check if it was sended by a bot with `otter_form_anti_spam_validation` filter hook. - If all ok, we apply some data preparation `otter_form_data_preparation`. This will add or change the data from the `$form_data` variable. - If everything goes well, we get the provider (the service that will receive the data) and run with the current data request of `$form_data`. diff --git a/docs/index.md b/docs/index.md index 2dad91af5..1d1395f28 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ # Gutenberg Blocks Otter Blocks -- [Environment installation](environment.md) +- [Environment installation](environment-installation.md) - [Handbook](handbook.md) - [Otter Family Plugins](otter-family-plugins.md) - [Project Structure](project-structure.md) diff --git a/docs/project-structure.md b/docs/project-structure.md index 2c3eff5a4..b8c6775e8 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -26,7 +26,7 @@ As time goes by, the project grows and evolves. The codebase becomes more comple - `woocommerce/` WooCommerce features and extensions - `inc/` contains the PHP files for all plugins - `css/` CSS dynamic generator for Blocks. It's used to generate the CSS for the blocks based on the user settings. - - `integration/` Form Block utilities + - `integrations/` Form Block utilities - `patterns/` contains the patterns for the Pattern Library - `plugins/` plugins functionality: Dynamic Content & Conditions, Stripe, WordPress Options definitions for Rest API, etc. - `render/` render classes for dynamic blocks (e.g.: Form File Field, Google/Leaflet Map, Plugin Card, Stripe Checkout, etc.) diff --git a/docs/uniit-and-e2e-testing.md b/docs/uniit-and-e2e-testing.md index e13de631e..49ca17137 100644 --- a/docs/uniit-and-e2e-testing.md +++ b/docs/uniit-and-e2e-testing.md @@ -6,30 +6,31 @@ Unit testing will be used to test functions (with [Jest](https://jestjs.io/docs/getting-started)) and React components (with [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/)). -The test will pe places in `tests/unit` +Unit tests are located in `src/blocks/test/unit`. They can be run with the command `npm run test:unit`. -For debugging the test, use `npm run test:unit:debug` +For debugging tests, use `npm run test:unit:debug`. ## E2E Testing -E2E are used to test the code on a running WordPress instance. They are very helpful for testing component that depends on the WP ecosystem like block, hooks (e.g.: `blockInit`). +E2E tests are used to test code on a running WordPress instance. They are very helpful for testing components that depend on the WP ecosystem like blocks and hooks (e.g. `blockInit`). [Docs for Gutenberg utils](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-e2e-test-utils/) -:warning: Most the code from Gutenberg is written in Puppeter, but they are migrating to Playwright which make the feature to not a very stable situation like Unit testing. [Source](https://make.wordpress.org/core/2022/03/23/migrating-wordpress-e2e-tests-to-playwright/) +The suite uses Playwright. +- [Playwright Docs](https://playwright.dev/docs/intro) +- [Gutenberg E2E tests](https://github.com/WordPress/gutenberg/tree/trunk/test/e2e) -- [Puppeter Docs](https://pptr.dev) -- [Playwright Docs](https://playwright.dev/docs/intro) and [Gutenberg Repo](https://github.com/WordPress/gutenberg/tree/trunk/test/e2e) +You can start the local WordPress environment with `npm run wp-env start`. -You will need to use Docker for creating the WP Instace via `npm run wp-env start` command. +Run the full E2E suite with `npm run test:e2e:playwright`. -The tests can be run with the command `npm run test:e2e'. +Run serial-only specs with `npm run test:e2e:playwright:serial`. -For interactive mode (a Chromium instance will be created to watch), use `npm run test:e2e:interactive' +Run parallel-safe specs with `npm run test:e2e:playwright:parallel`. -For debugging the test, use `npm run test:e2e:debug` +For interactive mode (Playwright UI), use `npm run test:e2e:playwright-ui`. :warning: The e2e is a very slow test. Expect minutes of running. diff --git a/eslint.config.cjs b/eslint.config.cjs new file mode 100644 index 000000000..71e87167b --- /dev/null +++ b/eslint.config.cjs @@ -0,0 +1,145 @@ +/** + * ESLint flat config for ESLint v10. + * + * Start from the @wordpress/scripts defaults, then restore Otter's previous + * style rules and rule severities from the legacy .eslintrc config. + */ +const wpScriptsConfig = require('@wordpress/scripts/config/eslint.config.cjs'); + +const plugins = wpScriptsConfig.reduce( + (allPlugins, config) => ({ + ...allPlugins, + ...(config.plugins || {}), + }), + {} +); + +module.exports = [ + ...wpScriptsConfig, + { + ignores: ['**/node_modules/**', '**/assets/**'], + }, + { + files: ['**/*.{js,jsx,ts,tsx}'], + plugins, + languageOptions: { + ecmaVersion: 2021, + sourceType: 'module', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + settings: { + 'import/resolver': { + node: { + extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'], + }, + }, + }, + rules: { + indent: ['error', 'tab'], + 'linebreak-style': ['error', 'unix'], + quotes: ['error', 'single'], + semi: ['error', 'always'], + 'prefer-destructuring': [ + 'warn', + { + array: false, + object: true, + }, + { + enforceForRenamedProperties: false, + }, + ], + 'array-bracket-spacing': [ + 'warn', + 'always', + { + arraysInArrays: false, + objectsInArrays: false, + }, + ], + 'key-spacing': [ + 'warn', + { + beforeColon: false, + afterColon: true, + }, + ], + 'object-curly-spacing': [ + 'warn', + 'always', + { + arraysInObjects: true, + objectsInObjects: false, + }, + ], + 'prettier/prettier': 'off', + 'react-hooks/rules-of-hooks': 'warn', + 'dot-notation': 'off', + '@wordpress/no-unsafe-wp-apis': 'warn', + 'no-undef': 'warn', + 'no-shadow': 'warn', + '@typescript-eslint/no-shadow': 'warn', + '@wordpress/no-base-control-with-label-without-id': 'warn', + 'no-unused-vars': 'warn', + '@typescript-eslint/no-unused-vars': 'warn', + 'jsdoc/require-returns-description': 'warn', + 'no-unused-expressions': 'warn', + 'jsdoc/require-returns-type': 'warn', + 'no-nested-ternary': 'warn', + 'no-console': 'warn', + 'jsdoc/require-param-type': 'warn', + 'jsdoc/no-undefined-types': 'warn', + 'jsx-a11y/click-events-have-key-events': 'warn', + 'jsx-a11y/no-static-element-interactions': 'warn', + 'import/no-extraneous-dependencies': 'off', + 'jsx-a11y/label-has-associated-control': 'warn', + 'jsx-a11y/alt-text': 'warn', + 'jsx-a11y/anchor-is-valid': 'warn', + 'jsx-a11y/no-noninteractive-element-interactions': 'warn', + 'jsx-a11y/no-autofocus': 'warn', + '@wordpress/i18n-text-domain': [ + 'error', + { + allowedTextDomain: [ + 'otter-blocks', + 'otter-pro', + 'blocks-export-import', + 'blocks-css', + 'blocks-animation', + ], + }, + ], + + // Keep the v10 migration scoped to the previous lint contract. + 'import/no-unresolved': 'off', + 'import/no-duplicates': 'off', + 'import/named': 'off', + 'import/default': 'off', + '@typescript-eslint/method-signature-style': 'off', + 'jest/valid-title': 'off', + }, + }, + { + files: [ + '**/*.{test,spec}.{js,jsx,ts,tsx}', + '**/test/**/*.{js,jsx,ts,tsx}', + ], + languageOptions: { + globals: { + describe: 'readonly', + it: 'readonly', + test: 'readonly', + expect: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + beforeAll: 'readonly', + afterAll: 'readonly', + jest: 'readonly', + }, + }, + }, +]; diff --git a/inc/class-blocks-animation.php b/inc/class-blocks-animation.php index af750a005..78181b08c 100644 --- a/inc/class-blocks-animation.php +++ b/inc/class-blocks-animation.php @@ -67,13 +67,6 @@ public function enqueue_editor_assets() { $asset_file = include BLOCKS_ANIMATION_PATH . '/build/animation/index.asset.php'; $is_using_otter_files = defined( 'BLOCKS_ANIMATION_OTTER' ); - wp_enqueue_style( - 'otter-animation', - BLOCKS_ANIMATION_URL . 'build/animation/index.css', - array(), - $asset_file['version'] - ); - if ( defined( 'OTTER_BLOCKS_VERSION' ) ) { array_push( $asset_file['dependencies'], 'otter-blocks' ); } @@ -129,6 +122,22 @@ public function enqueue_editor_assets() { * @access public */ public function enqueue_block_frontend_assets() { + if ( is_admin() ) { + // Editor context (including the iframed canvas): enqueue the animation + // styles here so WordPress loads them into the iframe natively. + // Enqueuing on `enqueue_block_editor_assets` would load them only in the + // parent document, and WordPress 6.9+ warns when copying them into the + // iframe. + $asset_file = include BLOCKS_ANIMATION_PATH . '/build/animation/index.asset.php'; + wp_enqueue_style( + 'otter-animation', + BLOCKS_ANIMATION_URL . 'build/animation/index.css', + array(), + $asset_file['version'] + ); + return; + } + if ( did_action( 'parse_request' ) && function_exists( 'amp_is_request' ) && amp_is_request() ) { self::$can_load_frontend = false; } @@ -425,7 +434,7 @@ public function remove_welcome_notice() { if ( ! isset( $_POST['nonce'] ) ) { return; } - if ( ! wp_verify_nonce( sanitize_text_field( $_POST['nonce'] ), 'otter_animation_dismiss_welcome_notice' ) ) { + if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'otter_animation_dismiss_welcome_notice' ) ) { return; } update_option( 'otter_animation_dismiss_welcome_notice', true ); diff --git a/inc/class-blocks-css.php b/inc/class-blocks-css.php index dd123e80a..8681c153e 100644 --- a/inc/class-blocks-css.php +++ b/inc/class-blocks-css.php @@ -131,7 +131,7 @@ function_exists( 'wp_is_block_theme' ) && } } - $templates_parts = get_block_templates( array( 'slugs__in' => $slugs ), 'wp_template_part' ); + $templates_parts = get_block_templates( array( 'slug__in' => $slugs ), 'wp_template_part' ); foreach ( $templates_parts as $templates_part ) { if ( ! empty( $templates_part->content ) && ! empty( $templates_part->slug ) && in_array( $templates_part->slug, $slugs ) ) { diff --git a/inc/class-main.php b/inc/class-main.php index c7a855b77..f4288c356 100644 --- a/inc/class-main.php +++ b/inc/class-main.php @@ -74,6 +74,7 @@ public function autoload_classes() { '\ThemeIsle\GutenbergBlocks\Plugins\Dashboard', '\ThemeIsle\GutenbergBlocks\Plugins\Dynamic_Content', '\ThemeIsle\GutenbergBlocks\Plugins\FSE_Onboarding', + '\ThemeIsle\GutenbergBlocks\Plugins\Form_Submissions', '\ThemeIsle\GutenbergBlocks\Plugins\Options_Settings', '\ThemeIsle\GutenbergBlocks\Plugins\Stripe_API', '\ThemeIsle\GutenbergBlocks\Render\Masonry_Variant', diff --git a/inc/class-patterns.php b/inc/class-patterns.php index b9abd34ce..8b4e86bf0 100644 --- a/inc/class-patterns.php +++ b/inc/class-patterns.php @@ -18,6 +18,20 @@ class Patterns { */ protected static $instance = null; + /** + * Endpoint serving the Pro pattern previews. + * + * @var string + */ + const UPSELLS_ENDPOINT = 'https://api.themeisle.com/templates-cloud/otter-patterns-preview'; + + /** + * Transient holding the fetched upsell preview manifest. + * + * @var string + */ + const UPSELLS_CACHE_KEY = 'otter_blocks_pro_patterns_preview_v1'; + /** * Method to define hooks needed. * @@ -26,6 +40,10 @@ class Patterns { */ public function init() { add_action( 'init', array( $this, 'register_patterns' ) ); + + if ( is_admin() ) { + add_action( 'init', array( $this, 'maybe_sync_upsell_patterns' ) ); + } } /** @@ -35,267 +53,315 @@ public function init() { */ public function register_patterns() { $block_pattern_categories = array( - 'otter-blocks' => array( 'label' => __( 'Otter Blocks', 'otter-blocks' ) ), - 'business-pack' => array( 'label' => __( 'Business Pack (Pages)', 'otter-blocks' ) ), - 'call-to-action' => array( 'label' => __( 'Call to Action', 'otter-blocks' ) ), - 'creator-pack' => array( 'label' => __( 'Creator Pack (Pages)', 'otter-blocks' ) ), - 'fitness-pack' => array( 'label' => __( 'Fitness Pack (Pages)', 'otter-blocks' ) ), - 'features' => array( 'label' => __( 'Features', 'otter-blocks' ) ), - 'forms' => array( 'label' => __( 'Forms', 'otter-blocks' ) ), - 'team' => array( 'label' => __( 'Team', 'otter-blocks' ) ), - 'pages' => array( 'label' => __( 'Pages', 'otter-blocks' ) ), - 'pricing' => array( 'label' => __( 'Pricing', 'otter-blocks' ) ), - 'recipes-pack' => array( 'label' => __( 'Recipes Pack (Pages)', 'otter-blocks' ) ), - 'testimonials' => array( 'label' => __( 'Testimonials', 'otter-blocks' ) ), + 'otter-blocks' => array( 'label' => __( 'Otter Blocks', 'otter-blocks' ) ), + + // Layout & Structure. + 'header' => array( 'label' => __( 'Hero', 'otter-blocks' ) ), + + // Content. + 'features' => array( 'label' => __( 'Features', 'otter-blocks' ) ), + 'gallery' => array( 'label' => __( 'Gallery', 'otter-blocks' ) ), + 'team' => array( 'label' => __( 'Team', 'otter-blocks' ) ), + 'text' => array( 'label' => __( 'Text', 'otter-blocks' ) ), + 'blog' => array( 'label' => __( 'Blog / News', 'otter-blocks' ) ), + 'faq' => array( 'label' => __( 'FAQ', 'otter-blocks' ) ), + 'timeline' => array( 'label' => __( 'Timeline', 'otter-blocks' ) ), + 'portfolio' => array( 'label' => __( 'Portfolio & Case Studies', 'otter-blocks' ) ), + + // Conversion. + 'call-to-action' => array( 'label' => __( 'Call to Action', 'otter-blocks' ) ), + 'forms' => array( 'label' => __( 'Forms', 'otter-blocks' ) ), + 'pricing' => array( 'label' => __( 'Pricing', 'otter-blocks' ) ), + 'contact' => array( 'label' => __( 'Contact', 'otter-blocks' ) ), + 'newsletter' => array( 'label' => __( 'Newsletter', 'otter-blocks' ) ), + 'waitlist' => array( 'label' => __( 'Waitlist / Coming Soon', 'otter-blocks' ) ), + + // Social Proof. + 'testimonials' => array( 'label' => __( 'Testimonials', 'otter-blocks' ) ), + 'stats' => array( 'label' => __( 'Stats', 'otter-blocks' ) ), + + // Media. + 'video' => array( 'label' => __( 'Video', 'otter-blocks' ) ), + 'icons' => array( 'label' => __( 'Icons & Illustrations', 'otter-blocks' ) ), + + // Page collections. + 'pages' => array( 'label' => __( 'Pages', 'otter-blocks' ) ), + 'architecture-pack' => array( 'label' => __( 'Architecture Pack (Pages)', 'otter-blocks' ) ), + 'brewery-pack' => array( 'label' => __( 'Brewery Pack (Pages)', 'otter-blocks' ) ), + 'business-pack' => array( 'label' => __( 'Business Pack (Pages)', 'otter-blocks' ) ), + 'cafe-pack' => array( 'label' => __( 'Cafe Pack (Pages)', 'otter-blocks' ) ), + 'climbing-pack' => array( 'label' => __( 'Climbing Gym Pack (Pages)', 'otter-blocks' ) ), + 'dining-pack' => array( 'label' => __( 'Fine Dining Pack (Pages)', 'otter-blocks' ) ), + 'fashion-pack' => array( 'label' => __( 'Fashion Pack (Pages)', 'otter-blocks' ) ), + 'festival-pack' => array( 'label' => __( 'Festival Pack (Pages)', 'otter-blocks' ) ), + 'fitness-pack' => array( 'label' => __( 'Fitness Pack (Pages)', 'otter-blocks' ) ), + 'outdoor-pack' => array( 'label' => __( 'Outdoor Pack (Pages)', 'otter-blocks' ) ), + 'photography-pack' => array( 'label' => __( 'Photography Pack (Pages)', 'otter-blocks' ) ), + 'tattoo-pack' => array( 'label' => __( 'Tattoo Studio Pack (Pages)', 'otter-blocks' ) ), + 'travel-pack' => array( 'label' => __( 'Travel Pack (Pages)', 'otter-blocks' ) ), + 'vinyl-pack' => array( 'label' => __( 'Record Shop Pack (Pages)', 'otter-blocks' ) ), + 'wellness-pack' => array( 'label' => __( 'Wellness Pack (Pages)', 'otter-blocks' ) ), ); $block_pattern_categories = apply_filters( 'otter_blocks_block_pattern_categories', $block_pattern_categories ); - + foreach ( $block_pattern_categories as $name => $properties ) { if ( ! \WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( $name ) ) { register_block_pattern_category( $name, $properties ); } } + // Patterns are ordered so that visually-similar styles sit next to each other. + // When the library lists everything under "All", these clumps keep matching + // looks together: the Atomic Wind set, the general sections, then each + // cross-cutting style family (Gradient Bold / Neon Spotlight / Mesh / + // Poster / Glass / Editorial), and finally the page packs. $block_patterns = array( + // Atomic Wind (aw-*) blocks. + array( + 'slug' => 'aw-cta-banner', + 'minimum' => 5.8, + ), array( - 'slug' => 'hero-area-with-button', + 'slug' => 'aw-testimonial-cards', 'minimum' => 5.8, ), array( - 'slug' => 'content-with-progress-bars', + 'slug' => 'aw-pricing-tiers', 'minimum' => 5.8, ), array( - 'slug' => 'text-with-image-columns', + 'slug' => 'aw-hero-split', 'minimum' => 5.8, ), array( - 'slug' => 'large-quote', + 'slug' => 'aw-stats-banner', 'minimum' => 5.8, ), array( - 'slug' => 'image-and-text-over-dark-background', + 'slug' => 'aw-bento-features', 'minimum' => 5.8, ), array( - 'slug' => 'contact-details-and-form', + 'slug' => 'aw-feature-split', 'minimum' => 5.8, ), array( - 'slug' => 'call-to-action-1', + 'slug' => 'aw-feature-cards', 'minimum' => 5.8, ), array( - 'slug' => 'testimonial-columns', + 'slug' => 'aw-team-grid', 'minimum' => 5.8, ), array( - 'slug' => 'testimonial-with-inline-image', + 'slug' => 'aw-faq-cards', 'minimum' => 5.8, ), array( - 'slug' => 'centered-testimonial-with-star-icons', - 'minimum' => 5.9, + 'slug' => 'aw-glow-cta', + 'minimum' => 5.8, ), array( - 'slug' => 'cover-boxes-with-title-and-button', - 'minimum' => 5.9, + 'slug' => 'aw-hero-centered', + 'minimum' => 5.8, ), array( - 'slug' => 'columns-with-image-features', + 'slug' => 'aw-quote-spotlight', 'minimum' => 5.8, ), array( - 'slug' => 'team-members', + 'slug' => 'aw-gallery-grid', 'minimum' => 5.8, ), array( - 'slug' => 'columns-with-icon-features', + 'slug' => 'aw-blog-cards', 'minimum' => 5.8, ), array( - 'slug' => 'columns-with-flip-boxes', + 'slug' => 'aw-timeline-steps', 'minimum' => 5.8, ), array( - 'slug' => 'icons-and-text', + 'slug' => 'aw-portfolio-grid', 'minimum' => 5.8, ), array( - 'slug' => 'gallery', - 'minimum' => 5.9, + 'slug' => 'aw-case-study-metrics', + 'minimum' => 5.8, ), array( - 'slug' => 'border-icon-features', + 'slug' => 'aw-review-stars', 'minimum' => 5.8, ), array( - 'slug' => 'border-pricing-table', + 'slug' => 'aw-icon-grid', 'minimum' => 5.8, ), array( - 'slug' => 'countdown', + 'slug' => 'aw-video-feature', 'minimum' => 5.8, ), + // General sections (default styling). array( - 'slug' => 'service-boxes-on-dark-background', + 'slug' => 'bold-image-hero', 'minimum' => 5.8, ), array( - 'slug' => 'content-with-features', + 'slug' => 'countdown-launch', 'minimum' => 5.8, ), array( - 'slug' => 'author-box', + 'slug' => 'contact-form-split', 'minimum' => 5.8, ), array( - 'slug' => 'call-to-action-2', + 'slug' => 'form-gradient-split', 'minimum' => 5.8, ), array( - 'slug' => 'call-to-action-3', + 'slug' => 'form-glow-card', 'minimum' => 5.8, ), array( - 'slug' => 'call-to-action-4', + 'slug' => 'pricing-columns', 'minimum' => 5.8, ), array( - 'slug' => 'call-to-action-5', + 'slug' => 'pricing-compare-simple', 'minimum' => 5.8, ), array( - 'slug' => 'call-to-action-6', + 'slug' => 'dark-cta-band', 'minimum' => 5.8, ), array( - 'slug' => 'call-to-action-7', + 'slug' => 'stats-row', 'minimum' => 5.8, ), array( - 'slug' => 'hero-1', + 'slug' => 'newsletter-signup', 'minimum' => 5.8, ), array( - 'slug' => 'hero-2', + 'slug' => 'newsletter-inline-bar', 'minimum' => 5.8, ), array( - 'slug' => 'form-1', + 'slug' => 'team-cards', 'minimum' => 5.8, ), array( - 'slug' => 'form-2', + 'slug' => 'content-checklist', 'minimum' => 5.8, ), array( - 'slug' => 'form-3', + 'slug' => 'maps-location', 'minimum' => 5.8, ), array( - 'slug' => 'form-4', + 'slug' => 'gallery-overlay-grid', 'minimum' => 5.8, ), array( - 'slug' => 'pricing', + 'slug' => 'gallery-feature-split', 'minimum' => 5.8, ), array( - 'slug' => 'team-1', + 'slug' => 'blog-featured-list', 'minimum' => 5.8, ), array( - 'slug' => 'team-2', + 'slug' => 'faq-two-column', 'minimum' => 5.8, ), array( - 'slug' => 'testimonials-1', + 'slug' => 'faq-boxed-list', 'minimum' => 5.8, ), array( - 'slug' => 'testimonials-2', + 'slug' => 'timeline-horizontal', 'minimum' => 5.8, ), array( - 'slug' => 'testimonials-3', + 'slug' => 'portfolio-overlay-grid', 'minimum' => 5.8, ), array( - 'slug' => 'features-1', + 'slug' => 'video-hero-play', 'minimum' => 5.8, ), array( - 'slug' => 'features-2', + 'slug' => 'icon-feature-trio', 'minimum' => 5.8, ), array( - 'slug' => 'features-3', + 'slug' => 'icon-grid-bordered', 'minimum' => 5.8, ), array( - 'slug' => 'features-4', + 'slug' => 'waitlist-coming-soon', 'minimum' => 5.8, ), array( - 'slug' => 'features-5', + 'slug' => 'contact-info-cards', 'minimum' => 5.8, ), array( - 'slug' => 'industrial-homepage', + 'slug' => 'team-spotlight-lead', 'minimum' => 5.8, ), array( - 'slug' => 'industrial-about', + 'slug' => 'text-two-column-prose', 'minimum' => 5.8, ), array( - 'slug' => 'industrial-services', + 'slug' => 'text-lead-statement', 'minimum' => 5.8, ), + // Page collections (full-page packs). array( - 'slug' => 'industrial-service', + 'slug' => 'cafe-homepage', 'minimum' => 5.8, ), array( - 'slug' => 'business-homepage', + 'slug' => 'cafe-menu', 'minimum' => 5.8, ), array( - 'slug' => 'business-about', + 'slug' => 'cafe-about', 'minimum' => 5.8, ), array( - 'slug' => 'business-services', + 'slug' => 'cafe-contact', 'minimum' => 5.8, ), array( - 'slug' => 'business-service', + 'slug' => 'travel-homepage', 'minimum' => 5.8, ), array( - 'slug' => 'business-contact', + 'slug' => 'travel-tours', 'minimum' => 5.8, ), array( - 'slug' => 'fitness-homepage', + 'slug' => 'travel-about', 'minimum' => 5.8, ), array( - 'slug' => 'fitness-about', + 'slug' => 'travel-contact', 'minimum' => 5.8, ), array( - 'slug' => 'fitness-groups', + 'slug' => 'wellness-homepage', 'minimum' => 5.8, ), array( - 'slug' => 'fitness-group', + 'slug' => 'wellness-classes', 'minimum' => 5.8, ), array( - 'slug' => 'fitness-contact', + 'slug' => 'wellness-contact', 'minimum' => 5.8, ), ); @@ -306,7 +372,7 @@ public function register_patterns() { } $pattern_file = OTTER_BLOCKS_PATH . '/inc/patterns/' . $block_pattern['slug'] . '.php'; - + register_block_pattern( 'otter-blocks/' . $block_pattern['slug'], require $pattern_file @@ -314,6 +380,97 @@ public function register_patterns() { } } + /** + * Pro pattern upsell entries for the Design Library. + * + * @return array> List of pattern entries. + */ + public static function get_upsell_patterns() { + if ( Pro::is_pro_active() ) { + return array(); + } + + $manifest = get_transient( self::UPSELLS_CACHE_KEY ); + + if ( ! is_array( $manifest ) ) { + return array(); + } + + return array_values( + array_filter( + array_map( + function ( $pattern ) { + if ( empty( $pattern['slug'] ) ) { + return null; + } + + $pattern['name'] = 'otter-blocks/' . $pattern['slug']; + $pattern['isPro'] = true; + + return $pattern; + }, + array_filter( $manifest, 'is_array' ) + ) + ) + ); + } + + /** + * Fetch the upsell preview manifest from the store if it isn't cached yet. + * + * Only reaches out when the cache is cold, so the editor doesn't make a + * blocking request on every load. + * + * @access public + * @return void + */ + public function maybe_sync_upsell_patterns() { + if ( ! boolval( get_option( 'themeisle_blocks_settings_patterns_library', true ) ) ) { + return; + } + + if ( Pro::is_pro_active() ) { + return; + } + + if ( false !== get_transient( self::UPSELLS_CACHE_KEY ) ) { + return; + } + + $this->sync_upsell_patterns(); + } + + /** + * Fetch the upsell preview manifest from the store and cache it. + * + * @access public + * @return void + */ + public function sync_upsell_patterns() { + $url = add_query_arg( + array( + 'site_url' => get_site_url(), + 'license_id' => apply_filters( 'product_otter_license_key', 'free' ), + 'cache' => gmdate( 'u' ), + ), + defined( 'OTTER_BLOCK_PATTERN_PREVIEWS_URL' ) ? OTTER_BLOCK_PATTERN_PREVIEWS_URL : self::UPSELLS_ENDPOINT + ); + + if ( function_exists( 'vip_safe_wp_remote_get' ) ) { + $response = vip_safe_wp_remote_get( esc_url_raw( $url ) ); + } else { + $response = wp_remote_get( esc_url_raw( $url ) ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get + } + + $response = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( ! is_array( $response ) || isset( $response['message'] ) || 0 === count( $response ) ) { + return; + } + + set_transient( self::UPSELLS_CACHE_KEY, $response, WEEK_IN_SECONDS ); + } + /** * Singleton method. * diff --git a/inc/class-pro.php b/inc/class-pro.php index a8c87baf4..bbfa4427e 100644 --- a/inc/class-pro.php +++ b/inc/class-pro.php @@ -241,17 +241,17 @@ public function render_metabox_upsell( $post_type ) { }
-

+

    -
  • -
  • -
  • -
  • +
  • +
  • +
  • +
- +
-

+

- +
false, + 'content-slider' => false, 'countdown' => false, 'form' => false, 'google-map' => false, @@ -80,6 +82,21 @@ class Registration { */ public static $is_fa_loaded = false; + /** + * Get the decoded global defaults for the editor, always as an object. + * + * The option can exist as an empty string or corrupt JSON, which json_decode + * turns into null — and a null localized value crashes every block's Edit + * component when the editor reads per-block defaults from it. + * + * @return object + */ + public static function get_editor_global_defaults() { + $defaults = json_decode( get_option( 'themeisle_blocks_settings_global_defaults', '{}' ) ); + + return is_object( $defaults ) ? $defaults : new \stdClass(); + } + /** * Initialize the class */ @@ -239,6 +256,30 @@ public function enqueue_block_editor_assets() { wp_set_script_translations( 'otter-blocks', 'otter-blocks' ); + // Separate bundle: loads only when the Patterns Library module is on. + if ( boolval( get_option( 'themeisle_blocks_settings_patterns_library', true ) ) ) { + $patterns_asset = include OTTER_BLOCKS_PATH . '/build/patterns-library/index.asset.php'; + + wp_enqueue_script( + 'otter-patterns-library', + OTTER_BLOCKS_URL . 'build/patterns-library/index.js', + array_merge( $patterns_asset['dependencies'], array( 'otter-blocks' ) ), + $patterns_asset['version'], + true + ); + + wp_set_script_translations( 'otter-patterns-library', 'otter-blocks' ); + + if ( file_exists( OTTER_BLOCKS_PATH . '/build/patterns-library/index.css' ) ) { + wp_enqueue_style( + 'otter-patterns-library', + OTTER_BLOCKS_URL . 'build/patterns-library/index.css', + array(), + $patterns_asset['version'] + ); + } + } + if ( defined( 'THEMEISLE_GUTENBERG_GOOGLE_MAPS_API' ) ) { $api = THEMEISLE_GUTENBERG_GOOGLE_MAPS_API; } else { @@ -247,26 +288,27 @@ public function enqueue_block_editor_assets() { global $wp_roles; - wp_localize_script( - 'otter-blocks', - 'themeisleGutenberg', - array( - 'hasNeve' => defined( 'NEVE_VERSION' ), - 'hasPro' => Pro::is_pro_installed(), - 'isProActive' => Pro::is_pro_active(), - 'upgradeLink' => tsdk_translate_link( tsdk_utmify( Pro::get_url(), 'editor', Pro::get_reference() ) ), - 'patternsLink' => tsdk_translate_link( tsdk_utmify( Pro::get_patterns_url(), 'editor', Pro::get_reference() ) ), - 'should_show_upsell' => Pro::should_show_upsell(), - 'assetsPath' => OTTER_BLOCKS_URL . 'assets', - 'updatePath' => admin_url( 'update-core.php' ), - 'optionsPath' => admin_url( 'admin.php?page=otter' ), - 'mapsAPI' => $api, - 'hasStripeAPI' => Stripe_API::has_keys(), - 'globalDefaults' => json_decode( get_option( 'themeisle_blocks_settings_global_defaults', '{}' ) ), - 'themeDefaults' => Main::get_global_defaults(), - 'imageSizes' => function_exists( 'is_wpcom_vip' ) ? array( 'thumbnail', 'medium', 'medium_large', 'large' ) : get_intermediate_image_sizes(), // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.get_intermediate_image_sizes_get_intermediate_image_sizes + $is_wp_ai_backend = AI_Client_Adaptor::BACKEND_WP === AI_Client_Adaptor::resolve_backend(); + + $can_track = 'yes' === get_option( 'otter_blocks_logger_flag', false ); + + $themeisle_gutenberg = array( + 'hasNeve' => defined( 'NEVE_VERSION' ), + 'hasPro' => Pro::is_pro_installed(), + 'isProActive' => Pro::is_pro_active(), + 'upgradeLink' => tsdk_translate_link( tsdk_utmify( Pro::get_url(), 'editor', Pro::get_reference() ) ), + 'patternsLink' => tsdk_translate_link( tsdk_utmify( Pro::get_patterns_url(), 'editor', Pro::get_reference() ) ), + 'should_show_upsell' => Pro::should_show_upsell(), + 'assetsPath' => OTTER_BLOCKS_URL . 'assets', + 'updatePath' => admin_url( 'update-core.php' ), + 'optionsPath' => admin_url( 'admin.php?page=otter' ), + 'mapsAPI' => $api, + 'hasStripeAPI' => Stripe_API::has_keys(), + 'globalDefaults' => self::get_editor_global_defaults(), + 'themeDefaults' => Main::get_global_defaults(), + 'imageSizes' => function_exists( 'is_wpcom_vip' ) ? array( 'thumbnail', 'medium', 'medium_large', 'large' ) : get_intermediate_image_sizes(), // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.get_intermediate_image_sizes_get_intermediate_image_sizes 'isWPVIP' => function_exists( 'is_wpcom_vip' ), - 'canTrack' => 'yes' === get_option( 'otter_blocks_logger_flag', false ) ? true : false, + 'canTrack' => $can_track ? true : false, 'userRoles' => $wp_roles->roles, 'isBlockEditor' => 'post' === $current_screen->base, 'postTypes' => get_post_types( @@ -293,12 +335,33 @@ public function enqueue_block_editor_assets() { 'version' => OTTER_BLOCKS_VERSION, 'isRTL' => is_rtl(), 'highlightDynamicText' => get_option( 'themeisle_blocks_settings_highlight_dynamic', true ), - 'hasOpenAiKey' => ! empty( get_option( 'themeisle_open_ai_api_key' ) ), + 'hasOpenAiKey' => $is_wp_ai_backend || ! empty( get_option( 'themeisle_open_ai_api_key' ) ), + 'aiClientActive' => $is_wp_ai_backend, + 'aiClientSupported' => function_exists( 'wp_ai_client_prompt' ), + 'hasAIProvider' => AI_Client_Adaptor::is_available(), + 'connectorsUrl' => esc_url( admin_url( 'options-connectors.php' ) ), 'hasPatternSources' => Template_Cloud::has_used_pattern_sources(), - ) + 'proPatterns' => boolval( get_option( 'themeisle_blocks_settings_patterns_library', true ) ) ? Patterns::get_upsell_patterns() : array(), ); - wp_enqueue_style( 'otter-editor', OTTER_BLOCKS_URL . 'build/blocks/editor.css', array( 'wp-edit-blocks', 'font-awesome-5', 'font-awesome-4-shims' ), $asset_file['version'] ); + if ( $can_track ) { + $themeisle_gutenberg['telemetry'] = array( + 'loggerData' => get_option( + 'otter_blocks_logger_data', + array( + 'blocks' => array(), + 'templates' => array(), + ) + ), + 'firstSaveDone' => (bool) get_option( 'otter_activation_first_save', false ), + ); + } + + wp_localize_script( + 'otter-blocks', + 'themeisleGutenberg', + $themeisle_gutenberg + ); add_filter( 'themeisle-sdk/survey/' . OTTER_PRODUCT_SLUG, array( Dashboard::class, 'get_survey_metadata' ), 10, 2 ); do_action( 'themeisle_internal_page', OTTER_PRODUCT_SLUG, 'editor' ); @@ -340,6 +403,23 @@ public function enqueue_block_assets() { global $wp_query, $wp_registered_sidebars; if ( is_admin() ) { + // In the editor (including the iframed canvas) enqueue the editor + // styles on `enqueue_block_assets` so WordPress loads them into the + // iframe natively. Enqueuing on `enqueue_block_editor_assets` would + // load them only in the parent document, and WordPress 6.9+ warns + // when it copies such styles into the iframe. + // + // Scripts enqueued here would also be injected into the iframe + // natively (via `_wp_get_iframed_editor_assets()`), but the iframe + // assets are resolved once on editor load, with no way to add them + // later. Heavy per-block scripts (Leaflet, Lottie, Glide) are + // therefore NOT enqueued here — they would load in every editor + // session regardless of the blocks used. Instead they are copied + // into the iframe on demand by `copyScriptAssetToIframe()` in + // `src/blocks/helpers/block-utility.js`, which keeps them lazy at + // the cost of client-side readiness tracking. + $asset_file = include OTTER_BLOCKS_PATH . '/build/blocks/blocks.asset.php'; + wp_enqueue_style( 'otter-editor', OTTER_BLOCKS_URL . 'build/blocks/editor.css', array( 'wp-edit-blocks', 'font-awesome-5', 'font-awesome-4-shims' ), $asset_file['version'] ); return; } @@ -547,11 +627,13 @@ function () { array( 'reRecaptchaSitekey' => get_option( 'themeisle_google_captcha_api_site_key' ), 'reRecaptchaAPIURL' => apply_filters( 'otter_blocks_recaptcha_api_url', 'https://www.google.com/recaptcha/api.js' ), + 'turnstileSitekey' => get_option( 'themeisle_cloudflare_turnstile_site_key' ), + 'turnstileAPIURL' => apply_filters( 'otter_blocks_turnstile_api_url', 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit' ), 'root' => esc_url_raw( rest_url() ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'messages' => array( 'submission' => __( 'Form submission from', 'otter-blocks' ), - 'captcha-not-loaded' => __( 'Captcha is not loaded. Please check your browser plugins to allow the Google reCaptcha.', 'otter-blocks' ), + 'captcha-not-loaded' => __( 'Captcha is not loaded. Please check your browser plugins to allow it.', 'otter-blocks' ), 'check-captcha' => __( 'Please check the captcha.', 'otter-blocks' ), 'invalid-email' => __( 'The email address is invalid!', 'otter-blocks' ), 'already-registered' => __( 'The email was already registered!', 'otter-blocks' ), @@ -642,6 +724,29 @@ function () { ); } + if ( ! self::$scripts_loaded['content-slider'] && has_block( 'themeisle-blocks/content-slider', $post ) ) { + $asset_file = include OTTER_BLOCKS_PATH . '/build/blocks/content-slider.asset.php'; + + wp_register_script( + 'otter-content-slider', + OTTER_BLOCKS_URL . 'build/blocks/content-slider.js', + $asset_file['dependencies'], + $asset_file['version'], + true + ); + + wp_script_add_data( 'otter-content-slider', 'defer', true ); + + wp_localize_script( + 'otter-content-slider', + 'themeisleGutenbergContentSlider', + array( + /* translators: %d: slide number. */ + 'goToSlide' => __( 'Go to slide %d', 'otter-blocks' ), + ) + ); + } + if ( ! self::$scripts_loaded['tabs'] && has_block( 'themeisle-blocks/tabs', $post ) ) { $asset_file = include OTTER_BLOCKS_PATH . '/build/blocks/tabs.asset.php'; wp_register_script( 'otter-tabs', OTTER_BLOCKS_URL . 'build/blocks/tabs.js', $asset_file['dependencies'], $asset_file['version'], true ); @@ -686,6 +791,8 @@ function () { * @access public */ public function enqueue_block_styles( $post ) { + $asset_file = null; + foreach ( self::$blocks as $block ) { if ( in_array( $block, self::$styles_loaded ) || ! has_block( 'themeisle-blocks/' . $block, $post ) ) { continue; @@ -713,7 +820,10 @@ public function enqueue_block_styles( $post ) { continue; } - $asset_file = include OTTER_BLOCKS_PATH . '/build/blocks/blocks.asset.php'; + // Read the shared asset file once, only when a matching block exists. + if ( null === $asset_file ) { + $asset_file = include OTTER_BLOCKS_PATH . '/build/blocks/blocks.asset.php'; + } $deps = array(); @@ -745,6 +855,7 @@ public function enqueue_block_styles( $post ) { public function register_blocks() { $dynamic_blocks = array( 'about-author' => '\ThemeIsle\GutenbergBlocks\Render\About_Author_Block', + 'form-captcha' => '\ThemeIsle\GutenbergBlocks\Render\Form_Captcha_Block', 'form-nonce' => '\ThemeIsle\GutenbergBlocks\Render\Form_Nonce_Block', 'google-map' => '\ThemeIsle\GutenbergBlocks\Render\Google_Map_Block', 'leaflet-map' => '\ThemeIsle\GutenbergBlocks\Render\Leaflet_Map_Block', @@ -768,10 +879,12 @@ public function register_blocks() { 'button', 'button-group', 'circle-counter', + 'content-slider', 'countdown', 'flip', 'font-awesome-icons', 'form', + 'form-captcha', 'form-input', 'form-nonce', 'form-textarea', @@ -819,6 +932,9 @@ public function register_blocks() { ) ); + // Shared asset file (version/deps) for all blocks — read once, not per block. + $asset_file = include OTTER_BLOCKS_PATH . '/build/blocks/blocks.asset.php'; + foreach ( self::$blocks as $block ) { $block_path = OTTER_BLOCKS_PATH . '/build/blocks/' . $block; $editor_style = OTTER_BLOCKS_URL . 'build/blocks/' . $block . '/editor.css'; @@ -837,8 +953,6 @@ public function register_blocks() { continue; } - $asset_file = include OTTER_BLOCKS_PATH . '/build/blocks/blocks.asset.php'; - $deps = array(); if ( isset( $local_dependencies[ $block ] ) ) { diff --git a/inc/integrations/api/form-request-data.php b/inc/integrations/api/form-request-data.php index b9658da1e..c4f425627 100644 --- a/inc/integrations/api/form-request-data.php +++ b/inc/integrations/api/form-request-data.php @@ -104,6 +104,19 @@ class Form_Data_Request { */ protected $warning_codes = array(); + /** + * Infrastructure failure code. + * + * A third pipeline outcome alongside success and rejection: the submission itself is valid, + * but a service it depends on is down (e.g. captcha provider unreachable). It is not an + * error at record-save time — the Submission Record is saved and marked failed — but it + * suppresses primary delivery and produces an error response for the visitor. + * + * @var string|null $infrastructure_failure_code Infrastructure failure code. + * @since 3.1 + */ + protected $infrastructure_failure_code = null; + /** * Saving mode of the form data. * @@ -230,6 +243,16 @@ public function change_provider( $provider ) { $this->changed_provider = $provider; } + /** + * Get the provider override. + * + * @return bool|string + * @since 3.1.10 + */ + public function get_changed_provider() { + return $this->changed_provider; + } + /** * Check if the root data of the request is set. * The root data is the top level structure of the request. @@ -449,6 +472,74 @@ public function set_error( $error_code, $error_details = null ) { $this->error_details = $error_details; } + /** + * Check if an infrastructure failure occurred (e.g. captcha provider unreachable). + * + * @return bool + * @since 3.1 + */ + public function has_infrastructure_failure() { + return ! empty( $this->infrastructure_failure_code ); + } + + /** + * Get the infrastructure failure code. + * + * @return string|null + * @since 3.1 + */ + public function get_infrastructure_failure_code() { + return $this->infrastructure_failure_code; + } + + /** + * Mark the submission as hit by an infrastructure failure. + * + * Unlike set_error(), this does not short-circuit the pipeline before record save: + * the Submission Record is still saved (and marked failed), primary delivery is + * skipped and the visitor sees the configured error message. + * + * @param string $code The failure code. + * @param string $details The failure details. + * @return void + * @since 3.1 + */ + public function mark_infrastructure_failure( $code, $details = null ) { + $this->infrastructure_failure_code = $code; + $this->add_warning( $code, $details ); + } + + /** + * Check if a Submission Record was saved for this request. + * + * @return bool + * @since 3.1 + */ + public function has_record_id() { + return ! empty( $this->metadata['otter_form_record_id'] ); + } + + /** + * Get the Submission Record post ID. + * + * @return int|null + * @since 3.1 + */ + public function get_record_id() { + return $this->has_record_id() ? (int) $this->metadata['otter_form_record_id'] : null; + } + + /** + * Set the Submission Record post ID. + * + * @param int $record_id The record post ID. + * @return void + * @since 3.1 + */ + public function set_record_id( $record_id ) { + $this->metadata['otter_form_record_id'] = $record_id; + } + /** * Get the first email from the input's form. * diff --git a/inc/integrations/api/form-response-data.php b/inc/integrations/api/form-response-data.php index 7d79d2d53..e696dc223 100644 --- a/inc/integrations/api/form-response-data.php +++ b/inc/integrations/api/form-response-data.php @@ -21,31 +21,34 @@ class Form_Data_Response { const SUCCESS_EMAIL_SEND = '0'; const SUCCESS_USER_SUBSCRIBED = '1'; - const ERROR_RUNTIME_ERROR = '10'; - const ERROR_FILE_UPLOAD = '11'; - const ERROR_FILE_UPLOAD_TYPE = '12'; - const ERROR_FILE_UPLOAD_TYPE_WP = '13'; - const ERROR_FILE_UPLOAD_MAX_FILES_NUMBER = '14'; - const ERROR_FILE_UPLOAD_MAX_SIZE = '15'; - const ERROR_MISSING_FILE_FIELD_OPTION = '16'; - const ERROR_AUTORESPONDER_MISSING_EMAIL_FIELD = '17'; - const ERROR_AUTORESPONDER_COULD_NOT_SEND = '18'; - const ERROR_MALFORMED_REQUEST = '19'; + const ERROR_RUNTIME_ERROR = '10'; + const ERROR_FILE_UPLOAD = '11'; + const ERROR_FILE_UPLOAD_TYPE = '12'; + const ERROR_FILE_UPLOAD_TYPE_WP = '13'; + const ERROR_FILE_UPLOAD_MAX_FILES_NUMBER = '14'; + const ERROR_FILE_UPLOAD_MAX_SIZE = '15'; + const ERROR_MISSING_FILE_FIELD_OPTION = '16'; + const ERROR_AUTORESPONDER_MISSING_EMAIL_FIELD = '17'; + const ERROR_AUTORESPONDER_COULD_NOT_SEND = '18'; + const ERROR_MALFORMED_REQUEST = '19'; + const ERROR_AUTORESPONDER_AI_GENERATION_FAILED = '20'; + const ERROR_AUTORESPONDER_AI_VALIDATION_FAILED = '21'; // Request validation errors. - const ERROR_MISSING_DATA = '101'; - const ERROR_MISSING_CAPTCHA = '102'; - const ERROR_MISSING_NONCE = '103'; - const ERROR_MISSING_EMAIL = '104'; - const ERROR_FORM_ID_INVALID = '105'; - const ERROR_EMAIL_NOT_SEND = '106'; - const ERROR_MISSING_PROVIDER = '107'; - const ERROR_MISSING_API_KEY = '108'; - const ERROR_MISSING_MAIL_LIST_ID = '109'; - const ERROR_BOT_DETECTED = '110'; - const ERROR_FILES_METADATA_FORMAT = '111'; - const ERROR_FILE_MISSING_BINARY = '112'; - const ERROR_MISSING_DUMP_DATA = '113'; + const ERROR_MISSING_DATA = '101'; + const ERROR_MISSING_CAPTCHA = '102'; + const ERROR_MISSING_NONCE = '103'; + const ERROR_MISSING_EMAIL = '104'; + const ERROR_FORM_ID_INVALID = '105'; + const ERROR_EMAIL_NOT_SEND = '106'; + const ERROR_MISSING_PROVIDER = '107'; + const ERROR_MISSING_API_KEY = '108'; + const ERROR_MISSING_MAIL_LIST_ID = '109'; + const ERROR_BOT_DETECTED = '110'; + const ERROR_FILES_METADATA_FORMAT = '111'; + const ERROR_FILE_MISSING_BINARY = '112'; + const ERROR_MISSING_DUMP_DATA = '113'; + const ERROR_CAPTCHA_PROVIDER_UNREACHABLE = '114'; @@ -60,6 +63,7 @@ class Form_Data_Response { const ERROR_PROVIDER_DUPLICATED_EMAIL = '208'; const ERROR_PROVIDER_CREDENTIAL_ERROR = '209'; const ERROR_WEBHOOK_COULD_NOT_TRIGGER = '210'; + const ERROR_CAPTCHA_NOT_CONFIGURED = '211'; const ERROR_RUNTIME_STRIPE_SESSION_VALIDATION = '300'; const ERROR_STRIPE_CHECKOUT_SESSION_CREATION = '301'; const ERROR_STRIPE_CHECKOUT_SESSION_NOT_FOUND = '302'; @@ -364,14 +368,15 @@ public static function get_error_code_message( $error_code ) { self::ERROR_MISSING_NONCE => __( 'Missing CSRF protection in form.', 'otter-blocks' ), self::ERROR_MISSING_FILE_FIELD_OPTION => __( 'The File Field is not registered. Please check the field in Editor.', 'otter-blocks' ), self::ERROR_FORM_ID_INVALID => __( 'Form ID is invalid.', 'otter-blocks' ), - self::ERROR_EMAIL_NOT_SEND => __( 'Email could not be send. Might be an error with the service.', 'otter-blocks' ), + self::ERROR_EMAIL_NOT_SEND => __( 'Email could not be sent. Might be an error with the service.', 'otter-blocks' ), self::ERROR_PROVIDER_INVALID_KEY => __( 'Invalid service authentication credentials.', 'otter-blocks' ), self::ERROR_PROVIDER_NOT_REGISTERED => __( 'The 3rd-party service is not registered.', 'otter-blocks' ), self::ERROR_PROVIDER_SUBSCRIBE_ERROR => __( 'Error received from service when subscribing the user.', 'otter-blocks' ), self::ERROR_MISSING_PROVIDER => __( 'Provider settings are missing.', 'otter-blocks' ), self::ERROR_MISSING_API_KEY => __( 'API Key is missing from settings.', 'otter-blocks' ), self::ERROR_MISSING_MAIL_LIST_ID => __( 'API Key is missing.', 'otter-blocks' ), - self::ERROR_INVALID_CAPTCHA_TOKEN => __( 'The reCaptcha token is invalid.', 'otter-blocks' ), + self::ERROR_INVALID_CAPTCHA_TOKEN => __( 'The captcha token is invalid.', 'otter-blocks' ), + self::ERROR_CAPTCHA_NOT_CONFIGURED => __( 'The captcha is not configured: the API keys are missing.', 'otter-blocks' ), self::ERROR_PROVIDER_INVALID_API_KEY_FORMAT => __( 'The API key format is invalid.', 'otter-blocks' ), self::ERROR_PROVIDER_CLIENT_ALREADY_REGISTERED => __( 'The user with this email was already registered.', 'otter-blocks' ), self::ERROR_PROVIDER_INVALID_EMAIL => __( 'The email address is invalid.', 'otter-blocks' ), @@ -379,17 +384,20 @@ public static function get_error_code_message( $error_code ) { self::ERROR_BOT_DETECTED => __( 'Failed to validate the data. Please wait 5 seconds and try again.', 'otter-blocks' ), self::ERROR_FILES_METADATA_FORMAT => __( 'The files metadata is invalid.', 'otter-blocks' ), self::ERROR_FILE_UPLOAD => __( 'The files could not be uploaded.', 'otter-blocks' ), - self::ERROR_PROVIDER_CREDENTIAL_ERROR => __( 'The Otter From Block service credentials are invalid.', 'otter-blocks' ), + self::ERROR_PROVIDER_CREDENTIAL_ERROR => __( 'The Otter Form Block service credentials are invalid.', 'otter-blocks' ), self::ERROR_FILE_UPLOAD_TYPE_WP => __( 'The file type is not allowed by host provider.', 'otter-blocks' ), self::ERROR_FILE_UPLOAD_TYPE => __( 'The file type is not allowed.', 'otter-blocks' ), - self::ERROR_FILE_UPLOAD_MAX_FILES_NUMBER => __( 'The number of files is too big.', 'otter-blocks' ), + self::ERROR_FILE_UPLOAD_MAX_FILES_NUMBER => __( 'The number of files is too large.', 'otter-blocks' ), self::ERROR_FILE_UPLOAD_MAX_SIZE => __( 'The file size exceed the limit.', 'otter-blocks' ), self::ERROR_AUTORESPONDER_MISSING_EMAIL_FIELD => __( 'The email field is missing from the Form Block with Autoresponder activated.', 'otter-blocks' ), self::ERROR_AUTORESPONDER_COULD_NOT_SEND => __( 'The email from Autoresponder could not be sent.', 'otter-blocks' ), + self::ERROR_AUTORESPONDER_AI_GENERATION_FAILED => __( 'The AI Autoresponder reply could not be generated. The fallback message was used.', 'otter-blocks' ), + self::ERROR_AUTORESPONDER_AI_VALIDATION_FAILED => __( 'The AI Autoresponder reply did not pass validation. The fallback message was used.', 'otter-blocks' ), self::ERROR_FILE_MISSING_BINARY => __( 'The file data is missing.', 'otter-blocks' ), self::ERROR_MALFORMED_REQUEST => __( 'The request is malformed.', 'otter-blocks' ), self::ERROR_WEBHOOK_COULD_NOT_TRIGGER => __( 'The webhook could not be triggered.', 'otter-blocks' ), self::ERROR_MISSING_DUMP_DATA => __( 'The form dump data is missing.', 'otter-blocks' ), + self::ERROR_CAPTCHA_PROVIDER_UNREACHABLE => __( 'The captcha service could not be reached. The submission was saved, but it was not delivered.', 'otter-blocks' ), self::ERROR_STRIPE_CHECKOUT_SESSION_CREATION => __( 'The Stripe Checkout session could not be created.', 'otter-blocks' ), self::ERROR_STRIPE_CHECKOUT_SESSION_NOT_FOUND => __( 'The Stripe Checkout session was not found.', 'otter-blocks' ), self::ERROR_STRIPE_PAYMENT_UNPAID => __( 'The payment was not completed.', 'otter-blocks' ), diff --git a/inc/integrations/class-form-providers.php b/inc/integrations/class-form-providers.php index 4960f9235..719643dd8 100644 --- a/inc/integrations/class-form-providers.php +++ b/inc/integrations/class-form-providers.php @@ -92,6 +92,11 @@ public function register_providers( $new_providers ) { * @since 2.0.3 */ public function select_provider_from_form_options( $form_request ) { + $changed_provider = $form_request->get_changed_provider(); + if ( is_string( $changed_provider ) && '' !== $changed_provider ) { + return $this->get_provider_handlers( $changed_provider ); + } + $form_options = $form_request->get_wp_options(); if ( $form_options->has_provider() && $form_options->has_credentials() ) { return $this->get_provider_handlers( $form_options->get_provider() ); diff --git a/inc/integrations/class-form-settings-data.php b/inc/integrations/class-form-settings-data.php index b21e6c910..78427a13b 100644 --- a/inc/integrations/class-form-settings-data.php +++ b/inc/integrations/class-form-settings-data.php @@ -51,6 +51,14 @@ class Form_Settings_Data { */ private $has_captcha = false; + /** + * Captcha provider. Null until the form options are saved with one, + * so legacy forms can be told apart from forms saved as reCaptcha. + * + * @var string|null + */ + private $captcha_provider = null; + /** * The metadata. * @@ -114,6 +122,13 @@ class Form_Settings_Data { */ private $bcc = ''; + /** + * The Reply-To email address. + * + * @var string + */ + private $reply_to = ''; + /** * The autoresponder data. * @@ -121,13 +136,33 @@ class Form_Settings_Data { */ private $autoresponder = array(); + /** + * The AI autoresponder data. + * + * @var array{enabled?: bool, prompt?: string} + */ + private $ai_autoresponder = array(); + /** * The location where the submissions are saved. * + * Legacy setting: submissions are now always saved to the database. Kept populated for + * older Otter Pro builds that still read it; superseded by $email_notification. + * * @var string */ private $submissions_save_location = ''; + /** + * Whether to send the owner an email notification for each submission. + * + * Replaces the save-location selector: database save is always on, only the email + * notification is configurable. + * + * @var bool + */ + private $email_notification = true; + /** * The webhook ID. * @@ -154,6 +189,10 @@ public function __construct( $integration_data ) { $this->set_captcha( $integration_data['hasCaptcha'] ); } + if ( isset( $integration_data['captchaProvider'] ) ) { + $this->set_captcha_provider( $integration_data['captchaProvider'] ); + } + $this->set_meta( $integration_data ); } @@ -225,6 +264,9 @@ public static function get_form_setting_from_wordpress_options( $form_option ) { if ( isset( $form['hasCaptcha'] ) ) { $integration->set_captcha( $form['hasCaptcha'] ); } + if ( isset( $form['captchaProvider'] ) ) { + $integration->set_captcha_provider( $form['captchaProvider'] ); + } if ( isset( $form['redirectLink'] ) ) { $integration->set_redirect_link( $form['redirectLink'] ); } @@ -249,9 +291,15 @@ public static function get_form_setting_from_wordpress_options( $form_option ) { if ( isset( $form['bcc'] ) ) { $integration->set_bcc( $form['bcc'] ); } + if ( isset( $form['replyTo'] ) ) { + $integration->set_reply_to( $form['replyTo'] ); + } if ( isset( $form['autoresponder'] ) && count( $form['autoresponder'] ) > 0 ) { $integration->set_autoresponder( $form['autoresponder'] ); } + if ( isset( $form['aiAutoresponder'] ) && is_array( $form['aiAutoresponder'] ) ) { + $integration->set_ai_autoresponder( $form['aiAutoresponder'] ); + } if ( isset( $form['integration'] ) ) { $integration->extract_integration_data( $form['integration'] ); } @@ -264,6 +312,24 @@ public static function get_form_setting_from_wordpress_options( $form_option ) { } elseif ( Pro::is_pro_active() ) { $integration->set_submissions_save_location( 'database-email' ); } + + /** + * Read-time migration of the legacy save-location setting: `email`, + * `database-email` and empty/missing (email was the de facto behavior) map to + * notification on, `database` maps to off. Legacy `database` only skipped the + * owner email when Pro was active (the old gate was + * `Pro::is_pro_active() && ! $can_send_email`), so without an active Pro + * license the email was always sent and we keep the notification on. The + * stored option is rewritten to the new `emailNotification` format when the + * form is next saved in the editor. + */ + if ( isset( $form['emailNotification'] ) ) { + $integration->set_email_notification( filter_var( $form['emailNotification'], FILTER_VALIDATE_BOOLEAN ) ); + } elseif ( ! empty( $form['submissionsSaveLocation'] ) && Pro::is_pro_active() ) { + $integration->set_email_notification( 'database' !== $form['submissionsSaveLocation'] ); + } else { + $integration->set_email_notification( true ); + } $integration->set_meta( $form ); if ( isset( $form['webhookId'] ) ) { $integration->set_webhook_id( $form['webhookId'] ); @@ -349,6 +415,21 @@ public function set_captcha( $has_captcha ) { return $this; } + /** + * Set captcha provider. + * + * @param mixed $provider Provider slug, as received from the form options payload. + * @return Form_Settings_Data + * @since 3.1.12 + */ + public function set_captcha_provider( $provider ) { + $provider = is_string( $provider ) ? sanitize_key( $provider ) : ''; + $allowed = array( 'recaptcha', 'turnstile' ); + + $this->captcha_provider = in_array( $provider, $allowed, true ) ? $provider : 'recaptcha'; + return $this; + } + /** * Check if it has the API Key and the list id set. * @@ -557,6 +638,26 @@ public function form_has_captcha() { return $this->has_captcha; } + /** + * Get captcha provider. + * + * @return string + * @since 3.1.12 + */ + public function get_captcha_provider() { + return null === $this->captcha_provider ? 'recaptcha' : $this->captcha_provider; + } + + /** + * Check if a captcha provider was saved with the form options. + * + * @return bool + * @since 3.1.12 + */ + public function has_captcha_provider() { + return null !== $this->captcha_provider; + } + /** * Get the redirect link. * @@ -699,6 +800,39 @@ public function set_bcc( $bcc ) { return $this; } + /** + * Get the Reply-To email address. + * + * @return string + */ + public function get_reply_to() { + return $this->reply_to; + } + + /** + * Check if it has a Reply-To email address. + * + * @return boolean + */ + public function has_reply_to() { + return ! empty( $this->reply_to ); + } + + /** + * Set the Reply-To email address. + * + * @param string $reply_to The Reply-To email address. + * @return Form_Settings_Data + */ + public function set_reply_to( $reply_to ) { + if ( filter_var( $reply_to, FILTER_VALIDATE_EMAIL ) !== false ) { + $this->reply_to = $reply_to; + } else { + $this->reply_to = ''; + } + return $this; + } + /** * Get the autoresponder. * @@ -728,6 +862,44 @@ public function set_autoresponder( $autoresponder ) { return $this; } + /** + * Check if the AI autoresponder is enabled and has a prompt. + * + * @return bool + */ + public function has_ai_autoresponder() { + return ! empty( $this->ai_autoresponder['enabled'] ) && ! empty( $this->ai_autoresponder['prompt'] ); + } + + /** + * Get the AI autoresponder data. + * + * @return array{enabled: bool, prompt: string} + */ + public function get_ai_autoresponder() { + return array( + 'enabled' => ! empty( $this->ai_autoresponder['enabled'] ), + 'prompt' => isset( $this->ai_autoresponder['prompt'] ) ? (string) $this->ai_autoresponder['prompt'] : '', + ); + } + + /** + * Set the AI autoresponder data. + * + * @param mixed $ai_autoresponder The AI autoresponder data (normalized to enabled/prompt). + * @return Form_Settings_Data + */ + public function set_ai_autoresponder( $ai_autoresponder ) { + $ai_autoresponder = is_array( $ai_autoresponder ) ? $ai_autoresponder : array(); + + $this->ai_autoresponder = array( + 'enabled' => ! empty( $ai_autoresponder['enabled'] ), + 'prompt' => isset( $ai_autoresponder['prompt'] ) ? (string) $ai_autoresponder['prompt'] : '', + ); + + return $this; + } + /** * * Get the submissions save location. @@ -749,6 +921,26 @@ public function set_submissions_save_location( $submissions_save_location ) { return $this; } + /** + * Check if the owner email notification is enabled. + * + * @return bool + */ + public function has_email_notification() { + return $this->email_notification; + } + + /** + * Set whether the owner email notification is enabled. + * + * @param bool $email_notification Whether the notification is enabled. + * @return $this + */ + public function set_email_notification( $email_notification ) { + $this->email_notification = (bool) $email_notification; + return $this; + } + /** * Set the webhook ID. * diff --git a/inc/integrations/class-form-utils.php b/inc/integrations/class-form-utils.php index e6df86cac..906369ca2 100644 --- a/inc/integrations/class-form-utils.php +++ b/inc/integrations/class-form-utils.php @@ -103,7 +103,12 @@ public static function save_file_from_field( $field, $files ) { try { $file_data = $files[ $file_data_key ]; - if ( 'svg' === pathinfo( $file_name, PATHINFO_EXTENSION ) ) { + $upload_name = isset( $file_data['name'] ) ? $file_data['name'] : $file_name; + $is_svg = 'svg' === strtolower( pathinfo( $upload_name, PATHINFO_EXTENSION ) ) + || 'svg' === strtolower( pathinfo( $file_name, PATHINFO_EXTENSION ) ) + || ( isset( $file_data['type'] ) && 'image/svg+xml' === $file_data['type'] ); + + if ( $is_svg ) { $file_contents = file_get_contents( $file_data['tmp_name'] ); $sanitizer = new Sanitizer(); diff --git a/inc/patterns/author-box.php b/inc/patterns/author-box.php deleted file mode 100644 index 0e744d82a..000000000 --- a/inc/patterns/author-box.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Author Box', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'featured' ), - 'content' => '

About the Author

John Peterson

Tousled letterpre tote bag bicycle rights cliche twee hashtag pokpo demos tanero lamina sime voti. Quinoa ramps hashtag yuccie, selfies af wolf. Thundercats brunch gastropub whatever poutine tattooed godard bespoke blog seitan flannel jianbing bitters cloud.. Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

', -); diff --git a/inc/patterns/aw-bento-features.php b/inc/patterns/aw-bento-features.php new file mode 100644 index 000000000..4eed6391f --- /dev/null +++ b/inc/patterns/aw-bento-features.php @@ -0,0 +1,13 @@ + __( 'Bento Feature Grid', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'features' ), + 'description' => __( 'Bento-style feature grid with mixed-size cards, gradient and dark tiles, icons and glow accents for showcasing product benefits, capabilities, and highlights.', 'otter-blocks' ), + 'content' => '
Everything included

One platform, every superpower

Stop stitching tools together. Everything your team needs lives in one place, beautifully connected.

Design systems that run themselves

Change a token once and watch it cascade across every page, component, and breakpoint — instantly.

Instant publishing

Push changes live in under a second, with automatic rollbacks if anything looks off.

Enterprise-grade security

SSO, audit logs, and granular roles come standard — no enterprise plan required.

Ready when you are

There is plenty more under the hood — integrations, analytics, automations, and a marketplace of templates.

Explore all features
', +); diff --git a/inc/patterns/aw-blog-cards.php b/inc/patterns/aw-blog-cards.php new file mode 100644 index 000000000..434ef3fbf --- /dev/null +++ b/inc/patterns/aw-blog-cards.php @@ -0,0 +1,13 @@ + __( 'Blog Post Cards', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'blog' ), + 'description' => __( 'Three-column blog post card grid with featured images, category tags, read time and excerpts for latest articles, news feed, and recent posts.', 'otter-blocks' ), + 'content' => '
From the blog

Ideas, guides and product news

View all articles
Product

How we cut dashboard load times in half

A behind-the-scenes look at the rewrite that made everything feel instant.

5 min read · Mar 12

Engineering

Designing an API your team will actually enjoy

Principles we follow so every endpoint feels obvious the first time.

8 min read · Mar 04

Culture

What a fully async week really looks like

No standups, no status meetings — here is how the work still ships.

6 min read · Feb 22

', +); diff --git a/inc/patterns/aw-case-study-metrics.php b/inc/patterns/aw-case-study-metrics.php new file mode 100644 index 000000000..780e7ae81 --- /dev/null +++ b/inc/patterns/aw-case-study-metrics.php @@ -0,0 +1,13 @@ + __( 'Case Study With Metrics', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'portfolio' ), + 'description' => __( 'Split two-column case study with image and headline results metrics, stats bar, and read more link for customer success stories and social proof.', 'otter-blocks' ), + 'content' => '
Case study · Northwind

How Northwind doubled activation in one quarter

By moving every team onto a shared source of truth, Northwind cut reporting busywork and shipped the features customers actually asked for.

2.1x

Activation rate

-40%

Reporting time

14k

New signups

Read the full story
', +); diff --git a/inc/patterns/aw-cta-banner.php b/inc/patterns/aw-cta-banner.php new file mode 100644 index 000000000..d6e1bf484 --- /dev/null +++ b/inc/patterns/aw-cta-banner.php @@ -0,0 +1,41 @@ + __( 'CTA Banner', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'call-to-action' ), + 'description' => __( 'Centered full-width gradient call-to-action banner with badge, headline and dual buttons for sign-up conversion, lead capture, and launch offers.', 'otter-blocks' ), + 'content' => ' +
+
+
+ + +Limited-time launch offer +
+ + + +

Ready to build something people love?

+ + + +

Join thousands of teams shipping faster. Start free today — no credit card, no setup, cancel whenever.

+ + + + +
+
+', +); diff --git a/inc/patterns/aw-faq-cards.php b/inc/patterns/aw-faq-cards.php new file mode 100644 index 000000000..61354fdf8 --- /dev/null +++ b/inc/patterns/aw-faq-cards.php @@ -0,0 +1,13 @@ + __( 'FAQ Cards', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'faq' ), + 'description' => __( 'Centered FAQ accordion with expandable bordered cards and plus-minus toggle icons for frequently asked questions, support answers, and help section.', 'otter-blocks' ), + 'content' => '
FAQ

Questions, answered

Everything people usually ask before getting started. Still curious? Our support team replies in under an hour.

Can I try it before paying?

Yes — every plan starts with a 14-day free trial. No credit card required, and you keep everything you build.

What happens to my data if I cancel?

You can export everything at any time. After cancellation we keep your data for 30 days, then it’s permanently deleted.

Do you offer discounts for nonprofits?

We do — registered nonprofits and educational institutions get 50% off any plan. Reach out to support with proof of status.

Can I change plans later?

Anytime. Upgrades apply instantly and downgrades take effect at the next billing cycle, with prorated credit.

Is there an API?

A full REST API ships with every plan, and webhooks are available from the Pro tier up.

', +); diff --git a/inc/patterns/aw-feature-cards.php b/inc/patterns/aw-feature-cards.php new file mode 100644 index 000000000..88889096e --- /dev/null +++ b/inc/patterns/aw-feature-cards.php @@ -0,0 +1,13 @@ + __( 'Feature Cards', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'features' ), + 'description' => __( 'Three-column feature card grid with icon badges, hover lift effect and learn-more links for highlighting product benefits, services, and capabilities.', 'otter-blocks' ), + 'content' => '
Built for scale

One platform, every superpower

Everything your team needs to plan, build, and launch — beautifully connected and ready on day one.

Design systems

Change a token once and watch it cascade across every page and breakpoint instantly.

Learn more

Instant publishing

Push changes live in under a second, with automatic rollbacks if anything looks off.

Learn more

Enterprise security

SSO, audit logs, and granular roles come standard — no enterprise plan required.

Learn more
', +); diff --git a/inc/patterns/aw-feature-split.php b/inc/patterns/aw-feature-split.php new file mode 100644 index 000000000..94f2a6369 --- /dev/null +++ b/inc/patterns/aw-feature-split.php @@ -0,0 +1,13 @@ + __( 'Feature Split', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'features' ), + 'description' => __( 'Split two-column feature section with checklist of benefits beside a glowing product image for highlighting capabilities, value props, and why-choose-us.', 'otter-blocks' ), + 'content' => '
Why teams switch

Everything in one place, working as one

Stop juggling tabs and exports. Plan, build, and ship from a single workspace that keeps everyone in sync.

Real-time collaboration

Edit together with your whole team, no refresh, no conflicts.

Version history built in

Roll back to any point with a single click — nothing is ever lost.

Connected to your stack

Sync with the tools you already use, from day one.

See all features
Product feature preview
', +); diff --git a/inc/patterns/aw-gallery-grid.php b/inc/patterns/aw-gallery-grid.php new file mode 100644 index 000000000..6a55a4cc5 --- /dev/null +++ b/inc/patterns/aw-gallery-grid.php @@ -0,0 +1,13 @@ + __( 'Gallery Masonry Grid', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'gallery' ), + 'description' => __( 'Masonry photo gallery grid with rounded images and zoom hover effect for showcasing portfolio work, photography, image collections, and visual showcase.', 'otter-blocks' ), + 'content' => '
Selected work

Moments worth framing

', +); diff --git a/inc/patterns/aw-glow-cta.php b/inc/patterns/aw-glow-cta.php new file mode 100644 index 000000000..ca0ccd48d --- /dev/null +++ b/inc/patterns/aw-glow-cta.php @@ -0,0 +1,13 @@ + __( 'Glow CTA', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'call-to-action' ), + 'description' => __( 'Centered dark call-to-action with neon glow blur orbs, badge and dual buttons for beta sign-up, conversion, lead capture, and above-the-fold promos.', 'otter-blocks' ), + 'content' => '
Now in public beta

The future of work starts here

Join the beta and shape what we build next. Free while in beta, founder pricing forever after.

', +); diff --git a/inc/patterns/aw-hero-centered.php b/inc/patterns/aw-hero-centered.php new file mode 100644 index 000000000..981eedd53 --- /dev/null +++ b/inc/patterns/aw-hero-centered.php @@ -0,0 +1,91 @@ + __( 'Centered Hero', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'header' ), + 'description' => __( 'Centered hero header above the fold with rating badge, headline, dual buttons and a browser dashboard mockup for landing page intro and product launch.', 'otter-blocks' ), + 'content' => ' +
+
+
+ + +Rated 4.9/5 by 2,400+ teams +
+ + + +

Ship your next idea in days, not quarters

+ + + +

One workspace to plan, build, and launch — connected to the tools your team already lives in, minus the tab chaos.

+ + + + + + + +

Free 14-day trial · No credit card · Cancel anytime

+ + + +
+
+
+ + + +
+ + + +
+
+ + + +
+
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+
+
+
+
+', +); diff --git a/inc/patterns/aw-hero-split.php b/inc/patterns/aw-hero-split.php new file mode 100644 index 000000000..f9429b104 --- /dev/null +++ b/inc/patterns/aw-hero-split.php @@ -0,0 +1,14 @@ + __( 'Split Hero', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'header' ), + 'keywords' => array( 'style:clean' ), + 'description' => __( 'Split two-column hero header with gradient headline, buttons, avatar social proof and product image for landing page intro and above-the-fold banner.', 'otter-blocks' ), + 'content' => '
New: AI-powered workflows

Design less.

Ship a lot more.

One workspace for your whole product team — plan, design, and launch without ever switching tools.

Customer avatarCustomer avatarCustomer avatar
Trusted by 2,000+ teams
Product dashboard preview
', +); diff --git a/inc/patterns/aw-icon-grid.php b/inc/patterns/aw-icon-grid.php new file mode 100644 index 000000000..51f62372e --- /dev/null +++ b/inc/patterns/aw-icon-grid.php @@ -0,0 +1,13 @@ + __( 'Icon Feature Grid', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'icons' ), + 'description' => __( 'Compact four-column icon feature grid with rounded icon badges and short labels for listing capabilities, services, perks, and product highlights.', 'otter-blocks' ), + 'content' => '
Built in

Everything you need, nothing you don’t

Enterprise security

Lightning fast

Real-time sync

Global edge

Smart alerts

200+ integrations

Live analytics

24/7 support

', +); diff --git a/inc/patterns/aw-portfolio-grid.php b/inc/patterns/aw-portfolio-grid.php new file mode 100644 index 000000000..c9a4bba75 --- /dev/null +++ b/inc/patterns/aw-portfolio-grid.php @@ -0,0 +1,13 @@ + __( 'Portfolio Showcase Grid', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'portfolio' ), + 'description' => __( 'Dark two-column portfolio showcase grid with image overlays, category tags and zoom hover for project work, case studies, and creative agency gallery.', 'otter-blocks' ), + 'content' => '
Portfolio

Recent projects we are proud of

Branding

Northwind identity system

Web design

Harbor commerce platform

Mobile app

Lumen meditation app

Campaign

Solstice launch film

', +); diff --git a/inc/patterns/aw-pricing-tiers.php b/inc/patterns/aw-pricing-tiers.php new file mode 100644 index 000000000..a083ff481 --- /dev/null +++ b/inc/patterns/aw-pricing-tiers.php @@ -0,0 +1,13 @@ + __( 'Pricing Tiers', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'pricing' ), + 'description' => __( 'Three-column pricing table with highlighted popular tier, checkmark feature lists and buttons for subscription plans, pricing comparison, and billing tiers.', 'otter-blocks' ), + 'content' => '
Pricing

Simple pricing that scales with you

Start free, upgrade when you grow. Every plan includes unlimited projects and our full block library.

Starter
$0/month

For side projects and getting started.

Up to 3 projects
Community support
1 GB storage
Get started
ProMost popular
$29/month

For growing teams that ship every week.

Unlimited projects
Priority support
100 GB storage
Advanced analytics
Start free trial
Enterprise
$99/month

For organizations with serious scale.

Everything in Pro
Dedicated manager
SSO & audit logs
Unlimited storage
Contact sales
', +); diff --git a/inc/patterns/aw-quote-spotlight.php b/inc/patterns/aw-quote-spotlight.php new file mode 100644 index 000000000..a03f899d9 --- /dev/null +++ b/inc/patterns/aw-quote-spotlight.php @@ -0,0 +1,53 @@ + __( 'Spotlight Quote', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'testimonials' ), + 'description' => __( 'Centered dark testimonial spotlight with large pull quote, five-star rating and author avatar for customer review, social proof, and single quote feature.', 'otter-blocks' ), + 'content' => ' +
+
+
+ + + +

We replaced four tools and an entire afternoon of status meetings. The team actually enjoys shipping again.

+ + + +
+ + + + + + + +
+ + + +
+
+MC +
+ + + +
+Maya Chen + + + +VP Engineering, Driftline +
+
+
+
+', +); diff --git a/inc/patterns/aw-review-stars.php b/inc/patterns/aw-review-stars.php new file mode 100644 index 000000000..a14169b97 --- /dev/null +++ b/inc/patterns/aw-review-stars.php @@ -0,0 +1,13 @@ + __( 'Star Rating Reviews', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'testimonials' ), + 'description' => __( 'Three-column testimonial cards with star ratings, quotes and author avatars for customer reviews, social proof, ratings wall, and feedback section.', 'otter-blocks' ), + 'content' => '

Rated 4.9 out of 5 by 2,400+ customers

“Setup took ten minutes and we never looked back. The whole team finally trusts the numbers.”

Maria HoltHead of Ops, Brightline

“The support is unreal. Real humans, real answers, usually within the hour.”

Devon CarterFounder, Maple & Co

“We replaced three tools with this one. The savings paid for the year in a month.”

Priya NairCTO, Fathom Labs
', +); diff --git a/inc/patterns/aw-stats-banner.php b/inc/patterns/aw-stats-banner.php new file mode 100644 index 000000000..88f64dd8c --- /dev/null +++ b/inc/patterns/aw-stats-banner.php @@ -0,0 +1,13 @@ + __( 'Gradient Stats Banner', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'stats' ), + 'description' => __( 'Dark gradient stats bar showing key numbers and metrics in a centered four-column grid for social proof, KPIs, by-the-numbers, and company milestones.', 'otter-blocks' ), + 'content' => '
By the numbers

Built for scale, trusted worldwide

12k+
Active teams
99.99%
Uptime
4.9/5
Average rating
150+
Countries served
', +); diff --git a/inc/patterns/aw-team-grid.php b/inc/patterns/aw-team-grid.php new file mode 100644 index 000000000..8ae6e45ad --- /dev/null +++ b/inc/patterns/aw-team-grid.php @@ -0,0 +1,13 @@ + __( 'Team Grid with Hover Effect', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'team' ), + 'description' => __( 'Team grid with grayscale hover portraits in a four-column layout for staff, founders, about us, meet the team, and employee headshots with roles.', 'otter-blocks' ), + 'content' => '
The team

The people behind the product

Small by design, senior by default. Hover over a portrait to say hello.

Portrait of Alex Rivera
Alex RiveraCo-founder & CEO
Portrait of Sofia Lindqvist
Sofia LindqvistHead of Design
Portrait of Marcus Bell
Marcus BellLead Engineer
Portrait of Aiko Tanaka
Aiko TanakaProduct Manager
', +); diff --git a/inc/patterns/aw-testimonial-cards.php b/inc/patterns/aw-testimonial-cards.php new file mode 100644 index 000000000..ee7820236 --- /dev/null +++ b/inc/patterns/aw-testimonial-cards.php @@ -0,0 +1,13 @@ + __( 'Testimonial Cards', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'testimonials' ), + 'description' => __( 'Three-column testimonial cards with five-star ratings, customer quotes, and avatars for social proof, reviews, customer feedback, and trust building.', 'otter-blocks' ), + 'content' => '
Loved by teams

Don’t just take our word for it

“We shipped our new dashboard in a weekend. What used to take a full sprint now happens before lunch.”

Portrait of Maya Chen
Maya ChenProduct Lead, Northwind

“Support actually answers, the docs are clear, and the product just works. Rare combination these days.”

Portrait of Diego Alvarez
Diego AlvarezCTO, Loophole

“We migrated three tools into one. Onboarding the team took an afternoon instead of a quarter.”

Portrait of Priya Nair
Priya NairOps Manager, Caddy
', +); diff --git a/inc/patterns/aw-timeline-steps.php b/inc/patterns/aw-timeline-steps.php new file mode 100644 index 000000000..f150a509b --- /dev/null +++ b/inc/patterns/aw-timeline-steps.php @@ -0,0 +1,13 @@ + __( 'Timeline Steps', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'timeline' ), + 'description' => __( 'Vertical timeline with numbered icon markers showing a step-by-step how-it-works process, onboarding flow, roadmap, and getting started guide.', 'otter-blocks' ), + 'content' => '
How it works

From signup to shipped in four steps

Connect your data

Plug in any source with a one-click integration. No engineering ticket required.

Build your view

Drag metrics onto a canvas and watch the dashboard assemble itself in real time.

Invite the team

Share a link and everyone gets the same answer — no exports, no stale screenshots.

Ship with confidence

Set alerts on the numbers that matter and let the data come to you.

', +); diff --git a/inc/patterns/aw-video-feature.php b/inc/patterns/aw-video-feature.php new file mode 100644 index 000000000..acb5685d4 --- /dev/null +++ b/inc/patterns/aw-video-feature.php @@ -0,0 +1,15 @@ + __( 'Video Feature', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'video' ), + 'description' => __( 'Dark centered video feature with a framed YouTube embed for product demos, explainer videos, watch the demo, tutorials, and webinar previews.', 'otter-blocks' ), + 'content' => '
Watch the demo

See it work in two minutes

No slides, no fluff — just a quick tour of how teams go from raw data to a shared answer.

+https://www.youtube.com/watch?v=WcS2Vi4IOYw +
', +); diff --git a/inc/patterns/blog-featured-list.php b/inc/patterns/blog-featured-list.php new file mode 100644 index 000000000..6462781b5 --- /dev/null +++ b/inc/patterns/blog-featured-list.php @@ -0,0 +1,13 @@ + __( 'Blog Featured & List', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'blog' ), + 'description' => __( 'Two-column blog layout pairing one large featured article with a stacked list of recent posts for news, magazine, latest stories, and article archives.', 'otter-blocks' ), + 'content' => '
Latest stories

Featured reading this week

Deep dive

The quiet architecture behind a product that scales

We pulled back the curtain on the decisions, trade-offs and late-night rewrites that let a small team ship like a much larger one.

9 min read — Jun 09
Read the story
Product

Shipping a redesign without breaking a single workflow

4 min read — Jun 06

Engineering

A pragmatic guide to feature flags at any size

7 min read — Jun 02

Design

Designing empty states that actually help people

5 min read — May 28

Culture

How we keep async writing from turning into noise

6 min read — May 21

', +); diff --git a/inc/patterns/bold-image-hero.php b/inc/patterns/bold-image-hero.php new file mode 100644 index 000000000..69d2fb937 --- /dev/null +++ b/inc/patterns/bold-image-hero.php @@ -0,0 +1,13 @@ + __( 'Bold Image Hero', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'header' ), + 'description' => __( 'Bold split two-column hero with full-bleed product image, badge, dual CTA buttons, and star rating for ecommerce shops, landing pages, and above the fold banners.', 'otter-blocks' ), + 'content' => '
New collection — Spring 2025

Make a statement the moment they land

Crafted for brands that refuse to blend in. A look that turns first impressions into lasting ones.

Loved by 12,000+ customers
Featured collection
Free shipping

On every order over $75

', +); diff --git a/inc/patterns/border-icon-features.php b/inc/patterns/border-icon-features.php deleted file mode 100644 index 7227e0cb4..000000000 --- a/inc/patterns/border-icon-features.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Border Icon Features', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'columns' ), - 'content' => '

Icon One

Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

Icon two

Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

Icon three

Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

', -); diff --git a/inc/patterns/border-pricing-table.php b/inc/patterns/border-pricing-table.php deleted file mode 100644 index e96ce541f..000000000 --- a/inc/patterns/border-pricing-table.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Border Pricing Table', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'columns', 'pricing' ), - 'content' => '

Essential

$59

Party snackwave four dollar toast tumeric cold-pressed.

Custom Layouts & Hooks

Unlimited Website Usage

Risk-Free Guarantee

Something else

Business

$129

Party snackwave four dollar toast tumeric cold-pressed.

Custom Layouts & Hooks

Unlimited Website Usage

Risk-Free Guarantee

Something else

VIP

$199

Party snackwave four dollar toast tumeric cold-pressed.

Custom Layouts & Hooks

Unlimited Website Usage

Risk-Free Guarantee

Something else

', -); diff --git a/inc/patterns/business-about.php b/inc/patterns/business-about.php deleted file mode 100644 index 5f3033310..000000000 --- a/inc/patterns/business-about.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Business - About', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'pages', 'business-pack' ), - 'content' => '
This is an overline
Our Team
Founder / CEO
Operations
Operations

Schedule a free consulting call with a representative

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/business-contact.php b/inc/patterns/business-contact.php deleted file mode 100644 index 01a500a54..000000000 --- a/inc/patterns/business-contact.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Business - Contact', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'pages', 'business-pack' ), - 'content' => '
Have a Question?

Visit our Office

Our Address

4814 Hewes Avenue
Salisbury, Maryland
21875

Call Us

Phone number: 443-669-4259

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/business-homepage.php b/inc/patterns/business-homepage.php deleted file mode 100644 index 40601aebd..000000000 --- a/inc/patterns/business-homepage.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Business - Homepage', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'pages', 'business-pack' ), - 'content' => '

Business consulting with a different approach

This is a description for the hero section. Synergestic actionables. Organic growth deep dive but circle back.

This is an overline

“... Synergestic actionables. Organic growth deep dive but circle back or but what\'s the real problem we\'re trying to solve here?...”

Jason Johnson

CEO, Founder

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/business-service.php b/inc/patterns/business-service.php deleted file mode 100644 index c89bdf054..000000000 --- a/inc/patterns/business-service.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Business - Service', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'pages', 'business-pack' ), - 'content' => '
hassle-free HR management

Feature 1
FEATURE 2

Schedule a free consulting call with a representative

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/business-services.php b/inc/patterns/business-services.php deleted file mode 100644 index 2fa1b030e..000000000 --- a/inc/patterns/business-services.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Business - Services', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'pages', 'business-pack' ), - 'content' => '
This is an overline

Start planning for a bright future for your company

Bring to the table win-win survival strategies to ensure proactive domination. At the end of the day, going forward, a new normal that has evolved from generation X is on the runway heading towards a streamlined cloud solution. User generated content in real-time will have multiple touchpoints for offshoring.

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/cafe-about.php b/inc/patterns/cafe-about.php new file mode 100644 index 000000000..d44456748 --- /dev/null +++ b/inc/patterns/cafe-about.php @@ -0,0 +1,13 @@ + __( 'Cafe - About', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'pages', 'cafe-pack' ), + 'description' => __( 'Full cafe about us page with split story hero, dark stats bar, values icon grid, team portraits, and visit CTA for coffee shops, restaurants, and local business.', 'otter-blocks' ), + 'content' => '
Our story

A neighborhood ritual since 2014

We opened with one secondhand espresso machine and a conviction: coffee tastes better when nobody is in a hurry. Ten years later the machine is nicer, the conviction is the same.

Meet the team
Barista preparing coffee behind the bar
10
years on the corner
32
partner farms
400k
cups poured

Sourced with care

Every bean is bought directly from growers we know by name, at prices set by them.

Community first

Open mic Thursdays, a free shelf library, and tap water for every dog on the block.

Obsessive quality

We cup every roast, dial in every morning, and pull the shot again if it is not right.

The team

The people behind the bar

Portrait of the head roaster

Jonas Meyer

Head roaster

Portrait of the cafe manager

Ana Petrescu

Cafe manager

Portrait of the pastry chef

Sam Okafor

Pastry chef

Come say hi

We are at 214 Main Street, Portland — pouring from 7am every day.

', +); diff --git a/inc/patterns/cafe-contact.php b/inc/patterns/cafe-contact.php new file mode 100644 index 000000000..1f7fbcd2c --- /dev/null +++ b/inc/patterns/cafe-contact.php @@ -0,0 +1,13 @@ + __( 'Cafe - Contact', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'pages', 'cafe-pack' ), + 'description' => __( 'Full cafe contact page with address and hours details, info cards, and a name-email-message contact form for coffee shops, restaurants, and local business get in touch.', 'otter-blocks' ), + 'content' => '
Contact

Stop by, say hello

Questions about catering, beans, or where to park — reach out, or just come in. The kettle is always on.

Visit us

Find us on the corner of 5th and Main

214 Main Street, Portland, OR
(503) 555-0117
hello@fifthandmain.cafe
Monday to Sunday, 7:00–18:00
Get directions
Cafe seating area with bar stools

Getting here

The streetcar stops half a block away, and the bike racks out front are covered.

Dogs welcome

The patio is theirs — water bowls out all day and treats behind the counter.

Catering and wholesale

Planning an event or stocking a shop? Send us a message and we will reply within one business day.

Say hello

Send us a message

Catering, wholesale beans, or a question about the menu — fill this in and we will get back within one business day.

Prefer email?

We read everything — questions, ideas, and compliments for the pastry chef included.

', +); diff --git a/inc/patterns/cafe-homepage.php b/inc/patterns/cafe-homepage.php new file mode 100644 index 000000000..d4aa9e133 --- /dev/null +++ b/inc/patterns/cafe-homepage.php @@ -0,0 +1,13 @@ + __( 'Cafe - Homepage', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'pages', 'cafe-pack' ), + 'description' => __( 'Full cafe homepage with image-overlay hero, feature icons, menu favorites cards, review quote, and location CTA for coffee shops, restaurants, and bakeries.', 'otter-blocks' ), + 'content' => '
Warmly lit cafe interior with a long wooden bar
Small-batch roasts, every day

Slow mornings start here

Single-origin espresso, butter-laminated pastries, and a sunlit corner with your name on it. Open from 7am, seven days a week.

Roasted in-house

We roast in small batches twice a week, so nothing on the bar is ever more than ten days old.

Baked every morning

Croissants, morning buns, and seasonal galettes come out of the oven before the first pour.

Made for lingering

Fast wifi, deep chairs, and no one will ever rush you out of your seat.

From the menu

House favorites

The three things regulars order on repeat.

Flat white with latte art

Velvet Flat White

$4.50

Double ristretto under silky steamed milk, finished with latte art.

Fresh croissants on a tray

Butter Croissant

$3.80

Laminated over three days for a shatter-crisp crust and a soft middle.

Breakfast plate with eggs and toast

The Morning Plate

$12.00

Sourdough, soft eggs, dressed greens, and whipped ricotta.

The flat white here ruined every other cafe for me. Worth crossing town for, every single time.

Smiling customer holding a coffee cupMaya R. — regular since 2019
Cafe seating area with bar stools
Visit us

On the corner of 5th and Main

214 Main Street, Portland, OR
Monday to Sunday, 7:00–18:00
(503) 555-0117
Get directions

Skip the line

Order ahead and your coffee is waiting on the bar when you walk in.

', +); diff --git a/inc/patterns/cafe-menu.php b/inc/patterns/cafe-menu.php new file mode 100644 index 000000000..b9d2a04fc --- /dev/null +++ b/inc/patterns/cafe-menu.php @@ -0,0 +1,13 @@ + __( 'Cafe - Menu', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'pages', 'cafe-pack' ), + 'description' => __( 'Full cafe menu page with priced drinks and food lists, dish cards, and a photo gallery for coffee shops, restaurants, price list, and food menu layouts.', 'otter-blocks' ), + 'content' => '
The menu

Good things, made slowly

Everything is prepared in-house, from the espresso blend to the last pastry of the day.

Drinks

Espresso & brews

Espresso

Seasonal single-origin, pulled short.

$3.00

Velvet Flat White

Double ristretto, silky steamed milk.

$4.50

Pour Over

Hand-brewed, rotating origins.

$5.00

Cold Brew

Steeped for eighteen hours, served over ice.

$4.80

Chai Latte

House-spiced chai, your choice of milk.

$4.60

Hot Chocolate

Dark chocolate, lightly sweet.

$4.20
Kitchen

All-day brunch

Served until 15:00 on weekdays and all day on weekends.

Breakfast plate with eggs and toast

The Morning Plate

$12.00

Sourdough, soft eggs, dressed greens, and whipped ricotta.

Croissant pastry close-up

Pastry Basket

$9.00

A rotating trio from the morning bake, with cultured butter and jam.

Avocado Toast

Chili crisp, pickled onion, sesame.

$9.50

Granola Bowl

House granola, yogurt, seasonal fruit.

$8.00

Grilled Cheese

Aged cheddar on sourdough, tomato jam.

$10.00

Soup of the Day

Always vegetarian, always with bread.

$7.50
Latte art being pouredCup filled with roasted coffee beansBarista tamping espresso grounds

Beans for home, coffee for crowds

Take a bag of this week’s roast home, or let us cater your next morning meeting.

', +); diff --git a/inc/patterns/call-to-action-1.php b/inc/patterns/call-to-action-1.php deleted file mode 100644 index 02e4410bf..000000000 --- a/inc/patterns/call-to-action-1.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Call to Action', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'call-to-action' ), - 'content' => '

A call to action section

A Call to action section

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/call-to-action-2.php b/inc/patterns/call-to-action-2.php deleted file mode 100644 index 4b58827f5..000000000 --- a/inc/patterns/call-to-action-2.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Call to Action', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'call-to-action' ), - 'content' => '

Want to work with me? Check my Services

I love making art and creating cool things. I\'ve been drawing since I was a kid and it\'s something I enjoy doing a lot.

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/call-to-action-3.php b/inc/patterns/call-to-action-3.php deleted file mode 100644 index d941c0edb..000000000 --- a/inc/patterns/call-to-action-3.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Call to Action', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'call-to-action' ), - 'content' => '

Download my eBook for Free

Follow me on social and get exclusive recipes and deals!

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/call-to-action-4.php b/inc/patterns/call-to-action-4.php deleted file mode 100644 index 56d33a2b9..000000000 --- a/inc/patterns/call-to-action-4.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Call to Action', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'call-to-action' ), - 'content' => '

Schedule a free consulting call with a representative

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/call-to-action-5.php b/inc/patterns/call-to-action-5.php deleted file mode 100644 index 557635d03..000000000 --- a/inc/patterns/call-to-action-5.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Call to Action', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'call-to-action' ), - 'content' => '

Follow me on Social!

Synergestic actionables. Organic growth deep dive but circle back or but what\'s the real problem we\'re trying to solve here?

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/call-to-action-6.php b/inc/patterns/call-to-action-6.php deleted file mode 100644 index d6f5d4e71..000000000 --- a/inc/patterns/call-to-action-6.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Call to Action', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'call-to-action' ), - 'content' => '
SECTION OVERLINE

Get an introductory lesson for free!

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/call-to-action-7.php b/inc/patterns/call-to-action-7.php deleted file mode 100644 index b670320ab..000000000 --- a/inc/patterns/call-to-action-7.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Call to Action', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'call-to-action', 'forms' ), - 'content' => '

Get a Quote

Jason Vickens

Partnerships Manager

(081) 1149-5421-2315u003c

JASON@INDUSTRIAL

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/centered-testimonial-with-star-icons.php b/inc/patterns/centered-testimonial-with-star-icons.php deleted file mode 100644 index 0bfe97de6..000000000 --- a/inc/patterns/centered-testimonial-with-star-icons.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Centered Testimonial with Star Icons', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'columns' ), - 'content' => '

"...Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque..."

Jason Doe

', -); diff --git a/inc/patterns/columns-with-flip-boxes.php b/inc/patterns/columns-with-flip-boxes.php deleted file mode 100644 index c409f9866..000000000 --- a/inc/patterns/columns-with-flip-boxes.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Columns with Flip Boxes', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'featured', 'columns' ), - 'content' => '

A section with Flipboxes

This is a section description

Artworks

Finely crafted artworks

It\'s Party time!

This is the content of the back side. Customise it to match your needs.

Posters

Finely crafted artworks

It\'s Party time!

This is the content of the back side. Customise it to match your needs.

Museums

Finely crafted artworks

It\'s Party time!

This is the content of the back side. Customise it to match your needs.

', -); diff --git a/inc/patterns/columns-with-icon-features.php b/inc/patterns/columns-with-icon-features.php deleted file mode 100644 index 95e886276..000000000 --- a/inc/patterns/columns-with-icon-features.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Columns with Icon Features', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'featured', 'columns' ), - 'content' => '

A section with title and icons

This is a section description

Feature

Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

Feature

Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

Feature

Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

', -); diff --git a/inc/patterns/columns-with-image-features.php b/inc/patterns/columns-with-image-features.php deleted file mode 100644 index 72f5f5851..000000000 --- a/inc/patterns/columns-with-image-features.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Columns with Image Features', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'featured', 'columns' ), - 'content' => '

Feature one

Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

Feature two

Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

Feature three

Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

', -); diff --git a/inc/patterns/contact-details-and-form.php b/inc/patterns/contact-details-and-form.php deleted file mode 100644 index 410322376..000000000 --- a/inc/patterns/contact-details-and-form.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Contact Details and Form', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'columns', 'forms', 'footer' ), - 'content' => '

Send us a message!

Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem.

0030 6964 12345

hello@otterblocks.com

21 Jump Street, San Francisco

Follow on Social

You agree to receive email communication from us by submitting this form.

', -); diff --git a/inc/patterns/contact-form-split.php b/inc/patterns/contact-form-split.php new file mode 100644 index 000000000..49824d65c --- /dev/null +++ b/inc/patterns/contact-form-split.php @@ -0,0 +1,13 @@ + __( 'Contact Form with Details', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'contact' ), + 'description' => __( 'Split two-column contact section pairing email, phone, and address details with a name-email-message form for get in touch, lead capture, and inquiry pages.', 'otter-blocks' ), + 'content' => '

Tell us about your project

Questions, ideas, or a full brief — drop us a line and a real person will get back to you within one business day.

hello@example.com

+1 (555) 987-6543

42 Harbor Lane, Austin TX

', +); diff --git a/inc/patterns/contact-info-cards.php b/inc/patterns/contact-info-cards.php new file mode 100644 index 000000000..a7736ca4d --- /dev/null +++ b/inc/patterns/contact-info-cards.php @@ -0,0 +1,13 @@ + __( 'Contact Info Cards', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'contact' ), + 'description' => __( 'Three-column contact info cards for email, phone, and address with icons, office hours, and social links for get in touch, support, and reach us sections.', 'otter-blocks' ), + 'content' => '
Get in touch

We would love to hear from you

Reach out through any channel below — our team usually replies within one business day.

Email us

Drop us a line anytime.

hello@company.com
Call us

Mon–Fri, 9am to 6pm.

+1 (555) 012-3456
Visit us

Come say hello in person.

100 Market St, Suite 400
Office hours: Monday – Friday, 9:00 – 18:00
', +); diff --git a/inc/patterns/content-checklist.php b/inc/patterns/content-checklist.php new file mode 100644 index 000000000..7bd7eabe0 --- /dev/null +++ b/inc/patterns/content-checklist.php @@ -0,0 +1,13 @@ + __( 'Content with Checklist', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'text' ), + 'description' => __( 'Split two-column content section with a green check-circle feature checklist, heading, image, and CTA button for benefits, included features, and what you get lists.', 'otter-blocks' ), + 'content' => '
Migrations

We move your site, you keep working

Switching platforms is usually painful. We have done it 400 times, so for you it is a calendar invite and a coffee.

Full content and SEO migration, zero downtime

Redirects mapped and verified automatically

Design refreshed during the move, not after

30 days of post-launch support included

Two people reviewing a migration plan on a laptop
', +); diff --git a/inc/patterns/content-with-features.php b/inc/patterns/content-with-features.php deleted file mode 100644 index f4cbc7910..000000000 --- a/inc/patterns/content-with-features.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Content with Features', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'columns' ), - 'content' => '
This is an overline

This is Heading two

Capitalize on low hanging fruit to identify a ballpark value added activity to beta test. Override the digital divide with additional clickthroughs from DevOps. Nanotechnology immersion along the information highway will close the loop on focusing solely on the bottom line.

Service title

Synergestic actionables. Organic growth deep dive but circle back or but what\'s the real problem we\'re trying to solve here?

Service title

Synergestic actionables. Organic growth deep dive but circle back or but what\'s the real problem we\'re trying to solve here?

Service title

Synergestic actionables. Organic growth deep dive but circle back or but what\'s the real problem we\'re trying to solve here?

', -); diff --git a/inc/patterns/content-with-progress-bars.php b/inc/patterns/content-with-progress-bars.php deleted file mode 100644 index cddaaf975..000000000 --- a/inc/patterns/content-with-progress-bars.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Content with Progress Bars', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'columns' ), - 'content' => '

Skills

Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta.

Statistic
50
Statistic
82
', -); diff --git a/inc/patterns/countdown-launch.php b/inc/patterns/countdown-launch.php new file mode 100644 index 000000000..c55eb03e9 --- /dev/null +++ b/inc/patterns/countdown-launch.php @@ -0,0 +1,13 @@ + __( 'Launch Countdown', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'waitlist' ), + 'description' => __( 'Dark centered countdown timer with email signup form for coming soon, product launch, waitlist, early access, and pre-launch reserve your seat campaigns.', 'otter-blocks' ), + 'content' => '
Doors open soon

Our biggest launch yet

Be first in line when we open — early birds get lifetime pricing and an exclusive onboarding session.

0
Day
0
Hour
0
Minute
0
Second
', +); diff --git a/inc/patterns/countdown.php b/inc/patterns/countdown.php deleted file mode 100644 index d7f45c0d1..000000000 --- a/inc/patterns/countdown.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Countdown', 'otter-blocks' ), - 'categories' => array( 'otter-blocks' ), - 'content' => '

Limited time Offer!

This is a section description

0
Day
0
Hour
0
Minute
0
Second
', -); diff --git a/inc/patterns/cover-boxes-with-title-and-button.php b/inc/patterns/cover-boxes-with-title-and-button.php deleted file mode 100644 index 38cd347cb..000000000 --- a/inc/patterns/cover-boxes-with-title-and-button.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Cover Boxes with Title and Button', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'featured', 'columns' ), - 'content' => '

Buildings

', -); diff --git a/inc/patterns/dark-cta-band.php b/inc/patterns/dark-cta-band.php new file mode 100644 index 000000000..869ab409a --- /dev/null +++ b/inc/patterns/dark-cta-band.php @@ -0,0 +1,13 @@ + __( 'Indigo CTA Band', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'call-to-action' ), + 'description' => __( 'Bold indigo full-width call-to-action band with centered heading and dual pill buttons for free trial, sign up, get started, and conversion CTA sections.', 'otter-blocks' ), + 'content' => '
Free 14-day trial

Stop reading. Start building.

Pick a template, drop in your content, and publish before the coffee goes cold. Your first project is free.

', +); diff --git a/inc/patterns/faq-boxed-list.php b/inc/patterns/faq-boxed-list.php new file mode 100644 index 000000000..3ef4cc149 --- /dev/null +++ b/inc/patterns/faq-boxed-list.php @@ -0,0 +1,13 @@ + __( 'FAQ Boxed List', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'faq' ), + 'description' => __( 'FAQ accordion with boxed expandable cards in a centered single column, plus and minus toggles, for support questions, help center, and common answers.', 'otter-blocks' ), + 'content' => '
Support

Everything you need to know

A quick rundown of the questions we hear most. Reach out anytime if yours isn’t covered here.

What’s included in the free trial?

The trial unlocks every feature on the Pro plan for 14 days, with no credit card and no limits on what you can build.

Can I import my existing content?

Yes. Bring your data over with our guided importer or drop in a CSV — mapping happens automatically.

How does billing work?

Pick monthly or annual billing. Annual saves you two months, and you can switch cadence whenever it suits you.

Do you integrate with my tools?

We connect with hundreds of apps out of the box, and anything we don’t cover can be wired up through Zapier or the API.

What kind of support do I get?

Every customer gets email and chat support with a median first reply under an hour. Pro plans add priority routing.

', +); diff --git a/inc/patterns/faq-two-column.php b/inc/patterns/faq-two-column.php new file mode 100644 index 000000000..66f71817f --- /dev/null +++ b/inc/patterns/faq-two-column.php @@ -0,0 +1,13 @@ + __( 'FAQ Two Column', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'faq' ), + 'description' => __( 'FAQ accordion in a split two-column layout with sticky intro and contact link beside collapsible chevron questions, for help center and common answers.', 'otter-blocks' ), + 'content' => '
FAQ

Frequently asked questions

Can’t find what you’re looking for? We’ve gathered the answers our customers reach for most so you can get moving faster.

Still have questions?

Contact our team

How long does setup take?

Most teams are up and running in under ten minutes. Connect a source, pick a template, and you’re live.

Do I need a developer?

Not at all. Everything is built for non-technical teams, though a full API is there when you want it.

Can I invite my whole team?

Yes — every plan includes unlimited viewers, so the people who need the numbers always have them.

Is my data secure?

All data is encrypted in transit and at rest. We’re SOC 2 Type II certified and audited annually.

What if I need to cancel?

Cancel in a single click, no phone calls. You keep access until the end of your current billing period.

Do you offer onboarding help?

Every annual plan comes with a guided onboarding session and a dedicated success manager for the first 90 days.

', +); diff --git a/inc/patterns/features-1.php b/inc/patterns/features-1.php deleted file mode 100644 index 861b3fbf1..000000000 --- a/inc/patterns/features-1.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Features', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'features', 'columns' ), - 'content' => '
WHY CHOOSE US

Trust

Quality

Innovation

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/features-2.php b/inc/patterns/features-2.php deleted file mode 100644 index 702743377..000000000 --- a/inc/patterns/features-2.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Features', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'features', 'columns' ), - 'content' => '
This is an overline
', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/features-3.php b/inc/patterns/features-3.php deleted file mode 100644 index 8aede8d68..000000000 --- a/inc/patterns/features-3.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Features', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'features', 'columns' ), - 'content' => '
SECTION OVERLINE

Why workout here?

Boost your Fitness

Click edit button to change this text. Lorem ipsum dolor sit amet, consectetur adipiscing elit.

Mindful Training

Click edit button to change this text. Lorem ipsum dolor sit amet, consectetur adipiscing elit.

Core strength

Click edit button to change this text. Lorem ipsum dolor sit amet, consectetur adipiscing elit.

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/features-4.php b/inc/patterns/features-4.php deleted file mode 100644 index 6896384bd..000000000 --- a/inc/patterns/features-4.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Features', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'features', 'columns' ), - 'content' => '
READ MORE
READ MORE
READ MORE
', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/features-5.php b/inc/patterns/features-5.php deleted file mode 100644 index 1d79d8d40..000000000 --- a/inc/patterns/features-5.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Features', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'features', 'columns' ), - 'content' => '
', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/fitness-about.php b/inc/patterns/fitness-about.php deleted file mode 100644 index 6fb39964a..000000000 --- a/inc/patterns/fitness-about.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Fitness - About', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'pages', 'fitness-pack' ), - 'content' => '

Our Gym

SECTION OVERLINE

A place to workout

https://www.youtube.com/watch?v=72BSZupb-1I

Experienced

Click edit button to change this text. Lorem ipsum dolor sit amet, consectetur adipiscing elit.

Professional

Click edit button to change this text. Lorem ipsum dolor sit amet, consectetur adipiscing elit.

Motivated

Click edit button to change this text. Lorem ipsum dolor sit amet, consectetur adipiscing elit.

SECTION OVERLINE

Get an introductory lesson for free!

List item 5

List item 5

List item 5

List item 5

List item 5

ALBERT GARRETT

IN THE CENTRE OF THE CITY

Come visit us!

9:00 AM TO 11:00 PM (SUNDAY CLOSED)

Kale chips knausgaard mustache blog fashion axe selfies salvia. Gluten-free post-ironic deep v typewriter. Cloud bread flannel poke, flexitarian vinyl iPhone church-key shaman williamsburg kitsch beard.

0032-773 88291HELLO@NEVEFIT.COM21 JUMP STREET, SAN FRANCISCO
', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/fitness-contact.php b/inc/patterns/fitness-contact.php deleted file mode 100644 index ca5c00176..000000000 --- a/inc/patterns/fitness-contact.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Fitness - Contact', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'pages', 'fitness-pack' ), - 'content' => '

Contact us

LET\'S MEET EACH OTHER0032-773 88291HELLO@NEVEFIT.COM21 JUMP STREET, SAN FRANCISCO
', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/fitness-group.php b/inc/patterns/fitness-group.php deleted file mode 100644 index a7c9b847b..000000000 --- a/inc/patterns/fitness-group.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Fitness - Group', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'pages', 'fitness-pack' ), - 'content' => '

Crossfit

BENEFITS
STRENGTH
80%
MINDSET
27%
ENDURANCE
39%

Monday


18:00-19:00

20:00-21:00

Wednesday


18:00-19:00

20:00-21:00

Friday


18:00-19:00

20:00-21:00

SECTION OVERLINE

Membership options

3 LESSONS PER WEEKSAVE 10%
3 LESSONS PER WEEKCANCEL ANYTIME
PAY AS YOU GOCANCEL ANYTIME
SECTION OVERLINE

With Jason Drone

Kale chips knausgaard mustache blog fashion axe selfies salvia. Gluten-free post-ironic deep v typewriter. Cloud bread flannel poke, flexitarian vinyl iPhone church-key shaman williamsburg kitsch beard.

SECTION OVERLINE

List item 5

List item 5

List item 5

List item 5

List item 5

JERRY FREEMAN

List item 5

List item 5

List item 5

List item 5

List item 5

JERRY FREEMAN

List item 5

List item 5

List item 5

List item 5

List item 5

JERRY FREEMAN
SECTION OVERLINE

Get an introductory lesson for free!

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/fitness-groups.php b/inc/patterns/fitness-groups.php deleted file mode 100644 index e79725ab1..000000000 --- a/inc/patterns/fitness-groups.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Fitness - Groups', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'pages', 'fitness-pack' ), - 'content' => '

Training Groups

Weights training


WITH Anne Anderson

WEDNESDAY 18:00-19:00

FRIDAY 20:00-21:00

Crossfit


WITH James Anderson

WEDNESDAY 18:00-19:00

FRIDAY 20:00-21:00


WITH JOHN SATHAM

WEDNESDAY 18:00-19:00

FRIDAY 20:00-21:00

EXPERT STAFF

Our Trainer team

CROSSFIT

James Anderson

Kale chips knausgaard mustache blog fashion axe selfies salvia.

WEIGHTS

Anne Anderson

Kale chips knausgaard mustache blog fashion axe selfies salvia.

PILATES

Mike Williams

Kale chips knausgaard mustache blog fashion axe selfies salvia.

SECTION OVERLINE

Weekly class schedule


18:00-19:00 - PILATES

20:00-21:00 - CROSSFIT

20:00-21:00 - WEIGHTS

20:00-21:00 - PILATES

20:00-21:00 - CROSSFIT


18:00-19:00 - PILATES

20:00-21:00 - CROSSFIT

20:00-21:00 - WEIGHTS

20:00-21:00 - PILATES

20:00-21:00 - CROSSFIT


18:00-19:00 - PILATES

20:00-21:00 - CROSSFIT

20:00-21:00 - WEIGHTS

20:00-21:00 - PILATES

20:00-21:00 - CROSSFIT


18:00-19:00 - PILATES

20:00-21:00 - CROSSFIT

20:00-21:00 - WEIGHTS

20:00-21:00 - PILATES

20:00-21:00 - CROSSFIT


18:00-19:00 - PILATES

20:00-21:00 - CROSSFIT

20:00-21:00 - WEIGHTS

20:00-21:00 - PILATES

20:00-21:00 - CROSSFIT


18:00-19:00 - PILATES

20:00-21:00 - CROSSFIT

20:00-21:00 - WEIGHTS

20:00-21:00 - PILATES

20:00-21:00 - CROSSFIT

SECTION OVERLINE

Get an introductory lesson for free!

SECTION OVERLINE

Frequently asked Questions

Can I have a personal training session?

Click edit button to change this text. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut elit tellus, luctus nec ullamcorper mattis, pulvinar dapibus leo.

I want more information on the schedule

Click edit button to change this text. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut elit tellus, luctus nec ullamcorper mattis, pulvinar dapibus leo.

Is the introductory lesson really free?

Click edit button to change this text. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut elit tellus, luctus nec ullamcorper mattis, pulvinar dapibus leo.

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/fitness-homepage.php b/inc/patterns/fitness-homepage.php deleted file mode 100644 index 9f8b20a64..000000000 --- a/inc/patterns/fitness-homepage.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Fitness - Homepage', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'pages', 'fitness-pack' ), - 'content' => '

A Cutting-edge Fitness Gym

Welcome to a Gym where training happens in a different way. Get active and come workout with us!

SECTION OVERLINE

Welcome to our Gym

SECTION OVERLINE

Why workout here?

Boost your Fitness

Click edit button to change this text. Lorem ipsum dolor sit amet, consectetur adipiscing elit.

Mindful Training

Click edit button to change this text. Lorem ipsum dolor sit amet, consectetur adipiscing elit.

Core strength

Click edit button to change this text. Lorem ipsum dolor sit amet, consectetur adipiscing elit.

SECTION OVERLINE

Testimonials

List item 5

List item 5

List item 5

List item 5

List item 5

ANTHONY MARTIN

List item 5

List item 5

List item 5

List item 5

List item 5

JERRY FREEMAN

SECTION OVERLINE

Our classes

Weights

Crossfit

Pilates

CONNECT ON SOCIAL
SECTION OVERLINE

Get an introductory lesson for free!

GYM IN ACTION

Image Gallery

8k

PEOPLE TRAINED

220

SQUARE FEET AREA

7

YEARS OF EXPERIENCE
IN THE CENTRE OF THE CITY

Come visit us!

9:00 AM TO 11:00 PM (SUNDAY CLOSED)0032-773 88291HELLO@NEVEFIT.COM21 JUMP STREET, SAN FRANCISCO
', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/form-1.php b/inc/patterns/form-1.php deleted file mode 100644 index da1dc29b3..000000000 --- a/inc/patterns/form-1.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Form', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'forms' ), - 'content' => '
', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/form-2.php b/inc/patterns/form-2.php deleted file mode 100644 index 6690f185e..000000000 --- a/inc/patterns/form-2.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Form', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'forms' ), - 'content' => '

I will be happy to hear from you! Feel free to view my services and work with me on your next project.

You agree to receive email communication from us by submitting this form and understand that your contact information will be stored with us.

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/form-3.php b/inc/patterns/form-3.php deleted file mode 100644 index 0b417e360..000000000 --- a/inc/patterns/form-3.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Form', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'forms' ), - 'content' => '
LET\'S MEET EACH OTHER0032-773 88291HELLO@NEVEFIT.COM21 JUMP STREET, SAN FRANCISCO
', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/form-4.php b/inc/patterns/form-4.php deleted file mode 100644 index 39b2f0c3b..000000000 --- a/inc/patterns/form-4.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Form', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'forms' ), - 'content' => '
WORKING WITH US
', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/form-glow-card.php b/inc/patterns/form-glow-card.php new file mode 100644 index 000000000..03ca59e4e --- /dev/null +++ b/inc/patterns/form-glow-card.php @@ -0,0 +1,13 @@ + __( 'Get Access Form on Dark', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'forms' ), + 'description' => __( 'Centered lead capture form on a dark glow gradient background with white card, for early access waitlist, request invite, and signup.', 'otter-blocks' ), + 'content' => '
Early access

Be the first to know

Request access and we’ll send your private invite the moment we open the next round.

', +); diff --git a/inc/patterns/form-gradient-split.php b/inc/patterns/form-gradient-split.php new file mode 100644 index 000000000..6811ee13a --- /dev/null +++ b/inc/patterns/form-gradient-split.php @@ -0,0 +1,13 @@ + __( 'Contact Form with Gradient Panel', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'forms' ), + 'description' => __( 'Split two-column contact form with a gradient info panel showing email, phone and address beside name, email and message fields for inquiries.', 'otter-blocks' ), + 'content' => '
Let’s talk

Let’s build something great together

Tell us about your project and a real person will get back to you within one business day.

hello@example.com
+1 (555) 987-6543
42 Harbor Lane, Austin TX
', +); diff --git a/inc/patterns/gallery-feature-split.php b/inc/patterns/gallery-feature-split.php new file mode 100644 index 000000000..6ba9c8027 --- /dev/null +++ b/inc/patterns/gallery-feature-split.php @@ -0,0 +1,13 @@ + __( 'Gallery Feature Split', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'gallery' ), + 'description' => __( 'Editorial image gallery with one large hero photo split beside a four-tile grid and gradient overlay captions, for photography portfolio and lookbook showcases.', 'otter-blocks' ), + 'content' => '
Behind the lens

One hero frame, a wall of detail

Editorial

The cover shoot

120+ frames

View full set
', +); diff --git a/inc/patterns/gallery-overlay-grid.php b/inc/patterns/gallery-overlay-grid.php new file mode 100644 index 000000000..df580ff7c --- /dev/null +++ b/inc/patterns/gallery-overlay-grid.php @@ -0,0 +1,13 @@ + __( 'Gallery Overlay Grid', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'gallery' ), + 'description' => __( 'Dark three-column image gallery grid with hover gradient overlay captions and categories, for photography portfolio, masonry showcase, and visual lookbook.', 'otter-blocks' ), + 'content' => '
Featured shots

A portfolio that speaks for itself

Hover any frame to read the story behind the shot. Every image is hand–picked from our latest sessions.

Architecture

Concrete & light

Portrait

Golden hour

Travel

Coastal mornings

Studio

Texture in motion

Street

City after rain

Nature

Quiet valleys

', +); diff --git a/inc/patterns/gallery.php b/inc/patterns/gallery.php deleted file mode 100644 index 50b85b5fb..000000000 --- a/inc/patterns/gallery.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Gallery', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'featured', 'gallery' ), - 'content' => '

Gallery section

A gallery with a text column and some text

', -); diff --git a/inc/patterns/hero-1.php b/inc/patterns/hero-1.php deleted file mode 100644 index 6ddd6683d..000000000 --- a/inc/patterns/hero-1.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Hero', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'header' ), - 'content' => '

A Cutting-edge Fitness Gym

Welcome to a Gym where training happens in a different way. Get active and come workout with us!

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/hero-2.php b/inc/patterns/hero-2.php deleted file mode 100644 index 795e21d6a..000000000 --- a/inc/patterns/hero-2.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Hero', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'featured', 'header' ), - 'content' => '

Business consulting with a different approach

This is a description for the hero section. Synergestic actionables. Organic growth deep dive but circle back.

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/hero-area-with-button.php b/inc/patterns/hero-area-with-button.php deleted file mode 100644 index 996c66537..000000000 --- a/inc/patterns/hero-area-with-button.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Hero Area with Button', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'featured', 'columns', 'header' ), - 'content' => '

Pumpkins & Penguins

Sed ut perspiciatis unde omnis iste natus

', -); diff --git a/inc/patterns/icon-feature-trio.php b/inc/patterns/icon-feature-trio.php new file mode 100644 index 000000000..5b9df7993 --- /dev/null +++ b/inc/patterns/icon-feature-trio.php @@ -0,0 +1,13 @@ + __( 'Icon Feature Trio', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'icons' ), + 'description' => __( 'Centered three-column icon feature grid with rounded icon badges, headings, and descriptions highlighting speed, security, and support benefits or services.', 'otter-blocks' ), + 'content' => '
Why teams choose us

Built for speed, scale, and peace of mind

Blazing fast

Sub-second loads on every page, powered by an edge network close to your users.

Secure by default

Encryption, SSO, and audit logs ship in the box — no add-ons, no surprises.

Always-on support

Real humans, around the clock, with a median first reply under five minutes.

', +); diff --git a/inc/patterns/icon-grid-bordered.php b/inc/patterns/icon-grid-bordered.php new file mode 100644 index 000000000..4ea29c5eb --- /dev/null +++ b/inc/patterns/icon-grid-bordered.php @@ -0,0 +1,13 @@ + __( 'Bordered Icon Grid', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'icons' ), + 'description' => __( 'Bordered three-column icon feature grid of six cells with dividers, listing product features and benefits like onboarding, analytics, security, and support.', 'otter-blocks' ), + 'content' => '
The essentials

Everything that comes standard

Fast onboarding

Get your team live in minutes, not weeks.

Unified workspace

Every tool and file in one tidy place.

Live analytics

Watch the numbers move as they happen.

Bank-grade security

Encrypted end to end, audited around the clock.

Real-time sync

Changes land everywhere the instant they happen.

24/7 support

Talk to a real person whenever you need one.

', +); diff --git a/inc/patterns/icons-and-text.php b/inc/patterns/icons-and-text.php deleted file mode 100644 index 1169834c0..000000000 --- a/inc/patterns/icons-and-text.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Icons and Text', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'columns' ), - 'content' => '

Just another Section with a title on the side

Customisable

Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

Online

Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

Secure

Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

Strategic

Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

', -); diff --git a/inc/patterns/image-and-text-over-dark-background.php b/inc/patterns/image-and-text-over-dark-background.php deleted file mode 100644 index de1875463..000000000 --- a/inc/patterns/image-and-text-over-dark-background.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Image and Text over Dark Background', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'featured', 'columns' ), - 'content' => '

Overline

Section with image

Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta.

', -); diff --git a/inc/patterns/industrial-about.php b/inc/patterns/industrial-about.php deleted file mode 100644 index ab902c738..000000000 --- a/inc/patterns/industrial-about.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Industrial - About', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'pages', 'business-pack' ), - 'content' => '

About Us

INNOVATION AND CRAFTSMANSHIP

Experienced Team

Centralized Software Delivery

Latest technology stack

Hella wayfarers microdosing sartorial single-origin coffee.
Chillwave life gentrify live edge williamsburg edison bulb,
subway tile.

COMPANY CEO

Vincent Reynolds

WHY CHOOSE US

Trust

Quality

Innovation

SECTION OVERLINE

CEO - COFOUNDER

HEAD OF R&D

MARKETING MANAGER

Our Achievements

Create custom header layouts in your favourite page builder and display.

990+

PROJECTS

130k

ACTIVE CUSTOMERS

45

YEARS OF EXPERIENCE

220+

PARTNERS
', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/industrial-homepage.php b/inc/patterns/industrial-homepage.php deleted file mode 100644 index 6032a4d5f..000000000 --- a/inc/patterns/industrial-homepage.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Industrial - Homepage', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'pages', 'business-pack' ), - 'content' => '

Industry Largest
Company

Ramps photo booth gastropub af, gluten-free cronut 3 wolf moon DIY umami
neutra vaporware vexillologist occupy. Lomo fam YOLO kitsch polaroid. Prism
kitsch kombucha keffiyeh art party direct trade mixtap.

HIGH PERFORMANCE SINCE 1974

About Us

WE ARE MATERIALYARD

We Provide Best Service

Adaptogen artisan aesthetic, lomo swag street art salvia kombucha e Freegan chia ullamco reprehenderit chicharrones, echo park esse tilde. 90’s hammock do cupidatat, edison bulb mumblecore vape cloud bread put a bird on it dreamcatcher artisan lomo raw denim.

WE WORK WITH GLOBAL INDUSTRIES

Industrial B2B Solutions

READ MORE
READ MORE
READ MORE
OUR CLIENTS
WHAT PEOPLE SAY

Our Clients Reviews

Adaptogen artisan aesthetic, lomo swag street art salvia kombucha e Freegan chia ullamco reprehenderit chicharrones, echo park esse tilde. 90’s hammock do cupidatat, edison bulb mumblecore vape cloud bread put a bird on it dreamcatcher artisan lomo raw denim.

List item 5

List item 5

List item 5

List item 5

List item 5

Adaptogen artisan aesthetic, lomo swag street art salvia ucha ennui fanny pack pinterest. Irony mixtape raclette before they sold out, microng tattooed synth enamel pin blog smito nest dale.

LARRY MITCHELL

CEO - Sepino

List item 5

List item 5

List item 5

List item 5

List item 5

Adaptogen artisan aesthetic, lomo swag street art salvia ucha ennui fanny pack pinterest. Irony mixtape raclette before they sold out, microng tattooed synth enamel pin blog smito nest dale.

JEREMY KNIGHT

Alasa Engineering

WORKING WITH US
', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/industrial-service.php b/inc/patterns/industrial-service.php deleted file mode 100644 index 5adbdc97a..000000000 --- a/inc/patterns/industrial-service.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Industrial - Service Details', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'pages', 'business-pack' ), - 'content' => '

Heavy Metal
Machinery

OUR MINDSET

Get a Quote

Jason Vickens

Partnerships Manager

u003cstrongu003e(081) 1149-5421-2315u003c/strongu003e

u003cstrongu003eJASON@INDUSTRIAL.TIu003c/strongu003e

OUR VISION
', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/industrial-services.php b/inc/patterns/industrial-services.php deleted file mode 100644 index 66cc5f9e5..000000000 --- a/inc/patterns/industrial-services.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Industrial - Services', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'pages', 'business-pack' ), - 'content' => '

Services

WE SHAPE THE FUTURE
READ MORE
READ MORE
READ MORE
READ MORE
DATA & CLOUD COMPUTING

How We Work

https://www.youtube.com/watch?v=AyWtIwwEgS0
SECTION OVERLINE
', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/large-quote.php b/inc/patterns/large-quote.php deleted file mode 100644 index 6d3ccf90d..000000000 --- a/inc/patterns/large-quote.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Large Quote', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'text' ), - 'content' => '

"...The greatest victory is that which requires no battle...."

Sun Tzu, The Art of War

', -); diff --git a/inc/patterns/maps-location.php b/inc/patterns/maps-location.php new file mode 100644 index 000000000..6ba0d5970 --- /dev/null +++ b/inc/patterns/maps-location.php @@ -0,0 +1,13 @@ + __( 'Map And Location Details', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'contact' ), + 'description' => __( 'Split two-column contact section with address, hours and email icon list beside an embedded interactive location map, for store visit and directions.', 'otter-blocks' ), + 'content' => '

Come say hello

Our studio sits right in the heart of the city. Drop by during opening hours or reach us using the details below.

200 Market Street, Barcelona

Mon–Fri, 9am to 6pm

hello@studio.com

', +); diff --git a/inc/patterns/newsletter-inline-bar.php b/inc/patterns/newsletter-inline-bar.php new file mode 100644 index 000000000..ee2fad32f --- /dev/null +++ b/inc/patterns/newsletter-inline-bar.php @@ -0,0 +1,13 @@ + __( 'Newsletter Inline Bar', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'newsletter' ), + 'description' => __( 'Inline newsletter signup bar in a bordered card with split two-column layout, headline beside an email subscribe field, for email list and weekly digest.', 'otter-blocks' ), + 'content' => '

Get the weekly digest

One concise email every Friday with the best of what we published. No spam, unsubscribe anytime.

Join 8,000+ subscribers. We respect your inbox.

', +); diff --git a/inc/patterns/newsletter-signup.php b/inc/patterns/newsletter-signup.php new file mode 100644 index 000000000..d2497f6a4 --- /dev/null +++ b/inc/patterns/newsletter-signup.php @@ -0,0 +1,13 @@ + __( 'Newsletter Signup', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'newsletter' ), + 'description' => __( 'Centered newsletter signup on a dark background with headline and email subscribe form, for email list opt-in, weekly digest, and lead capture.', 'otter-blocks' ), + 'content' => '
Newsletter

One useful email, every Friday

Design tips, product updates, and zero fluff. Join 8,000+ readers — unsubscribe in one click whenever.

No spam, ever. We’ll never share your email.

', +); diff --git a/inc/patterns/portfolio-overlay-grid.php b/inc/patterns/portfolio-overlay-grid.php new file mode 100644 index 000000000..df5d482c5 --- /dev/null +++ b/inc/patterns/portfolio-overlay-grid.php @@ -0,0 +1,13 @@ + __( 'Portfolio Overlay Grid', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'portfolio' ), + 'description' => __( 'Dark three-column portfolio grid of project cards with hover gradient overlays, category tags, and arrow icons, for case studies, agency work, and showcase.', 'otter-blocks' ), + 'content' => '
Selected work

Work that speaks for itself

Branding

Aurora rebrand

Web design

Meridian platform

Mobile app

Vela fitness

Campaign

Cobalt launch

Packaging

Lyra goods

Motion

Nimbus film

', +); diff --git a/inc/patterns/pricing-columns.php b/inc/patterns/pricing-columns.php new file mode 100644 index 000000000..fe20c4a89 --- /dev/null +++ b/inc/patterns/pricing-columns.php @@ -0,0 +1,13 @@ + __( 'Pricing Columns', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'pricing' ), + 'description' => __( 'Three-column pricing table with a highlighted most-popular plan, monthly prices, checkmark feature lists, and CTA buttons, for subscription plans and tiers.', 'otter-blocks' ), + 'content' => '
Pricing

Every plan includes a 14-day free trial and all core features. No hidden fees, ever.

Hobby

$12

per user / month

For side projects and weekend ideas.

2 projects

Basic analytics

Community support

1 seat

Most popular
Studio

$36

per user / month

For teams that ship every single week.

Unlimited projects

Advanced analytics

Priority support

15 seats

Agency

$79

per user / month

For agencies juggling many clients.

Everything in Studio

White-label exports

Client workspaces

Unlimited seats

', +); diff --git a/inc/patterns/pricing-compare-simple.php b/inc/patterns/pricing-compare-simple.php new file mode 100644 index 000000000..8939e3326 --- /dev/null +++ b/inc/patterns/pricing-compare-simple.php @@ -0,0 +1,13 @@ + __( 'Simple Pricing Comparison', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'pricing' ), + 'description' => __( 'Minimal three-column pricing comparison with bordered cards, a raised gradient most-popular tier, checkmark feature lists, and CTAs, for subscription plans.', 'otter-blocks' ), + 'content' => '
Plans

Pick the plan that fits

No hidden fees, cancel anytime. Switch or scale your plan whenever you need.

Basic

$12/mo

For freelancers and solo makers.

5 active projects
10 GB storage
Email support
Basic analytics
Choose Basic
Most popular

Growth

$32/mo

For teams shipping every week.

Unlimited projects
250 GB storage
Priority support
Advanced analytics
Team collaboration
Start free trial

Scale

$79/mo

For companies with serious scale.

Everything in Growth
Unlimited storage
SSO & audit logs
Dedicated manager
Contact sales
', +); diff --git a/inc/patterns/pricing.php b/inc/patterns/pricing.php deleted file mode 100644 index cc92b9de8..000000000 --- a/inc/patterns/pricing.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Pricing', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'pricing' ), - 'content' => '

Illustration

$200

Lorem ipsum dolor sit amet, consectetur adipiscing.


Custom Layouts & Hooks

Unlimited Website Usage

Risk-Free Guarantee

Digital Design

$900

Lorem ipsum dolor sit amet, consectetur adipiscing.


Custom Layouts & Hooks

Unlimited Website Usage

Risk-Free Guarantee

Graphic design

$700

Lorem ipsum dolor sit amet, consectetur adipiscing.


Custom Layouts & Hooks

Unlimited Website Usage

Risk-Free Guarantee

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/service-boxes-on-dark-background.php b/inc/patterns/service-boxes-on-dark-background.php deleted file mode 100644 index b345f49ce..000000000 --- a/inc/patterns/service-boxes-on-dark-background.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Service Boxes on Dark Background', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'featured', 'columns' ), - 'content' => '

Subheader

Services Content

Tousled letterpre tote bag bicycle rights cliche twee hashtag pokpo demos tanero lamina sime voti. Quinoa ramps hashtag yuccie, selfies af wolf. Thundercats brunch gastropub whatever poutine tattooed godard bespoke blog seitan flannel jianbing bitters cloud.

Service 1

Tousled letterpre tote bag bicycle rights cliche twee hashtag pokpo demos tanero lamina sime voti. Wolf moon shoreditch biodiesel hoodie kale chips bitter.

Service 3

Tousled letterpre tote bag bicycle rights cliche twee hashtag pokpo demos tanero lamina sime voti. Wolf moon shoreditch biodiesel hoodie kale chips bitter.

Service 2

Tousled letterpre tote bag bicycle rights cliche twee hashtag pokpo demos tanero lamina sime voti. Wolf moon shoreditch biodiesel hoodie kale chips bitter.

', -); diff --git a/inc/patterns/stats-row.php b/inc/patterns/stats-row.php new file mode 100644 index 000000000..1c9209c9f --- /dev/null +++ b/inc/patterns/stats-row.php @@ -0,0 +1,13 @@ + __( 'Stats Row', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'stats' ), + 'description' => __( 'Centered four-column stats bar with big number counters and labels, for key metrics, company numbers, achievements, and social proof figures.', 'otter-blocks' ), + 'content' => '
200+

Projects shipped

98%

Client retention

14

Industry awards

3

Continents covered

', +); diff --git a/inc/patterns/team-1.php b/inc/patterns/team-1.php deleted file mode 100644 index b6502a9a2..000000000 --- a/inc/patterns/team-1.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Team 1', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'columns', 'team' ), - 'content' => '
SECTION OVERLINE

CEO - COFOUNDER

HEAD OF R&D

MARKETING MANAGER

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/team-2.php b/inc/patterns/team-2.php deleted file mode 100644 index 684ee77ee..000000000 --- a/inc/patterns/team-2.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Team 2', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'columns', 'team' ), - 'content' => '
Our Team
Founder / CEO
Operations
Operations
', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/team-cards.php b/inc/patterns/team-cards.php new file mode 100644 index 000000000..873d5a3dd --- /dev/null +++ b/inc/patterns/team-cards.php @@ -0,0 +1,13 @@ + __( 'Team Cards', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'team' ), + 'description' => __( 'Centered three-column team grid of member cards with rounded portraits, names, roles, and bios, for about page staff, founders, and meet the team.', 'otter-blocks' ), + 'content' => '
Meet the team

No layers, no ticket queues. When you write in, one of these three replies.

Portrait of Ingrid Holm

Ingrid Holm

Co-founder

Started three companies, sold two, still answers support on Tuesdays.

Portrait of Jonas Petros

Jonas Petros

Head of Engineering

Wrote the first commit and the ten thousandth. Allergic to flaky tests.

Portrait of Carmen Reyes

Carmen Reyes

Customer Success

Has personally onboarded over a thousand teams and remembers most of them.

', +); diff --git a/inc/patterns/team-members.php b/inc/patterns/team-members.php deleted file mode 100644 index b2f6d62aa..000000000 --- a/inc/patterns/team-members.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Team Members', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'featured', 'columns', 'team' ), - 'content' => '

Our Team

The whole is much more than the sum of it\'s part. Meet our team!

Jake Austin

Founder

Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

John Peterson

Technical Lead

Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

Mary Andrews

Marketing

Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

Josh Bourne

Support

Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque.

', -); diff --git a/inc/patterns/team-spotlight-lead.php b/inc/patterns/team-spotlight-lead.php new file mode 100644 index 000000000..b6476f1cf --- /dev/null +++ b/inc/patterns/team-spotlight-lead.php @@ -0,0 +1,13 @@ + __( 'Team Spotlight Lead', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'team' ), + 'description' => __( 'Team section featuring a founder spotlight split layout above a three-column team grid with photos, roles, and social icons for about pages and leadership bios', 'otter-blocks' ), + 'content' => '
Leadership

Meet the people behind the work

Founder portrait
Founder & CEO

Maya Okonkwo

“We started this company on one belief — that great tools should feel invisible. Fifteen years later, that is still the only metric I care about.”

Daniel Reyes

Daniel Reyes

Head of Design
Priya Nair

Priya Nair

VP Engineering
Liam Chen

Liam Chen

Head of Product
', +); diff --git a/inc/patterns/testimonial-columns.php b/inc/patterns/testimonial-columns.php deleted file mode 100644 index 592c2404f..000000000 --- a/inc/patterns/testimonial-columns.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Testimonial Columns', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'testimonials', 'featured', 'columns' ), - 'content' => '

"...Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque..."

Jason Doe

"...Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque..."

Jason Doe

', -); diff --git a/inc/patterns/testimonial-with-inline-image.php b/inc/patterns/testimonial-with-inline-image.php deleted file mode 100644 index 6e9230cfa..000000000 --- a/inc/patterns/testimonial-with-inline-image.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Testimonial with Inline Image', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'testimonials' ), - 'content' => '

"...Sed ut perspiciatis unde omnis natus error sit voluptatem accusantium doloremque..."

Jason Doe

', -); diff --git a/inc/patterns/testimonials-1.php b/inc/patterns/testimonials-1.php deleted file mode 100644 index bc9079731..000000000 --- a/inc/patterns/testimonials-1.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Testimonials 1', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'columns', 'testimonials' ), - 'content' => '
Jason Johnson
Julia Williams
MARK Spencer
', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/testimonials-2.php b/inc/patterns/testimonials-2.php deleted file mode 100644 index 4df716a6f..000000000 --- a/inc/patterns/testimonials-2.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Testimonials 2', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'columns', 'testimonials' ), - 'content' => '
SECTION OVERLINE

Testimonials

List item 5

List item 5

List item 5

List item 5

List item 5

ANTHONY MARTIN

List item 5

List item 5

List item 5

List item 5

List item 5

JERRY FREEMAN

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/testimonials-3.php b/inc/patterns/testimonials-3.php deleted file mode 100644 index b99d40924..000000000 --- a/inc/patterns/testimonials-3.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Testimonials 3', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'columns', 'testimonials' ), - 'content' => '
WHAT PEOPLE SAY

Our Clients Reviews

Adaptogen artisan aesthetic, lomo swag street art salvia kombucha e Freegan chia ullamco reprehenderit chicharrones, echo park esse tilde. 90’s hammock do cupidatat, edison bulb mumblecore vape cloud bread put a bird on it dreamcatcher artisan lomo raw denim.

List item 5

List item 5

List item 5

List item 5

List item 5

Adaptogen artisan aesthetic, lomo swag street art salvia ucha ennui fanny pack pinterest. Irony mixtape raclette before they sold out, microng tattooed synth enamel pin blog smito nest dale.

LARRY MITCHELL

CEO - Sepino

List item 5

List item 5

List item 5

List item 5

List item 5

Adaptogen artisan aesthetic, lomo swag street art salvia ucha ennui fanny pack pinterest. Irony mixtape raclette before they sold out, microng tattooed synth enamel pin blog smito nest dale.

JEREMY KNIGHT

Alasa Engineering

', // phpcs:ignore WordPressVIPMinimum.Security.Mustache.OutputNotation -); diff --git a/inc/patterns/text-lead-statement.php b/inc/patterns/text-lead-statement.php new file mode 100644 index 000000000..908134b8f --- /dev/null +++ b/inc/patterns/text-lead-statement.php @@ -0,0 +1,13 @@ + __( 'Text Lead Statement', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'text' ), + 'description' => __( 'Dark centered mission statement section with a large lead paragraph and author byline, ideal for company values, brand manifesto, or about-us intro copy', 'otter-blocks' ), + 'content' => '
Our mission

We are here to give every team the tools to ship their best work — without the busywork, the bloat, or the endless tabs. Simplicity is the whole product.

Author
Maya OkonkwoFounder & CEO
', +); diff --git a/inc/patterns/text-two-column-prose.php b/inc/patterns/text-two-column-prose.php new file mode 100644 index 000000000..ec87f5fd4 --- /dev/null +++ b/inc/patterns/text-two-column-prose.php @@ -0,0 +1,13 @@ + __( 'Text Two Column Prose', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'text' ), + 'description' => __( 'Editorial two-column prose section with a heading, intro, and side-by-side body text, great for our-story, about-us narrative, and long-form content blocks', 'otter-blocks' ), + 'content' => '
Our story

Why we build the way we do

We believe software should get out of your way. Every decision we make starts from that single, stubborn principle — and it shapes everything from our roadmap to the words on this page.

A focus on craft

Speed matters, but not at the cost of care. We sweat the transitions, the empty states, and the error messages nobody else reads — because those are the moments that earn trust.

That attention compounds. The result is a product that feels calm under pressure, even as it does more.

Built in the open

Our changelog is public, our roadmap is shaped by the people who use the product, and our support team sits two desks from engineering.

Read the full manifesto
', +); diff --git a/inc/patterns/text-with-image-columns.php b/inc/patterns/text-with-image-columns.php deleted file mode 100644 index b8090fc12..000000000 --- a/inc/patterns/text-with-image-columns.php +++ /dev/null @@ -1,12 +0,0 @@ - __( 'Text with Image Columns', 'otter-blocks' ), - 'categories' => array( 'otter-blocks', 'columns' ), - 'content' => '

Overline

Section with image

Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque.

', -); diff --git a/inc/patterns/timeline-horizontal.php b/inc/patterns/timeline-horizontal.php new file mode 100644 index 000000000..6df8199ec --- /dev/null +++ b/inc/patterns/timeline-horizontal.php @@ -0,0 +1,13 @@ + __( 'Horizontal Timeline', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'timeline' ), + 'description' => __( 'Horizontal numbered process timeline in a four-column grid showing steps from discovery to launch, ideal for how-it-works, roadmaps, and workflow stages', 'otter-blocks' ), + 'content' => '
Process

A clear path from start to launch

01

Discover

We map your goals and audit what you already have so nothing gets rebuilt twice.

02

Design

A working prototype lands in your inbox within the first week, ready for real feedback.

03

Build

We ship in weekly increments so you can watch progress instead of waiting for a big reveal.

04

Launch

Go live with confidence, then lean on us for monitoring and hands-on support after release.

', +); diff --git a/inc/patterns/travel-about.php b/inc/patterns/travel-about.php new file mode 100644 index 000000000..5597a9e87 --- /dev/null +++ b/inc/patterns/travel-about.php @@ -0,0 +1,13 @@ + __( 'Travel - About', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'pages', 'travel-pack' ), + 'description' => __( 'Travel about-us page with split hero, dark stats bar, three-column values icon grid, team member grid, and closing CTA banner for tour and adventure brands', 'otter-blocks' ), + 'content' => '
About us

Built by guides, not brochures

We started as three trail guides who kept hearing the same thing: travelers wanted fewer sights and more life. So we built trips around the people and places we already loved.

See open departures
Two hikers walking a mountain trail
2016
founded
38
local guides
98%
would travel again

Fair to hosts

Guides and hosts set their own rates. We have never haggled a homestay.

Light footprint

Trains over planes where we can, and groups small enough to leave no trace.

Zero tourist traps

If a place needs a queue-jump ticket, it is probably not on our route.

The crew

The people drawing the maps

Portrait of the founder

Tomás Rivera

Founder & guide

Portrait of the head of routes

Lena Hartmann

Head of routes

Portrait of the operations lead

David Mensah

Operations

Portrait of the Italy guide

Sofia Marini

Guide, Italy

Come find your people

Most travelers arrive solo and leave with a group chat that never goes quiet.

', +); diff --git a/inc/patterns/travel-contact.php b/inc/patterns/travel-contact.php new file mode 100644 index 000000000..424ccdf0a --- /dev/null +++ b/inc/patterns/travel-contact.php @@ -0,0 +1,13 @@ + __( 'Travel - Contact', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'pages', 'travel-pack' ), + 'description' => __( 'Travel contact page with address and phone details split layout, three-column service icon grid, and a centered contact form for trip enquiries and bookings', 'otter-blocks' ), + 'content' => '
Contact

Talk to a real human

Route questions, date changes, or just wondering which trip fits — we reply within one business day, and it is never a bot.

Base camp

112 Compass Row, Portland, OR
(503) 555-0184
hello@waymarktours.com
Mon–Fri 9:00–18:00 · Sat 10:00–14:00
Get directions
Two guides talking on a mountain trail

Trip consultations

Book a free twenty-minute call and we will match you to a departure — no pressure, no upsell.

Group bookings

Traveling as six or more? We can hold a whole departure for your crew — still capped at ten.

Visas and travel docs

Every booking comes with a paperwork checklist, and we walk you through anything unclear.

Send us a note

Tell us where you want to go and when — a real guide reads every message and replies within one business day.

Every trip starts with hello

Tell us where you are dreaming of and we will take it from there — no inbox spam, ever.

', +); diff --git a/inc/patterns/travel-homepage.php b/inc/patterns/travel-homepage.php new file mode 100644 index 000000000..2051e7c3f --- /dev/null +++ b/inc/patterns/travel-homepage.php @@ -0,0 +1,13 @@ + __( 'Travel - Homepage', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'pages', 'travel-pack' ), + 'description' => __( 'Travel homepage with full-width image hero, featured trip cards grid, three-column benefits, dark stats bar, star testimonial, and booking CTA for tour operators', 'otter-blocks' ), + 'content' => '
Snow-covered mountain range at golden hour
Small groups, big horizons

Go where the road ends

Hand-built itineraries across twelve countries, led by local guides and capped at ten travelers. No flags, no megaphones.

Featured trips

Where to next?

Three departures our travelers cannot stop talking about.

Turquoise water on a tropical beach

Island Hopping, Philippines

from $1,900

Eight days of hidden lagoons, night markets, and boats that feel like home.

Venice canal with gondolas

Hidden Venice, Italy

from $1,450

Five days in the quiet quarters, with the gondola crowds far behind you.

View of Mount Everest

Everest Foothills, Nepal

from $2,800

Twelve days of teahouse trails beneath the biggest skyline on Earth.

Local guides, always

Every trip is led by someone who grew up there — not a script.

Ten travelers, max

Small enough to fit around one dinner table. Most groups leave as friends.

Book with confidence

Free date changes up to 30 days out, and a full refund if we cancel.

12
countries
94
itineraries
4.9
average rating
15k
happy travelers

Our guide knew every back alley and every grandmother selling dumplings. It felt nothing like a tour and everything like visiting an old friend.

Happy traveler smiling outdoorsPriya S. — Hidden Venice, 2025

Your next story starts here

Seats for the autumn season are open now — most trips fill within a month.

', +); diff --git a/inc/patterns/travel-tours.php b/inc/patterns/travel-tours.php new file mode 100644 index 000000000..68ac18ff7 --- /dev/null +++ b/inc/patterns/travel-tours.php @@ -0,0 +1,13 @@ + __( 'Travel - Tours', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'pages', 'travel-pack' ), + 'description' => __( 'Travel tours listing page with a three-column tour cards grid showing prices and durations, an included-features split section, and a quiz CTA for trip packages', 'otter-blocks' ), + 'content' => '
Tours

Pick your pace

Slow city wanders, hard mountain miles, and everything in between. Every departure is small-group and guide-led.

Hiker on a desert canyon trail
6 days
max 10

Rim to River, Arizona

Canyon trails, slot hikes, and nights under more stars than you have ever seen.

from $1,650View dates
Kayak entering a calm mountain lake
7 days
max 8

Northern Lakes, Canada

Paddle between cabins on glassy water, with loons for an alarm clock.

from $2,100View dates
Wind-carved desert dunes
9 days
max 10

Dune Routes, Morocco

Camel caravans, Berber camps, and tea poured from a great height.

from $1,950View dates
Open highway winding through hills
What you get

Every trip includes

All stays, hand-picked and locally owned
A local guide with you, end to end
Never more than ten travelers
Airport pickup and all transfers
Carbon offset on every seat

Not sure which trip fits?

Answer five questions and we will shortlist three departures for you — no inbox spam, ever.

', +); diff --git a/inc/patterns/video-hero-play.php b/inc/patterns/video-hero-play.php new file mode 100644 index 000000000..1d5362c9c --- /dev/null +++ b/inc/patterns/video-hero-play.php @@ -0,0 +1,15 @@ + __( 'Video Hero With Play', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'video' ), + 'description' => __( 'Dark centered video hero with an embedded YouTube player in a rounded frame, headline, and caption for product demos, explainer films, and watch-the-video sections', 'otter-blocks' ), + 'content' => '
Watch the film

A product story worth pressing play on

+https://www.youtube.com/watch?v=WcS2Vi4IOYw +

Two minutes, zero jargon — see exactly how teams ship faster from the first click.

', +); diff --git a/inc/patterns/waitlist-coming-soon.php b/inc/patterns/waitlist-coming-soon.php new file mode 100644 index 000000000..cb30e0944 --- /dev/null +++ b/inc/patterns/waitlist-coming-soon.php @@ -0,0 +1,13 @@ + __( 'Coming Soon Waitlist', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'waitlist' ), + 'description' => __( 'Dark coming-soon waitlist hero with glowing mesh gradient blobs, gradient headline, email signup form, and avatar social proof for product launch and early access', 'otter-blocks' ), + 'content' => '
The countdown is on

Worth the wait

We open the doors on December 31. Join the waitlist now and we’ll hand you the keys before anyone else — plus a founding-member discount that never expires.

MemberMemberMemberMember
1,200+ already on the list
', +); diff --git a/inc/patterns/wellness-classes.php b/inc/patterns/wellness-classes.php new file mode 100644 index 000000000..7b6f5f4e6 --- /dev/null +++ b/inc/patterns/wellness-classes.php @@ -0,0 +1,13 @@ + __( 'Wellness - Classes', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'pages', 'wellness-pack' ), + 'description' => __( 'Wellness classes page with a class schedule cards grid, a three-column pricing table for drop-in, monthly, and annual plans, and a free-trial CTA for yoga studios', 'otter-blocks' ), + 'content' => '
Schedule

A class for every hour of your day

From sunrise flows to wind-down restorative sessions, every class is capped at fourteen mats.

Sunrise yoga pose outdoors

Sunrise Flow

Wake the body up slowly with a bright, breath-led morning sequence.

Mon / Wed / Fri — 6:30
Sat — 8:00
Instructor in a bright yoga studio

Mat Pilates

Core-deep, low-impact strength work that you feel for two days, gently.

Tue / Thu — 12:15
Sun — 10:00
Smooth spa stones stacked on a towel

Restore & Unwind

Bolsters, blankets, and long supported holds to close the day down.

Mon–Fri — 19:30
Pricing

Come once, or live here

Every option includes mats, props, and tea after class.

Drop-in

$18
/class
Any single class
Mat and props included
Tea after class
Get started

Monthly

$89
/month
Unlimited classes
Free mat storage
One guest pass a month
Pause or cancel anytime
Get started

Annual

$890
/year
Two months free
Everything in Monthly
Two workshop credits
Get started

Your first class is free

Try any class on the schedule. If it is not for you, the tea is still on us.

', +); diff --git a/inc/patterns/wellness-contact.php b/inc/patterns/wellness-contact.php new file mode 100644 index 000000000..412e25ceb --- /dev/null +++ b/inc/patterns/wellness-contact.php @@ -0,0 +1,13 @@ + __( 'Wellness - Contact', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'pages', 'wellness-pack' ), + 'description' => __( 'Wellness contact page with a studio address and hours split layout, three-column visit-info icon grid, and an email CTA banner for yoga studios and spas', 'otter-blocks' ), + 'content' => '
Contact

Come breathe with us

Questions about classes, memberships, or private sessions — we answer everything within a day.

The studio

48 Alder Lane, Brooklyn, NY
(718) 555-0142
hello@stillwater.studio
Mon–Sat 6:30–21:00 · Sun 8:00–14:00
Get directions
Cup of green tea on a wooden table

Easy to find

Two blocks from the Alder Lane stop, with bike racks right out front.

Book ahead

Classes cap at fourteen mats, so evening slots go fast — reserve in the app.

First time?

Arrive ten minutes early and we will set you up with a mat and a tour.

We answer fast

Write to us about anything — from beginner nerves to private group sessions.

', +); diff --git a/inc/patterns/wellness-homepage.php b/inc/patterns/wellness-homepage.php new file mode 100644 index 000000000..676186b7a --- /dev/null +++ b/inc/patterns/wellness-homepage.php @@ -0,0 +1,13 @@ + __( 'Wellness - Homepage', 'otter-blocks' ), + 'categories' => array( 'otter-blocks', 'pages', 'wellness-pack' ), + 'description' => __( 'Wellness homepage with split image hero, class cards grid, three-column benefits, star testimonial, dark membership pricing, and free-class CTA for yoga and meditation studios', 'otter-blocks' ), + 'content' => '
New: sunrise classes at 6:30

Move gently. Breathe deeply.

A neighborhood studio for yoga, breathwork, and meditation — built for every body and every level, including absolute beginners.

Person holding a yoga pose in soft light
Classes

Find your practice

Small classes, soft light, and teachers who learn your name.

Yoga practice in a bright room

Slow Flow Yoga

60 min · all levels

A steady, breath-led sequence that builds heat without the hurry.

Person meditating cross-legged

Guided Meditation

30 min · all levels

Sit, settle, and let someone else hold the timer for once.

Deep stretching exercise on a mat

Deep Stretch

45 min · all levels

Long holds and slow releases for desk-tight shoulders and hips.

Sleep better

Evening classes are paced to send you home already half asleep, in the best way.

Quiet the noise

Ten minutes of guided breath does what three espressos cannot.

Stronger, softer

Build real strength while being kinder to your joints than any gym.

I came for the stretching and stayed for the silence. This studio quietly rewired my weeks.

Portrait of a smiling studio memberIrene T. — member for two years
Membership

One simple membership

$89
/month

Unlimited classes, free mat storage, and a guest pass every month. Pause or cancel anytime.

First class is on us

No experience needed — just comfortable clothes and twenty minutes of curiosity.

', +); diff --git a/inc/plugins/class-atomic-wind-blocks.php b/inc/plugins/class-atomic-wind-blocks.php index f56d33e28..9f99f4746 100644 --- a/inc/plugins/class-atomic-wind-blocks.php +++ b/inc/plugins/class-atomic-wind-blocks.php @@ -55,6 +55,7 @@ public function run() { add_filter( 'render_block', array( $this, 'render_post_fields' ), 20, 2 ); add_filter( 'render_block', array( $this, 'render_animation_attrs' ), 10, 2 ); add_filter( 'render_block', array( $this, 'render_state_attrs' ), 10, 2 ); + add_action( 'template_redirect', array( $this, 'maybe_render_css_warm_page' ), 0 ); add_action( 'save_post', array( $this, 'clear_cached_css' ) ); add_filter( 'block_categories_all', array( $this, 'register_category' ) ); } @@ -210,8 +211,10 @@ public function enqueue_icons_data() { ) . ';' . 'window.atomicWindEditor = ' . wp_json_encode( array( - 'restUrl' => rest_url( 'otter/v1/atomic-wind' ), - 'nonce' => wp_create_nonce( 'wp_rest' ), + 'restUrl' => rest_url( 'otter/v1/atomic-wind' ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'warmUrl' => home_url( '/' ), + 'warmNonce' => wp_create_nonce( 'atomic_wind_css_warm' ), ) ) . ';', 'before' @@ -383,6 +386,130 @@ public function clear_cached_css( $post_id ) { delete_post_meta( $post_id, '_atomic_wind_css' ); } + /** + * Render a stripped, frontend-shaped page for warming the CSS cache. + * + * @return void + */ + public function maybe_render_css_warm_page() { + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- nonce is verified below via can_render_css_warm(). + if ( empty( $_GET['atomic_wind_css_warm'] ) || empty( $_GET['post_id'] ) ) { + return; + } + + $nonce = sanitize_text_field( wp_unslash( $_GET['atomic_wind_css_warm'] ) ); + $post_id = absint( wp_unslash( $_GET['post_id'] ) ); + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + if ( ! $this->can_render_css_warm( $post_id, $nonce ) ) { + return; + } + + $post = get_post( $post_id ); + if ( ! $post instanceof \WP_Post || ! $this->post_has_atomic_wind_blocks( $post ) ) { + // Nothing to warm — leave the (already-cleared) cache empty. + return; + } + + nocache_headers(); + header( 'Content-Type: text/html; charset=' . get_option( 'blog_charset' ) ); + header( 'X-Robots-Tag: noindex, nofollow', true ); + + echo $this->render_css_warm_page_html( $post ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- self-contained document built below with per-part escaping. + exit; + } + + /** + * Whether the current request may render the CSS warm page for a post. + * + * @param int $post_id Post ID from the request. + * @param string $nonce Nonce from the request. + * @return bool + */ + private function can_render_css_warm( $post_id, $nonce ) { + return $post_id > 0 + && false !== wp_verify_nonce( $nonce, 'atomic_wind_css_warm' ) + && current_user_can( 'edit_post', $post_id ); + } + + /** + * Build the minimal HTML document for the CSS warm iframe. + * + * Kept separate from the request handling so it stays pure/testable: it + * returns a string and neither reads superglobals nor calls exit(). + * + * @param \WP_Post $post_obj Post to render. + * @return string + */ + private function render_css_warm_page_html( \WP_Post $post_obj ) { + global $post; + + $previous = $post; + $post = $post_obj; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- temporary, restored below, so render_block filters see the warmed post. + setup_postdata( $post ); + $content = do_blocks( $post_obj->post_content ); + wp_reset_postdata(); + $post = $previous; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restore the original global. + + $generator = $this->build_asset_url( 'tailwind-generator-frontend' ); + $builder = $this->build_asset_url( 'style-builder' ); + + $config = wp_json_encode( + array( + 'postId' => (int) $post_obj->ID, + 'restUrl' => rest_url( 'otter/v1/atomic-wind' ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + ) + ); + + $post_id_json = wp_json_encode( (int) $post_obj->ID ); + + // Base reset so the in-browser generator measures the real frontend layout. + $base_css = '[class*="wp-block-atomic-wind-"]{margin:0;max-width:unset;}[class*="wp-block-atomic-wind-"] p{margin:0;}'; + + // On css-saved, notify the opener (the editor) so it can drop the iframe. + // The payload carries no secret; the editor still validates the message origin. + $ping = 'document.addEventListener("atomic-wind:css-saved",function(){' + . 'if(window.parent&&window.parent!==window){' + . 'var t="*";try{if(document.referrer){t=new URL(document.referrer).origin;}}catch(e){}' + . 'window.parent.postMessage({type:"atomic-wind:css-warmed",postId:' . $post_id_json . '},t);}});'; + + $html = ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= $content; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + + return $html; + } + + /** + * Build a versioned URL for an atomic-wind build script. + * + * @param string $name Script base name (without extension), e.g. `style-builder`. + * @return string + */ + private function build_asset_url( $name ) { + $asset_file = $this->build_path() . '/' . $name . '.asset.php'; + $version = OTTER_BLOCKS_VERSION; + + if ( file_exists( $asset_file ) ) { + $asset = include $asset_file; + if ( isset( $asset['version'] ) ) { + $version = $asset['version']; + } + } + + return add_query_arg( 'ver', $version, $this->plugin_url( 'build/atomic-wind/' . $name . '.js' ) ); + } + /** * Sanitize CSS for storage without stripping @property syntax descriptors. * diff --git a/inc/plugins/class-dashboard.php b/inc/plugins/class-dashboard.php index 80d07cbcd..a3f4a8085 100644 --- a/inc/plugins/class-dashboard.php +++ b/inc/plugins/class-dashboard.php @@ -10,6 +10,7 @@ use ThemeIsle\GutenbergBlocks\Pro; use ThemeIsle\GutenbergBlocks\Plugins\FSE_Onboarding; use ThemeIsle\GutenbergBlocks\Plugins\Template_Cloud; +use ThemeIsle\GutenbergBlocks\Server\AI_Client_Adaptor; /** * Class Dashboard @@ -23,6 +24,31 @@ class Dashboard { */ protected static $instance = null; + /** + * Transient key for the cached Otter pages count. + * + * @var string + */ + const PAGES_COUNT_CACHE_KEY = 'otter_blocks_pages_with_css_count'; + + /** + * Post meta keys used as markers that a page uses Otter blocks. + * + * @var string[] + */ + const PAGES_COUNT_META_KEYS = array( + '_themeisle_gutenberg_block_stylesheet', + '_themeisle_gutenberg_block_styles', + '_atomic_wind_css', + ); + + /** + * Largest exact page count we display; above this the heading shows "100+". + * + * @var int + */ + const PAGES_COUNT_DISPLAY_CAP = 100; + /** * Initialize the class */ @@ -39,6 +65,231 @@ public function init() { } add_filter( 'themeisle-sdk/survey/' . OTTER_PRODUCT_SLUG, array( __CLASS__, 'get_survey_metadata' ), 10, 2 ); + + add_action( 'otter_pro_uninstall_feedback_popup_header_after_heading', [ $this, 'uninstall_feedback_popup_after_heading' ] ); + add_action( 'otter_blocks_uninstall_feedback_popup_header_after_heading', [ $this, 'uninstall_feedback_popup_after_heading' ] ); + + add_action( 'added_post_meta', array( $this, 'maybe_invalidate_pages_count_cache' ), 10, 4 ); + add_action( 'updated_post_meta', array( $this, 'maybe_invalidate_pages_count_cache' ), 10, 4 ); + add_action( 'deleted_post_meta', array( $this, 'maybe_invalidate_pages_count_cache' ), 10, 4 ); + } + + /** + * Count pages that use Otter blocks, inferred from generated CSS post meta. + * + * Stops counting one past the display cap so large sites avoid a full-table + * aggregate; callers treat a result above the cap as "many". + * + * @return int + */ + private function get_number_of_pages() { + $cached = get_transient( self::PAGES_COUNT_CACHE_KEY ); + + if ( false !== $cached ) { + return (int) $cached; + } + + global $wpdb; + + $limit = self::PAGES_COUNT_DISPLAY_CAP + 1; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $post_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT DISTINCT pm.post_id + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE p.post_status NOT IN ( 'trash', 'auto-draft' ) + AND p.post_type IN ( 'page', 'post' ) + AND pm.meta_key IN ( %s, %s, %s ) + AND pm.meta_value != '' + LIMIT %d", + self::PAGES_COUNT_META_KEYS[0], + self::PAGES_COUNT_META_KEYS[1], + self::PAGES_COUNT_META_KEYS[2], + $limit + ) + ); + + $result = count( $post_ids ); + + set_transient( self::PAGES_COUNT_CACHE_KEY, $result, DAY_IN_SECONDS ); + + return $result; + } + + + /** + * Inline styles and header links for the Otter uninstall feedback popup. + * + * Fires inside `.popup--header`, right after the heading, via the SDK + * `{product_key}_uninstall_feedback_popup_header_after_heading` action. + * + * @return void + */ + public function uninstall_feedback_popup_after_heading() { + static $printed_style = false; + + $count = $this->get_number_of_pages(); + + // Leave the modal untouched when no pages use Otter blocks. + if ( $count < 1 ) { + return; + } + + $display = $count > self::PAGES_COUNT_DISPLAY_CAP + ? self::PAGES_COUNT_DISPLAY_CAP . '+' + : (string) $count; + + /* translators: %s: number of pages, e.g. "13". */ + $pages_label = sprintf( _n( '%s page', '%s pages', $count, 'otter-blocks' ), $display ); + + $message = sprintf( + /* translators: %s: number of pages, already wrapped in , e.g. "13 pages". */ + __( 'Otter Blocks is active on %s. Uninstalling may break parts of your site.', 'otter-blocks' ), + '' . esc_html( $pages_label ) . '' + ); + + $documentation_url = Pro::get_docs_url(); + $support_url = Pro::is_pro_active() + ? 'https://store.themeisle.com/' + : 'https://wordpress.org/support/plugin/otter-blocks/'; + ?> +
+
+
+

array() ) ); ?>

+
+ +
+ + + %s %s', - esc_html__( 'Submissions', 'otter-blocks' ), - esc_html__( 'Pro', 'otter-blocks' ) - ), - 'manage_options', - 'form-submissions-free', - array( $this, 'form_submissions_callback' ), - 10 - ); - add_submenu_page( 'otter', __( 'Blocks', 'otter-blocks' ), @@ -110,6 +347,7 @@ public function add_inline_css() { .o-menu-submissions { display: flex; align-items: center; + gap: 6px; } .o-menu-badge { @@ -142,56 +380,6 @@ public function menu_callback() { echo '
'; } - /** - * The content of the form submissions upsell page. - */ - public function form_submissions_callback() { - ?> - -
-
- Otter Form Submissions Upsell -

-

- -
-
- __( 'Otter Tutorials', 'otter-blocks' ), + 'videoLink' => 'https://youtube.com/playlist?list=' . $playlist_id, + 'thumbnail' => null, + ); + + $feed = fetch_feed( 'https://www.youtube.com/feeds/videos.xml?playlist_id=' . $playlist_id ); + + if ( is_wp_error( $feed ) ) { + return $data; + } + + $item = $feed->get_item(); + + if ( ! $item ) { + return $data; + } + + // YouTube nests inside , so the item-level + // get_thumbnail() returns null; SimplePie exposes it via the enclosure. + $enclosure = $item->get_enclosure(); + $thumbnail = $enclosure ? $enclosure->get_thumbnail() : ''; + + $data['videoTitle'] = $item->get_title(); + $data['videoLink'] = $item->get_permalink(); + $data['thumbnail'] = ! empty( $thumbnail ) ? $thumbnail : null; + + return $data; + } + /** * Get the dashboard data to store in global object. * @@ -282,6 +511,10 @@ public function get_dashboard_data() { ), 'neveInstalled' => defined( 'NEVE_VERSION' ), 'hasPatternSources' => Template_Cloud::has_used_pattern_sources(), + 'aiClientAvailable' => AI_Client_Adaptor::is_available(), + 'aiClientSupported' => function_exists( 'wp_ai_client_prompt' ), + 'connectorsUrl' => esc_url( admin_url( 'options-connectors.php' ) ), + 'youtubePlaylistData' => $this->get_youtube_playlist_data(), ); $global_data = apply_filters( 'otter_dashboard_data', $global_data ); @@ -342,7 +575,7 @@ public function survey_elements() { public function form_submission_elements() { $screen = get_current_screen(); - if ( 'edit-otter_form_record' === $screen->id || 'otter-blocks_page_form-submissions-free' === $screen->id ) { + if ( 'edit-otter_form_record' === $screen->id ) { $this->the_otter_banner(); } } @@ -401,6 +634,46 @@ private function the_otter_banner() { font-size: 14px; max-height: 35px; } + + .wp-core-ui .button.o-locked-action, + .wp-core-ui .button.o-locked-action:focus { + display: inline-flex; + align-items: center; + gap: 6px; + color: #ED6F57; + border-color: #ED6F57; + } + + .wp-core-ui .button.o-locked-action:hover, + .wp-core-ui .button.o-locked-action:active { + color: #E25C4F; + border-color: #E25C4F; + background: #fdf1ef; + } + + .o-locked-action .dashicons-lock { + font-size: 15px; + width: 15px; + height: 15px; + display: inline-flex; + align-items: center; + justify-content: center; + } + + .o-locked-action .o-menu-badge { + opacity: 1; + } + + .otter-banner__actions { + display: inline-flex; + align-items: center; + gap: 12px; + } + + .o-pro-notice { + color: #757575; + font-size: 12px; + }
@@ -413,6 +686,21 @@ private function the_otter_banner() { + + + + + + + + +
@@ -472,7 +760,8 @@ public function form_submissions_widget() { */ public function form_submissions_widget_content() { - $is_active = Pro::is_pro_active(); + // Submission storage lives in the lite plugin: the widget shows real data for every plan. + $is_active = post_type_exists( 'otter_form_record' ); $entries = array(); $count = 0; $posts_filter = 'all'; @@ -546,74 +835,6 @@ public function form_submissions_widget_content() { ?> + '', + 'title' => __( 'Title', 'otter-blocks' ), + 'form' => __( 'Form ID', 'otter-blocks' ), + 'post_url' => __( 'Post', 'otter-blocks' ), + 'ID' => __( 'ID', 'otter-blocks' ), + 'delivery' => __( 'Delivery', 'otter-blocks' ), + 'submission_date' => __( 'Submission Date', 'otter-blocks' ), + ); + } + + /** + * Set the table sortable columns. + * + * @return array + */ + public function form_record_sortable_columns() { + return array( + 'title' => __( 'Title', 'otter-blocks' ), + 'ID' => __( 'ID', 'otter-blocks' ), + 'submission_date' => __( 'Submission Date', 'otter-blocks' ), + ); + } + + /** + * Set form records bulk actions. + * + * @return array + */ + public function form_record_bulk_actions() { + $status = isset( $_GET['post_status'] ) ? sanitize_text_field( wp_unslash( $_GET['post_status'] ) ) : 'all'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $bulk_actions = array(); + + if ( 'trash' !== $status ) { + $bulk_actions['trash'] = __( 'Move to Trash', 'otter-blocks' ); + + if ( 'unread' !== $status ) { + $bulk_actions['unread'] = __( 'Mark as Unread', 'otter-blocks' ); + } + + if ( 'read' !== $status ) { + $bulk_actions['read'] = __( 'Mark as Read', 'otter-blocks' ); + } + } else { + $bulk_actions['untrash'] = __( 'Restore', 'otter-blocks' ); + $bulk_actions['delete'] = __( 'Delete Permanently', 'otter-blocks' ); + } + + return $bulk_actions; + } + + /** + * Manage form records row actions. + * + * @param array $actions The current row actions. + * @param WP_Post $post The current post object. + * + * @return array + */ + public function form_record_row_actions( $actions, $post ) { + if ( Form_Submissions::FORM_RECORD_TYPE !== $post->post_type ) { + return $actions; + } + + unset( $actions['inline hide-if-no-js'] ); + unset( $actions['edit'] ); + + $status = $post->post_status; + if ( 'trash' !== $status ) { + $actions['view'] = sprintf( + '%s', + get_edit_post_link( $post->ID ), + __( 'View', 'otter-blocks' ) + ); + } + + if ( 'unread' === $status ) { + $actions['read'] = sprintf( + '%s', + 'row-read', + $post->ID, + wp_create_nonce( 'read-' . Form_Submissions::FORM_RECORD_TYPE . '_' . $post->ID ), + __( 'Mark as Read', 'otter-blocks' ) + ); + } elseif ( 'trash' !== $status ) { + $actions['unread'] = sprintf( + '%s', + 'row-unread', + $post->ID, + wp_create_nonce( 'unread-' . Form_Submissions::FORM_RECORD_TYPE . '_' . $post->ID ), + __( 'Mark as Unread', 'otter-blocks' ) + ); + } + + return $actions; + } + + /** + * Manage form record columns. + * + * @param string $column The column name. + * @param int $post_id The post ID. + * + * @return string The column value. + */ + public function form_record_column_values( $column, $post_id ) { + $meta = get_post_meta( $post_id, Form_Submissions::FORM_RECORD_META_KEY, true ); + + switch ( $column ) { + case 'title': + if ( get_post_status( $post_id ) !== 'trash' ) { + $this->format_based_on_status( + sprintf( + '%2$s', + esc_url( get_edit_post_link( $post_id ) ), + esc_html( get_the_title( $post_id ) ) + ), + get_post_status( $post_id ) + ); + break; + } + + echo esc_html( get_the_title( $post_id ) ); + break; + case 'form': + // Filtering is a Pro feature; without it, keep linking to the form on the source page. + if ( Pro::is_pro_active() ) { + $url = add_query_arg( + array( + 'post_type' => Form_Submissions::FORM_RECORD_TYPE, + 'otter_form_filter' => $meta['form']['value'], + 'filter_action' => 'Filter', + 'filters_nonce' => wp_create_nonce( 'filter' ), + ), + admin_url( 'edit.php' ) + ); + } else { + $url = $meta['post_url']['value'] . '#' . $meta['form']['value']; + } + + $this->format_based_on_status( + sprintf( + '%2$s', + esc_url( $url ), + esc_html( substr( $meta['form']['value'], -8 ) ) + ), + get_post_status( $post_id ) + ); + break; + case 'post_url': + // If the post ID is set, use that to get the title and URL for better accuracy. + if ( ! empty( $meta['post_id'] ) ) { + $source_post = '0' !== $meta['post_id']['value'] ? $meta['post_id']['value'] : get_option( 'page_for_posts' ); + $title = get_the_title( $source_post ); + $url = get_permalink( $source_post ); + } else { + if ( function_exists( 'wpcom_vip_url_to_postid' ) ) { + $source_post = wpcom_vip_url_to_postid( $meta['post_url']['value'] ); + } else { + $source_post = url_to_postid( $meta['post_url']['value'] ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.url_to_postid_url_to_postid + } + + $source_post = 0 !== $source_post ? $source_post : get_option( 'page_for_posts' ); + $title = $source_post ? get_the_title( $source_post ) : $meta['post_url']['value']; + $url = $meta['post_url']['value']; + } + + if ( empty( $url ) && isset( $meta['post_url']['value'] ) ) { + $url = $meta['post_url']['value']; + } + + if ( '' === trim( (string) $title ) ) { + $title = __( '(no title)', 'otter-blocks' ); + } + + $this->format_based_on_status( + sprintf( + '%2$s', + esc_url( $url ), + esc_html( $title ) + ), + get_post_status( $post_id ) + ); + break; + case 'ID': + $this->format_based_on_status( substr( strval( $post_id ), -8 ), get_post_status( $post_id ) ); + break; + case 'delivery': + $this->render_delivery_status( $post_id ); + break; + case 'submission_date': + $this->format_based_on_status( + esc_html( get_the_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $post_id ) ), + get_post_status( $post_id ) + ); + break; + } + + return $column; + } + + /** + * Render the Delivery Status for a record. + * + * @param int $post_id The record post ID. + * @return void + */ + private function render_delivery_status( $post_id ) { + $status = get_post_meta( $post_id, Form_Submissions::DELIVERY_STATUS_META_KEY, true ); + + if ( Form_Submissions::DELIVERY_STATUS_FAILED === $status ) { + $errors = get_post_meta( $post_id, Form_Submissions::DELIVERY_ERRORS_META_KEY, true ); + $messages = array(); + + if ( is_array( $errors ) ) { + foreach ( $errors as $error ) { + $messages[] = ( isset( $error['action'] ) ? $error['action'] . ': ' : '' ) . ( isset( $error['message'] ) ? $error['message'] : '' ); + } + } + + printf( + '%s', + esc_attr( implode( ' | ', $messages ) ), + esc_html__( 'Failed', 'otter-blocks' ) + ); + return; + } + + if ( Form_Submissions::DELIVERY_STATUS_COMPLETE === $status ) { + printf( '%s', esc_html__( 'Complete', 'otter-blocks' ) ); + return; + } + + echo '—'; + } + + /** + * Make unread rows bold. + * + * @param string $content Content. + * @param string $status The post status. + */ + private function format_based_on_status( $content, $status ) { + if ( 'unread' === $status ) { + echo '' . wp_kses_post( $content ) . ''; + return; + } + + echo wp_kses_post( $content ); + } +} diff --git a/inc/plugins/class-form-records-meta-box.php b/inc/plugins/class-form-records-meta-box.php new file mode 100644 index 000000000..f279c9520 --- /dev/null +++ b/inc/plugins/class-form-records-meta-box.php @@ -0,0 +1,476 @@ +ID, Form_Submissions::ISSUES_META_KEY, true ) : false; + + if ( is_array( $issues ) && ! empty( $issues ) ) { + add_meta_box( + 'form_record_errors_meta_box', + esc_html__( 'Errors', 'otter-blocks' ), + array( $this, 'errors_meta_box_markup' ), + Form_Submissions::FORM_RECORD_TYPE + ); + } + + // Only show the AI Autoresponder box when this record actually has AI audit data. + $ai_autoresponder = ( $post instanceof \WP_Post ) ? get_post_meta( $post->ID, Form_Submissions::AI_AUTORESPONDER_META_KEY, true ) : false; + + if ( is_array( $ai_autoresponder ) && ! empty( $ai_autoresponder ) ) { + add_meta_box( + 'ai_autoresponder_meta_box', + esc_html__( 'AI Autoresponder', 'otter-blocks' ), + array( $this, 'ai_autoresponder_meta_box_markup' ), + Form_Submissions::FORM_RECORD_TYPE + ); + } + + // this will replace the default publish box, that's why it's using its id. + add_meta_box( + 'submitpost', + esc_html__( 'Update', 'otter-blocks' ), + array( $this, 'update_meta_box_markup' ), + Form_Submissions::FORM_RECORD_TYPE, + 'side' + ); + } + + /** + * Render the Errors meta box listing every issue the submission encountered. + * + * @param WP_Post $post The form record post. + * @return void + */ + public function errors_meta_box_markup( $post ) { + $issues = get_post_meta( $post->ID, Form_Submissions::ISSUES_META_KEY, true ); + + if ( ! is_array( $issues ) || empty( $issues ) ) { + return; + } + ?> + + + + + + + + + + + + + + + +
+ ID, Form_Submissions::AI_AUTORESPONDER_META_KEY, true ); + + if ( ! is_array( $ai_autoresponder ) || empty( $ai_autoresponder ) ) { + return; + } + + $ai_outcome = isset( $ai_autoresponder['outcome'] ) ? $ai_autoresponder['outcome'] : ''; + $ai_valid = ! empty( $ai_autoresponder['valid'] ); + $ai_reason = isset( $ai_autoresponder['reason'] ) ? $ai_autoresponder['reason'] : ''; + $ai_tokens = isset( $ai_autoresponder['used_tokens'] ) ? (int) $ai_autoresponder['used_tokens'] : 0; + $ai_body = isset( $ai_autoresponder['generated_body'] ) ? $ai_autoresponder['generated_body'] : ''; + + $ai_outcome_labels = array( + 'ai' => __( 'AI reply sent', 'otter-blocks' ), + 'fallback' => __( 'Fallback message sent', 'otter-blocks' ), + 'none' => __( 'Nothing sent', 'otter-blocks' ), + ); + $ai_outcome_label = isset( $ai_outcome_labels[ $ai_outcome ] ) ? $ai_outcome_labels[ $ai_outcome ] : $ai_outcome; + ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ post_type ) { + return; + } + + if ( empty( $_POST['action'] ) || 'editpost' !== $_POST['action'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return; + } + + if ( empty( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['_wpnonce'] ) ), 'update-post_' . $post->ID ) ) { + wp_die( esc_html__( 'Nonce not verified.', 'otter-blocks' ) ); + } + + if ( ! current_user_can( 'edit_post', $post_id ) ) { + wp_die( esc_html__( 'User cannot edit this post.', 'otter-blocks' ) ); + } + + $meta = get_post_meta( $post_id, Form_Submissions::FORM_RECORD_META_KEY, true ); + + foreach ( $_POST as $key => $value ) { + if ( 0 !== strpos( $key, 'otter_meta_' ) || ! is_string( $value ) ) { + continue; + } + + $id = substr( $key, -8 ); + $value = sanitize_textarea_field( wp_unslash( $value ) ); + + if ( isset( $meta['inputs'][ $id ] ) && $meta['inputs'][ $id ]['value'] !== $value ) { + $meta['inputs'][ $id ]['value'] = $value; + } + } + + // Slash the value: update_post_meta() unslashes it, which would otherwise strip backslashes from the stored data. + update_post_meta( $post_id, Form_Submissions::FORM_RECORD_META_KEY, wp_slash( $meta ) ); + } + + /** + * Render form record meta box. + * + * @param WP_Post $post The post object. + * @return void + */ + public function fields_meta_box_markup( $post ) { + $meta = get_post_meta( $post->ID, Form_Submissions::FORM_RECORD_META_KEY, true ); + $previous_field_option = ''; + + + if ( empty( $meta ) ) { + return; + } + + $inputs = array(); + foreach ( $meta['inputs'] as $id => $field ) { + if ( empty( $field ) || 'stripe-field' === $field['type'] ) { + continue; + } + + $inputs[ $id ] = $field; + } + + ?> + + + $field ) { ?> + + + + + + +
+ + render_field( $field, $id ); ?>
+ + + + + + + + + + + + + + + + ID, Form_Submissions::FORM_RECORD_META_KEY, true ); + $delivery_status = get_post_meta( $post->ID, Form_Submissions::DELIVERY_STATUS_META_KEY, true ); + $delivery_errors = get_post_meta( $post->ID, Form_Submissions::DELIVERY_ERRORS_META_KEY, true ); + + if ( empty( $meta ) || ! is_array( $meta ) ) { + return; + } + + $form_label = isset( $meta['form']['label'] ) ? $meta['form']['label'] : __( 'Form', 'otter-blocks' ); + $form_value = isset( $meta['form']['value'] ) ? $meta['form']['value'] : ''; + $post_url = isset( $meta['post_url']['value'] ) ? $meta['post_url']['value'] : ''; + ?> +
+ +
+
+ %s', + 'trash', + intval( $post->ID ), + esc_attr( wp_create_nonce( 'trash-post_' . $post->ID ) ), + esc_html__( 'Move to Trash', 'otter-blocks' ) + ); + ?> +
+ +
+ ', + esc_html__( 'Update', 'otter-blocks' ) + ); + ?> +
+
+
+
+ + array( + 'name' => esc_html_x( 'Form Submissions', '', 'otter-blocks' ), + 'singular_name' => esc_html_x( 'Form Submission', '', 'otter-blocks' ), + 'search_items' => esc_html__( 'Search Submissions', 'otter-blocks' ), + 'all_items' => esc_html__( 'Form Submissions', 'otter-blocks' ), + 'view_item' => esc_html__( 'View Submission', 'otter-blocks' ), + 'update_item' => esc_html__( 'Update Submission', 'otter-blocks' ), + 'not_found' => esc_html__( 'No submissions found', 'otter-blocks' ), + 'not_found_in_trash' => esc_html__( 'No submissions found in the Trash', 'otter-blocks' ), + ), + 'capability_type' => Form_Submissions::FORM_RECORD_TYPE, + 'capabilities' => array( + 'create_posts' => 'create_otter_form_records', + ), + 'description' => __( 'Holds the data from the form submissions', 'otter-blocks' ), + 'public' => false, + 'show_ui' => true, + 'show_in_rest' => false, + 'supports' => array( 'title' ), + ) + ); + + register_post_status( + 'read', + array( + 'label' => _x( 'Read', 'otter-form-record', 'otter-blocks' ), + 'public' => true, + 'exclude_from_search' => false, + 'show_in_admin_all_list' => true, + 'show_in_admin_status_list' => true, + /* translators: %s the number of posts */ + 'label_count' => _n_noop( + 'Read (%s)', + 'Read (%s)', + 'otter-blocks' + ), + ) + ); + + register_post_status( + 'unread', + array( + 'label' => _x( 'Unread', 'otter-form-record', 'otter-blocks' ), + 'public' => true, + 'exclude_from_search' => false, + 'show_in_admin_all_list' => true, + 'show_in_admin_status_list' => true, + /* translators: %s the number of posts */ + 'label_count' => _n_noop( + 'Unread (%s)', + 'Unread (%s)', + 'otter-blocks' + ), + ) + ); + } + + /** + * Set custom capabilities for otter_form_record. + * + * @return void + */ + public function set_form_records_cap() { + $role = get_role( 'administrator' ); + + if ( null === $role ) { + return; + } + + if ( ! method_exists( $role, 'add_cap' ) ) { + return; + } + + $role->add_cap( 'edit_' . Form_Submissions::FORM_RECORD_TYPE ); + $role->add_cap( 'read_' . Form_Submissions::FORM_RECORD_TYPE ); + $role->add_cap( 'delete_' . Form_Submissions::FORM_RECORD_TYPE ); + $role->add_cap( 'edit_' . Form_Submissions::FORM_RECORD_TYPE . 's' ); + $role->add_cap( 'read_' . Form_Submissions::FORM_RECORD_TYPE . 's' ); + $role->add_cap( 'delete_' . Form_Submissions::FORM_RECORD_TYPE . 's' ); + $role->remove_cap( 'create_' . Form_Submissions::FORM_RECORD_TYPE ); + $role->remove_cap( 'create_' . Form_Submissions::FORM_RECORD_TYPE . 's' ); + } +} diff --git a/inc/plugins/class-form-submissions.php b/inc/plugins/class-form-submissions.php new file mode 100644 index 000000000..83571518a --- /dev/null +++ b/inc/plugins/class-form-submissions.php @@ -0,0 +1,906 @@ +register(); + + /** + * Shim for version skew: an older Otter Pro build with an active license still owns + * storage (its Form_Emails_Storing registers the CPT, the dashboard and the save + * hook), so defer the UI/CPT registration to it to avoid double registration; the + * updated Pro build is a thin extension and exposes the `is_thin_extension` marker. + * + * Storage ordering must not regress though: the bridge removes the legacy + * after-delivery save and lets Lite create the record before delivery, without the + * old save-location gate. + */ + if ( + class_exists( '\ThemeIsle\OtterPro\Plugins\Form_Emails_Storing' ) && + ! method_exists( '\ThemeIsle\OtterPro\Plugins\Form_Emails_Storing', 'is_thin_extension' ) && + class_exists( '\ThemeIsle\OtterPro\Plugins\License' ) && + \ThemeIsle\OtterPro\Plugins\License::has_active_license() + ) { + add_action( 'otter_form_record_save', array( $this, 'bridge_legacy_pro_store' ) ); + add_action( 'otter_form_after_submit', array( $this, 'record_delivery_status' ), PHP_INT_MAX ); + add_action( 'otter_form_issues_handler', array( $this, 'record_delivery_status' ), PHP_INT_MAX ); + return; + } + + ( new Form_Records_Post_Type() )->register(); + + /** + * Save the submission before any delivery action runs, then record the delivery + * outcome after all the after-submit actions have finished. The issues handler runs + * in the request's `finally` block, so the status is also written when a delivery + * handler throws and `otter_form_after_submit` never fires. + */ + add_action( 'otter_form_record_save', array( $this, 'store_form_record' ) ); + add_action( 'otter_form_after_submit', array( $this, 'record_delivery_status' ), PHP_INT_MAX ); + add_action( 'otter_form_issues_handler', array( $this, 'record_delivery_status' ), PHP_INT_MAX ); + + ( new Form_Records_List_Table() )->register(); + + // Status mutations triggered from the list table. + add_filter( 'handle_bulk_actions-edit-' . self::FORM_RECORD_TYPE, array( $this, 'handle_form_record_bulk_actions' ), 0, 3 ); + add_filter( 'wp_untrash_post_status', array( $this, 'restore_status_on_untrash' ), 10, 3 ); + + ( new Form_Records_Filters() )->register(); + + // Implement row actions behaviour. + add_action( 'admin_action_row-read', array( $this, 'read_otter_form_record' ) ); + add_action( 'admin_action_row-unread', array( $this, 'unread_otter_form_record' ) ); + add_action( 'admin_action_edit', array( $this, 'mark_read_on_edit' ) ); + + ( new Form_Records_Meta_Box() )->register(); + + add_filter( 'otter_form_record_confirm', array( $this, 'confirm_submission' ), 10, 2 ); + + add_action( 'draft_to_unread', array( $this, 'apply_hooks_on_draft_transition' ), 10 ); + add_action( 'otter_form_update_record_meta_dump', array( $this, 'update_submission_dump_data' ), 10, 2 ); + add_action( 'otter_form_automatic_confirmation', array( $this, 'move_old_stripe_draft_sessions_to_unread' ) ); + + ( new Form_Records_Export() )->register(); + } + + /** + * Get delivery failure actions keyed by response error code. + * + * @return array + */ + public static function get_delivery_failure_actions() { + return array( + Form_Data_Response::ERROR_CAPTCHA_PROVIDER_UNREACHABLE => 'captcha', + Form_Data_Response::ERROR_EMAIL_NOT_SEND => 'email', + Form_Data_Response::ERROR_WEBHOOK_COULD_NOT_TRIGGER => 'webhook', + Form_Data_Response::ERROR_PROVIDER_SUBSCRIBE_ERROR => 'subscribe', + Form_Data_Response::ERROR_PROVIDER_CREDENTIAL_ERROR => 'subscribe', + Form_Data_Response::ERROR_PROVIDER_INVALID_KEY => 'subscribe', + Form_Data_Response::ERROR_PROVIDER_INVALID_API_KEY_FORMAT => 'subscribe', + Form_Data_Response::ERROR_PROVIDER_INVALID_EMAIL => 'subscribe', + Form_Data_Response::ERROR_MISSING_EMAIL => 'subscribe', + Form_Data_Response::ERROR_PROVIDER_NOT_REGISTERED => 'provider', + Form_Data_Response::ERROR_RUNTIME_ERROR => 'provider', + ); + } + + /** + * Store form record in custom post type. + * + * Runs on `otter_form_record_save`, before primary delivery. Every valid submission + * is saved, regardless of plan or the former "Email Only" setting. + * + * @param Form_Data_Request $form_data The form data object. + * @return Form_Data_Request The form data object. + */ + public function store_form_record( $form_data ) { + if ( + ( ! class_exists( 'ThemeIsle\GutenbergBlocks\Integration\Form_Data_Request' ) ) || + ! ( $form_data instanceof Form_Data_Request ) || + $form_data->has_error() + ) { + return $form_data; + } + + $form_options = $form_data->get_wp_options(); + + if ( ! isset( $form_options ) ) { + return $form_data; + } + + /** + * Payment confirmation re-fires the submit hooks for a Record that already exists + * (the confirmed draft) — never create a second Record for it. + */ + if ( $form_data->is_duplicate() || $form_data->has_record_id() ) { + return $form_data; + } + + $post_id = wp_insert_post( + array( + 'post_type' => self::FORM_RECORD_TYPE, + // An infrastructure failure never reaches payment, so its record is a regular (visible) one. + 'post_status' => $form_data->is_temporary() && ! $form_data->has_infrastructure_failure() ? 'draft' : 'unread', + ) + ); + + if ( ! $post_id ) { + return $form_data; + } + + wp_update_post( + array( + 'ID' => $post_id, + /* translators: %s the ID of the submission */ + 'post_title' => sprintf( __( 'Submission #%s', 'otter-blocks' ), $post_id ), + ) + ); + + $meta = array( + 'form' => array( + 'label' => __( 'Form', 'otter-blocks' ), + 'value' => $form_data->get_data_from_payload( 'formId' ), + ), + 'post_url' => array( + 'label' => __( 'Post URL', 'otter-blocks' ), + 'value' => $form_data->get_data_from_payload( 'postUrl' ), + ), + 'post_id' => array( + 'label' => __( 'Post ID', 'otter-blocks' ), + 'value' => $form_data->get_data_from_payload( 'postId' ), + ), + 'dump' => array( + 'label' => __( 'Dumped data', 'otter-blocks' ), + 'value' => $form_data->is_temporary() ? $form_data->dump_data() : array(), + ), + ); + + $form_inputs = $form_data->get_fields(); + $uploaded_files = $form_data->get_uploaded_files_path(); + $media_files = $form_data->get_files_loaded_to_media_library(); + + foreach ( $form_inputs as $input ) { + if ( ! isset( $input['id'] ) ) { + continue; + } + + $id = substr( $input['id'], -8 ); + + if ( 'file' === $input['type'] ) { + $id .= $input['metadata']['name'] . '_' . $input['metadata']['size']; + + $meta['inputs'][ $id ] = array( + 'label' => $input['label'], + 'value' => $input['value'], + 'type' => $input['type'], + 'metadata' => $input['metadata'], + ); + + $file_data_key = $input['metadata']['data']; + + if ( isset( $media_files[ $file_data_key ] ) ) { + $meta['inputs'][ $id ] = array_merge( + $meta['inputs'][ $id ], + array( + 'path' => $media_files[ $file_data_key ]['file_path'], + 'mime_type' => $media_files[ $file_data_key ]['file_type'], + 'attachment_id' => $media_files[ $file_data_key ]['file_id'], + 'saved_in_media' => true, + ) + ); + } elseif ( isset( $uploaded_files[ $file_data_key ] ) ) { + $meta['inputs'][ $id ] = array_merge( + $meta['inputs'][ $id ], + array( + 'path' => $uploaded_files[ $file_data_key ]['file_path'], + 'mime_type' => $uploaded_files[ $file_data_key ]['file_type'], + 'saved_in_media' => false, + ) + ); + } + } else { + $meta['inputs'][ $id ] = array( + 'label' => $input['label'], + 'value' => $input['value'], + 'type' => $input['type'], + 'metadata' => $input['metadata'], + ); + } + } + + // Slash the value: add_post_meta() unslashes it, which would otherwise strip backslashes from the submitted data. + add_post_meta( $post_id, self::FORM_RECORD_META_KEY, wp_slash( $meta ) ); + + wp_cache_delete( 'otter_form_records', 'otter_form' ); + + $form_data->set_record_id( $post_id ); + + if ( $form_data->has_infrastructure_failure() ) { + $this->write_delivery_meta( + $post_id, + self::DELIVERY_STATUS_FAILED, + array( + array( + 'action' => 'captcha', + 'code' => $form_data->get_infrastructure_failure_code(), + 'message' => Form_Data_Response::get_error_code_message( $form_data->get_infrastructure_failure_code() ), + ), + ) + ); + } + + return $form_data; + } + + /** + * Bridge the save-before-delivery hook for an older Otter Pro build that still owns + * storage UI/CPT registration, and unhook its legacy after-delivery save so the + * record is neither lost on delivery failure nor stored twice. + * + * @param Form_Data_Request $form_data The form data object. + * @return Form_Data_Request The form data object. + */ + public function bridge_legacy_pro_store( $form_data ) { + if ( ! class_exists( '\ThemeIsle\OtterPro\Plugins\Form_Emails_Storing' ) ) { + return $form_data; + } + + $legacy = \ThemeIsle\OtterPro\Plugins\Form_Emails_Storing::$instance; + + if ( ! $legacy ) { + return $form_data; + } + + remove_action( 'otter_form_after_submit', array( $legacy, 'store_form_record' ) ); + + /** + * The legacy Form_Pro_Features deletes uploads after delivery when the save + * location is 'email'; now that the record is always saved it would delete the + * files the record references, so unhook it as well. + */ + if ( class_exists( '\ThemeIsle\OtterPro\Plugins\Form_Pro_Features' ) ) { + $legacy_features = \ThemeIsle\OtterPro\Plugins\Form_Pro_Features::$instance; + + // @phpstan-ignore-next-line the method is missing in older Otter Pro versions. + if ( $legacy_features && method_exists( $legacy_features, 'clean_files_from_uploads' ) ) { + remove_action( 'otter_form_after_submit', array( $legacy_features, 'clean_files_from_uploads' ) ); + } + } + + return $this->store_form_record( $form_data ); + } + + /** + * Record the Delivery Status on the Submission Record after all primary delivery + * actions have run. Autoresponder failures do not affect the Delivery Status. + * + * @param Form_Data_Request $form_data The form data object. + * @return void + */ + public function record_delivery_status( $form_data ) { + if ( + ! ( $form_data instanceof Form_Data_Request ) || + ! $form_data->has_record_id() || + $form_data->is_temporary() // Payment-gated: delivery runs only after payment confirmation. + ) { + return; + } + + if ( $form_data->has_metadata( self::AI_AUTORESPONDER_META_KEY ) ) { + $audit = $form_data->metadata[ self::AI_AUTORESPONDER_META_KEY ]; + + if ( is_array( $audit ) ) { + update_post_meta( $form_data->get_record_id(), self::AI_AUTORESPONDER_META_KEY, wp_slash( $audit ) ); + } + } + + $delivery_actions = self::get_delivery_failure_actions(); + + $errors = array(); + + foreach ( $form_data->get_warning_codes() as $warning ) { + if ( ! isset( $delivery_actions[ $warning['code'] ] ) ) { + continue; + } + + $errors[] = array( + 'action' => $delivery_actions[ $warning['code'] ], + 'code' => $warning['code'], + 'message' => ! empty( $warning['details'] ) ? $warning['details'] : Form_Data_Response::get_error_code_message( $warning['code'] ), + ); + } + + // Errors are not always doubled by a warning (e.g. a failed wp_mail in send_default_email). + if ( $form_data->has_error() && isset( $delivery_actions[ $form_data->get_error_code() ] ) ) { + $already_recorded = false; + foreach ( $errors as $error ) { + if ( $error['code'] === $form_data->get_error_code() ) { + $already_recorded = true; + break; + } + } + + if ( ! $already_recorded ) { + $errors[] = array( + 'action' => $delivery_actions[ $form_data->get_error_code() ], + 'code' => $form_data->get_error_code(), + 'message' => Form_Data_Response::get_error_code_message( $form_data->get_error_code() ), + ); + } + } + + $this->write_delivery_meta( + $form_data->get_record_id(), + empty( $errors ) ? self::DELIVERY_STATUS_COMPLETE : self::DELIVERY_STATUS_FAILED, + $errors + ); + + // Separately record EVERY issue (warnings + a blocking error), regardless of + // whether it counts as a delivery failure. AI autoresponder failures are + // surfaced here but intentionally do not fail delivery (the fallback is sent). + $issues = array(); + + foreach ( $form_data->get_warning_codes() as $warning ) { + $issues[] = array( + 'code' => $warning['code'], + 'message' => ! empty( $warning['details'] ) ? $warning['details'] : Form_Data_Response::get_error_code_message( $warning['code'] ), + ); + } + + if ( $form_data->has_error() ) { + $issues[] = array( + 'code' => $form_data->get_error_code(), + 'message' => Form_Data_Response::get_error_code_message( $form_data->get_error_code() ), + ); + } + + if ( empty( $issues ) ) { + delete_post_meta( $form_data->get_record_id(), self::ISSUES_META_KEY ); + } else { + update_post_meta( $form_data->get_record_id(), self::ISSUES_META_KEY, wp_slash( $issues ) ); + } + } + + /** + * Write the delivery status meta on a Record. + * + * @param int $record_id The record post ID. + * @param string $status The delivery status. + * @param array> $errors The delivery errors. + * @return void + */ + private function write_delivery_meta( $record_id, $status, $errors = array() ) { + update_post_meta( $record_id, self::DELIVERY_STATUS_META_KEY, $status ); + + if ( empty( $errors ) ) { + delete_post_meta( $record_id, self::DELIVERY_ERRORS_META_KEY ); + } else { + update_post_meta( $record_id, self::DELIVERY_ERRORS_META_KEY, $errors ); + } + } + + /** + * Handle form record bulk actions. + * + * @param string $redirect The redirect URL. + * @param string $doaction The action being taken. + * @param array $object_ids The object IDs. + * + * @return string + */ + public function handle_form_record_bulk_actions( $redirect, $doaction, $object_ids ) { + $redirect = remove_query_arg( 'post_status', $redirect ); + + switch ( $doaction ) { + case 'read': + foreach ( $object_ids as $object_id ) { + if ( ! current_user_can( 'edit_post', $object_id ) ) { + continue; + } + + wp_update_post( + array( + 'ID' => $object_id, + 'post_status' => 'read', + ) + ); + } + + $redirect = add_query_arg( 'post_status', 'read', $redirect ); + break; + case 'unread': + foreach ( $object_ids as $object_id ) { + if ( ! current_user_can( 'edit_post', $object_id ) ) { + continue; + } + + wp_update_post( + array( + 'ID' => $object_id, + 'post_status' => 'unread', + ) + ); + } + + $redirect = add_query_arg( 'post_status', 'unread', $redirect ); + break; + } + + return $redirect; + } + + /** + * Restore a form record to its pre-trash status when it is untrashed. + * + * WP core restores untrashed posts to 'draft', which for form records is the + * payment-pending status: a confirmed 'unread'/'read' record would show up as + * pending, while a genuinely pending 'draft' record must stay 'draft' so + * `confirm_submission()` can still deliver it after payment. + * + * @param string $new_status The status the post is about to be restored to. + * @param int $post_id The post ID. + * @param string $previous_status The status the post had before it was trashed. + * + * @return string + */ + public function restore_status_on_untrash( $new_status, $post_id, $previous_status ) { + if ( self::FORM_RECORD_TYPE !== get_post_type( $post_id ) || empty( $previous_status ) ) { + return $new_status; + } + + return $previous_status; + } + + /** + * Mark form record as read when it is edited. + * + * @return void + */ + public function mark_read_on_edit() { + if ( ! isset( $_REQUEST['post'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return; + } + + $post = intval( wp_unslash( $_REQUEST['post'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! get_post( $post ) || self::FORM_RECORD_TYPE !== get_post_type( $post ) ) { + return; + } + + if ( ! current_user_can( 'edit_post', $post ) ) { + return; + } + + $status = get_post_status( $post ); + if ( 'unread' === $status ) { + wp_update_post( + array( + 'ID' => $post, + 'post_status' => 'read', + ) + ); + } + } + + /** + * Check request nonce and post ID. + * + * @param string $action The action name. + * + * @return string The post ID. + */ + public function check_posts( $action ) { + $id = ! empty( $_REQUEST[ self::FORM_RECORD_TYPE ] ) ? sanitize_text_field( wp_unslash( $_REQUEST[ self::FORM_RECORD_TYPE ] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $post = get_post( $id ); + + if ( empty( $_REQUEST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_key( wp_unslash( $_REQUEST['_wpnonce'] ) ), $action . '-' . self::FORM_RECORD_TYPE . '_' . $id ) ) { + wp_die( esc_html__( 'Security check failed', 'otter-blocks' ) ); + } + + if ( ! isset( $_REQUEST[ self::FORM_RECORD_TYPE ] ) ) { + wp_die( esc_html__( 'Post ID is required', 'otter-blocks' ) ); + } + + if ( ! $post ) { + wp_die( esc_html__( 'Invalid post ID', 'otter-blocks' ) ); + } + + if ( self::FORM_RECORD_TYPE !== $post->post_type ) { + wp_die( esc_html__( 'Invalid post type', 'otter-blocks' ) ); + } + + if ( ! current_user_can( 'edit_post', $post->ID ) ) { + wp_die( esc_html__( 'You are not allowed to manage this submission.', 'otter-blocks' ) ); + } + + return $id; + } + + /** + * Read form record. + * + * @return void + */ + public function read_otter_form_record() { + $id = intval( $this->check_posts( 'read' ) ); + wp_update_post( + array( + 'ID' => $id, + 'post_status' => 'read', + ) + ); + + wp_safe_redirect( remove_query_arg( array( 'action', self::FORM_RECORD_TYPE, '_wpnonce' ), admin_url( 'edit.php?post_type=' . self::FORM_RECORD_TYPE ) ) ); + exit; + } + + /** + * Unread form record. + * + * @return void + */ + public function unread_otter_form_record() { + $id = intval( $this->check_posts( 'unread' ) ); + wp_update_post( + array( + 'ID' => $id, + 'post_status' => 'unread', + ) + ); + + wp_safe_redirect( remove_query_arg( array( 'action', self::FORM_RECORD_TYPE, '_wpnonce' ), admin_url( 'edit.php?post_type=' . self::FORM_RECORD_TYPE ) ) ); + exit; + } + + /** + * Confirm submission. + * + * @param Form_Data_Response $response The response. + * @param \WP_REST_Request $request The request. + * @return Form_Data_Response + */ + public function confirm_submission( $response, $request ) { + + $session_id = $request->get_param( 'stripe_checkout' ); + + $stripe = new Stripe_API(); + + $stripe_response = $stripe->create_request( 'get_session', $session_id ); + + if ( is_wp_error( $stripe_response ) || ! is_object( $stripe_response ) || ! isset( $stripe_response->payment_status ) ) { + $response->set_code( Form_Data_Response::ERROR_STRIPE_CHECKOUT_SESSION_NOT_FOUND ); + return $response; + } + + $is_paid = 'paid' === $stripe_response->payment_status; + + if ( ! $is_paid ) { + $response->set_code( Form_Data_Response::ERROR_STRIPE_PAYMENT_UNPAID ); + return $response; + } + + $record_id = isset( $stripe_response->metadata['otter_form_record_id'] ) ? $stripe_response->metadata['otter_form_record_id'] : null; + + if ( empty( $record_id ) ) { + $response->set_code( Form_Data_Response::ERROR_STRIPE_METADATA_RECORD_NOT_FOUND ); + return $response; + } + + if ( isset( $stripe_response->metadata['otter_redirect_link'] ) ) { + $response->add_response_field( 'redirectLink', $stripe_response->metadata['otter_redirect_link'] ); + } + + $post_status = get_post_status( $record_id ); + + // If the post status is not 'draft', then the submission has already been confirmed. + if ( 'draft' !== $post_status ) { + $response->set_code( Form_Data_Response::SUCCESS_EMAIL_SEND ); + $response->mark_as_success(); + return $response; + } + + wp_update_post( + array( + 'ID' => $record_id, + 'post_status' => 'unread', + ) + ); + + $response->set_code( Form_Data_Response::SUCCESS_EMAIL_SEND ); + $response->mark_as_success(); + + return $response; + } + + /** + * Apply the 'after_submit' action when changing the status from 'draft' to 'unread'. + * + * @param WP_Post $post The post. + */ + public function apply_hooks_on_draft_transition( $post ) { + if ( self::FORM_RECORD_TYPE !== $post->post_type ) { + return; + } + + $meta = get_post_meta( $post->ID, self::FORM_RECORD_META_KEY, true ); + + if ( ! isset( $meta['dump'] ) || empty( $meta['dump']['value'] ) ) { + return; + } + + $form_data = Form_Data_Request::create_from_dump( $meta['dump']['value'] ); + $form_data->mark_as_duplicate(); + $form_data->set_record_id( $post->ID ); + $form_options = Form_Settings_Data::get_form_setting_from_wordpress_options( $form_data->get_data_from_payload( 'formOption' ) ); + $form_data->set_form_options( $form_options ); + $form_data = Form_Server::pull_fields_options_for_form( $form_data ); + + do_action( 'otter_form_on_submission_confirmed', $form_data ); + + if ( + ! isset( $form_data ) || + ( ! class_exists( 'ThemeIsle\GutenbergBlocks\Integration\Form_Data_Request' ) ) || + ! ( $form_data instanceof \ThemeIsle\GutenbergBlocks\Integration\Form_Data_Request ) + ) { + return; + } + + do_action( 'otter_form_after_submit', $form_data ); + } + + /** + * Update the submission dump data. + * + * @param Form_Data_Request $form_data The form data. + * @param int $record_id The record ID. + */ + public function update_submission_dump_data( $form_data, $record_id ) { + + if ( ! get_post( $record_id ) ) { + return; + } + + $meta = get_post_meta( $record_id, self::FORM_RECORD_META_KEY, true ); + $meta = is_array( $meta ) ? $meta : array(); + $meta = array_merge( + $meta, + array( + 'dump' => array( + 'label' => 'Dumped data', + 'value' => $form_data->is_temporary() ? $form_data->dump_data() : array(), + ), + ) + ); + // Slash the value: update_post_meta() unslashes it, which would otherwise strip backslashes from the stored data. + update_post_meta( $record_id, self::FORM_RECORD_META_KEY, wp_slash( $meta ) ); + + if ( + $form_data->is_temporary() && + $form_data->has_metadata( 'otter_form_stripe_checkout_session_id' ) + ) { + $this->schedule_automatic_confirmation(); + } + } + + /** + * Move old drafts to unread. + */ + public function move_old_stripe_draft_sessions_to_unread() { + $now = current_time( 'mysql' ); + + // Calculate the time 15 minutes ago. + $time_15_minutes_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-15 minutes', strtotime( $now ) ) ); + + $args = array( + 'post_type' => self::FORM_RECORD_TYPE, + 'post_status' => 'draft', + 'posts_per_page' => 10, + 'orderby' => 'date', + 'order' => 'DESC', + 'date_query' => array( + 'before' => $time_15_minutes_ago, + ), + ); + + $query = new WP_Query( $args ); + if ( $query->have_posts() ) { + + try { + $stripe = new Stripe_API(); + + while ( $query->have_posts() ) { + $query->the_post(); + + // Get the meta data. + $meta = get_post_meta( get_the_ID(), self::FORM_RECORD_META_KEY, true ); + + // Check if we have a Stripe session id in the meta dump data. + if ( ! isset( $meta['dump']['value']['metadata']['otter_form_stripe_checkout_session_id'] ) ) { + continue; + } + + $stripe_checkout_session_id = $meta['dump']['value']['metadata']['otter_form_stripe_checkout_session_id']; + + // Check if the session has status of paid. + $session = $stripe->create_request( 'get_session', $stripe_checkout_session_id ); + + if ( is_wp_error( $session ) ) { + continue; + } + + $is_paid = isset( $session->payment_status ) && 'paid' === $session->payment_status; + + if ( ! $is_paid ) { + continue; + } + + wp_update_post( + array( + 'ID' => get_the_ID(), + 'post_status' => 'unread', + ) + ); + } + } catch ( \Exception $e ) { + // Do nothing. + return; + } + } + + if ( ! $this->has_pending_stripe_draft_sessions() ) { + wp_clear_scheduled_hook( 'otter_form_automatic_confirmation' ); + } + } + + /** + * Schedule the automatic confirmation. + * + * @return void + */ + public function schedule_automatic_confirmation() { + if ( ! wp_next_scheduled( 'otter_form_automatic_confirmation' ) ) { + wp_schedule_event( time(), 'hourly', 'otter_form_automatic_confirmation' ); + } + } + + /** + * Check whether there are draft records waiting on a Stripe checkout session. + * + * @return bool + */ + private function has_pending_stripe_draft_sessions() { + $query = new WP_Query( + array( + 'post_type' => self::FORM_RECORD_TYPE, + 'post_status' => 'draft', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + array( + 'key' => self::FORM_RECORD_META_KEY, + 'value' => 'otter_form_stripe_checkout_session_id', + 'compare' => 'LIKE', + ), + ), + ) + ); + + return $query->have_posts(); + } + + /** + * The instance method for the static class. + * Defines and returns the instance of the static class. + * + * @static + * @access public + * @return Form_Submissions + */ + public static function instance() { + if ( is_null( self::$instance ) ) { + self::$instance = new self(); + self::$instance->init(); + } + + return self::$instance; + } + + /** + * Throw error on object clone + * + * The whole idea of the singleton design pattern is that there is a single + * object therefore, we don't want the object to be cloned. + * + * @access public + * @return void + */ + public function __clone() { + // Cloning instances of the class is forbidden. + _doing_it_wrong( __FUNCTION__, esc_html__( 'Cheatin’ huh?', 'otter-blocks' ), '1.0.0' ); + } + + /** + * Disable unserializing of the class + * + * @access public + * @return void + */ + public function __wakeup() { + // Unserializing instances of the class is forbidden. + _doing_it_wrong( __FUNCTION__, esc_html__( 'Cheatin’ huh?', 'otter-blocks' ), '1.0.0' ); + } +} diff --git a/inc/plugins/class-limited-offers.php b/inc/plugins/class-limited-offers.php index 527e27f19..11a351097 100644 --- a/inc/plugins/class-limited-offers.php +++ b/inc/plugins/class-limited-offers.php @@ -152,7 +152,7 @@ public function get_localized_data() { * @return void */ public function disable_notification_ajax() { - if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['nonce'] ), 'dismiss_themeisle_event_notice_otter' ) ) { + if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['nonce'] ) ), 'dismiss_themeisle_event_notice_otter' ) ) { wp_die( esc_html( __( 'Invalid nonce! Refresh the page and try again.', 'otter-blocks' ) ) ); } diff --git a/inc/plugins/class-options-settings.php b/inc/plugins/class-options-settings.php index d4080ce13..0d12a1545 100644 --- a/inc/plugins/class-options-settings.php +++ b/inc/plugins/class-options-settings.php @@ -15,6 +15,31 @@ */ class Options_Settings { + /** + * Canonical AI toolbar actions option. + */ + const AI_TOOLBAR_ACTIONS_OPTION = 'themeisle_blocks_settings_ai_toolbar_actions'; + + /** + * Migration flag for AI toolbar actions. + */ + const AI_TOOLBAR_ACTIONS_MIGRATED_OPTION = 'themeisle_blocks_ai_toolbar_actions_migrated'; + + /** + * WordPress AI Client provider/model preferences for Otter AI features. + */ + const AI_WP_CLIENT_OPTION = 'themeisle_blocks_settings_ai_wp_client'; + + /** + * Legacy AI toolbar actions option. + */ + const LEGACY_TOOLBAR_ACTIONS_OPTION = 'themeisle_blocks_settings_prompt_actions'; + + /** + * Maximum number of custom toolbar actions. + */ + const MAX_CUSTOM_TOOLBAR_ACTIONS = 5; + /** * The main instance var. * @@ -26,11 +51,34 @@ class Options_Settings { * Initialize the class */ public function init() { + $this->maybe_default_atomic_wind_blocks(); add_action( 'init', array( $this, 'register_settings' ), 99 ); + add_action( 'init', array( $this, 'migrate_ai_toolbar_actions' ), 100 ); add_action( 'init', array( $this, 'default_block' ), 99 ); add_action( 'init', array( $this, 'register_meta' ), 19 ); } + /** + * Enable Atomic Wind for fresh Otter installs that never set the option. + * + * @return void + */ + public function maybe_default_atomic_wind_blocks() { + if ( get_option( 'themeisle_blocks_settings_atomic_wind_defaulted', false ) ) { + return; + } + + if ( 'NOT_SET' === get_option( 'themeisle_blocks_settings_atomic_wind_blocks', 'NOT_SET' ) ) { + $installed = (int) get_option( 'otter_blocks_install', 0 ); + + if ( 0 === $installed || $installed > ( time() - DAY_IN_SECONDS ) ) { + add_option( 'themeisle_blocks_settings_atomic_wind_blocks', true ); + } + } + + update_option( 'themeisle_blocks_settings_atomic_wind_defaulted', true ); + } + /** * Register Settings * @@ -239,6 +287,17 @@ public function register_settings() { ) ); + register_setting( + 'themeisle_blocks_settings', + 'otter_activation_first_save', + array( + 'type' => 'boolean', + 'description' => __( 'Whether the activation first-save milestone has already been tracked for this site.', 'otter-blocks' ), + 'show_in_rest' => true, + 'default' => false, + ) + ); + register_setting( 'themeisle_blocks_settings', 'themeisle_google_map_block_api_key', @@ -287,6 +346,30 @@ public function register_settings() { ) ); + register_setting( + 'themeisle_blocks_settings', + 'themeisle_cloudflare_turnstile_site_key', + array( + 'type' => 'string', + 'description' => __( 'Cloudflare Turnstile Site key for the Form Block.', 'otter-blocks' ), + 'sanitize_callback' => 'sanitize_text_field', + 'show_in_rest' => true, + 'default' => '', + ) + ); + + register_setting( + 'themeisle_blocks_settings', + 'themeisle_cloudflare_turnstile_secret_key', + array( + 'type' => 'string', + 'description' => __( 'Cloudflare Turnstile Secret key for the Form Block.', 'otter-blocks' ), + 'sanitize_callback' => 'sanitize_text_field', + 'show_in_rest' => true, + 'default' => '', + ) + ); + register_setting( 'themeisle_blocks_settings', 'themeisle_blocks_settings_default_block', @@ -344,12 +427,21 @@ function ( $item ) { if ( isset( $item['bcc'] ) ) { $item['bcc'] = sanitize_text_field( $item['bcc'] ); } + if ( isset( $item['replyTo'] ) ) { + $item['replyTo'] = sanitize_email( $item['replyTo'] ); + } if ( isset( $item['autoresponder']['body'] ) ) { $item['autoresponder']['body'] = wp_kses( $item['autoresponder']['body'], $this::get_allowed_mail_html() ); } if ( isset( $item['autoresponder']['subject'] ) ) { $item['autoresponder']['subject'] = sanitize_text_field( $item['autoresponder']['subject'] ); } + if ( isset( $item['aiAutoresponder']['enabled'] ) ) { + $item['aiAutoresponder']['enabled'] = ! empty( $item['aiAutoresponder']['enabled'] ); + } + if ( isset( $item['aiAutoresponder']['prompt'] ) ) { + $item['aiAutoresponder']['prompt'] = sanitize_textarea_field( $item['aiAutoresponder']['prompt'] ); + } if ( isset( $item['submitMessage'] ) ) { $item['submitMessage'] = sanitize_text_field( $item['submitMessage'] ); } @@ -372,6 +464,10 @@ function ( $item ) { $item['submissionsSaveLocation'] = sanitize_text_field( $item['submissionsSaveLocation'] ); } + if ( isset( $item['emailNotification'] ) ) { + $item['emailNotification'] = rest_sanitize_boolean( $item['emailNotification'] ); + } + if ( isset( $item['requiredFields'] ) ) { if ( is_array( $item['requiredFields'] ) ) { $item['requiredFields'] = array_map( 'sanitize_text_field', $item['requiredFields'] ); @@ -424,6 +520,9 @@ function ( $item ) { 'bcc' => array( 'type' => 'string', ), + 'replyTo' => array( + 'type' => 'string', + ), 'autoresponder' => array( 'type' => 'object', 'properties' => array( @@ -435,6 +534,17 @@ function ( $item ) { ), ), ), + 'aiAutoresponder' => array( + 'type' => 'object', + 'properties' => array( + 'enabled' => array( + 'type' => 'boolean', + ), + 'prompt' => array( + 'type' => 'string', + ), + ), + ), 'integration' => array( 'type' => 'object', 'properties' => array( @@ -455,6 +565,9 @@ function ( $item ) { 'submissionsSaveLocation' => array( 'type' => 'string', ), + 'emailNotification' => array( + 'type' => array( 'boolean', 'number', 'string' ), + ), 'webhookId' => array( 'type' => 'string', ), @@ -699,9 +812,63 @@ function ( $item ) { 'themeisle_blocks_settings', 'themeisle_otter_ai_usage', array( - 'type' => 'object', - 'description' => __( 'Usage of Otter AI features.', 'otter-blocks' ), - 'show_in_test' => array( + 'type' => 'object', + 'description' => __( 'Usage of Otter AI features.', 'otter-blocks' ), + 'sanitize_callback' => function ( $value ) { + $sanitized = array( + 'usage_count' => array(), + 'prompts' => array(), + ); + + if ( ! is_array( $value ) ) { + return $sanitized; + } + + if ( isset( $value['usage_count'] ) && is_array( $value['usage_count'] ) ) { + foreach ( $value['usage_count'] as $item ) { + if ( ! is_array( $item ) || ! isset( $item['key'], $item['value'] ) || ! is_string( $item['key'] ) || ! is_numeric( $item['value'] ) ) { + continue; + } + + $sanitized['usage_count'][] = array( + 'key' => substr( sanitize_text_field( $item['key'] ), 0, 100 ), + 'value' => max( 0, (int) $item['value'] ), + ); + + if ( count( $sanitized['usage_count'] ) >= 50 ) { + break; + } + } + } + + if ( isset( $value['prompts'] ) && is_array( $value['prompts'] ) ) { + foreach ( $value['prompts'] as $item ) { + if ( ! is_array( $item ) || ! isset( $item['key'], $item['values'] ) || ! is_string( $item['key'] ) || ! is_array( $item['values'] ) ) { + continue; + } + + $values = array(); + + foreach ( $item['values'] as $prompt ) { + if ( is_string( $prompt ) ) { + $values[] = substr( sanitize_textarea_field( $prompt ), 0, 1000 ); + } + } + + $sanitized['prompts'][] = array( + 'key' => substr( sanitize_text_field( $item['key'] ), 0, 100 ), + 'values' => array_slice( $values, -10 ), + ); + + if ( count( $sanitized['prompts'] ) >= 50 ) { + break; + } + } + } + + return $sanitized; + }, + 'show_in_rest' => array( 'schema' => array( 'type' => 'object', 'properties' => array( @@ -714,7 +881,7 @@ function ( $item ) { 'type' => 'string', ), 'value' => array( - 'type' => 'string', + 'type' => 'integer', ), ), ), @@ -741,7 +908,7 @@ function ( $item ) { ), ), ), - 'default' => array(), + 'default' => array(), ) ); @@ -777,64 +944,90 @@ function ( $item ) { ) ); + $toolbar_actions_schema = array( + 'type' => 'array', + 'description' => __( 'The prompt actions list of toolbar.', 'otter-blocks' ), + 'sanitize_callback' => array( self::class, 'sanitize_prompt_actions' ), + 'default' => self::get_default_prompt_actions(), + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'string', + ), + 'title' => array( + 'type' => 'string', + ), + 'prompt' => array( + 'type' => 'string', + ), + 'enabled' => array( + 'type' => 'boolean', + ), + 'custom' => array( + 'type' => 'boolean', + ), + ), + ), + ), + ), + ); + + register_setting( + 'themeisle_blocks_settings', + self::LEGACY_TOOLBAR_ACTIONS_OPTION, + $toolbar_actions_schema + ); + + register_setting( + 'themeisle_blocks_settings', + self::AI_TOOLBAR_ACTIONS_OPTION, + $toolbar_actions_schema + ); + + register_setting( + 'themeisle_blocks_settings', + self::AI_TOOLBAR_ACTIONS_MIGRATED_OPTION, + array( + 'type' => 'boolean', + 'description' => __( 'Whether AI toolbar actions were migrated to the canonical option.', 'otter-blocks' ), + 'show_in_rest' => true, + 'default' => false, + ) + ); + register_setting( 'themeisle_blocks_settings', - 'themeisle_blocks_settings_prompt_actions', + self::AI_WP_CLIENT_OPTION, array( - 'type' => 'array', - 'description' => __( 'The prompt actions list of toolbar.', 'otter-blocks' ), - 'sanitize_callback' => function ( $prompts ) { - return array_map( - function ( $item ) { - if ( isset( $item['title'] ) ) { - $item['title'] = sanitize_text_field( $item['title'] ); - } - if ( isset( $item['prompt'] ) ) { - $item['prompt'] = sanitize_text_field( $item['prompt'] ); - } - return $item; - }, - $prompts - ); - }, - 'default' => array( - array( - 'title' => 'Fix Grammar', - 'prompt' => 'Fix any grammatical errors in the following: {text_input}', - ), - array( - 'title' => 'Rephrase', - 'prompt' => 'Rephrase the following following: {text_input}', - ), - array( - 'title' => 'Make Shorter', - 'prompt' => 'Summarize or shorten the following: {text_input}', - ), - array( - 'title' => 'Make Longer', - 'prompt' => 'Expand or elaborate on the following: {text_input}', - ), - ), + 'type' => 'object', + 'description' => __( 'WordPress AI Client provider and model preferences for Otter AI features.', 'otter-blocks' ), + 'sanitize_callback' => array( $this, 'sanitize_ai_wp_client_settings' ), 'show_in_rest' => array( 'schema' => array( - 'type' => 'array', - 'items' => array( - 'type' => 'object', - 'properties' => array( - 'title' => array( - 'type' => 'string', - ), - 'prompt' => array( - 'type' => 'string', - ), + 'type' => 'object', + 'properties' => array( + 'provider' => array( + 'type' => 'string', + 'default' => '', + ), + 'model' => array( + 'type' => 'string', + 'default' => '', ), ), ), ), + 'default' => array( + 'provider' => '', + 'model' => '', + ), ) ); - register_setting( 'themeisle_blocks_settings', Template_Cloud::SOURCES_SETTING_KEY, @@ -916,6 +1109,331 @@ public function default_block() { ); } + /** + * Built-in toolbar action ids. + * + * @static + * @access public + * + * @return array + */ + public static function get_known_prompt_action_ids() { + return array( 'rewrite', 'summarize', 'expand', 'shorten', 'translate', 'tone', 'grammar', 'simplify' ); + } + + /** + * Default toolbar prompt actions. + * + * @static + * @access public + * + * @return array> + */ + public static function get_default_prompt_actions() { + return array( + array( + 'id' => 'rewrite', + 'title' => __( 'Rewrite', 'otter-blocks' ), + 'prompt' => __( 'Rewrite this for clarity and flow.', 'otter-blocks' ), + 'enabled' => true, + 'custom' => false, + ), + array( + 'id' => 'summarize', + 'title' => __( 'Summarize', 'otter-blocks' ), + 'prompt' => __( 'Summarize this concisely.', 'otter-blocks' ), + 'enabled' => true, + 'custom' => false, + ), + array( + 'id' => 'expand', + 'title' => __( 'Expand', 'otter-blocks' ), + 'prompt' => __( 'Expand this with useful supporting detail.', 'otter-blocks' ), + 'enabled' => true, + 'custom' => false, + ), + array( + 'id' => 'shorten', + 'title' => __( 'Shorten', 'otter-blocks' ), + 'prompt' => __( 'Make this shorter while preserving the key meaning.', 'otter-blocks' ), + 'enabled' => true, + 'custom' => false, + ), + array( + 'id' => 'translate', + 'title' => __( 'Translate', 'otter-blocks' ), + 'prompt' => __( 'Translate this into English.', 'otter-blocks' ), + 'enabled' => true, + 'custom' => false, + ), + array( + 'id' => 'tone', + 'title' => __( 'Change Tone', 'otter-blocks' ), + 'prompt' => __( 'Rewrite this in a more professional tone.', 'otter-blocks' ), + 'enabled' => true, + 'custom' => false, + ), + array( + 'id' => 'grammar', + 'title' => __( 'Fix Grammar', 'otter-blocks' ), + 'prompt' => __( 'Fix any spelling and grammar mistakes, keeping the original tone.', 'otter-blocks' ), + 'enabled' => true, + 'custom' => false, + ), + array( + 'id' => 'simplify', + 'title' => __( 'Simplify', 'otter-blocks' ), + 'prompt' => __( 'Simplify this so it is easier to read.', 'otter-blocks' ), + 'enabled' => true, + 'custom' => false, + ), + ); + } + + /** + * Sanitize toolbar prompt actions. + * + * @static + * @access public + * + * @param mixed $prompts Prompt actions payload. + * + * @return array> + */ + public static function sanitize_prompt_actions( $prompts ) { + if ( ! is_array( $prompts ) ) { + return array(); + } + + $known_ids = self::get_known_prompt_action_ids(); + $custom_count = 0; + $sanitized = array(); + + foreach ( $prompts as $item ) { + if ( ! is_array( $item ) ) { + continue; + } + + $action = array(); + + if ( isset( $item['id'] ) ) { + $action['id'] = sanitize_key( $item['id'] ); + } + + if ( isset( $item['title'] ) ) { + $action['title'] = sanitize_text_field( $item['title'] ); + } + + if ( isset( $item['prompt'] ) ) { + $action['prompt'] = sanitize_textarea_field( $item['prompt'] ); + } + + if ( empty( $action['title'] ) && empty( $action['prompt'] ) ) { + continue; + } + + $action['enabled'] = isset( $item['enabled'] ) ? self::sanitize_boolean_value( $item['enabled'] ) : true; + + if ( isset( $item['custom'] ) ) { + $action['custom'] = self::sanitize_boolean_value( $item['custom'] ); + } else { + $action['custom'] = ! isset( $action['id'] ) || ! in_array( $action['id'], $known_ids, true ); + } + + if ( $action['custom'] ) { + ++$custom_count; + + if ( $custom_count > self::MAX_CUSTOM_TOOLBAR_ACTIONS ) { + $action['enabled'] = false; + } + + if ( empty( $action['id'] ) ) { + $action['id'] = self::generate_custom_toolbar_action_id(); + } + } + + $sanitized[] = $action; + } + + return $sanitized; + } + + /** + * Coerce a mixed REST payload value into a boolean. + * + * Toolbar action flags arrive as `mixed`; rest_sanitize_boolean only accepts + * bool|string|int, so non-scalar values fall back to false. + * + * @static + * @access private + * + * @param mixed $value Raw value from the request payload. + * + * @return bool Sanitized boolean value. + */ + private static function sanitize_boolean_value( $value ) { + if ( is_bool( $value ) || is_string( $value ) || is_int( $value ) ) { + return rest_sanitize_boolean( $value ); + } + + return false; + } + + /** + * Append the builtin default actions whose ids are missing from the given list. + * + * Existing actions keep their position; missing builtins are appended in + * their default order. + * + * @static + * @access public + * + * @param mixed $actions Toolbar actions list. + * + * @return array> + */ + public static function merge_missing_builtin_actions( $actions ) { + if ( ! is_array( $actions ) ) { + $actions = array(); + } + + $existing_ids = array(); + + foreach ( $actions as $action ) { + if ( is_array( $action ) && ! empty( $action['id'] ) ) { + $existing_ids[] = $action['id']; + } + } + + foreach ( self::get_default_prompt_actions() as $builtin ) { + if ( ! in_array( $builtin['id'], $existing_ids, true ) ) { + $actions[] = $builtin; + } + } + + return $actions; + } + + /** + * Generate a stable custom toolbar action id. + * + * @static + * @access public + * @return string + */ + public static function generate_custom_toolbar_action_id() { + if ( function_exists( 'wp_generate_uuid4' ) ) { + return 'custom-' . wp_generate_uuid4(); + } + + return 'custom-' . wp_generate_password( 8, false, false ); + } + + /** + * Migrate legacy toolbar actions into the canonical option. + * + * @access public + * @return void + */ + public function migrate_ai_toolbar_actions() { + if ( get_option( self::AI_TOOLBAR_ACTIONS_MIGRATED_OPTION ) ) { + return; + } + + $canonical = get_option( self::AI_TOOLBAR_ACTIONS_OPTION, null ); + + if ( is_array( $canonical ) && ! empty( $canonical ) ) { + update_option( self::AI_TOOLBAR_ACTIONS_MIGRATED_OPTION, true ); + return; + } + + $legacy = get_option( self::LEGACY_TOOLBAR_ACTIONS_OPTION, null ); + + if ( is_array( $legacy ) && ! empty( $legacy ) ) { + $migrated = self::merge_missing_builtin_actions( self::sanitize_prompt_actions( $legacy ) ); + } else { + $migrated = self::get_default_prompt_actions(); + } + + update_option( self::AI_TOOLBAR_ACTIONS_OPTION, $migrated ); + update_option( self::AI_TOOLBAR_ACTIONS_MIGRATED_OPTION, true ); + } + + /** + * Read toolbar actions from the canonical option with legacy fallback. + * + * @static + * @access public + * + * @return array> + */ + public static function get_ai_toolbar_actions() { + $actions = get_option( self::AI_TOOLBAR_ACTIONS_OPTION, null ); + + if ( is_array( $actions ) && ! empty( $actions ) ) { + return $actions; + } + + $legacy = get_option( self::LEGACY_TOOLBAR_ACTIONS_OPTION, null ); + + if ( is_array( $legacy ) && ! empty( $legacy ) ) { + return $legacy; + } + + return self::get_default_prompt_actions(); + } + + /** + * Sanitize WordPress AI Client provider/model settings. + * + * @param mixed $value Raw option value. + * @return array{provider: string, model: string} + */ + public function sanitize_ai_wp_client_settings( $value ) { + $sanitized = array( + 'provider' => '', + 'model' => '', + ); + + if ( ! is_array( $value ) ) { + return $sanitized; + } + + if ( isset( $value['provider'] ) && is_string( $value['provider'] ) ) { + $sanitized['provider'] = sanitize_key( $value['provider'] ); + } + + if ( isset( $value['model'] ) && is_string( $value['model'] ) ) { + $sanitized['model'] = sanitize_text_field( $value['model'] ); + } + + return $sanitized; + } + + /** + * Get saved WordPress AI Client provider/model preferences for Otter AI. + * + * Empty provider means auto-select among configured connectors. + * Empty model means auto-select within the chosen provider. + * + * @return array{provider: string, model: string} + */ + public static function get_ai_wp_client_config() { + $stored = get_option( self::AI_WP_CLIENT_OPTION, array() ); + + if ( ! is_array( $stored ) ) { + return array( + 'provider' => '', + 'model' => '', + ); + } + + return array( + 'provider' => isset( $stored['provider'] ) && is_string( $stored['provider'] ) ? sanitize_key( $stored['provider'] ) : '', + 'model' => isset( $stored['model'] ) && is_string( $stored['model'] ) ? sanitize_text_field( $stored['model'] ) : '', + ); + } + /** * Get allowed HTML for mail body. * diff --git a/inc/render/class-about-author-block.php b/inc/render/class-about-author-block.php index f220aa4bb..928e697b2 100644 --- a/inc/render/class-about-author-block.php +++ b/inc/render/class-about-author-block.php @@ -23,9 +23,10 @@ class About_Author_Block { */ public function render( $attributes ) { $img_markup = sprintf( - '', + '%3$s', esc_url( get_author_posts_url( intval( get_the_author_meta( 'ID' ) ) ) ), - esc_attr( get_avatar_url( get_the_author_meta( 'ID' ), array( 'size' => 130 ) ) ) + esc_url( get_avatar_url( get_the_author_meta( 'ID' ), array( 'size' => 130 ) ) ), + esc_attr( get_the_author_meta( 'display_name' ) ) ); $title_markup = sprintf( diff --git a/inc/render/class-form-captcha-block.php b/inc/render/class-form-captcha-block.php new file mode 100644 index 000000000..734e0b5b8 --- /dev/null +++ b/inc/render/class-form-captcha-block.php @@ -0,0 +1,50 @@ + $attributes Block attrs. + * @return mixed|string + */ + public function render( $attributes ) { + $provider = isset( $attributes['provider'] ) && in_array( $attributes['provider'], array( 'recaptcha', 'turnstile' ), true ) ? $attributes['provider'] : 'recaptcha'; + + $output = '
'; + + if ( ! $this->has_keys( $provider ) && current_user_can( 'edit_posts' ) ) { + $output .= '

' . esc_html__( 'Captcha is not configured — form submissions will be blocked. Add the API keys in Settings > Otter > Integrations.', 'otter-blocks' ) . '

'; + } + + $output .= '
'; + return $output; + } + + /** + * Check if the API keys are set for the given provider. + * + * @param string $provider Captcha provider. + * @return bool + */ + private function has_keys( $provider ) { + if ( 'turnstile' === $provider ) { + return ! empty( get_option( 'themeisle_cloudflare_turnstile_site_key' ) ) && ! empty( get_option( 'themeisle_cloudflare_turnstile_secret_key' ) ); + } + + return ! empty( get_option( 'themeisle_google_captcha_api_site_key' ) ) && ! empty( get_option( 'themeisle_google_captcha_api_secret_key' ) ); + } +} diff --git a/inc/render/class-form-multiple-choice.php b/inc/render/class-form-multiple-choice.php index 055892328..99fc28488 100644 --- a/inc/render/class-form-multiple-choice.php +++ b/inc/render/class-form-multiple-choice.php @@ -60,7 +60,7 @@ public function render( $attributes ) { if ( 'select' === $field_type ) { $output .= $this->render_select_field( $label, $options, $id, $mapped_name, $has_multiple_selection, $is_required ); } else { - $output .= ''; + $output .= ''; $output .= '
'; @@ -141,7 +141,7 @@ public function render_field( $type, $label, $value, $name, $id, $checked = fals * @return string */ public function render_select_field( $label, $options_array, $id, $name, $is_multiple, $is_required ) { - $output = ''; + $output = ''; $output .= ' + + changeCondition( e.target.value, index, condIdx ) } - className="components-select-control__input w-full" - id={ `o-conditions-${ index }-${ condIdx }` } - > - - - { Object.keys( conditions ).map( groupKey => { - return ( - - { conditions[groupKey].conditions.map( o => ) } - - ); - }) } - - - - { 'userRoles' === condObj.type && ( - changeArrayValue( roles, index, condIdx, 'roles' ) } - __experimentalExpandOnFocus={ true } - __experimentalValidateInput={ newValue => Object.keys( window.themeisleGutenberg.userRoles ).includes( newValue ) } - /> - ) } - - { 'postAuthor' === condObj.type && ( - changeArrayValue( authors, index, condIdx, 'authors' ) } - /> - ) } - - { 'postCategory' === condObj.type && ( - changeArrayValue( categories, index, condIdx, 'categories' ) } - /> - ) } - - { 'postTag' === condObj.type && ( - changeArrayValue( tags, index, condIdx, 'tags' ) } - /> - ) } - - { 'postType' === condObj.type && ( - changeArrayValue( types, index, condIdx, 'post_types' ) } - __experimentalExpandOnFocus={ true } - __experimentalValidateInput={ newValue => postTypes.includes( newValue ) } - /> - ) } - - { 'screenSize' === condObj.type && ( - - toggleValueInArray( 'mobile', index, condIdx, 'screen_sizes' )} - /> - toggleValueInArray( 'tablet', index, condIdx, 'screen_sizes' )} - /> - toggleValueInArray( 'desktop', index, condIdx, 'screen_sizes' )} - /> - - ) } - - { 'stripePurchaseHistory' === condObj.type && ( - - { Boolean( window.themeisleGutenberg.hasStripeAPI ) && ( - changeValue( product, index, condIdx, 'product' ) } - /> - ) } - - { ! Boolean( window.themeisleGutenberg.hasStripeAPI ) && ( -

- { __( 'You need to set your Stripe API keys in the Otter Dashboard.', 'otter-blocks' ) } - { ' ' } - { __( 'Visit Dashboard', 'otter-blocks' ) } -

- ) } -
- ) } - - { applyFilters( 'otter.blockConditions.controls', '', index, condIdx, condObj, attributes.otterConditions, setAttributes, changeValue ) } - - { toggleVisibility.includes( condObj.type ) && ( - changeVisibility( e, index, condIdx ) } - /> - ) } - - - - { ( 1 < group.length && condIdx !== group.length - 1 ) && ( - - ) } - - ) ) } - - - - - { ( 1 < attributes.otterConditions.length && index !== attributes.otterConditions.length - 1 ) && ( - - ) } - - ); - }) } +

{ __( 'Display the block if…', 'otter-blocks' ) }

+ + { attributes.otterConditions && attributes.otterConditions.map( ( group, index ) => ( + + { 0 < index && } + + removeGroup( index ) } + onAddCondition={ value => addCondition( index, value ) } + onChangeCondition={ ( value, condIdx ) => changeCondition( value, index, condIdx ) } + onRemoveCondition={ condIdx => removeCondition( index, condIdx ) } + renderConditionControls={ ( condObj, condIdx ) => renderConditionControls( condObj, index, condIdx ) } + /> + + ) ) } + + ) : ( + <> +

{__('Almost there — reload to continue', 'otter-blocks')}

+

+ {__( + 'Atomic Wind blocks are now enabled. Reload the editor to load them. Save any pending changes first.', + 'otter-blocks', + )} +

+ + + )} +
+ ); +}; + +export default EnableAtomicWind; diff --git a/src/blocks/plugins/patterns-library/index.js b/src/blocks/plugins/patterns-library/index.js index a22f0bb77..6eb0fb382 100644 --- a/src/blocks/plugins/patterns-library/index.js +++ b/src/blocks/plugins/patterns-library/index.js @@ -33,6 +33,10 @@ const ModalButton = () => { icon={ otterIcon } className="o-library__button" onClick={ () => setOpen( true ) } + // The button is appended into the editor header NavigableToolbar; mark it as a + // toolbar item so it is keyboard-navigable and does not trip the + // "custom components as toolbar controls" deprecation (WP 5.6+). + data-toolbar-item="true" > { __( 'Design Library', 'otter-blocks' ) } diff --git a/src/blocks/plugins/patterns-library/library.js b/src/blocks/plugins/patterns-library/library.js index ae866af22..8fa8859d1 100644 --- a/src/blocks/plugins/patterns-library/library.js +++ b/src/blocks/plugins/patterns-library/library.js @@ -1,372 +1,848 @@ -/** - * External dependencies. - */ -import classnames from 'classnames'; - /** * WordPress dependencies. */ -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { parse } from '@wordpress/blocks'; -import { - Button, - DropdownMenu, - Modal, - SearchControl, - Spinner -} from '@wordpress/components'; +import { Modal } from '@wordpress/components'; -import { - useDispatch, - useSelect -} from '@wordpress/data'; +import { useDebounce } from '@wordpress/compose'; -import { - useMemo, - useState -} from '@wordpress/element'; +import { dispatch, select, useDispatch, useSelect } from '@wordpress/data'; -import { external, grid, Icon } from '@wordpress/icons'; +import { useCallback, useEffect, useMemo, useState } from '@wordpress/element'; + +import { close, Icon, search } from '@wordpress/icons'; /** * Internal dependencies. */ import { insertBlockBelow } from '../../helpers/block-utility'; -import Template from './template'; +import { otterMascot } from '../../helpers/icons'; +import Sidebar from './sidebar'; +import Topbar, { SmartTagRow, TopbarSkeleton, TagRowSkeleton } from './topbar'; +import Template, { UPSELL_BLOCK } from './template'; import Preview from './preview'; import CloudLibraryPlaceholder from './cloudLibraryPlaceholder'; +import EnableAtomicWind from './enableAtomicWind'; + +import { + createSearchIndex, + fuzzyMatches, + matchToken, + parseQuery, + similar, + suggestTags, +} from './smart'; + +import { accentContent } from './accent'; const CLOUD_EMPTY_CATEGORY = 'cloud-empty'; -const Library = ({ - onClose -}) => { - const { insertBlocks } = useDispatch( 'core/block-editor' ); - const { set } = useDispatch( 'core/preferences' ); +// Section categories bucketed into meaningful sidebar groups. Mirrors the +// taxonomy registered in inc/class-patterns.php. Groups only render the +// categories that at least one pattern actually uses, so empty (future) +// categories listed here stay hidden until patterns adopt them. +const SECTION_GROUPS = [ + { + id: 'structure', + label: __('Layout & Structure', 'otter-blocks'), + categories: [ 'header', 'page-sections' ], + }, + { + id: 'content', + label: __('Content', 'otter-blocks'), + categories: [ + 'features', + 'gallery', + 'team', + 'text', + 'blog', + 'faq', + 'timeline', + 'portfolio', + ], + }, + { + id: 'convert', + label: __('Conversion', 'otter-blocks'), + categories: [ + 'call-to-action', + 'forms', + 'pricing', + 'contact', + 'newsletter', + 'waitlist', + ], + }, + { + id: 'proof', + label: __('Social Proof', 'otter-blocks'), + categories: [ + 'testimonials', + 'stats', + 'case-studies', + 'clients', + ], + }, + { + id: 'media', + label: __('Media', 'otter-blocks'), + categories: [ 'video', 'maps', 'icons' ], + }, +]; + +// Categories that are containers, not browsable destinations. +const HIDDEN_CATEGORIES = [ 'otter-blocks', 'featured', 'pages' ]; + +// Refine facets where each pattern has exactly one value, so selecting a tag +// replaces any other active tag in the same group instead of ANDing with it. +// Tone and layout are single-value by nature; among curated metadata only +// `style` is exclusive (a pattern has one style, but can serve many use cases). +const EXCLUSIVE_META_GROUPS = [ 'style' ]; + +// The exclusivity bucket a tag belongs to, or null when it stacks freely. +const exclusiveBucket = (tag) => { + if ('tone' === tag.kind || 'layout' === tag.kind || 'access' === tag.kind) { + return tag.kind; + } + + if ('meta' === tag.kind && EXCLUSIVE_META_GROUPS.includes(tag.group)) { + return 'meta:' + tag.group; + } + + return null; +}; + +const isPackCategory = (name) => name.endsWith('-pack'); + +const isCloudCategory = (name) => name.startsWith('ti-tc-'); + +// A pattern's stable identity: the slug stays constant when it switches from an +// upsell (`otter-blocks/`) to the real Pro version (`otter-pro/`). +const slugOf = (name) => (name || '').split('/').pop(); + +const isPagePattern = (pattern) => + pattern.categories.some( + (category) => isPackCategory(category) || 'pages' === category, + ); - const { - clientID, - getFavorites - } = useSelect( ( select ) => { - const { getSelectedBlockClientId } = select( 'core/block-editor' ); - const { get } = select( 'core/preferences' ); +// Block types that aren't registered at all (e.g. a disabled block module) +// parse into core/missing wrappers — collect their original names. +const findMissingBlocks = (blocks) => + blocks.flatMap((block) => [ + ...('core/missing' === block.name && block.attributes?.originalName + ? [ block.attributes.originalName ] + : []), + ...findMissingBlocks(block.innerBlocks || []), + ]); + +const Library = ({ onClose }) => { + const { insertBlocks } = useDispatch('core/block-editor'); + const { createSuccessNotice, createWarningNotice } = + useDispatch('core/notices'); + const { set } = useDispatch('core/preferences'); + + const { clientID, favorites, columns, accent } = useSelect((select) => { + const { getSelectedBlockClientId } = select('core/block-editor'); + const { get } = select('core/preferences'); return { clientID: getSelectedBlockClientId(), - getFavorites: get( 'themeisle/otter-blocks', 'patterns-favorites' ) || [] + favorites: get('themeisle/otter-blocks', 'patterns-favorites') || [], + columns: get('themeisle/otter-blocks', 'patterns-columns') || 3, + accent: get('themeisle/otter-blocks', 'patterns-accent') || null, }; }, []); - const toggleFavorite = ( pattern ) => { - const favorites = getFavorites; - - if ( favorites.includes( pattern ) ) { - set( 'themeisle/otter-blocks', 'patterns-favorites', favorites.filter( name => name !== pattern ) ); - } else { - set( 'themeisle/otter-blocks', 'patterns-favorites', [ ...favorites, pattern ]); - } - }; + // Every template is built on Atomic Wind blocks, which only register when + // their setting is on. With them off the library can't render or insert + // anything, so gate the whole thing behind a one-click enable prompt. + const atomicWindEnabled = useSelect( + (select) => Boolean(select('core/blocks').getBlockType('atomic-wind/box')), + [], + ); - const { - patterns, - categories, - tcCategories, - isResolvingPatterns - } = useSelect( ( select ) => { - const { - getBlockPatterns, - getBlockPatternCategories, - isResolving - } = select( 'core' ); - - const patterns = getBlockPatterns()?.filter( pattern => pattern?.categories && pattern?.categories.includes( 'otter-blocks' ) ); - - const allCategories = getBlockPatternCategories(); - - const patternCategories = [ ...new Set( patterns.flatMap( pattern => pattern.categories.filter( category => { - return ! category.startsWith( 'ti-tc-' ); - } ) ) ) ]; - const tcPatternCategories = [ ...new Set( patterns.flatMap( pattern => pattern.categories.filter( category => { return category.startsWith( 'ti-tc-' ); } ) ) ) ]; - - const categories = [ ...allCategories.filter( category => patternCategories.includes( category?.name ) ) ]; - const tcCategories = [ ...allCategories.filter( category => tcPatternCategories.includes( category?.name ) ) ]; - - categories.forEach( category => { - if ( 'otter-blocks' === category?.name ) { - category.label = __( 'All', 'otter-blocks' ); + const setColumns = (value) => + set('themeisle/otter-blocks', 'patterns-columns', value); + + const setAccent = (value) => + set('themeisle/otter-blocks', 'patterns-accent', value); + + // Match by slug so a pattern favorited as an upsell stays favorited once Pro + // registers it; the stored value keeps its original full name. + const toggleFavorite = useCallback( + (name) => { + const slug = slugOf(name); + + if (favorites.some((favorite) => slugOf(favorite) === slug)) { + set( + 'themeisle/otter-blocks', + 'patterns-favorites', + favorites.filter((favorite) => slugOf(favorite) !== slug), + ); + } else { + set('themeisle/otter-blocks', 'patterns-favorites', [ + ...favorites, + name, + ]); } + }, + [ favorites ], + ); + + // Slug set for favorite membership checks across the library. + const favoriteSlugs = useMemo( + () => new Set(favorites.map(slugOf)), + [ favorites ], + ); - if ( 'header' === category?.name ) { - category.label = __( 'Hero', 'otter-blocks' ); + // Return only raw store references from useSelect — filtering in the + // callback would produce a fresh array on every store change, which + // useSelect's shallow comparison treats as a new value. + const { allPatterns, allCategories, isResolvingPatterns } = useSelect( + (select) => { + const { + getBlockPatterns, + getBlockPatternCategories, + hasFinishedResolution, + } = select('core'); + + return { + allPatterns: getBlockPatterns(), + allCategories: getBlockPatternCategories() || [], + isResolvingPatterns: + !hasFinishedResolution('getBlockPatterns') || + !hasFinishedResolution('getBlockPatternCategories'), + }; + }, + [], + ); + + const registeredPatterns = useMemo( + () => + allPatterns?.filter( + (pattern) => + pattern?.categories && pattern?.categories.includes('otter-blocks'), + ) || [], + [ allPatterns ], + ); + + // Static Pro upsell cards. With a license, Otter Pro registers the real + // patterns (otter-pro/, also tagged otter-blocks) and they arrive in + // registeredPatterns above — so drop any upsell whose slug is already + // registered, leaving only the not-yet-unlocked ones as upsells. + const proUpsells = useMemo( + () => + (window.themeisleGutenberg?.proPatterns || []).map((pattern) => ({ + ...pattern, + content: '', + categories: pattern.categories || [ 'otter-blocks' ], + keywords: pattern.keywords || [], + elements: pattern.elements || [], + isPro: true, + })), + [], + ); + + const patterns = useMemo(() => { + const registeredSlugs = new Set( + registeredPatterns.map((pattern) => slugOf(pattern.name)), + ); + + return [ + ...registeredPatterns, + ...proUpsells.filter( + (pattern) => !registeredSlugs.has(slugOf(pattern.name)), + ), + ]; + }, [ registeredPatterns, proUpsells ]); + + // A stored favorite can point at a pattern that's neither registered nor an + // upsell anymore; it can't render when filtered, so count only favorites + // (by slug) that still resolve to a known pattern. + const favCount = useMemo(() => { + const known = new Set(patterns.map((pattern) => slugOf(pattern.name))); + let count = 0; + + favoriteSlugs.forEach((slug) => { + if (known.has(slug)) { + count++; } }); - categories.sort( ( a, b ) => a.label.localeCompare( b.label ) ); + return count; + }, [ patterns, favoriteSlugs ]); + + const { sectionGroups, collections, tcCategories, categoryLabels } = + useMemo(() => { + const usedCategories = [ + ...new Set(patterns.flatMap((pattern) => pattern.categories)), + ]; + + const categoryLabels = {}; + + allCategories.forEach((category) => { + categoryLabels[category.name] = category.label; + }); + + // Patterns in the "header" category are heroes. + categoryLabels.header = __('Hero', 'otter-blocks'); + + const sectionCategories = usedCategories.filter( + (name) => + !HIDDEN_CATEGORIES.includes(name) && + !isPackCategory(name) && + !isCloudCategory(name), + ); + + const sectionGroups = SECTION_GROUPS.map((group) => ({ + ...group, + categories: group.categories + .filter((name) => sectionCategories.includes(name)) + .map((name) => ({ name, label: categoryLabels[name] || name })), + })).filter((group) => Boolean(group.categories.length)); + + const leftovers = sectionCategories.filter( + (name) => + !SECTION_GROUPS.some((group) => group.categories.includes(name)), + ); + + if (leftovers.length) { + sectionGroups.push({ + id: 'more', + label: __('More', 'otter-blocks'), + categories: leftovers + .map((name) => ({ name, label: categoryLabels[name] || name })) + .sort((a, b) => a.label.localeCompare(b.label)), + }); + } + + // Packs that contain at least one free (registered) pattern; the rest + // are Pro-only collections. + const freePackCategories = new Set(); + patterns.forEach((pattern) => { + if (!pattern.isPro) { + pattern.categories.forEach((category) => { + if (isPackCategory(category)) { + freePackCategories.add(category); + } + }); + } + }); + + const collections = usedCategories + .filter(isPackCategory) + .map((name) => { + const label = (categoryLabels[name] || name).replace( + /\s*\(Pages\)\s*$/, + '', + ); + categoryLabels[name] = label; + + return { name, label, isPro: !freePackCategories.has(name) }; + }) + // Free packs first, then Pro; alphabetical within each group. + .sort((a, b) => + a.isPro === b.isPro + ? a.label.localeCompare(b.label) + : a.isPro + ? 1 + : -1, + ); + + const tcCategories = usedCategories + .filter(isCloudCategory) + .map((name) => ({ name, label: categoryLabels[name] || name })) + .sort((a, b) => a.label.localeCompare(b.label)); + + return { + sectionGroups, + collections, + tcCategories, + categoryLabels, + }; + }, [ patterns, allCategories ]); + + const [ mode, setModeRaw ] = useState('sections'); + const [ activeCategory, setActiveCategory ] = useState('all'); + const [ query, setQuery ] = useState(''); + + // The input tracks `query` keystroke by keystroke; everything downstream + // (token parsing, fuzzy search, filtering) reads the debounced copy so + // the grid doesn't churn mid-word. Clearing skips the delay — stale + // results lingering after the field empties read as a bug. + const [ debouncedQuery, setDebouncedQuery ] = useState(''); + const updateDebouncedQuery = useDebounce(setDebouncedQuery, 250); + + useEffect(() => { + if (!query) { + updateDebouncedQuery.cancel(); + setDebouncedQuery(''); + return; + } + + updateDebouncedQuery(query); + }, [ query, updateDebouncedQuery ]); + const [ favOnly, setFavOnly ] = useState(false); + const [ sort, setSort ] = useState('featured'); + const [ activeTags, setActiveTags ] = useState([]); + const [ preview, setPreview ] = useState(null); - // Move footer category to the end. - const footerCategory = categories.find( category => 'footer' === category.name ); + const isPage = 'pages' === mode; - if ( footerCategory ) { - categories.push( categories.splice( categories.indexOf( footerCategory ), 1 )[0]); - } + const setMode = (value) => { + setModeRaw(value); + setActiveCategory('all'); + }; - // Remove featured category. - const featuredCategory = categories.find( category => 'featured' === category.name ); + // Patterns in the current mode, before any filtering. + const modePatterns = useMemo(() => { + return patterns.filter((pattern) => isPage === isPagePattern(pattern)); + }, [ patterns, isPage ]); - if ( featuredCategory ) { - categories.splice( categories.indexOf( featuredCategory ), 1 ); - } + // Per-category counts for the sidebar, independent of search and filters. + const counts = useMemo(() => { + const counts = { all: modePatterns.length }; - const packCategories = categories.filter( category => category.name.includes( '-pack' ) ); + modePatterns.forEach((pattern) => { + pattern.categories.forEach((category) => { + counts[category] = (counts[category] || 0) + 1; + }); + }); - packCategories.forEach( packCategory => { - const index = categories.indexOf( packCategory ); - if ( -1 < index ) { - categories.splice( index, 1 ); - } + return counts; + }, [ modePatterns ]); + + // Scope: mode + category. Used for tag suggestions and filtering. + const scope = useMemo(() => { + return modePatterns.filter( + (pattern) => + 'all' === activeCategory || pattern.categories.includes(activeCategory), + ); + }, [ modePatterns, activeCategory ]); + + const parsed = useMemo(() => parseQuery(debouncedQuery), [ debouncedQuery ]); + + // Fuzzy matching for the free-text part of the query, so typos and + // partial words still find patterns. Indexed over all patterns — the + // scope filters below narrow the results. + const searchIndex = useMemo( + () => createSearchIndex(patterns, categoryLabels), + [ patterns, categoryLabels ], + ); + + const textMatches = useMemo( + () => fuzzyMatches(searchIndex, parsed.text), + [ searchIndex, parsed ], + ); + + // Refine tags with live counts. The suggested set stays stable per scope + // (it doesn't reshuffle as you filter), but each tag's count is recomputed + // against the current search, favorites and active tags so it reflects what + // clicking it would actually return — and any tag that would yield nothing + // is flagged disabled, so a filter never leads to an empty grid. + const tags = useMemo(() => { + const suggested = suggestTags(scope, 12); + + // Everything except the tag facets: these always constrain the counts. + const base = scope + .filter((pattern) => !favOnly || favoriteSlugs.has(slugOf(pattern.name))) + .filter((pattern) => + parsed.tokens.every((token) => matchToken(pattern, token)), + ) + .filter((pattern) => !textMatches || textMatches.has(pattern.name)); + + return suggested.map((tag) => { + const bucket = exclusiveBucket(tag); + + // Selecting a tag in an exclusive group replaces that group's current + // choice, so its count ignores other active tags in the same bucket; + // independent tags stack on top of every active tag. + const others = activeTags.filter( + (item) => !bucket || exclusiveBucket(item) !== bucket, + ); + + const count = base.filter( + (pattern) => + matchToken(pattern, tag) && + others.every((token) => matchToken(pattern, token)), + ).length; + + return { ...tag, count, disabled: 0 === count }; }); + }, [ scope, favOnly, favoriteSlugs, parsed, textMatches, activeTags ]); - // Adding all the Template Packs to the top. - const allCategoryIndex = categories.findIndex( category => category.label === __( 'All', 'otter-blocks' ) ); + // Clear tag selections when the scope changes. + useEffect(() => { + setActiveTags([]); + }, [ mode, activeCategory ]); - categories.splice( allCategoryIndex + 1, 0, ...packCategories ); + const toggleTag = (tag) => { + setActiveTags((current) => { + if (current.some((item) => item.key === tag.key)) { + return current.filter((item) => item.key !== tag.key); + } - return { - patterns, - tcCategories, - categories, - isResolvingPatterns: isResolving( 'core', 'getBlockPatterns' ) || isResolving( 'core', 'getBlockPatternCategories' ) - }; - }, []); + // Single-choice facets (tone, layout, style) would AND into an empty + // set if two values were active, so selecting one drops any other tag + // in the same exclusivity bucket. + const bucket = exclusiveBucket(tag); + const kept = bucket + ? current.filter((item) => exclusiveBucket(item) !== bucket) + : current; + + return [ + ...kept, + { key: tag.key, kind: tag.kind, group: tag.group, value: tag.value }, + ]; + }); + }; - const [ selectedCategory, setSelectedCategory ] = useState( 'otter-blocks' ); - const [ searchInput, setSearchInput ] = useState( '' ); - const [ layout, setLayout ] = useState( 3 ); - const [ selectedTemplate, setSelectedTemplate ] = useState( null ); - const [ bulkSelection, setBulkSelection ] = useState([]); + // Removing a parsed token strips its source word from the query string. + const onRemoveToken = (token) => { + setQuery((current) => + current + .split(/\s+/) + .filter((word) => word.toLowerCase() !== token.word) + .join(' '), + ); + }; - const filteredPatterns = useMemo( () => { - const currentCategory = 'favorites' === selectedCategory ? patterns.filter( pattern => getFavorites.includes( pattern.name ) ) : patterns.filter( pattern => pattern.categories.includes( selectedCategory ) ); + const primaryCategoryLabel = (pattern) => { + const category = pattern.categories.find( + (name) => !HIDDEN_CATEGORIES.includes(name), + ); + return category ? categoryLabels[category] || '' : ''; + }; - if ( searchInput ) { - return currentCategory.filter( pattern => pattern.title.toLowerCase().includes( searchInput.toLowerCase() ) ); + const filteredPatterns = useMemo(() => { + let result = scope + .filter((pattern) => !favOnly || favoriteSlugs.has(slugOf(pattern.name))) + .filter((pattern) => + parsed.tokens.every((token) => matchToken(pattern, token)), + ) + .filter((pattern) => + activeTags.every((token) => matchToken(pattern, token)), + ) + .filter((pattern) => !textMatches || textMatches.has(pattern.name)); + + if ('az' === sort) { + result = [ ...result ].sort((a, b) => a.title.localeCompare(b.title)); + } else if ('za' === sort) { + result = [ ...result ].sort((a, b) => b.title.localeCompare(a.title)); + } else if (textMatches) { + result = [ ...result ].sort( + (a, b) => textMatches.get(a.name) - textMatches.get(b.name), + ); } - return currentCategory; - }, [ patterns, selectedCategory, searchInput ]); + return result; + }, [ scope, parsed, activeTags, favOnly, favoriteSlugs, sort, textMatches ]); - const onBulkSelection = ( pattern ) => { - if ( bulkSelection.includes( pattern ) ) { - setBulkSelection( bulkSelection.filter( name => name !== pattern ) ); - } else { - setBulkSelection([ ...bulkSelection, pattern ]); + // "More like this" suggestions for the preview overlay. + const similarPatterns = useMemo(() => { + if (!preview) { + return []; } - }; - const insertPattern = ( pattern ) => { - const blocks = parse( pattern ); + return similar( + preview, + patterns.filter( + (pattern) => isPagePattern(preview) === isPagePattern(pattern), + ), + 4, + ); + }, [ preview, patterns ]); + + const insertPattern = useCallback( + (pattern) => { + // Pro upsell cards carry no content — they can't be inserted. Send the + // user to the Pro patterns page in a new tab and leave the library open + // so they can keep browsing. + if (pattern.isPro) { + const url = + window.themeisleGutenberg?.patternsLink || + window.themeisleGutenberg?.upgradeLink; + + if (url) { + window.open(url, '_blank'); + } + + return; + } - if ( clientID ) { - insertBlockBelow( clientID, blocks ); - } else { - insertBlocks( parse( pattern ) ); - } + // With Pro active the upsell banner removes itself right after + // insertion anyway — skip it up front. + const blocks = parse(accentContent(pattern, accent)).filter( + (block) => + UPSELL_BLOCK !== block.name || + !Boolean(window.themeisleGutenberg?.hasPro), + ); + + // Inserting unregistered blocks would just dump raw markup into the + // canvas — tell the user what's missing instead. Atomic Wind blocks + // only register when their toggle in Otter's settings is on, so for + // those we can point at the fix directly. + const missing = [ ...new Set(findMissingBlocks(blocks)) ]; + + if (missing.length) { + const needsAtomicWind = missing.some((name) => + name.startsWith('atomic-wind/'), + ); + const optionsPath = window.themeisleGutenberg?.optionsPath; + + createWarningNotice( + needsAtomicWind + ? sprintf( + // translators: %s is the name of the template. + __( + '"%s" needs the Atomic Wind blocks, which are currently disabled. Enable them in Otter\'s settings, then insert the template again.', + 'otter-blocks', + ), + pattern.title, + ) + : sprintf( + // translators: %1$s is the name of the template, %2$s is a list of block names. + __( + '"%1$s" needs blocks that aren\'t available on this site: %2$s', + 'otter-blocks', + ), + pattern.title, + missing.join(', '), + ), + { + isDismissible: true, + actions: + needsAtomicWind && optionsPath + ? [ + { + label: __('Open Otter settings', 'otter-blocks'), + onClick: () => window.open(optionsPath, '_blank'), + }, + ] + : [], + }, + ); + + onClose(); + return; + } - onClose(); - }; + // Blocks disabled in the Block Manager are folded into the editor's + // allowedBlockTypes setting, and insertBlocks silently drops every + // block that isn't allowed. Inserting a chosen template is explicit + // intent — lift the restriction for the duration of the insert. + const { allowedBlockTypes } = select('core/block-editor').getSettings(); + const { updateSettings } = dispatch('core/block-editor'); + + updateSettings({ allowedBlockTypes: true }); + + try { + if (clientID) { + insertBlockBelow(clientID, blocks); + } else { + insertBlocks(blocks); + } + } finally { + updateSettings({ allowedBlockTypes }); + } - const onBulkInsertion = () => { - const blocks = bulkSelection.map( pattern => parse( patterns.find( item => item.name === pattern ).content ) ).flat(); + createSuccessNotice( + sprintf( + // translators: %s is the name of the inserted template. + __('"%s" inserted.', 'otter-blocks'), + pattern.title, + ), + { type: 'snackbar' }, + ); + + onClose(); + }, + [ clientID, accent ], + ); - if ( clientID ) { - insertBlockBelow( clientID, blocks ); - } else { - insertBlocks( blocks ); + const resetFilters = () => { + setQuery(''); + setFavOnly(false); + setActiveCategory('all'); + setActiveTags([]); + }; + + const onRequestClose = () => { + if (preview) { + setPreview(null); + return; } onClose(); }; + const isCloudPlaceholder = CLOUD_EMPTY_CATEGORY === activeCategory; + return ( - { ! selectedTemplate && ( - <> - { isResolvingPatterns && ( -
- -
- ) } - -
- { getFavorites?.length > 0 && ( - - )} - - {( tcCategories.length < 1 && Boolean( window?.themeisleGutenberg?.hasPatternSources ) ) && ( - - ) } - - { ( ! Boolean( window.themeisleGutenberg.hasPro ) ) && ( - - ) } - - - {tcCategories.length > 0 && ( - <> -

- {__('Cloud Libraries', 'otter-blocks')} -

- -
    - {tcCategories.map(category => ( -
  • - -
  • - ))} -
- - )} - -

- { __( 'Categories', 'otter-blocks' ) } -

- -
    - { categories.map( category => ( -
  • - -
  • - ) )} -
-
- -
- {( selectedCategory === CLOUD_EMPTY_CATEGORY && Boolean( window?.themeisleGutenberg?.hasPatternSources ) ) && } - {selectedCategory !== CLOUD_EMPTY_CATEGORY && ( - <> -
- +
+ {otterMascot({})} +

{__('Design Library', 'otter-blocks')}

+
+ + + + +
+ {!atomicWindEnabled && } + + {atomicWindEnabled && ( + <> + + +
+ {isResolvingPatterns && ( + <> + + + +
+
+ {Array.from({ length: columns * 3 }).map((_, index) => ( +
+
+
+
+
+ + +
+
+ ))} +
+
+ + )} + + {isCloudPlaceholder && } + + {!isCloudPlaceholder && !isResolvingPatterns && ( + <> + - setLayout(2) - }, - { - title: __('3 Column', 'otter-blocks'), - onClick: () => setLayout(3) - }, - { - title: __('4 Column', 'otter-blocks'), - onClick: () => setLayout(4) - } - ]} + tag.key)} + onToggle={toggleTag} /> -
- -
- {!filteredPatterns.length && ( -

- {__('No patterns found.', 'otter-blocks')} -

- )} - - {filteredPatterns.map(pattern => ( -