From e92bf22036ec9b97265bc959bcbff3800453e7e1 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Mon, 2 Nov 2026 22:52:48 +0800 Subject: [PATCH 01/34] feat(admin-interface): add Subscriber gating defaults to settings page Feature: admin-interface/settings-page/subscriber-gate-defaults Implements: - US-001: default gate headline and text with fallback on read - US-002: unlock cookie lifetime, whole days 1 to 365, else 30 - US-003: Publication and Subscriber gating saves keep each other's values The Subscriber gating section prints for every administrator, including when the Publication section is hidden. Options::get_gate_settings() returns final values for the gated content block. --- includes/Admin/Options.php | 135 ++++++++++++++-- includes/Admin/Registrar.php | 162 +++++++++++++++++++ includes/Admin/Views/settings-page.php | 22 ++- tests/phpunit/SubscriberGateDefaultsTest.php | 156 ++++++++++++++++++ 4 files changed, 454 insertions(+), 21 deletions(-) create mode 100644 tests/phpunit/SubscriberGateDefaultsTest.php diff --git a/includes/Admin/Options.php b/includes/Admin/Options.php index a48d048..cf7ae33 100644 --- a/includes/Admin/Options.php +++ b/includes/Admin/Options.php @@ -27,8 +27,32 @@ final class Options { private const DEFAULTS = [ 'publication_id' => '', 'post_template_id' => '', + 'gate_headline' => '', + 'gate_text' => '', + 'gate_cookie_days' => '', ]; + /** + * Lowest unlock cookie lifetime, in days. + * + * @var int + */ + public const GATE_COOKIE_DAYS_MIN = 1; + + /** + * Highest unlock cookie lifetime, in days. + * + * @var int + */ + public const GATE_COOKIE_DAYS_MAX = 365; + + /** + * Built-in unlock cookie lifetime, in days. + * + * @var int + */ + public const GATE_COOKIE_DAYS_DEFAULT = 30; + /** * Register the option with the Settings API. * @@ -62,34 +86,119 @@ public static function get(): array { return wp_parse_args( $saved, self::DEFAULTS ); } + /** + * Built-in default gate headline. + * + * @return string + * @since 1.2.0 + */ + public static function default_gate_headline(): string { + return __( 'Subscribe to keep reading', 'beehiiv' ); + } + + /** + * Built-in default gate text. + * + * @return string + * @since 1.2.0 + */ + public static function default_gate_text(): string { + return __( 'Enter your email to unlock the full story and get our newsletter.', 'beehiiv' ); + } + + /** + * Gate defaults with every fallback applied. + * + * A blank, whitespace-only, missing or invalid saved value reads as the + * built-in default. The fallback is applied here, on read, and is never + * written back to the stored option. + * + * @return array{headline: string, text: string, cookie_days: int} + * @since 1.2.0 + */ + public static function get_gate_settings(): array { + $settings = self::get(); + + $headline = trim( (string) $settings['gate_headline'] ); + $text = trim( (string) $settings['gate_text'] ); + + return [ + 'headline' => '' !== $headline ? $headline : self::default_gate_headline(), + 'text' => '' !== $text ? $text : self::default_gate_text(), + 'cookie_days' => self::resolve_gate_cookie_days( $settings['gate_cookie_days'] ), + ]; + } + + /** + * Resolve a stored cookie lifetime to a whole number of days from 1 to 365. + * + * Anything else (blank, text, decimal, zero, negative, over 365) becomes 30. + * + * @param mixed $value Stored or submitted value. + * @return int + * @since 1.2.0 + */ + public static function resolve_gate_cookie_days( $value ): int { + if ( is_int( $value ) ) { + $value = (string) $value; + } + + if ( ! is_string( $value ) || ! preg_match( '/^\s*\d+\s*$/', $value ) ) { + return self::GATE_COOKIE_DAYS_DEFAULT; + } + + $days = (int) trim( $value ); + + if ( $days < self::GATE_COOKIE_DAYS_MIN || $days > self::GATE_COOKIE_DAYS_MAX ) { + return self::GATE_COOKIE_DAYS_DEFAULT; + } + + return $days; + } + /** * Sanitize settings before saving. * + * The settings form posts the Publication fields and the Subscriber gating + * fields together, and the Publication fields are absent when that section + * is hidden. Only keys present in the submission change; every other stored + * value is kept. + * * @param mixed $input Raw option value from the form. * @return array * @since 1.0.0 */ public static function sanitize( $input ): array { + $current = self::get(); + if ( ! is_array( $input ) ) { - return self::get(); + return $current; } - $publication_id = isset( $input['publication_id'] ) - ? sanitize_text_field( (string) $input['publication_id'] ) - : ''; - $post_template_id = isset( $input['post_template_id'] ) - ? sanitize_text_field( (string) $input['post_template_id'] ) - : ''; + foreach ( [ 'publication_id', 'post_template_id' ] as $key ) { + if ( isset( $input[ $key ] ) ) { + $current[ $key ] = sanitize_text_field( (string) $input[ $key ] ); + } + } + + // Plain text only: tags are removed and surrounding whitespace trimmed. + foreach ( [ 'gate_headline', 'gate_text' ] as $key ) { + if ( isset( $input[ $key ] ) ) { + $current[ $key ] = trim( sanitize_text_field( (string) $input[ $key ] ) ); + } + } + + // Stored as submitted (digits only); the 1 to 365 rule applies on read. + if ( isset( $input['gate_cookie_days'] ) ) { + $current['gate_cookie_days'] = sanitize_text_field( (string) $input['gate_cookie_days'] ); + } // Saving settings clears the cached template list for the selected // publication so the dropdown reflects any beehiiv-side changes. - if ( '' !== $publication_id ) { - Cache::delete_post_templates( $publication_id ); + if ( '' !== $current['publication_id'] ) { + Cache::delete_post_templates( $current['publication_id'] ); } - return [ - 'publication_id' => $publication_id, - 'post_template_id' => $post_template_id, - ]; + return $current; } } diff --git a/includes/Admin/Registrar.php b/includes/Admin/Registrar.php index 8b831b4..45407b2 100644 --- a/includes/Admin/Registrar.php +++ b/includes/Admin/Registrar.php @@ -26,6 +26,19 @@ final class Registrar { */ private const PAGE_SETTINGS_SECTION_ID = 'beehiiv_page_settings'; + /** + * Settings API section ID for the subscriber gating defaults. + */ + private const GATE_SETTINGS_SECTION_ID = 'beehiiv_gate_settings'; + + /** + * Settings API page slug that holds the subscriber gating section. + * + * Separate from the main page slug so the section prints whether or not + * the Publication section is shown. + */ + public const GATE_SETTINGS_PAGE = Config::PLUGIN_SLUG . '_gating'; + /** * Whether Settings API registration has already run this request. * @@ -45,6 +58,7 @@ public static function register(): void { self::$registered = true; Options::register(); + self::register_gate_settings_section( self::GATE_SETTINGS_PAGE ); if ( Manager::is_connected() ) { self::register_page_settings_section( Config::PLUGIN_SLUG ); @@ -88,6 +102,154 @@ private static function register_page_settings_section( string $page_slug ): voi ); } + /** + * Register the subscriber gating section and fields. + * + * @param string $page_slug Settings API page slug for this section. + * @since 1.2.0 + */ + private static function register_gate_settings_section( string $page_slug ): void { + add_settings_section( + self::GATE_SETTINGS_SECTION_ID, + __( 'Subscriber gating', 'beehiiv' ), + [ self::class, 'render_gate_settings_description' ], + $page_slug + ); + + add_settings_field( + 'beehiiv_gate_headline', + __( 'Default gate headline', 'beehiiv' ), + [ self::class, 'render_gate_headline_field' ], + $page_slug, + self::GATE_SETTINGS_SECTION_ID, + [ 'label_for' => 'beehiiv_gate_headline' ] + ); + + add_settings_field( + 'beehiiv_gate_text', + __( 'Default gate text', 'beehiiv' ), + [ self::class, 'render_gate_text_field' ], + $page_slug, + self::GATE_SETTINGS_SECTION_ID, + [ 'label_for' => 'beehiiv_gate_text' ] + ); + + add_settings_field( + 'beehiiv_gate_cookie_days', + __( 'Unlock cookie lifetime', 'beehiiv' ), + [ self::class, 'render_gate_cookie_days_field' ], + $page_slug, + self::GATE_SETTINGS_SECTION_ID, + [ 'label_for' => 'beehiiv_gate_cookie_days' ] + ); + } + + /** + * Subscriber gating section intro text. + * + * @since 1.2.0 + */ + public static function render_gate_settings_description(): void { + echo '

' . esc_html__( + 'Defaults for gated content blocks. A gated content block can override the headline and text.', + 'beehiiv' + ) . '

'; + } + + /** + * Default gate headline field. + * + * @since 1.2.0 + */ + public static function render_gate_headline_field(): void { + $settings = Options::get(); + ?> + +

+ +

+ + +

+ +

+ + + +

+ +

+ - -
- + -
- + } + + do_settings_sections( Registrar::GATE_SETTINGS_PAGE ); + submit_button( __( 'Save settings', 'beehiiv' ) ); + ?> + diff --git a/tests/phpunit/SubscriberGateDefaultsTest.php b/tests/phpunit/SubscriberGateDefaultsTest.php new file mode 100644 index 0000000..8e9aa57 --- /dev/null +++ b/tests/phpunit/SubscriberGateDefaultsTest.php @@ -0,0 +1,156 @@ + 'pub_1', + 'post_template_id' => 'tpl_1', + ) + ); + + $gate = Options::get_gate_settings(); + + $this->assertSame( 'Subscribe to keep reading', $gate['headline'] ); + $this->assertSame( 'Enter your email to unlock the full story and get our newsletter.', $gate['text'] ); + $this->assertSame( 30, $gate['cookie_days'] ); + $this->assertSame( 'pub_1', Options::get()['publication_id'] ); + } + + /** + * AC-004: blank or whitespace-only values read as the default and are not stored as it. + */ + public function test_blank_values_fall_back_on_read_not_on_save(): void { + $saved = Options::sanitize( + array( + 'gate_headline' => ' ', + 'gate_text' => '', + ) + ); + + $this->assertSame( '', $saved['gate_headline'] ); + $this->assertSame( '', $saved['gate_text'] ); + + update_option( Config::OPTION_NAME, $saved ); + + $gate = Options::get_gate_settings(); + $this->assertSame( 'Subscribe to keep reading', $gate['headline'] ); + $this->assertSame( 'Enter your email to unlock the full story and get our newsletter.', $gate['text'] ); + } + + /** + * AC-005: tags are removed on save. + */ + public function test_headline_and_text_are_plain_text(): void { + $saved = Options::sanitize( + array( + 'gate_headline' => 'Join us ', + 'gate_text' => 'Read on', + ) + ); + + $this->assertSame( 'Join us', $saved['gate_headline'] ); + $this->assertSame( 'Read on', $saved['gate_text'] ); + } + + /** + * AC-008 to AC-010: cookie lifetime accepts whole days 1 to 365, anything else is 30. + * + * @dataProvider cookie_days_provider + * + * @param mixed $input Submitted value. + * @param int $expected Resolved days. + */ + public function test_cookie_days_resolution( $input, int $expected ): void { + update_option( Config::OPTION_NAME, Options::sanitize( array( 'gate_cookie_days' => $input ) ) ); + + $this->assertSame( $expected, Options::get_gate_settings()['cookie_days'] ); + } + + /** + * @return array + */ + public function cookie_days_provider(): array { + return array( + 'one' => array( '1', 1 ), + 'seven' => array( '7', 7 ), + 'max' => array( '365', 365 ), + 'blank' => array( '', 30 ), + 'zero' => array( '0', 30 ), + 'negative' => array( '-5', 30 ), + 'decimal' => array( '7.5', 30 ), + 'text' => array( 'abc', 30 ), + 'over' => array( '366', 30 ), + 'huge' => array( '99999999999', 30 ), + ); + } + + /** + * AC-006 / AC-011: saved values are returned for the form to show again. + */ + public function test_saved_values_round_trip(): void { + update_option( + Config::OPTION_NAME, + Options::sanitize( + array( + 'gate_headline' => 'Join the list', + 'gate_text' => 'Sign up free.', + 'gate_cookie_days' => '14', + ) + ) + ); + + $settings = Options::get(); + $this->assertSame( 'Join the list', $settings['gate_headline'] ); + $this->assertSame( 'Sign up free.', $settings['gate_text'] ); + $this->assertSame( '14', $settings['gate_cookie_days'] ); + $this->assertSame( 14, Options::get_gate_settings()['cookie_days'] ); + } + + /** + * AC-012: saving the gate fields keeps the publication settings, and the reverse. + */ + public function test_each_save_keeps_the_other_sections_values(): void { + update_option( + Config::OPTION_NAME, + array( + 'publication_id' => 'pub_1', + 'post_template_id' => 'tpl_1', + 'gate_headline' => 'Custom', + ) + ); + + $after_gate_save = Options::sanitize( array( 'gate_cookie_days' => '10' ) ); + $this->assertSame( 'pub_1', $after_gate_save['publication_id'] ); + $this->assertSame( 'tpl_1', $after_gate_save['post_template_id'] ); + $this->assertSame( 'Custom', $after_gate_save['gate_headline'] ); + + $after_publication_save = Options::sanitize( + array( + 'publication_id' => 'pub_2', + 'post_template_id' => '', + ) + ); + $this->assertSame( 'pub_2', $after_publication_save['publication_id'] ); + $this->assertSame( '', $after_publication_save['post_template_id'] ); + $this->assertSame( 'Custom', $after_publication_save['gate_headline'] ); + } +} From 415943afcf5b1679fdad3dc4854dff60964fb14a Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Mon, 2 Nov 2026 22:55:36 +0800 Subject: [PATCH 02/34] feat(custom-blocks): add gated content and gate divider blocks Feature: custom-blocks/gated-content-block/gated-content-block Implements: - US-001: gated block with Subscribe form ID, Headline and Text - US-002: optional gate divider, valid only directly inside the gated block - US-003: server-rendered gate card over blurred, height-limited locked content - US-004: unlock in place when the block's own form is submitted - US-005: site-wide unlock cookie, logged-in bypass, first gate per page - US-006: locked content is inert and hidden from assistive technology The gate view script loads only on pages that render a gated block. --- includes/Blocks/GatedContent.php | 163 ++++++++++++++++++ .../blocks/gated-content-divider/block.json | 21 +++ src/js/blocks/gated-content-divider/edit.js | 19 ++ .../blocks/gated-content-divider/editor.scss | 12 ++ src/js/blocks/gated-content-divider/index.js | 11 ++ src/js/blocks/gated-content/block.json | 35 ++++ src/js/blocks/gated-content/edit.js | 89 ++++++++++ src/js/blocks/gated-content/editor.scss | 20 +++ src/js/blocks/gated-content/index.js | 13 ++ src/js/blocks/gated-content/render.php | 14 ++ src/js/blocks/gated-content/style.scss | 82 +++++++++ src/js/blocks/gated-content/view.js | 66 +++++++ tests/js/gated-content-view.test.js | 85 +++++++++ tests/phpunit/GatedContentRenderTest.php | 147 ++++++++++++++++ 14 files changed, 777 insertions(+) create mode 100644 includes/Blocks/GatedContent.php create mode 100644 src/js/blocks/gated-content-divider/block.json create mode 100644 src/js/blocks/gated-content-divider/edit.js create mode 100644 src/js/blocks/gated-content-divider/editor.scss create mode 100644 src/js/blocks/gated-content-divider/index.js create mode 100644 src/js/blocks/gated-content/block.json create mode 100644 src/js/blocks/gated-content/edit.js create mode 100644 src/js/blocks/gated-content/editor.scss create mode 100644 src/js/blocks/gated-content/index.js create mode 100644 src/js/blocks/gated-content/render.php create mode 100644 src/js/blocks/gated-content/style.scss create mode 100644 src/js/blocks/gated-content/view.js create mode 100644 tests/js/gated-content-view.test.js create mode 100644 tests/phpunit/GatedContentRenderTest.php diff --git a/includes/Blocks/GatedContent.php b/includes/Blocks/GatedContent.php new file mode 100644 index 0000000..6caaaec --- /dev/null +++ b/includes/Blocks/GatedContent.php @@ -0,0 +1,163 @@ + $attributes Block attributes. + * @param WP_Block $block Block instance. + * @return string + * @since 1.2.0 + */ + public static function render( array $attributes, WP_Block $block ): string { + $preview = []; + $locked = []; + $split = false; + $count = 0; + + foreach ( $block->inner_blocks as $inner_block ) { + if ( self::DIVIDER_NAME === $inner_block->name ) { + // Only the first divider counts. A divider never prints anything. + $split = true; + continue; + } + + $html = $inner_block->render(); + + if ( $split || ( 0 < $count && ! self::has_divider( $block ) ) ) { + $locked[] = $html; + } else { + $preview[] = $html; + } + + ++$count; + } + + $form_id = isset( $attributes['formId'] ) ? sanitize_text_field( $attributes['formId'] ) : ''; + + // Nothing to gate, no form to unlock with, logged-in user, or a second + // gate on the page: show the content in full. + if ( + empty( $locked ) + || '' === $form_id + || is_user_logged_in() + || self::$gate_rendered + ) { + return sprintf( + '
%s
', + get_block_wrapper_attributes(), + implode( '', array_merge( $preview, $locked ) ) + ); + } + + self::$gate_rendered = true; + + $defaults = Options::get_gate_settings(); + $headline = isset( $attributes['headline'] ) ? trim( sanitize_text_field( $attributes['headline'] ) ) : ''; + $text = isset( $attributes['text'] ) ? trim( sanitize_text_field( $attributes['text'] ) ) : ''; + $headline = '' !== $headline ? $headline : $defaults['headline']; + $text = '' !== $text ? $text : $defaults['text']; + + $form_script = wp_get_script_tag( + [ + 'src' => 'https://subscribe-forms.beehiiv.com/v3/loader.js', + 'async' => true, + 'data-beehiiv-form' => $form_id, + ] + ); + + $card = sprintf( + '

%1$s

' + . '

%2$s

%3$s
', + esc_html( $headline ), + esc_html( $text ), + $form_script + ); + + $gate = sprintf( + '
%4$s' + . '
', + esc_attr( $form_id ), + esc_attr( self::COOKIE_NAME ), + (int) $defaults['cookie_days'], + $card, + implode( '', $locked ) + ); + + return sprintf( + '
%2$s%3$s
', + get_block_wrapper_attributes(), + implode( '', $preview ), + $gate + ); + } + + /** + * Whether a block has a divider among its direct inner blocks. + * + * @param WP_Block $block Gated content block. + * @return bool + */ + private static function has_divider( WP_Block $block ): bool { + foreach ( $block->inner_blocks as $inner_block ) { + if ( self::DIVIDER_NAME === $inner_block->name ) { + return true; + } + } + + return false; + } +} diff --git a/src/js/blocks/gated-content-divider/block.json b/src/js/blocks/gated-content-divider/block.json new file mode 100644 index 0000000..badfd9f --- /dev/null +++ b/src/js/blocks/gated-content-divider/block.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "beehiiv/gated-content-divider", + "version": "1.0.0", + "title": "beehiiv Gate Divider", + "category": "beehiiv-blocks", + "icon": "minus", + "description": "Marks where the preview ends inside a beehiiv Gated Content block.", + "keywords": [ "beehiiv", "gate", "divider" ], + "textdomain": "beehiiv", + "parent": [ "beehiiv/gated-content" ], + "supports": { + "html": false, + "anchor": true, + "multiple": false, + "reusable": false + }, + "editorScript": "file:./index.js", + "editorStyle": "file:./index.css" +} diff --git a/src/js/blocks/gated-content-divider/edit.js b/src/js/blocks/gated-content-divider/edit.js new file mode 100644 index 0000000..f45e5c4 --- /dev/null +++ b/src/js/blocks/gated-content-divider/edit.js @@ -0,0 +1,19 @@ +import { __ } from '@wordpress/i18n'; +import { useBlockProps } from '@wordpress/block-editor'; + +export default function Edit() { + const blockProps = useBlockProps( { + className: 'beehiiv-gate-divider', + } ); + + return ( +
+ + { __( + 'Preview ends here. Content below is gated.', + 'beehiiv' + ) } + +
+ ); +} diff --git a/src/js/blocks/gated-content-divider/editor.scss b/src/js/blocks/gated-content-divider/editor.scss new file mode 100644 index 0000000..6b55299 --- /dev/null +++ b/src/js/blocks/gated-content-divider/editor.scss @@ -0,0 +1,12 @@ +.wp-block-beehiiv-gated-content-divider { + border-top: 2px dashed #757575; + margin: 1.5em 0; + padding-top: 4px; + text-align: center; + + .beehiiv-gate-divider__label { + color: #50575e; + font-size: 12px; + text-transform: uppercase; + } +} diff --git a/src/js/blocks/gated-content-divider/index.js b/src/js/blocks/gated-content-divider/index.js new file mode 100644 index 0000000..b3cca97 --- /dev/null +++ b/src/js/blocks/gated-content-divider/index.js @@ -0,0 +1,11 @@ +import { registerBlockType } from '@wordpress/blocks'; + +import metadata from './block.json'; +import Edit from './edit'; + +import './editor.scss'; + +registerBlockType( metadata.name, { + edit: Edit, + save: () => null, +} ); diff --git a/src/js/blocks/gated-content/block.json b/src/js/blocks/gated-content/block.json new file mode 100644 index 0000000..2b4c048 --- /dev/null +++ b/src/js/blocks/gated-content/block.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "beehiiv/gated-content", + "version": "1.0.0", + "title": "beehiiv Gated Content", + "category": "beehiiv-blocks", + "icon": "lock", + "description": "Shows a preview, then blurs and locks the rest until the reader subscribes through a beehiiv form.", + "keywords": [ "beehiiv", "gate", "paywall", "subscribe", "newsletter" ], + "textdomain": "beehiiv", + "supports": { + "html": false, + "anchor": true + }, + "attributes": { + "formId": { + "type": "string", + "default": "" + }, + "headline": { + "type": "string", + "default": "" + }, + "text": { + "type": "string", + "default": "" + } + }, + "render": "file:./render.php", + "editorScript": "file:./index.js", + "editorStyle": "file:./index.css", + "style": "file:./style-index.css", + "viewScript": "file:./view.js" +} diff --git a/src/js/blocks/gated-content/edit.js b/src/js/blocks/gated-content/edit.js new file mode 100644 index 0000000..9dcb05f --- /dev/null +++ b/src/js/blocks/gated-content/edit.js @@ -0,0 +1,89 @@ +import { __ } from '@wordpress/i18n'; +import { useEffect } from '@wordpress/element'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { + InnerBlocks, + InspectorControls, + useBlockProps, +} from '@wordpress/block-editor'; +import { PanelBody, TextControl, TextareaControl } from '@wordpress/components'; + +const DIVIDER_BLOCK = 'beehiiv/gated-content-divider'; + +export default function Edit( { attributes, setAttributes, clientId } ) { + const { formId, headline, text } = attributes; + + const dividerIds = useSelect( + ( select ) => + select( 'core/block-editor' ) + .getBlocks( clientId ) + .filter( ( block ) => block.name === DIVIDER_BLOCK ) + .map( ( block ) => block.clientId ), + [ clientId ] + ); + const { removeBlocks } = useDispatch( 'core/block-editor' ); + + // A gated block holds at most one divider. Keep the first one. + useEffect( () => { + if ( dividerIds.length > 1 ) { + removeBlocks( dividerIds.slice( 1 ) ); + } + }, [ dividerIds, removeBlocks ] ); + + const blockProps = useBlockProps( { + className: dividerIds.length + ? 'beehiiv-gated-content--has-divider' + : 'beehiiv-gated-content--no-divider', + } ); + + return ( + <> + + + + setAttributes( { formId: value.trim() } ) + } + help={ __( + 'Form must be Inline and set to Show a success message, not Redirect.', + 'beehiiv' + ) } + /> + + setAttributes( { headline: value } ) + } + help={ __( + 'Leave blank to use the default from the beehiiv settings page.', + 'beehiiv' + ) } + /> + + setAttributes( { text: value } ) + } + help={ __( + 'Leave blank to use the default from the beehiiv settings page.', + 'beehiiv' + ) } + /> + + + +
+ +
+ + ); +} diff --git a/src/js/blocks/gated-content/editor.scss b/src/js/blocks/gated-content/editor.scss new file mode 100644 index 0000000..5b06621 --- /dev/null +++ b/src/js/blocks/gated-content/editor.scss @@ -0,0 +1,20 @@ +.wp-block-beehiiv-gated-content { + border-inline-start: 3px solid #1e1e1e; + padding-inline-start: 12px; + + // With no divider, the first block is the preview. Mark where the wall falls. + &.beehiiv-gated-content--no-divider + > .block-editor-inner-blocks + > .block-editor-block-list__layout + > .wp-block:first-child:not(:last-child)::after { + border-bottom: 2px dashed #757575; + color: #50575e; + content: "Preview ends here. Content below is gated."; + display: block; + font-size: 12px; + margin-top: 1.5em; + padding-bottom: 4px; + text-align: center; + text-transform: uppercase; + } +} diff --git a/src/js/blocks/gated-content/index.js b/src/js/blocks/gated-content/index.js new file mode 100644 index 0000000..b28e7af --- /dev/null +++ b/src/js/blocks/gated-content/index.js @@ -0,0 +1,13 @@ +import { registerBlockType } from '@wordpress/blocks'; +import { InnerBlocks } from '@wordpress/block-editor'; + +import metadata from './block.json'; +import Edit from './edit'; + +import './editor.scss'; +import './style.scss'; + +registerBlockType( metadata.name, { + edit: Edit, + save: () => , +} ); diff --git a/src/js/blocks/gated-content/render.php b/src/js/blocks/gated-content/render.php new file mode 100644 index 0000000..c2f73da --- /dev/null +++ b/src/js/blocks/gated-content/render.php @@ -0,0 +1,14 @@ + part.split( '=' )[ 0 ] === name ); +} + +function setCookie( name, days ) { + const maxAge = days * 24 * 60 * 60; + const secure = window.location.protocol === 'https:' ? '; Secure' : ''; + document.cookie = `${ name }=1; Max-Age=${ maxAge }; Path=/; SameSite=Lax${ secure }`; +} + +function unlock( gate ) { + const locked = gate.querySelector( '.beehiiv-gated-content__locked' ); + const card = gate.querySelector( '.beehiiv-gated-content__card' ); + + gate.classList.add( 'is-unlocked' ); + + if ( locked ) { + locked.removeAttribute( 'aria-hidden' ); + locked.removeAttribute( 'inert' ); + } + + if ( card ) { + window.setTimeout( () => card.classList.add( 'is-hidden' ), 300 ); + } +} + +// Only the first gate on a page counts. +const gate = document.querySelector( '[data-beehiiv-gate]' ); + +if ( gate ) { + const cookieName = gate.dataset.cookieName; + const cookieDays = parseInt( gate.dataset.cookieDays, 10 ) || 30; + const formId = gate.dataset.formId; + + if ( hasCookie( cookieName ) ) { + unlock( gate ); + } else { + window.addEventListener( 'message', ( event ) => { + if ( + ! ALLOWED_ORIGINS.includes( event.origin ) || + ! event.data || + event.data.type !== SUBMITTED_TYPE || + String( event.data.externalEmbedId ) !== formId + ) { + return; + } + + setCookie( cookieName, cookieDays ); + unlock( gate ); + } ); + } +} diff --git a/tests/js/gated-content-view.test.js b/tests/js/gated-content-view.test.js new file mode 100644 index 0000000..10d6504 --- /dev/null +++ b/tests/js/gated-content-view.test.js @@ -0,0 +1,85 @@ +/* eslint-disable jsdoc/check-tag-names */ +/** + * @jest-environment jsdom + */ + +const GATE_HTML = ` +
+
+
+ +
+
`; + +function load() { + jest.resetModules(); + require( '../../src/js/blocks/gated-content/view.js' ); +} + +function post( data, origin = 'https://subscribe-forms.beehiiv.com' ) { + window.dispatchEvent( new MessageEvent( 'message', { data, origin } ) ); +} + +const gate = () => document.querySelector( '[data-beehiiv-gate]' ); +const locked = () => document.querySelector( '.beehiiv-gated-content__locked' ); + +describe( 'gated content view', () => { + beforeEach( () => { + document.body.innerHTML = GATE_HTML; + document.cookie = 'beehiiv_gate_unlocked=; Max-Age=0; Path=/'; + } ); + + it( 'unlocks and sets the cookie when the matching form is submitted', () => { + load(); + post( { + type: 'BEEHIIV_SUBSCRIBER_FORM_SUBMITTED', + externalEmbedId: 'form_1', + } ); + + expect( gate().classList.contains( 'is-unlocked' ) ).toBe( true ); + expect( locked().hasAttribute( 'aria-hidden' ) ).toBe( false ); + expect( locked().hasAttribute( 'inert' ) ).toBe( false ); + expect( document.cookie ).toContain( 'beehiiv_gate_unlocked=1' ); + } ); + + it( 'accepts the legacy embed origin', () => { + load(); + post( + { + type: 'BEEHIIV_SUBSCRIBER_FORM_SUBMITTED', + externalEmbedId: 'form_1', + }, + 'https://embeds.beehiiv.com' + ); + + expect( gate().classList.contains( 'is-unlocked' ) ).toBe( true ); + } ); + + it( 'stays locked for a different form, origin or message type', () => { + load(); + post( { + type: 'BEEHIIV_SUBSCRIBER_FORM_SUBMITTED', + externalEmbedId: 'other', + } ); + post( + { + type: 'BEEHIIV_SUBSCRIBER_FORM_SUBMITTED', + externalEmbedId: 'form_1', + }, + 'https://evil.example' + ); + post( { type: 'SOMETHING_ELSE', externalEmbedId: 'form_1' } ); + + expect( gate().classList.contains( 'is-unlocked' ) ).toBe( false ); + expect( locked().hasAttribute( 'inert' ) ).toBe( true ); + expect( document.cookie ).not.toContain( 'beehiiv_gate_unlocked' ); + } ); + + it( 'removes the gate when the unlock cookie already exists', () => { + document.cookie = 'beehiiv_gate_unlocked=1; Path=/'; + load(); + + expect( gate().classList.contains( 'is-unlocked' ) ).toBe( true ); + expect( locked().hasAttribute( 'aria-hidden' ) ).toBe( false ); + } ); +} ); diff --git a/tests/phpunit/GatedContentRenderTest.php b/tests/phpunit/GatedContentRenderTest.php new file mode 100644 index 0000000..26ce6ed --- /dev/null +++ b/tests/phpunit/GatedContentRenderTest.php @@ -0,0 +1,147 @@ +' . $inner . '' ); + } + + private function p( string $text ): string { + return '

' . $text . '

'; + } + + private function divider(): string { + return ''; + } + + /** + * AC-007: with no divider, the first inner block is the preview. + */ + public function test_first_block_is_preview_without_divider(): void { + $html = $this->render_gate( $this->p( 'ONE' ) . $this->p( 'TWO' ) . $this->p( 'THREE' ) ); + + $this->assertMatchesRegularExpression( '/ONE.*beehiiv-gated-content__gate/s', $html ); + $this->assertMatchesRegularExpression( '/beehiiv-gated-content__locked[^>]*>.*TWO.*THREE/s', $html ); + $this->assertDoesNotMatchRegularExpression( '/beehiiv-gated-content__locked[^>]*>[^<]*

ONE/s', $html ); + } + + /** + * AC-009 / AC-014: a divider splits preview and gated content; gated content stays in the source. + */ + public function test_divider_splits_preview_and_locked(): void { + $html = $this->render_gate( $this->p( 'ONE' ) . $this->p( 'TWO' ) . $this->divider() . $this->p( 'THREE' ) ); + + $this->assertMatchesRegularExpression( '/ONE.*TWO.*beehiiv-gated-content__gate/s', $html ); + $this->assertMatchesRegularExpression( '/beehiiv-gated-content__locked[^>]*>.*THREE/s', $html ); + $this->assertStringNotContainsString( 'beehiiv-gate-divider', $html ); + } + + /** + * AC-012 / AC-013 / AC-026 to AC-028: gate card, locked container, inert and hidden from AT. + */ + public function test_markup_locks_content_and_carries_form_and_copy(): void { + $html = $this->render_gate( $this->p( 'ONE' ) . $this->p( 'TWO' ) ); + + $this->assertStringContainsString( 'data-form-id="form_1"', $html ); + $this->assertStringContainsString( 'data-beehiiv-form="form_1"', $html ); + $this->assertStringContainsString( 'aria-hidden="true" inert', $html ); + $this->assertStringContainsString( 'Subscribe to keep reading', $html ); + $this->assertStringContainsString( 'data-cookie-days="30"', $html ); + } + + /** + * AC-004 / AC-020: block copy overrides defaults; cookie lifetime comes from the settings. + */ + public function test_block_copy_and_site_defaults(): void { + update_option( + Config::OPTION_NAME, + Options::sanitize( + array( + 'gate_headline' => 'Site headline', + 'gate_cookie_days' => '14', + ) + ) + ); + + $html = $this->render_gate( $this->p( 'ONE' ) . $this->p( 'TWO' ), 'form_1', ',"text":"Block text"' ); + + $this->assertStringContainsString( 'Site headline', $html ); + $this->assertStringContainsString( 'Block text', $html ); + $this->assertStringContainsString( 'data-cookie-days="14"', $html ); + } + + /** + * AC-022: logged-in users see the content with no gate. + */ + public function test_logged_in_user_sees_no_gate(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + + $html = $this->render_gate( $this->p( 'ONE' ) . $this->p( 'TWO' ) ); + + $this->assertStringContainsString( 'TWO', $html ); + $this->assertStringNotContainsString( 'beehiiv-gated-content__gate', $html ); + $this->assertStringNotContainsString( 'inert', $html ); + } + + /** + * AC-025: only the first gated block counts. + */ + public function test_only_first_gate_counts(): void { + $first = $this->render_gate( $this->p( 'A1' ) . $this->p( 'A2' ) ); + $second = $this->render_gate( $this->p( 'B1' ) . $this->p( 'B2' ), 'form_2' ); + + $this->assertStringContainsString( 'beehiiv-gated-content__gate', $first ); + $this->assertStringNotContainsString( 'beehiiv-gated-content__gate', $second ); + $this->assertStringContainsString( 'B2', $second ); + } + + /** + * AC-015 / edge cases: empty form ID or nothing to gate renders content ungated. + */ + public function test_no_gate_without_form_id_or_gated_content(): void { + $no_form = $this->render_gate( $this->p( 'ONE' ) . $this->p( 'TWO' ), '' ); + $this->assertStringNotContainsString( 'beehiiv-gated-content__gate', $no_form ); + + GatedContent::reset(); + $only_preview = $this->render_gate( $this->p( 'ONE' ) ); + $this->assertStringNotContainsString( 'beehiiv-gated-content__gate', $only_preview ); + } + + /** + * AC-008: a divider outside a gated block prints nothing. + */ + public function test_stray_divider_prints_nothing(): void { + $this->assertSame( '', trim( do_blocks( $this->divider() ) ) ); + } +} From 59ca990b1031bb1cc15f43873edc402349abe711 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 01:20:46 +0800 Subject: [PATCH 03/34] fix(custom-blocks): stop gated content render loop with several preview blocks has_divider() re-iterated the shared inner block list from inside the loop over that same list, so the cursor reset and the loop never ended whenever two or more blocks came before the divider (or no divider was present). --- includes/Blocks/GatedContent.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/includes/Blocks/GatedContent.php b/includes/Blocks/GatedContent.php index 6caaaec..8582415 100644 --- a/includes/Blocks/GatedContent.php +++ b/includes/Blocks/GatedContent.php @@ -67,6 +67,10 @@ public static function render( array $attributes, WP_Block $block ): string { $split = false; $count = 0; + // Work out once, before looping. The inner block list shares one cursor, + // so scanning it again inside the loop below would restart the loop forever. + $has_divider = self::has_divider( $block ); + foreach ( $block->inner_blocks as $inner_block ) { if ( self::DIVIDER_NAME === $inner_block->name ) { // Only the first divider counts. A divider never prints anything. @@ -76,7 +80,7 @@ public static function render( array $attributes, WP_Block $block ): string { $html = $inner_block->render(); - if ( $split || ( 0 < $count && ! self::has_divider( $block ) ) ) { + if ( $split || ( 0 < $count && ! $has_divider ) ) { $locked[] = $html; } else { $preview[] = $html; From dcc08335fe866b6fae254e3b6353b545ca107ccf Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 01:25:58 +0800 Subject: [PATCH 04/34] fix(custom-blocks): hide gate card at once for returning readers and add theme fade variable --- src/js/blocks/gated-content/style.scss | 10 +++++++++- src/js/blocks/gated-content/view.js | 9 ++++++--- tests/js/gated-content-view.test.js | 11 +++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/js/blocks/gated-content/style.scss b/src/js/blocks/gated-content/style.scss index b4e39d3..13488cc 100644 --- a/src/js/blocks/gated-content/style.scss +++ b/src/js/blocks/gated-content/style.scss @@ -20,7 +20,8 @@ user-select: none; &::after { - background: linear-gradient(to bottom, rgba(255, 255, 255, 0), #fff); + // Themes with a dark or tinted page can set --beehiiv-gate-fade. + background: linear-gradient(to bottom, transparent, var(--beehiiv-gate-fade, #fff)); bottom: 0; content: ""; height: 40%; @@ -73,6 +74,13 @@ } } +@media (prefers-reduced-motion: reduce) { + + .wp-block-beehiiv-gated-content .beehiiv-gated-content__card { + transition: none; + } +} + @media (max-width: 600px) { .wp-block-beehiiv-gated-content .beehiiv-gated-content__card { diff --git a/src/js/blocks/gated-content/view.js b/src/js/blocks/gated-content/view.js index 9f11b80..a5460cd 100644 --- a/src/js/blocks/gated-content/view.js +++ b/src/js/blocks/gated-content/view.js @@ -22,7 +22,7 @@ function setCookie( name, days ) { document.cookie = `${ name }=1; Max-Age=${ maxAge }; Path=/; SameSite=Lax${ secure }`; } -function unlock( gate ) { +function unlock( gate, immediate = false ) { const locked = gate.querySelector( '.beehiiv-gated-content__locked' ); const card = gate.querySelector( '.beehiiv-gated-content__card' ); @@ -33,7 +33,10 @@ function unlock( gate ) { locked.removeAttribute( 'inert' ); } - if ( card ) { + if ( card && immediate ) { + // Returning reader: remove the card now so the gate does not linger. + card.classList.add( 'is-hidden' ); + } else if ( card ) { window.setTimeout( () => card.classList.add( 'is-hidden' ), 300 ); } } @@ -47,7 +50,7 @@ if ( gate ) { const formId = gate.dataset.formId; if ( hasCookie( cookieName ) ) { - unlock( gate ); + unlock( gate, true ); } else { window.addEventListener( 'message', ( event ) => { if ( diff --git a/tests/js/gated-content-view.test.js b/tests/js/gated-content-view.test.js index 10d6504..826358e 100644 --- a/tests/js/gated-content-view.test.js +++ b/tests/js/gated-content-view.test.js @@ -82,4 +82,15 @@ describe( 'gated content view', () => { expect( gate().classList.contains( 'is-unlocked' ) ).toBe( true ); expect( locked().hasAttribute( 'aria-hidden' ) ).toBe( false ); } ); + + it( 'hides the card at once for a returning reader', () => { + document.cookie = 'beehiiv_gate_unlocked=1; Path=/'; + load(); + + expect( + document + .querySelector( '.beehiiv-gated-content__card' ) + .classList.contains( 'is-hidden' ) + ).toBe( true ); + } ); } ); From c1a506315e716e6e3bbba4a3edec198e63b886f8 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 01:46:53 +0800 Subject: [PATCH 05/34] fix(custom-blocks): init gate after DOM ready and restyle gate card over faded content --- src/js/blocks/gated-content/style.scss | 37 ++++++++++++++------- src/js/blocks/gated-content/view.js | 46 ++++++++++++++++---------- tests/js/gated-content-view.test.js | 19 +++++++++++ 3 files changed, 73 insertions(+), 29 deletions(-) diff --git a/src/js/blocks/gated-content/style.scss b/src/js/blocks/gated-content/style.scss index 13488cc..f008fc5 100644 --- a/src/js/blocks/gated-content/style.scss +++ b/src/js/blocks/gated-content/style.scss @@ -12,8 +12,8 @@ } .beehiiv-gated-content__locked { - filter: blur(6px); - max-height: 24rem; + filter: blur(8px); + max-height: 30rem; overflow: hidden; pointer-events: none; position: relative; @@ -21,33 +21,46 @@ &::after { // Themes with a dark or tinted page can set --beehiiv-gate-fade. - background: linear-gradient(to bottom, transparent, var(--beehiiv-gate-fade, #fff)); + background: linear-gradient(to bottom, transparent, var(--beehiiv-gate-fade, #fff) 85%); bottom: 0; content: ""; - height: 40%; + height: 60%; left: 0; position: absolute; right: 0; } } + // The card sits over the bottom of the blurred, faded content. .beehiiv-gated-content__card { - align-self: start; + align-self: end; background: #fff; - border-radius: 8px; - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15); + border: 1px solid #e5e5e5; + border-radius: 16px; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.12); + box-sizing: border-box; justify-self: center; - margin: 1.5rem 0 0; + margin: 0; max-width: 100%; - padding: 1.5rem; + padding: 2rem; position: relative; + text-align: center; transition: opacity 0.3s ease; - width: 32rem; + width: 44rem; z-index: 2; } .beehiiv-gated-content__headline { - margin-top: 0; + font-size: 1.75rem; + font-weight: 700; + line-height: 1.2; + margin: 0 0 0.5rem; + } + + .beehiiv-gated-content__text { + color: #50575e; + font-size: 1.125rem; + margin: 0 0 1.25rem; } .beehiiv-gated-content__gate.is-unlocked { @@ -84,7 +97,7 @@ @media (max-width: 600px) { .wp-block-beehiiv-gated-content .beehiiv-gated-content__card { - padding: 1rem; + padding: 1.25rem; width: 100%; } } diff --git a/src/js/blocks/gated-content/view.js b/src/js/blocks/gated-content/view.js index a5460cd..72cf391 100644 --- a/src/js/blocks/gated-content/view.js +++ b/src/js/blocks/gated-content/view.js @@ -41,29 +41,41 @@ function unlock( gate, immediate = false ) { } } -// Only the first gate on a page counts. -const gate = document.querySelector( '[data-beehiiv-gate]' ); +function init() { + // Only the first gate on a page counts. + const gate = document.querySelector( '[data-beehiiv-gate]' ); + + if ( ! gate ) { + return; + } -if ( gate ) { const cookieName = gate.dataset.cookieName; const cookieDays = parseInt( gate.dataset.cookieDays, 10 ) || 30; const formId = gate.dataset.formId; if ( hasCookie( cookieName ) ) { unlock( gate, true ); - } else { - window.addEventListener( 'message', ( event ) => { - if ( - ! ALLOWED_ORIGINS.includes( event.origin ) || - ! event.data || - event.data.type !== SUBMITTED_TYPE || - String( event.data.externalEmbedId ) !== formId - ) { - return; - } - - setCookie( cookieName, cookieDays ); - unlock( gate ); - } ); + return; } + + window.addEventListener( 'message', ( event ) => { + if ( + ! ALLOWED_ORIGINS.includes( event.origin ) || + ! event.data || + event.data.type !== SUBMITTED_TYPE || + String( event.data.externalEmbedId ) !== formId + ) { + return; + } + + setCookie( cookieName, cookieDays ); + unlock( gate ); + } ); +} + +// Block themes can print this script in the head, before the gate exists. +if ( document.readyState === 'loading' ) { + document.addEventListener( 'DOMContentLoaded', init ); +} else { + init(); } diff --git a/tests/js/gated-content-view.test.js b/tests/js/gated-content-view.test.js index 826358e..f52e7dd 100644 --- a/tests/js/gated-content-view.test.js +++ b/tests/js/gated-content-view.test.js @@ -93,4 +93,23 @@ describe( 'gated content view', () => { .classList.contains( 'is-hidden' ) ).toBe( true ); } ); + + it( 'still works when the script runs before the gate is in the page', () => { + document.body.innerHTML = ''; + Object.defineProperty( document, 'readyState', { + configurable: true, + get: () => 'loading', + } ); + load(); + + document.body.innerHTML = GATE_HTML; + document.dispatchEvent( new Event( 'DOMContentLoaded' ) ); + post( { + type: 'BEEHIIV_SUBSCRIBER_FORM_SUBMITTED', + externalEmbedId: 'form_1', + } ); + delete document.readyState; + + expect( gate().classList.contains( 'is-unlocked' ) ).toBe( true ); + } ); } ); From c7b3988332da11f33f709a5cdfb5e05f2d5cf310 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 01:53:46 +0800 Subject: [PATCH 06/34] feat(custom-blocks): add card background and text color options to gated content block --- includes/Blocks/GatedContent.php | 18 ++++++++++++++++-- src/js/blocks/gated-content/block.json | 8 ++++++++ src/js/blocks/gated-content/edit.js | 24 +++++++++++++++++++++++- src/js/blocks/gated-content/style.scss | 2 +- tests/phpunit/GatedContentRenderTest.php | 23 +++++++++++++++++++++++ 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/includes/Blocks/GatedContent.php b/includes/Blocks/GatedContent.php index 8582415..b5b2a8b 100644 --- a/includes/Blocks/GatedContent.php +++ b/includes/Blocks/GatedContent.php @@ -122,12 +122,26 @@ public static function render( array $attributes, WP_Block $block ): string { ] ); + // Optional per-block card colors. Anything that is not a hex color is dropped. + $card_bg = isset( $attributes['cardBackgroundColor'] ) ? sanitize_hex_color( $attributes['cardBackgroundColor'] ) : ''; + $card_color = isset( $attributes['cardTextColor'] ) ? sanitize_hex_color( $attributes['cardTextColor'] ) : ''; + $card_style = ''; + + if ( $card_bg ) { + $card_style .= 'background-color:' . $card_bg . ';'; + } + + if ( $card_color ) { + $card_style .= 'color:' . $card_color . ';--beehiiv-gate-card-color:' . $card_color . ';'; + } + $card = sprintf( - '

%1$s

' + '

%1$s

' . '

%2$s

%3$s
', esc_html( $headline ), esc_html( $text ), - $form_script + $form_script, + '' !== $card_style ? ' style="' . esc_attr( $card_style ) . '"' : '' ); $gate = sprintf( diff --git a/src/js/blocks/gated-content/block.json b/src/js/blocks/gated-content/block.json index 2b4c048..84d7577 100644 --- a/src/js/blocks/gated-content/block.json +++ b/src/js/blocks/gated-content/block.json @@ -25,6 +25,14 @@ "text": { "type": "string", "default": "" + }, + "cardBackgroundColor": { + "type": "string", + "default": "" + }, + "cardTextColor": { + "type": "string", + "default": "" } }, "render": "file:./render.php", diff --git a/src/js/blocks/gated-content/edit.js b/src/js/blocks/gated-content/edit.js index 9dcb05f..6512de9 100644 --- a/src/js/blocks/gated-content/edit.js +++ b/src/js/blocks/gated-content/edit.js @@ -4,6 +4,7 @@ import { useSelect, useDispatch } from '@wordpress/data'; import { InnerBlocks, InspectorControls, + PanelColorSettings, useBlockProps, } from '@wordpress/block-editor'; import { PanelBody, TextControl, TextareaControl } from '@wordpress/components'; @@ -11,7 +12,8 @@ import { PanelBody, TextControl, TextareaControl } from '@wordpress/components'; const DIVIDER_BLOCK = 'beehiiv/gated-content-divider'; export default function Edit( { attributes, setAttributes, clientId } ) { - const { formId, headline, text } = attributes; + const { formId, headline, text, cardBackgroundColor, cardTextColor } = + attributes; const dividerIds = useSelect( ( select ) => @@ -77,6 +79,26 @@ export default function Edit( { attributes, setAttributes, clientId } ) { ) } /> + + setAttributes( { + cardBackgroundColor: value || '', + } ), + }, + { + label: __( 'Text', 'beehiiv' ), + value: cardTextColor, + onChange: ( value ) => + setAttributes( { cardTextColor: value || '' } ), + }, + ] } + />
diff --git a/src/js/blocks/gated-content/style.scss b/src/js/blocks/gated-content/style.scss index f008fc5..47837c3 100644 --- a/src/js/blocks/gated-content/style.scss +++ b/src/js/blocks/gated-content/style.scss @@ -58,7 +58,7 @@ } .beehiiv-gated-content__text { - color: #50575e; + color: var(--beehiiv-gate-card-color, #50575e); font-size: 1.125rem; margin: 0 0 1.25rem; } diff --git a/tests/phpunit/GatedContentRenderTest.php b/tests/phpunit/GatedContentRenderTest.php index 26ce6ed..1f275e3 100644 --- a/tests/phpunit/GatedContentRenderTest.php +++ b/tests/phpunit/GatedContentRenderTest.php @@ -144,4 +144,27 @@ public function test_no_gate_without_form_id_or_gated_content(): void { public function test_stray_divider_prints_nothing(): void { $this->assertSame( '', trim( do_blocks( $this->divider() ) ) ); } + + /** + * AC-030 / AC-031: the card takes optional hex colors. Anything else is dropped. + */ + public function test_card_colors_are_applied_and_sanitized(): void { + $html = $this->render_gate( + $this->p( 'ONE' ) . $this->p( 'TWO' ), + 'form_1', + ',"cardBackgroundColor":"#112233","cardTextColor":"#ffffff"' + ); + + $this->assertStringContainsString( 'background-color:#112233', $html ); + $this->assertStringContainsString( 'color:#ffffff', $html ); + + GatedContent::reset(); + $html = $this->render_gate( + $this->p( 'ONE' ) . $this->p( 'TWO' ), + 'form_1', + ',"cardBackgroundColor":"red;x:y","cardTextColor":"javascript:1"' + ); + + $this->assertStringNotContainsString( 'style="', explode( 'beehiiv-gated-content__card', $html )[1] ?? '' ); + } } From 969e477c9f04dc25cf54f0ec01b5fef08761afc2 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 01:55:16 +0800 Subject: [PATCH 07/34] fix(custom-blocks): apply card text color to the gate headline --- src/js/blocks/gated-content/style.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/js/blocks/gated-content/style.scss b/src/js/blocks/gated-content/style.scss index 47837c3..6ff0075 100644 --- a/src/js/blocks/gated-content/style.scss +++ b/src/js/blocks/gated-content/style.scss @@ -51,6 +51,7 @@ } .beehiiv-gated-content__headline { + color: var(--beehiiv-gate-card-color, inherit); font-size: 1.75rem; font-weight: 700; line-height: 1.2; From 8c3776d3df520f418abc20fd9a0b9c159adb33a3 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 02:10:06 +0800 Subject: [PATCH 08/34] fix(custom-blocks): keep gate on repeat renders, allow a divider per gated block, restore publication reset on save --- includes/Admin/Options.php | 18 ++++++++++--- includes/Admin/Registrar.php | 5 ++++ includes/Blocks/GatedContent.php | 19 ++++++++------ .../blocks/gated-content-divider/block.json | 1 - tests/phpunit/GatedContentRenderTest.php | 13 ++++++++++ tests/phpunit/SubscriberGateDefaultsTest.php | 25 +++++++++++++++++++ 6 files changed, 70 insertions(+), 11 deletions(-) diff --git a/includes/Admin/Options.php b/includes/Admin/Options.php index cf7ae33..5a0d291 100644 --- a/includes/Admin/Options.php +++ b/includes/Admin/Options.php @@ -32,6 +32,13 @@ final class Options { 'gate_cookie_days' => '', ]; + /** + * Form key that the Publication section posts when it is shown. + * + * @var string + */ + public const PUBLICATION_SECTION_FLAG = '_publication_section'; + /** * Lowest unlock cookie lifetime, in days. * @@ -175,9 +182,14 @@ public static function sanitize( $input ): array { return $current; } - foreach ( [ 'publication_id', 'post_template_id' ] as $key ) { - if ( isset( $input[ $key ] ) ) { - $current[ $key ] = sanitize_text_field( (string) $input[ $key ] ); + // The Publication section marks itself in the submission. When it was shown, + // a missing field means it was cleared or not offered, so it resets to blank, + // as before. When it was hidden, the stored values stay. + if ( ! empty( $input[ self::PUBLICATION_SECTION_FLAG ] ) ) { + foreach ( [ 'publication_id', 'post_template_id' ] as $key ) { + $current[ $key ] = isset( $input[ $key ] ) + ? sanitize_text_field( (string) $input[ $key ] ) + : ''; } } diff --git a/includes/Admin/Registrar.php b/includes/Admin/Registrar.php index 45407b2..b99f0c8 100644 --- a/includes/Admin/Registrar.php +++ b/includes/Admin/Registrar.php @@ -256,6 +256,11 @@ class="small-text" * @since 1.0.0 */ public static function render_page_settings_description(): void { + printf( + '', + esc_attr( Config::OPTION_NAME . '[' . Options::PUBLICATION_SECTION_FLAG . ']' ) + ); + echo '

' . esc_html__( 'Default beehiiv publication and post template for newsletters sent from this site.', 'beehiiv' diff --git a/includes/Blocks/GatedContent.php b/includes/Blocks/GatedContent.php index b5b2a8b..7a7a5fa 100644 --- a/includes/Blocks/GatedContent.php +++ b/includes/Blocks/GatedContent.php @@ -38,11 +38,14 @@ final class GatedContent { public const COOKIE_NAME = 'beehiiv_gate_unlocked'; /** - * Whether a gate has already been rendered on this page. + * Signature of the gate that owns this page, or empty when none has rendered yet. * - * @var bool + * Rendering the same block again (an SEO or excerpt pass) matches the owner + * and still shows the gate. A different gated block is a second gate. + * + * @var string */ - private static $gate_rendered = false; + private static $gate_owner = ''; /** * Forget which gate was rendered (new page / tests). @@ -50,7 +53,7 @@ final class GatedContent { * @since 1.2.0 */ public static function reset(): void { - self::$gate_rendered = false; + self::$gate_owner = ''; } /** @@ -89,7 +92,9 @@ public static function render( array $attributes, WP_Block $block ): string { ++$count; } - $form_id = isset( $attributes['formId'] ) ? sanitize_text_field( $attributes['formId'] ) : ''; + $post_id = $block->context['postId'] ?? get_the_ID(); + $signature = md5( (string) wp_json_encode( [ $post_id, $block->parsed_block ] ) ); + $form_id = isset( $attributes['formId'] ) ? sanitize_text_field( $attributes['formId'] ) : ''; // Nothing to gate, no form to unlock with, logged-in user, or a second // gate on the page: show the content in full. @@ -97,7 +102,7 @@ public static function render( array $attributes, WP_Block $block ): string { empty( $locked ) || '' === $form_id || is_user_logged_in() - || self::$gate_rendered + || ( '' !== self::$gate_owner && self::$gate_owner !== $signature ) ) { return sprintf( '

%s
', @@ -106,7 +111,7 @@ public static function render( array $attributes, WP_Block $block ): string { ); } - self::$gate_rendered = true; + self::$gate_owner = $signature; $defaults = Options::get_gate_settings(); $headline = isset( $attributes['headline'] ) ? trim( sanitize_text_field( $attributes['headline'] ) ) : ''; diff --git a/src/js/blocks/gated-content-divider/block.json b/src/js/blocks/gated-content-divider/block.json index badfd9f..181573b 100644 --- a/src/js/blocks/gated-content-divider/block.json +++ b/src/js/blocks/gated-content-divider/block.json @@ -13,7 +13,6 @@ "supports": { "html": false, "anchor": true, - "multiple": false, "reusable": false }, "editorScript": "file:./index.js", diff --git a/tests/phpunit/GatedContentRenderTest.php b/tests/phpunit/GatedContentRenderTest.php index 1f275e3..5f433c1 100644 --- a/tests/phpunit/GatedContentRenderTest.php +++ b/tests/phpunit/GatedContentRenderTest.php @@ -167,4 +167,17 @@ public function test_card_colors_are_applied_and_sanitized(): void { $this->assertStringNotContainsString( 'style="', explode( 'beehiiv-gated-content__card', $html )[1] ?? '' ); } + + /** + * A repeat render of the same post (SEO, excerpt) must not consume the one-gate slot. + */ + public function test_repeat_render_of_same_gate_still_gates(): void { + $inner = $this->p( 'ONE' ) . $this->p( 'TWO' ); + + $first = $this->render_gate( $inner ); + $second = $this->render_gate( $inner ); + + $this->assertStringContainsString( 'data-beehiiv-gate', $first ); + $this->assertStringContainsString( 'data-beehiiv-gate', $second ); + } } diff --git a/tests/phpunit/SubscriberGateDefaultsTest.php b/tests/phpunit/SubscriberGateDefaultsTest.php index 8e9aa57..1537312 100644 --- a/tests/phpunit/SubscriberGateDefaultsTest.php +++ b/tests/phpunit/SubscriberGateDefaultsTest.php @@ -145,6 +145,7 @@ public function test_each_save_keeps_the_other_sections_values(): void { $after_publication_save = Options::sanitize( array( + Options::PUBLICATION_SECTION_FLAG => '1', 'publication_id' => 'pub_2', 'post_template_id' => '', ) @@ -153,4 +154,28 @@ public function test_each_save_keeps_the_other_sections_values(): void { $this->assertSame( '', $after_publication_save['post_template_id'] ); $this->assertSame( 'Custom', $after_publication_save['gate_headline'] ); } + + /** + * A shown Publication section that posts no template clears the stored template. + */ + public function test_shown_publication_section_clears_missing_fields(): void { + update_option( + Config::OPTION_NAME, + array( + 'publication_id' => 'pub_1', + 'post_template_id' => 'tpl_gone', + ) + ); + + $saved = Options::sanitize( + array( + Options::PUBLICATION_SECTION_FLAG => '1', + 'publication_id' => 'pub_1', + ) + ); + + $this->assertSame( 'pub_1', $saved['publication_id'] ); + $this->assertSame( '', $saved['post_template_id'] ); + $this->assertArrayNotHasKey( Options::PUBLICATION_SECTION_FLAG, $saved ); + } } From 79d559174c0c5f7294420d07899a323be4c630d3 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 02:21:13 +0800 Subject: [PATCH 09/34] feat(custom-blocks): show a missing-publication notice with a settings link in the gated block sidebar --- src/js/blocks/gated-content/edit.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/js/blocks/gated-content/edit.js b/src/js/blocks/gated-content/edit.js index 6512de9..bc8334a 100644 --- a/src/js/blocks/gated-content/edit.js +++ b/src/js/blocks/gated-content/edit.js @@ -7,11 +7,20 @@ import { PanelColorSettings, useBlockProps, } from '@wordpress/block-editor'; -import { PanelBody, TextControl, TextareaControl } from '@wordpress/components'; +import { + Notice, + PanelBody, + TextControl, + TextareaControl, +} from '@wordpress/components'; + +import { useBeehiivEditorConfig } from '../../editor/post-settings/hooks/use-beehiiv-editor-config'; +import renderSettingsLinkMessage from '../../editor/post-settings/utils/render-settings-link-message'; const DIVIDER_BLOCK = 'beehiiv/gated-content-divider'; export default function Edit( { attributes, setAttributes, clientId } ) { + const { settingsUrl, hasPublication } = useBeehiivEditorConfig(); const { formId, headline, text, cardBackgroundColor, cardTextColor } = attributes; @@ -45,6 +54,17 @@ export default function Edit( { attributes, setAttributes, clientId } ) { title={ __( 'Gate settings', 'beehiiv' ) } initialOpen={ true } > + { settingsUrl && ! hasPublication && ( + + { renderSettingsLinkMessage( + __( + 'No beehiiv publication is set. Choose one in beehiiv settings.', + 'beehiiv' + ), + settingsUrl + ) } + + ) } Date: Tue, 3 Nov 2026 02:22:26 +0800 Subject: [PATCH 10/34] feat(admin-interface): add a default subscribe form ID to the Subscriber gating settings --- includes/Admin/Options.php | 7 ++-- includes/Admin/Registrar.php | 36 +++++++++++++++++++ includes/Blocks/GatedContent.php | 5 +-- includes/Editor/PostSettings.php | 1 + src/js/blocks/gated-content/edit.js | 6 ++-- .../hooks/use-beehiiv-editor-config.js | 1 + tests/phpunit/GatedContentRenderTest.php | 16 +++++++++ tests/phpunit/SubscriberGateDefaultsTest.php | 14 ++++++++ 8 files changed, 80 insertions(+), 6 deletions(-) diff --git a/includes/Admin/Options.php b/includes/Admin/Options.php index 5a0d291..79ebfbe 100644 --- a/includes/Admin/Options.php +++ b/includes/Admin/Options.php @@ -29,6 +29,7 @@ final class Options { 'post_template_id' => '', 'gate_headline' => '', 'gate_text' => '', + 'gate_form_id' => '', 'gate_cookie_days' => '', ]; @@ -120,7 +121,7 @@ public static function default_gate_text(): string { * built-in default. The fallback is applied here, on read, and is never * written back to the stored option. * - * @return array{headline: string, text: string, cookie_days: int} + * @return array{headline: string, text: string, form_id: string, cookie_days: int} * @since 1.2.0 */ public static function get_gate_settings(): array { @@ -132,6 +133,7 @@ public static function get_gate_settings(): array { return [ 'headline' => '' !== $headline ? $headline : self::default_gate_headline(), 'text' => '' !== $text ? $text : self::default_gate_text(), + 'form_id' => trim( (string) $settings['gate_form_id'] ), 'cookie_days' => self::resolve_gate_cookie_days( $settings['gate_cookie_days'] ), ]; } @@ -194,7 +196,8 @@ public static function sanitize( $input ): array { } // Plain text only: tags are removed and surrounding whitespace trimmed. - foreach ( [ 'gate_headline', 'gate_text' ] as $key ) { + // The form ID has no built-in default: blank means no site-wide form. + foreach ( [ 'gate_headline', 'gate_text', 'gate_form_id' ] as $key ) { if ( isset( $input[ $key ] ) ) { $current[ $key ] = trim( sanitize_text_field( (string) $input[ $key ] ) ); } diff --git a/includes/Admin/Registrar.php b/includes/Admin/Registrar.php index b99f0c8..3fa645d 100644 --- a/includes/Admin/Registrar.php +++ b/includes/Admin/Registrar.php @@ -134,6 +134,15 @@ private static function register_gate_settings_section( string $page_slug ): voi [ 'label_for' => 'beehiiv_gate_text' ] ); + add_settings_field( + 'beehiiv_gate_form_id', + __( 'Default subscribe form ID', 'beehiiv' ), + [ self::class, 'render_gate_form_id_field' ], + $page_slug, + self::GATE_SETTINGS_SECTION_ID, + [ 'label_for' => 'beehiiv_gate_form_id' ] + ); + add_settings_field( 'beehiiv_gate_cookie_days', __( 'Unlock cookie lifetime', 'beehiiv' ), @@ -214,6 +223,33 @@ class="large-text" + +

+ +

+ context['postId'] ?? get_the_ID(); $signature = md5( (string) wp_json_encode( [ $post_id, $block->parsed_block ] ) ); - $form_id = isset( $attributes['formId'] ) ? sanitize_text_field( $attributes['formId'] ) : ''; + $defaults = Options::get_gate_settings(); + $form_id = isset( $attributes['formId'] ) ? trim( sanitize_text_field( $attributes['formId'] ) ) : ''; + $form_id = '' !== $form_id ? $form_id : $defaults['form_id']; // Nothing to gate, no form to unlock with, logged-in user, or a second // gate on the page: show the content in full. @@ -113,7 +115,6 @@ public static function render( array $attributes, WP_Block $block ): string { self::$gate_owner = $signature; - $defaults = Options::get_gate_settings(); $headline = isset( $attributes['headline'] ) ? trim( sanitize_text_field( $attributes['headline'] ) ) : ''; $text = isset( $attributes['text'] ) ? trim( sanitize_text_field( $attributes['text'] ) ) : ''; $headline = '' !== $headline ? $headline : $defaults['headline']; diff --git a/includes/Editor/PostSettings.php b/includes/Editor/PostSettings.php index bc09e81..b249d04 100644 --- a/includes/Editor/PostSettings.php +++ b/includes/Editor/PostSettings.php @@ -404,6 +404,7 @@ private static function get_editor_config(): array { 'hasPostTemplate' => '' !== trim( (string) ( $settings['post_template_id'] ?? '' ) ), 'publicationId' => trim( (string) ( $settings['publication_id'] ?? '' ) ), 'defaultPostTemplateId' => trim( (string) ( $settings['post_template_id'] ?? '' ) ), + 'gateFormId' => Options::get_gate_settings()['form_id'], 'canPublishPosts' => current_user_can( 'publish_posts' ), 'defaultPublicationId' => $default_pub_id, 'publications' => $is_connected ? PublicationResolver::get_editor_publications() : [], diff --git a/src/js/blocks/gated-content/edit.js b/src/js/blocks/gated-content/edit.js index bc8334a..aa69666 100644 --- a/src/js/blocks/gated-content/edit.js +++ b/src/js/blocks/gated-content/edit.js @@ -20,7 +20,8 @@ import renderSettingsLinkMessage from '../../editor/post-settings/utils/render-s const DIVIDER_BLOCK = 'beehiiv/gated-content-divider'; export default function Edit( { attributes, setAttributes, clientId } ) { - const { settingsUrl, hasPublication } = useBeehiivEditorConfig(); + const { settingsUrl, hasPublication, gateFormId } = + useBeehiivEditorConfig(); const { formId, headline, text, cardBackgroundColor, cardTextColor } = attributes; @@ -68,11 +69,12 @@ export default function Edit( { attributes, setAttributes, clientId } ) { setAttributes( { formId: value.trim() } ) } help={ __( - 'Form must be Inline and set to Show a success message, not Redirect.', + 'Leave blank to use the default form ID from the beehiiv settings page. Form must be Inline and set to Show a success message, not Redirect.', 'beehiiv' ) } /> diff --git a/src/js/editor/post-settings/hooks/use-beehiiv-editor-config.js b/src/js/editor/post-settings/hooks/use-beehiiv-editor-config.js index 100bed7..ea2ba11 100644 --- a/src/js/editor/post-settings/hooks/use-beehiiv-editor-config.js +++ b/src/js/editor/post-settings/hooks/use-beehiiv-editor-config.js @@ -12,6 +12,7 @@ const DEFAULT_CONFIG = { hasPostTemplate: false, publicationId: '', defaultPostTemplateId: '', + gateFormId: '', canPublishPosts: false, defaultPublicationId: '', publications: [], diff --git a/tests/phpunit/GatedContentRenderTest.php b/tests/phpunit/GatedContentRenderTest.php index 5f433c1..4586376 100644 --- a/tests/phpunit/GatedContentRenderTest.php +++ b/tests/phpunit/GatedContentRenderTest.php @@ -180,4 +180,20 @@ public function test_repeat_render_of_same_gate_still_gates(): void { $this->assertStringContainsString( 'data-beehiiv-gate', $first ); $this->assertStringContainsString( 'data-beehiiv-gate', $second ); } + + /** + * A block with no form ID of its own uses the default form ID from settings. + */ + public function test_blank_form_id_uses_settings_default(): void { + update_option( Config::OPTION_NAME, Options::sanitize( array( 'gate_form_id' => 'default_form' ) ) ); + + $html = $this->render_gate( $this->p( 'ONE' ) . $this->p( 'TWO' ), '' ); + + $this->assertStringContainsString( 'data-form-id="default_form"', $html ); + + GatedContent::reset(); + $html = $this->render_gate( $this->p( 'ONE' ) . $this->p( 'TWO' ), 'own_form' ); + + $this->assertStringContainsString( 'data-form-id="own_form"', $html ); + } } diff --git a/tests/phpunit/SubscriberGateDefaultsTest.php b/tests/phpunit/SubscriberGateDefaultsTest.php index 1537312..4874216 100644 --- a/tests/phpunit/SubscriberGateDefaultsTest.php +++ b/tests/phpunit/SubscriberGateDefaultsTest.php @@ -178,4 +178,18 @@ public function test_shown_publication_section_clears_missing_fields(): void { $this->assertSame( '', $saved['post_template_id'] ); $this->assertArrayNotHasKey( Options::PUBLICATION_SECTION_FLAG, $saved ); } + + /** + * The default form ID is plain text, trimmed, and blank when unset. + */ + public function test_default_form_id_is_trimmed_plain_text(): void { + $this->assertSame( '', Options::get_gate_settings()['form_id'] ); + + update_option( + Config::OPTION_NAME, + Options::sanitize( array( 'gate_form_id' => ' form_1 ' ) ) + ); + + $this->assertSame( 'form_1', Options::get_gate_settings()['form_id'] ); + } } From a9efe0600c7c6621fc9e4aef55d7fcaea74e2bc5 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 02:25:45 +0800 Subject: [PATCH 11/34] style(custom-blocks): use a dotted outline on the gated block in the editor --- src/js/blocks/gated-content/editor.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/js/blocks/gated-content/editor.scss b/src/js/blocks/gated-content/editor.scss index 5b06621..9a15113 100644 --- a/src/js/blocks/gated-content/editor.scss +++ b/src/js/blocks/gated-content/editor.scss @@ -1,6 +1,7 @@ .wp-block-beehiiv-gated-content { - border-inline-start: 3px solid #1e1e1e; - padding-inline-start: 12px; + border: 2px dotted #e0e0e0; + border-radius: 6px; + padding: 16px 18px; // With no divider, the first block is the preview. Mark where the wall falls. &.beehiiv-gated-content--no-divider From 5a574779ac800130db240c983b226d75bf728268 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 18:14:29 +0800 Subject: [PATCH 12/34] feat(gated-content): remove default headline and text; blank hides them --- includes/Admin/Options.php | 33 +++----------------- includes/Admin/Registrar.php | 20 ++---------- includes/Blocks/GatedContent.php | 19 +++++++---- src/js/blocks/gated-content/edit.js | 4 +-- tests/phpunit/GatedContentRenderTest.php | 12 ++++++- tests/phpunit/SubscriberGateDefaultsTest.php | 14 ++++----- 6 files changed, 40 insertions(+), 62 deletions(-) diff --git a/includes/Admin/Options.php b/includes/Admin/Options.php index 79ebfbe..091d080 100644 --- a/includes/Admin/Options.php +++ b/includes/Admin/Options.php @@ -94,32 +94,12 @@ public static function get(): array { return wp_parse_args( $saved, self::DEFAULTS ); } - /** - * Built-in default gate headline. - * - * @return string - * @since 1.2.0 - */ - public static function default_gate_headline(): string { - return __( 'Subscribe to keep reading', 'beehiiv' ); - } - - /** - * Built-in default gate text. - * - * @return string - * @since 1.2.0 - */ - public static function default_gate_text(): string { - return __( 'Enter your email to unlock the full story and get our newsletter.', 'beehiiv' ); - } - /** * Gate defaults with every fallback applied. * - * A blank, whitespace-only, missing or invalid saved value reads as the - * built-in default. The fallback is applied here, on read, and is never - * written back to the stored option. + * A blank headline or text stays empty, and the gate card leaves it out. + * An invalid cookie lifetime falls back to the default on read; the + * fallback is never written back to the stored option. * * @return array{headline: string, text: string, form_id: string, cookie_days: int} * @since 1.2.0 @@ -127,12 +107,9 @@ public static function default_gate_text(): string { public static function get_gate_settings(): array { $settings = self::get(); - $headline = trim( (string) $settings['gate_headline'] ); - $text = trim( (string) $settings['gate_text'] ); - return [ - 'headline' => '' !== $headline ? $headline : self::default_gate_headline(), - 'text' => '' !== $text ? $text : self::default_gate_text(), + 'headline' => trim( (string) $settings['gate_headline'] ), + 'text' => trim( (string) $settings['gate_text'] ), 'form_id' => trim( (string) $settings['gate_form_id'] ), 'cookie_days' => self::resolve_gate_cookie_days( $settings['gate_cookie_days'] ), ]; diff --git a/includes/Admin/Registrar.php b/includes/Admin/Registrar.php index 3fa645d..4de9927 100644 --- a/includes/Admin/Registrar.php +++ b/includes/Admin/Registrar.php @@ -181,15 +181,7 @@ class="regular-text" value="" />

- +

" />

- +

' . esc_html( $headline ) . '' + : ''; + $text_html = '' !== $text + ? '

' . esc_html( $text ) . '

' + : ''; + $card = sprintf( - '

%1$s

' - . '

%2$s

%3$s
', - esc_html( $headline ), - esc_html( $text ), - $form_script, - '' !== $card_style ? ' style="' . esc_attr( $card_style ) . '"' : '' + '
%2$s%3$s%4$s
', + '' !== $card_style ? ' style="' . esc_attr( $card_style ) . '"' : '', + $headline_html, + $text_html, + $form_script ); $gate = sprintf( diff --git a/src/js/blocks/gated-content/edit.js b/src/js/blocks/gated-content/edit.js index aa69666..26b700d 100644 --- a/src/js/blocks/gated-content/edit.js +++ b/src/js/blocks/gated-content/edit.js @@ -85,7 +85,7 @@ export default function Edit( { attributes, setAttributes, clientId } ) { setAttributes( { headline: value } ) } help={ __( - 'Leave blank to use the default from the beehiiv settings page.', + 'Leave blank to use the default from the beehiiv settings page. If that is blank too, no headline is shown.', 'beehiiv' ) } /> @@ -96,7 +96,7 @@ export default function Edit( { attributes, setAttributes, clientId } ) { setAttributes( { text: value } ) } help={ __( - 'Leave blank to use the default from the beehiiv settings page.', + 'Leave blank to use the default from the beehiiv settings page. If that is blank too, no text is shown.', 'beehiiv' ) } /> diff --git a/tests/phpunit/GatedContentRenderTest.php b/tests/phpunit/GatedContentRenderTest.php index 4586376..56a4218 100644 --- a/tests/phpunit/GatedContentRenderTest.php +++ b/tests/phpunit/GatedContentRenderTest.php @@ -76,10 +76,20 @@ public function test_markup_locks_content_and_carries_form_and_copy(): void { $this->assertStringContainsString( 'data-form-id="form_1"', $html ); $this->assertStringContainsString( 'data-beehiiv-form="form_1"', $html ); $this->assertStringContainsString( 'aria-hidden="true" inert', $html ); - $this->assertStringContainsString( 'Subscribe to keep reading', $html ); $this->assertStringContainsString( 'data-cookie-days="30"', $html ); } + /** + * Blank headline and text leave no empty heading or paragraph in the card. + */ + public function test_blank_headline_and_text_are_not_rendered(): void { + $html = $this->render_gate( $this->p( 'ONE' ) . $this->p( 'TWO' ) ); + + $this->assertStringNotContainsString( 'beehiiv-gated-content__headline', $html ); + $this->assertStringNotContainsString( 'beehiiv-gated-content__text', $html ); + $this->assertStringContainsString( 'beehiiv-gated-content__card', $html ); + } + /** * AC-004 / AC-020: block copy overrides defaults; cookie lifetime comes from the settings. */ diff --git a/tests/phpunit/SubscriberGateDefaultsTest.php b/tests/phpunit/SubscriberGateDefaultsTest.php index 4874216..e3dbe60 100644 --- a/tests/phpunit/SubscriberGateDefaultsTest.php +++ b/tests/phpunit/SubscriberGateDefaultsTest.php @@ -18,7 +18,7 @@ class SubscriberGateDefaultsTest extends WP_UnitTestCase { /** * AC-013 / AC-014: settings saved before the feature read as the built-in defaults. */ - public function test_legacy_settings_read_built_in_defaults(): void { + public function test_legacy_settings_read_empty_headline_and_text(): void { update_option( Config::OPTION_NAME, array( @@ -29,16 +29,16 @@ public function test_legacy_settings_read_built_in_defaults(): void { $gate = Options::get_gate_settings(); - $this->assertSame( 'Subscribe to keep reading', $gate['headline'] ); - $this->assertSame( 'Enter your email to unlock the full story and get our newsletter.', $gate['text'] ); + $this->assertSame( '', $gate['headline'] ); + $this->assertSame( '', $gate['text'] ); $this->assertSame( 30, $gate['cookie_days'] ); $this->assertSame( 'pub_1', Options::get()['publication_id'] ); } /** - * AC-004: blank or whitespace-only values read as the default and are not stored as it. + * AC-004: blank or whitespace-only values stay empty. */ - public function test_blank_values_fall_back_on_read_not_on_save(): void { + public function test_blank_values_stay_empty(): void { $saved = Options::sanitize( array( 'gate_headline' => ' ', @@ -52,8 +52,8 @@ public function test_blank_values_fall_back_on_read_not_on_save(): void { update_option( Config::OPTION_NAME, $saved ); $gate = Options::get_gate_settings(); - $this->assertSame( 'Subscribe to keep reading', $gate['headline'] ); - $this->assertSame( 'Enter your email to unlock the full story and get our newsletter.', $gate['text'] ); + $this->assertSame( '', $gate['headline'] ); + $this->assertSame( '', $gate['text'] ); } /** From 76e4b3016e0db18b883681b7c8b24d1aa3e3cf0a Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 18:20:18 +0800 Subject: [PATCH 13/34] feat(gated-content): start new blocks with paragraph, divider, paragraph --- includes/Blocks/GatedContent.php | 5 +++++ src/js/blocks/gated-content/edit.js | 9 +++++++++ tests/phpunit/GatedContentRenderTest.php | 9 +++++++++ 3 files changed, 23 insertions(+) diff --git a/includes/Blocks/GatedContent.php b/includes/Blocks/GatedContent.php index 0ee5ff1..af1517c 100644 --- a/includes/Blocks/GatedContent.php +++ b/includes/Blocks/GatedContent.php @@ -83,6 +83,11 @@ public static function render( array $attributes, WP_Block $block ): string { $html = $inner_block->render(); + // The starter template adds empty paragraphs. They do not count as content. + if ( 1 === preg_match( '#^\s*(]*>(\s| |)*

)?\s*$#i', $html ) ) { + continue; + } + if ( $split || ( 0 < $count && ! $has_divider ) ) { $locked[] = $html; } else { diff --git a/src/js/blocks/gated-content/edit.js b/src/js/blocks/gated-content/edit.js index 26b700d..d080938 100644 --- a/src/js/blocks/gated-content/edit.js +++ b/src/js/blocks/gated-content/edit.js @@ -19,6 +19,14 @@ import renderSettingsLinkMessage from '../../editor/post-settings/utils/render-s const DIVIDER_BLOCK = 'beehiiv/gated-content-divider'; +// A new gated block starts with a preview paragraph, the divider, and a gated +// paragraph, so editors can see the divider is a block they can move. +const TEMPLATE = [ + [ 'core/paragraph' ], + [ DIVIDER_BLOCK ], + [ 'core/paragraph' ], +]; + export default function Edit( { attributes, setAttributes, clientId } ) { const { settingsUrl, hasPublication, gateFormId } = useBeehiivEditorConfig(); @@ -125,6 +133,7 @@ export default function Edit( { attributes, setAttributes, clientId } ) {
diff --git a/tests/phpunit/GatedContentRenderTest.php b/tests/phpunit/GatedContentRenderTest.php index 56a4218..2bc50ea 100644 --- a/tests/phpunit/GatedContentRenderTest.php +++ b/tests/phpunit/GatedContentRenderTest.php @@ -79,6 +79,15 @@ public function test_markup_locks_content_and_carries_form_and_copy(): void { $this->assertStringContainsString( 'data-cookie-days="30"', $html ); } + /** + * The starter template's empty paragraphs are not content: nothing to gate. + */ + public function test_empty_starter_paragraphs_do_not_create_a_gate(): void { + $html = $this->render_gate( $this->p( '' ) . $this->divider() . $this->p( '' ) ); + + $this->assertStringNotContainsString( 'beehiiv-gated-content__gate', $html ); + } + /** * Blank headline and text leave no empty heading or paragraph in the card. */ From 416cadf7afd85f8d51564c1b67070750d36e0586 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 18:20:32 +0800 Subject: [PATCH 14/34] refactor(gated-content): rely on the block template only; drop empty-paragraph filter --- includes/Blocks/GatedContent.php | 5 ----- tests/phpunit/GatedContentRenderTest.php | 9 --------- 2 files changed, 14 deletions(-) diff --git a/includes/Blocks/GatedContent.php b/includes/Blocks/GatedContent.php index af1517c..0ee5ff1 100644 --- a/includes/Blocks/GatedContent.php +++ b/includes/Blocks/GatedContent.php @@ -83,11 +83,6 @@ public static function render( array $attributes, WP_Block $block ): string { $html = $inner_block->render(); - // The starter template adds empty paragraphs. They do not count as content. - if ( 1 === preg_match( '#^\s*(]*>(\s| |)*

)?\s*$#i', $html ) ) { - continue; - } - if ( $split || ( 0 < $count && ! $has_divider ) ) { $locked[] = $html; } else { diff --git a/tests/phpunit/GatedContentRenderTest.php b/tests/phpunit/GatedContentRenderTest.php index 2bc50ea..56a4218 100644 --- a/tests/phpunit/GatedContentRenderTest.php +++ b/tests/phpunit/GatedContentRenderTest.php @@ -79,15 +79,6 @@ public function test_markup_locks_content_and_carries_form_and_copy(): void { $this->assertStringContainsString( 'data-cookie-days="30"', $html ); } - /** - * The starter template's empty paragraphs are not content: nothing to gate. - */ - public function test_empty_starter_paragraphs_do_not_create_a_gate(): void { - $html = $this->render_gate( $this->p( '' ) . $this->divider() . $this->p( '' ) ); - - $this->assertStringNotContainsString( 'beehiiv-gated-content__gate', $html ); - } - /** * Blank headline and text leave no empty heading or paragraph in the card. */ From e9b5bdbad20a044cd872eb3df7f8adbc8c0a4dd9 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 18:25:12 +0800 Subject: [PATCH 15/34] feat(gated-content): gate every gated block on a page; one subscribe unlocks all --- includes/Blocks/GatedContent.php | 34 ++++-------------------- src/js/blocks/gated-content/view.js | 26 +++++++++--------- tests/js/gated-content-view.test.js | 16 +++++++++++ tests/phpunit/GatedContentRenderTest.php | 14 ++++------ 4 files changed, 40 insertions(+), 50 deletions(-) diff --git a/includes/Blocks/GatedContent.php b/includes/Blocks/GatedContent.php index 0ee5ff1..fa0d421 100644 --- a/includes/Blocks/GatedContent.php +++ b/includes/Blocks/GatedContent.php @@ -37,25 +37,6 @@ final class GatedContent { */ public const COOKIE_NAME = 'beehiiv_gate_unlocked'; - /** - * Signature of the gate that owns this page, or empty when none has rendered yet. - * - * Rendering the same block again (an SEO or excerpt pass) matches the owner - * and still shows the gate. A different gated block is a second gate. - * - * @var string - */ - private static $gate_owner = ''; - - /** - * Forget which gate was rendered (new page / tests). - * - * @since 1.2.0 - */ - public static function reset(): void { - self::$gate_owner = ''; - } - /** * Render the block. * @@ -92,19 +73,16 @@ public static function render( array $attributes, WP_Block $block ): string { ++$count; } - $post_id = $block->context['postId'] ?? get_the_ID(); - $signature = md5( (string) wp_json_encode( [ $post_id, $block->parsed_block ] ) ); - $defaults = Options::get_gate_settings(); - $form_id = isset( $attributes['formId'] ) ? trim( sanitize_text_field( $attributes['formId'] ) ) : ''; - $form_id = '' !== $form_id ? $form_id : $defaults['form_id']; + $defaults = Options::get_gate_settings(); + $form_id = isset( $attributes['formId'] ) ? trim( sanitize_text_field( $attributes['formId'] ) ) : ''; + $form_id = '' !== $form_id ? $form_id : $defaults['form_id']; - // Nothing to gate, no form to unlock with, logged-in user, or a second - // gate on the page: show the content in full. + // Nothing to gate, no form to unlock with, or a logged-in user: show the + // content in full. if ( empty( $locked ) || '' === $form_id || is_user_logged_in() - || ( '' !== self::$gate_owner && self::$gate_owner !== $signature ) ) { return sprintf( '
%s
', @@ -113,8 +91,6 @@ public static function render( array $attributes, WP_Block $block ): string { ); } - self::$gate_owner = $signature; - $headline = isset( $attributes['headline'] ) ? trim( sanitize_text_field( $attributes['headline'] ) ) : ''; $text = isset( $attributes['text'] ) ? trim( sanitize_text_field( $attributes['text'] ) ) : ''; $headline = '' !== $headline ? $headline : $defaults['headline']; diff --git a/src/js/blocks/gated-content/view.js b/src/js/blocks/gated-content/view.js index 72cf391..316713f 100644 --- a/src/js/blocks/gated-content/view.js +++ b/src/js/blocks/gated-content/view.js @@ -1,8 +1,8 @@ /** * Front-end behavior for the beehiiv Gated Content block. * - * Unlocks the first gate on the page when its own beehiiv form is submitted, - * and removes the gate when the unlock cookie is already present. + * Unlocks every gate on the page when one of the gates' beehiiv forms is + * submitted, and removes the gates when the unlock cookie is already present. */ const ALLOWED_ORIGINS = [ 'https://subscribe-forms.beehiiv.com', @@ -42,34 +42,36 @@ function unlock( gate, immediate = false ) { } function init() { - // Only the first gate on a page counts. - const gate = document.querySelector( '[data-beehiiv-gate]' ); + const gates = Array.from( + document.querySelectorAll( '[data-beehiiv-gate]' ) + ); - if ( ! gate ) { + if ( ! gates.length ) { return; } - const cookieName = gate.dataset.cookieName; - const cookieDays = parseInt( gate.dataset.cookieDays, 10 ) || 30; - const formId = gate.dataset.formId; - + // The cookie is site-wide, so every gate shares the same name and lifetime. + const cookieName = gates[ 0 ].dataset.cookieName; if ( hasCookie( cookieName ) ) { - unlock( gate, true ); + gates.forEach( ( gate ) => unlock( gate, true ) ); return; } + const cookieDays = parseInt( gates[ 0 ].dataset.cookieDays, 10 ) || 30; + const formIds = gates.map( ( gate ) => gate.dataset.formId ); + window.addEventListener( 'message', ( event ) => { if ( ! ALLOWED_ORIGINS.includes( event.origin ) || ! event.data || event.data.type !== SUBMITTED_TYPE || - String( event.data.externalEmbedId ) !== formId + ! formIds.includes( String( event.data.externalEmbedId ) ) ) { return; } setCookie( cookieName, cookieDays ); - unlock( gate ); + gates.forEach( ( gate ) => unlock( gate ) ); } ); } diff --git a/tests/js/gated-content-view.test.js b/tests/js/gated-content-view.test.js index f52e7dd..a89fa99 100644 --- a/tests/js/gated-content-view.test.js +++ b/tests/js/gated-content-view.test.js @@ -42,6 +42,22 @@ describe( 'gated content view', () => { expect( document.cookie ).toContain( 'beehiiv_gate_unlocked=1' ); } ); + it( 'unlocks every gate when any gate form is submitted', () => { + document.body.innerHTML = + GATE_HTML + GATE_HTML.replace( 'form_1', 'form_2' ); + load(); + post( { + type: 'BEEHIIV_SUBSCRIBER_FORM_SUBMITTED', + externalEmbedId: 'form_2', + } ); + + const gates = document.querySelectorAll( '[data-beehiiv-gate]' ); + expect( gates ).toHaveLength( 2 ); + gates.forEach( ( g ) => + expect( g.classList.contains( 'is-unlocked' ) ).toBe( true ) + ); + } ); + it( 'accepts the legacy embed origin', () => { load(); post( diff --git a/tests/phpunit/GatedContentRenderTest.php b/tests/phpunit/GatedContentRenderTest.php index 56a4218..952593b 100644 --- a/tests/phpunit/GatedContentRenderTest.php +++ b/tests/phpunit/GatedContentRenderTest.php @@ -18,7 +18,6 @@ class GatedContentRenderTest extends WP_UnitTestCase { public function set_up(): void { parent::set_up(); - GatedContent::reset(); wp_set_current_user( 0 ); delete_option( Config::OPTION_NAME ); } @@ -125,15 +124,15 @@ public function test_logged_in_user_sees_no_gate(): void { } /** - * AC-025: only the first gated block counts. + * AC-025: every gated block on a page renders its own gate and form. */ - public function test_only_first_gate_counts(): void { + public function test_each_gated_block_renders_its_own_gate(): void { $first = $this->render_gate( $this->p( 'A1' ) . $this->p( 'A2' ) ); $second = $this->render_gate( $this->p( 'B1' ) . $this->p( 'B2' ), 'form_2' ); - $this->assertStringContainsString( 'beehiiv-gated-content__gate', $first ); - $this->assertStringNotContainsString( 'beehiiv-gated-content__gate', $second ); - $this->assertStringContainsString( 'B2', $second ); + $this->assertStringContainsString( 'data-form-id="form_1"', $first ); + $this->assertStringContainsString( 'data-form-id="form_2"', $second ); + $this->assertStringContainsString( 'beehiiv-gated-content__gate', $second ); } /** @@ -143,7 +142,6 @@ public function test_no_gate_without_form_id_or_gated_content(): void { $no_form = $this->render_gate( $this->p( 'ONE' ) . $this->p( 'TWO' ), '' ); $this->assertStringNotContainsString( 'beehiiv-gated-content__gate', $no_form ); - GatedContent::reset(); $only_preview = $this->render_gate( $this->p( 'ONE' ) ); $this->assertStringNotContainsString( 'beehiiv-gated-content__gate', $only_preview ); } @@ -168,7 +166,6 @@ public function test_card_colors_are_applied_and_sanitized(): void { $this->assertStringContainsString( 'background-color:#112233', $html ); $this->assertStringContainsString( 'color:#ffffff', $html ); - GatedContent::reset(); $html = $this->render_gate( $this->p( 'ONE' ) . $this->p( 'TWO' ), 'form_1', @@ -201,7 +198,6 @@ public function test_blank_form_id_uses_settings_default(): void { $this->assertStringContainsString( 'data-form-id="default_form"', $html ); - GatedContent::reset(); $html = $this->render_gate( $this->p( 'ONE' ) . $this->p( 'TWO' ), 'own_form' ); $this->assertStringContainsString( 'data-form-id="own_form"', $html ); From 5f88fe125f84a078db4b9f669598c0eadb3f40f8 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 18:45:04 +0800 Subject: [PATCH 16/34] fix(gated-content): center the beehiiv form inside the gate card --- src/js/blocks/gated-content/style.scss | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/js/blocks/gated-content/style.scss b/src/js/blocks/gated-content/style.scss index 6ff0075..951c570 100644 --- a/src/js/blocks/gated-content/style.scss +++ b/src/js/blocks/gated-content/style.scss @@ -33,12 +33,15 @@ // The card sits over the bottom of the blurred, faded content. .beehiiv-gated-content__card { + align-items: center; align-self: end; background: #fff; border: 1px solid #e5e5e5; border-radius: 16px; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.12); box-sizing: border-box; + display: flex; + flex-direction: column; justify-self: center; margin: 0; max-width: 100%; @@ -50,6 +53,16 @@ z-index: 2; } + // The beehiiv loader adds the form next to its script. Center it in the card. + .beehiiv-gated-content__card > :not(script) { + max-width: 100%; + } + + .beehiiv-gated-content__headline, + .beehiiv-gated-content__text { + width: 100%; + } + .beehiiv-gated-content__headline { color: var(--beehiiv-gate-card-color, inherit); font-size: 1.75rem; From cc7fc8233a2fbb5770d73fe5fcac4f6ca7a50740 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 18:46:37 +0800 Subject: [PATCH 17/34] Revert "fix(gated-content): center the beehiiv form inside the gate card" The form's alignment is set inside the beehiiv form itself, so the card CSS had no effect. --- src/js/blocks/gated-content/style.scss | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/js/blocks/gated-content/style.scss b/src/js/blocks/gated-content/style.scss index 951c570..6ff0075 100644 --- a/src/js/blocks/gated-content/style.scss +++ b/src/js/blocks/gated-content/style.scss @@ -33,15 +33,12 @@ // The card sits over the bottom of the blurred, faded content. .beehiiv-gated-content__card { - align-items: center; align-self: end; background: #fff; border: 1px solid #e5e5e5; border-radius: 16px; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.12); box-sizing: border-box; - display: flex; - flex-direction: column; justify-self: center; margin: 0; max-width: 100%; @@ -53,16 +50,6 @@ z-index: 2; } - // The beehiiv loader adds the form next to its script. Center it in the card. - .beehiiv-gated-content__card > :not(script) { - max-width: 100%; - } - - .beehiiv-gated-content__headline, - .beehiiv-gated-content__text { - width: 100%; - } - .beehiiv-gated-content__headline { color: var(--beehiiv-gate-card-color, inherit); font-size: 1.75rem; From 027cbd8d71761c64f3d801b1e9e4d19866e8d06b Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 18:53:14 +0800 Subject: [PATCH 18/34] feat(gated-content): size the gate card to the form width --- src/js/blocks/gated-content/style.scss | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/js/blocks/gated-content/style.scss b/src/js/blocks/gated-content/style.scss index 6ff0075..4eef82e 100644 --- a/src/js/blocks/gated-content/style.scss +++ b/src/js/blocks/gated-content/style.scss @@ -42,11 +42,13 @@ justify-self: center; margin: 0; max-width: 100%; + min-width: min( 100%, 20rem ); padding: 2rem; position: relative; text-align: center; transition: opacity 0.3s ease; - width: 44rem; + // The card follows the width of the beehiiv form inside it. + width: fit-content; z-index: 2; } From 237a8ccc49627b4d87d1153a02b16103788c25b6 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 19:00:47 +0800 Subject: [PATCH 19/34] style(gated-content): set gate card min width to 400px --- src/js/blocks/gated-content/style.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/blocks/gated-content/style.scss b/src/js/blocks/gated-content/style.scss index 4eef82e..9df9c87 100644 --- a/src/js/blocks/gated-content/style.scss +++ b/src/js/blocks/gated-content/style.scss @@ -42,7 +42,7 @@ justify-self: center; margin: 0; max-width: 100%; - min-width: min( 100%, 20rem ); + min-width: min( 100%, 400px ); padding: 2rem; position: relative; text-align: center; From 670843ef040166511fe20e5fd74f8f9a0fdfc04a Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 19:01:48 +0800 Subject: [PATCH 20/34] style(gated-content): set gate card min width to 500px --- src/js/blocks/gated-content/style.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/blocks/gated-content/style.scss b/src/js/blocks/gated-content/style.scss index 9df9c87..2c8440f 100644 --- a/src/js/blocks/gated-content/style.scss +++ b/src/js/blocks/gated-content/style.scss @@ -42,7 +42,7 @@ justify-self: center; margin: 0; max-width: 100%; - min-width: min( 100%, 400px ); + min-width: min( 100%, 500px ); padding: 2rem; position: relative; text-align: center; From 693b9b597ab09d6784a7aba99cfea378463ee496 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 19:05:30 +0800 Subject: [PATCH 21/34] feat(gated-content): gate the whole block when there is no divider --- includes/Blocks/GatedContent.php | 6 ++---- src/js/blocks/gated-content/edit.js | 6 +----- src/js/blocks/gated-content/editor.scss | 16 ---------------- tests/phpunit/GatedContentRenderTest.php | 9 ++++----- 4 files changed, 7 insertions(+), 30 deletions(-) diff --git a/includes/Blocks/GatedContent.php b/includes/Blocks/GatedContent.php index fa0d421..3309dab 100644 --- a/includes/Blocks/GatedContent.php +++ b/includes/Blocks/GatedContent.php @@ -49,7 +49,6 @@ public static function render( array $attributes, WP_Block $block ): string { $preview = []; $locked = []; $split = false; - $count = 0; // Work out once, before looping. The inner block list shares one cursor, // so scanning it again inside the loop below would restart the loop forever. @@ -64,13 +63,12 @@ public static function render( array $attributes, WP_Block $block ): string { $html = $inner_block->render(); - if ( $split || ( 0 < $count && ! $has_divider ) ) { + // With no divider, the whole block is gated. + if ( $split || ! $has_divider ) { $locked[] = $html; } else { $preview[] = $html; } - - ++$count; } $defaults = Options::get_gate_settings(); diff --git a/src/js/blocks/gated-content/edit.js b/src/js/blocks/gated-content/edit.js index d080938..72cc196 100644 --- a/src/js/blocks/gated-content/edit.js +++ b/src/js/blocks/gated-content/edit.js @@ -50,11 +50,7 @@ export default function Edit( { attributes, setAttributes, clientId } ) { } }, [ dividerIds, removeBlocks ] ); - const blockProps = useBlockProps( { - className: dividerIds.length - ? 'beehiiv-gated-content--has-divider' - : 'beehiiv-gated-content--no-divider', - } ); + const blockProps = useBlockProps(); return ( <> diff --git a/src/js/blocks/gated-content/editor.scss b/src/js/blocks/gated-content/editor.scss index 9a15113..5997049 100644 --- a/src/js/blocks/gated-content/editor.scss +++ b/src/js/blocks/gated-content/editor.scss @@ -2,20 +2,4 @@ border: 2px dotted #e0e0e0; border-radius: 6px; padding: 16px 18px; - - // With no divider, the first block is the preview. Mark where the wall falls. - &.beehiiv-gated-content--no-divider - > .block-editor-inner-blocks - > .block-editor-block-list__layout - > .wp-block:first-child:not(:last-child)::after { - border-bottom: 2px dashed #757575; - color: #50575e; - content: "Preview ends here. Content below is gated."; - display: block; - font-size: 12px; - margin-top: 1.5em; - padding-bottom: 4px; - text-align: center; - text-transform: uppercase; - } } diff --git a/tests/phpunit/GatedContentRenderTest.php b/tests/phpunit/GatedContentRenderTest.php index 952593b..b2f43ae 100644 --- a/tests/phpunit/GatedContentRenderTest.php +++ b/tests/phpunit/GatedContentRenderTest.php @@ -45,14 +45,13 @@ private function divider(): string { } /** - * AC-007: with no divider, the first inner block is the preview. + * AC-007: with no divider, the whole block is gated and there is no preview. */ - public function test_first_block_is_preview_without_divider(): void { + public function test_whole_block_is_gated_without_divider(): void { $html = $this->render_gate( $this->p( 'ONE' ) . $this->p( 'TWO' ) . $this->p( 'THREE' ) ); - $this->assertMatchesRegularExpression( '/ONE.*beehiiv-gated-content__gate/s', $html ); - $this->assertMatchesRegularExpression( '/beehiiv-gated-content__locked[^>]*>.*TWO.*THREE/s', $html ); - $this->assertDoesNotMatchRegularExpression( '/beehiiv-gated-content__locked[^>]*>[^<]*

ONE/s', $html ); + $this->assertMatchesRegularExpression( '/beehiiv-gated-content__locked[^>]*>.*ONE.*TWO.*THREE/s', $html ); + $this->assertDoesNotMatchRegularExpression( '/ONE.*beehiiv-gated-content__gate/s', $html ); } /** From 511e424834ef7117e87b304a8a7916ea73ba977b Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 19:10:14 +0800 Subject: [PATCH 22/34] fix(gated-content): follow the theme base color for the fade and card --- src/js/blocks/gated-content/style.scss | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/js/blocks/gated-content/style.scss b/src/js/blocks/gated-content/style.scss index 2c8440f..ecb5220 100644 --- a/src/js/blocks/gated-content/style.scss +++ b/src/js/blocks/gated-content/style.scss @@ -20,8 +20,8 @@ user-select: none; &::after { - // Themes with a dark or tinted page can set --beehiiv-gate-fade. - background: linear-gradient(to bottom, transparent, var(--beehiiv-gate-fade, #fff) 85%); + // The fade follows the theme's base color. Set --beehiiv-gate-fade to override it. + background: linear-gradient(to bottom, transparent, var(--beehiiv-gate-fade, var(--wp--preset--color--base, #fff)) 85%); bottom: 0; content: ""; height: 60%; @@ -34,8 +34,8 @@ // The card sits over the bottom of the blurred, faded content. .beehiiv-gated-content__card { align-self: end; - background: #fff; - border: 1px solid #e5e5e5; + background: var(--beehiiv-gate-fade, var(--wp--preset--color--base, #fff)); + border: 1px solid color-mix(in srgb, currentcolor 15%, transparent); border-radius: 16px; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.12); box-sizing: border-box; @@ -61,7 +61,8 @@ } .beehiiv-gated-content__text { - color: var(--beehiiv-gate-card-color, #50575e); + color: var(--beehiiv-gate-card-color, inherit); + opacity: 0.8; font-size: 1.125rem; margin: 0 0 1.25rem; } From 0b76661f63804b6526131a3a54d3feafc325c3b3 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 20:53:50 +0800 Subject: [PATCH 23/34] fix(gated-content): address code review findings --- includes/Admin/Registrar.php | 5 +- includes/Blocks/GatedContent.php | 24 +++------- src/js/blocks/gated-content-divider/edit.js | 32 +++++++++++-- src/js/blocks/gated-content/block.json | 4 ++ src/js/blocks/gated-content/edit.js | 53 ++++++++++++--------- src/js/blocks/gated-content/style.scss | 4 +- src/js/blocks/gated-content/view.js | 50 +++++++++++++++---- tests/js/gated-content-view.test.js | 18 +++++++ tests/phpunit/GatedContentRenderTest.php | 17 ++++--- 9 files changed, 140 insertions(+), 67 deletions(-) diff --git a/includes/Admin/Registrar.php b/includes/Admin/Registrar.php index 4de9927..96a47b9 100644 --- a/includes/Admin/Registrar.php +++ b/includes/Admin/Registrar.php @@ -163,6 +163,10 @@ public static function render_gate_settings_description(): void { 'Defaults for gated content blocks. A gated content block can override the headline and text.', 'beehiiv' ) . '

'; + echo '

' . esc_html__( + 'Gated content stays in the page source. Use gating to prompt a subscription, not to protect private content.', + 'beehiiv' + ) . '

'; } /** @@ -302,7 +306,6 @@ public static function render_publication_id_field(): void { id="beehiiv_publication_id" class="beehiiv-settings-select" name="" - required >
', esc_attr( $form_id ), esc_attr( self::COOKIE_NAME ), (int) $defaults['cookie_days'], $card, implode( '', $locked ), - esc_attr__( 'Content unlocked.', 'beehiiv' ) + esc_attr__( 'Content unlocked.', 'beehiiv' ), + self::get_boot_script() ); return sprintf( @@ -161,11 +164,12 @@ public static function render( array $attributes, WP_Block $block ): string { } /** - * Inline script that runs as the card is parsed, before the first paint. + * Inline script that runs at the end of the gate, before view.js. * * A reader who already has the unlock cookie gets the gate opened at once, so - * the blurred content never flashes, and the form loader is never requested. - * Everyone else gets the form loader right away, with no wait for view.js. + * the blurred content does not wait for view.js, and the form loader is never + * requested. Everyone else gets the form loader right away, with no wait for + * view.js. The script sits after the locked content so it can open that too. * * The script is the same for every gate. It reads the cookie name and form ID * from the gate's own data attributes, so no value is written into the script. diff --git a/readme.txt b/readme.txt index eeef2b8..95f4438 100644 --- a/readme.txt +++ b/readme.txt @@ -26,6 +26,7 @@ Write once in WordPress, publish to your site, and queue the same post for deliv * Manage newsletter settings from the block editor * Add a newsletter signup form to your website * Include sponsored content in your newsletters +* Gate part of a post behind a beehiiv subscribe form with the Gated Content block = Supported blocks = @@ -92,6 +93,10 @@ It is used to authenticate your account, sync publication settings, publish Word When you connect your account, the plugin exchanges OAuth credentials with beehiiv. When you send a post as a newsletter, post content and related newsletter settings are sent to the beehiiv API. The subscribe form loads beehiiv’s form script so visitors can join your publication. += Cookie = + +The Gated Content block sets one first-party cookie, `beehiiv_gate_unlocked`, in a reader's browser after they subscribe through the block's form. It keeps the content unlocked on later visits. It holds no personal data. You choose how long it lasts (1 to 365 days, 30 by default) in the beehiiv settings. Add it to your privacy policy or cookie notice if you use the block. + Terms of Use: [beehiiv.com/tou](https://www.beehiiv.com/tou) Privacy Policy: [beehiiv.com/privacy](https://www.beehiiv.com/privacy) @@ -145,6 +150,13 @@ It is visible in the editor but does not render on the front end of your site. == Changelog == += 1.2.0 = +* Added a Gated Content block. It shows a preview, then blurs and locks the rest of the content until the reader subscribes through a beehiiv form. +* Added a Gate Divider block to mark where the preview ends inside a Gated Content block. +* Added a "Subscriber gating" section to the beehiiv settings page for the default headline, text, subscribe form ID and unlock cookie lifetime. +* Added an optional background and text color for the gate card. +* Logged-in users always see the full content. + = 1.1.0 = * Added a newsletter title and subtitle for each post, used as the email subject line and subtitle on beehiiv. * Added a "show title and subtitle in email" toggle for each post. diff --git a/src/js/blocks/gated-content/style.scss b/src/js/blocks/gated-content/style.scss index 653289c..a9dab7b 100644 --- a/src/js/blocks/gated-content/style.scss +++ b/src/js/blocks/gated-content/style.scss @@ -31,6 +31,11 @@ } } + // Moved to when the content unlocks; the container itself is not a control. + .beehiiv-gated-content__locked:focus { + outline: none; + } + // The card sits over the bottom of the blurred, faded content. .beehiiv-gated-content__card { align-self: end; @@ -46,7 +51,6 @@ padding: 2rem; position: relative; text-align: center; - transition: opacity 0.3s ease; // The card follows the width of the beehiiv form inside it. width: fit-content; z-index: 2; @@ -90,20 +94,31 @@ } } + // Fade out, then leave the layout. Browsers without discrete transitions + // skip the fade and hide the card at once. .beehiiv-gated-content__card { + display: none; opacity: 0; pointer-events: none; + transition: opacity 0.3s ease, display 0.3s allow-discrete; } + } - .beehiiv-gated-content__card.is-hidden { - display: none; - } + // Visually hidden status message for assistive technology. + .beehiiv-gated-content__status { + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; } } @media (prefers-reduced-motion: reduce) { - .wp-block-beehiiv-gated-content .beehiiv-gated-content__card { + .wp-block-beehiiv-gated-content .beehiiv-gated-content__gate.is-unlocked .beehiiv-gated-content__card { transition: none; } } diff --git a/src/js/blocks/gated-content/view.js b/src/js/blocks/gated-content/view.js index c245616..54fea21 100644 --- a/src/js/blocks/gated-content/view.js +++ b/src/js/blocks/gated-content/view.js @@ -31,7 +31,7 @@ function announce( gate, locked ) { if ( message ) { const status = document.createElement( 'p' ); - status.className = 'screen-reader-text'; + status.className = 'beehiiv-gated-content__status'; status.setAttribute( 'role', 'status' ); gate.appendChild( status ); // Set the text after the node is in the page so the change is announced. @@ -46,9 +46,9 @@ function announce( gate, locked ) { } } -function unlock( gate, immediate = false ) { +// The card fade and removal are CSS only, driven by the is-unlocked class. +function unlock( gate, announceUnlock = false ) { const locked = gate.querySelector( '.beehiiv-gated-content__locked' ); - const card = gate.querySelector( '.beehiiv-gated-content__card' ); gate.classList.add( 'is-unlocked' ); @@ -57,16 +57,9 @@ function unlock( gate, immediate = false ) { locked.removeAttribute( 'inert' ); } - if ( ! immediate ) { + if ( announceUnlock ) { announce( gate, locked ); } - - if ( card && immediate ) { - // Returning reader: remove the card now so the gate does not linger. - card.classList.add( 'is-hidden' ); - } else if ( card ) { - window.setTimeout( () => card.classList.add( 'is-hidden' ), 300 ); - } } function init() { @@ -74,13 +67,14 @@ function init() { document.querySelectorAll( '[data-beehiiv-gate]' ) ); - // The unlock cookie is site-wide, so one cookie unlocks every gate. + // The unlock cookie is site-wide, so one cookie unlocks every gate. The + // inline boot script already did this; this covers pages where it was blocked. const unlocked = gates.some( ( gate ) => hasCookie( gate.dataset.cookieName ) ); if ( unlocked ) { - gates.forEach( ( gate ) => unlock( gate, true ) ); + gates.forEach( ( gate ) => unlock( gate ) ); return; } @@ -88,7 +82,7 @@ function init() { return; } - window.addEventListener( 'message', ( event ) => { + const onMessage = ( event ) => { if ( ! ALLOWED_ORIGINS.includes( event.origin ) || ! event.data || @@ -110,8 +104,13 @@ function init() { source.dataset.cookieName, parseInt( source.dataset.cookieDays, 10 ) || 30 ); - gates.forEach( ( gate ) => unlock( gate ) ); - } ); + + // Unlock once. Only the gate that was submitted takes focus and announces. + window.removeEventListener( 'message', onMessage ); + gates.forEach( ( gate ) => unlock( gate, gate === source ) ); + }; + + window.addEventListener( 'message', onMessage ); } // Block themes can print this script in the head, before the gate exists. diff --git a/tests/e2e/specs/gated-content-block.spec.js b/tests/e2e/specs/gated-content-block.spec.js new file mode 100644 index 0000000..643ee6f --- /dev/null +++ b/tests/e2e/specs/gated-content-block.spec.js @@ -0,0 +1,181 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin } = require( '../utils/auth' ); +const { + wpCli, + ensurePluginActive, + ensurePrettyPermalinks, +} = require( '../utils/wp-cli' ); + +/** + * PRDs: requirements/05-custom-blocks/4-gated-content-block/gated-content-block.prd.md + * (PRD-05.4.01) and requirements/08-admin-interface/1-settings-page/subscriber-gate-defaults.prd.md. + * + * The beehiiv form itself is an iframe from subscribe-forms.beehiiv.com, which + * a test must not depend on. The loader request is aborted, and a form submit + * is simulated with the same message the real form sends: a message event with + * the beehiiv origin, the submitted type and the form ID. + */ + +const SUBMITTED = 'BEEHIIV_SUBSCRIBER_FORM_SUBMITTED'; +const COOKIE = 'beehiiv_gate_unlocked'; +const createdPostIds = []; + +function gatedBlock( formId, body ) { + return ( + `` + + `

${ body }-PREVIEW

` + + '' + + `

${ body }-LOCKED

` + + '' + ); +} + +function createPost( title, content ) { + const b64 = Buffer.from( content ).toString( 'base64' ); + const id = wpCli( + `eval 'echo wp_insert_post( array( "post_title" => "${ title }", "post_status" => "publish", "post_content" => base64_decode( "${ b64 }" ) ) );'` + ); + createdPostIds.push( id ); + return id; +} + +async function submit( page, formId ) { + await page.evaluate( + ( { type, id } ) => { + window.dispatchEvent( + new MessageEvent( 'message', { + origin: 'https://subscribe-forms.beehiiv.com', + data: { type, externalEmbedId: id }, + } ) + ); + }, + { type: SUBMITTED, id: formId } + ); +} + +test.describe( 'Gated Content block', () => { + test.beforeAll( () => { + ensurePluginActive(); + ensurePrettyPermalinks(); + } ); + + test.afterAll( () => { + createdPostIds.forEach( ( id ) => + wpCli( `post delete ${ id } --force` ) + ); + } ); + + test.beforeEach( async ( { page } ) => { + await page.route( '**/subscribe-forms.beehiiv.com/**', ( route ) => + route.abort() + ); + } ); + + test( 'logged-out reader sees the preview, a locked gate and the form loader (AC-009, AC-012, AC-026 to AC-028)', async ( { + page, + } ) => { + const id = createPost( 'Gate basic', gatedBlock( 'form_e2e_1', 'A' ) ); + await page.goto( `/?p=${ id }` ); + + await expect( page.getByText( 'A-PREVIEW' ) ).toBeVisible(); + const locked = page.locator( '.beehiiv-gated-content__locked' ); + await expect( locked ).toHaveAttribute( 'inert', '' ); + await expect( locked ).toHaveAttribute( 'aria-hidden', 'true' ); + await expect( page.getByText( 'Join to read' ) ).toBeVisible(); + await expect( + page.locator( 'script[data-beehiiv-form="form_e2e_1"]' ) + ).toHaveCount( 1 ); + } ); + + test( 'submitting the gate form unlocks the content and sets the cookie (AC-016, AC-018, AC-019)', async ( { + page, + } ) => { + const id = createPost( 'Gate unlock', gatedBlock( 'form_e2e_2', 'B' ) ); + await page.goto( `/?p=${ id }` ); + await submit( page, 'form_e2e_2' ); + + const locked = page.locator( '.beehiiv-gated-content__locked' ); + await expect( locked ).not.toHaveAttribute( 'inert', /.*/ ); + await expect( locked ).not.toHaveAttribute( 'aria-hidden', /.*/ ); + await expect( + page.locator( '.beehiiv-gated-content__card' ) + ).toBeHidden(); + const cookies = await page.context().cookies(); + expect( cookies.some( ( c ) => c.name === COOKIE ) ).toBe( true ); + } ); + + test( 'a different form does not unlock the gate (AC-017)', async ( { + page, + } ) => { + const id = createPost( + 'Gate other form', + gatedBlock( 'form_e2e_3', 'C' ) + ); + await page.goto( `/?p=${ id }` ); + await submit( page, 'some_other_form' ); + + await expect( + page.locator( '.beehiiv-gated-content__locked' ) + ).toHaveAttribute( 'inert', '' ); + } ); + + test( 'a returning reader sees no gate and no form loader (AC-021, AC-023)', async ( { + page, + context, + } ) => { + const id = createPost( + 'Gate returning', + gatedBlock( 'form_e2e_4', 'D' ) + ); + const { hostname } = new URL( + process.env.WP_BASE_URL || 'http://localhost:8891' + ); + await context.addCookies( [ + { name: COOKIE, value: '1', domain: hostname, path: '/' }, + ] ); + await page.goto( `/?p=${ id }` ); + + await expect( page.getByText( 'D-LOCKED' ) ).toBeVisible(); + await expect( + page.locator( '.beehiiv-gated-content__card' ) + ).toBeHidden(); + await expect( page.locator( 'script[data-beehiiv-form]' ) ).toHaveCount( + 0 + ); + await expect( + page.locator( '.beehiiv-gated-content__locked' ) + ).not.toHaveAttribute( 'inert', /.*/ ); + } ); + + test( 'every gate on a page unlocks together (AC-025, BR-006)', async ( { + page, + } ) => { + const id = createPost( + 'Gate two', + gatedBlock( 'form_e2e_5', 'E' ) + gatedBlock( 'form_e2e_6', 'F' ) + ); + await page.goto( `/?p=${ id }` ); + await expect( + page.locator( '.beehiiv-gated-content__gate' ) + ).toHaveCount( 2 ); + + await submit( page, 'form_e2e_6' ); + + await expect( + page.locator( '.beehiiv-gated-content__gate.is-unlocked' ) + ).toHaveCount( 2 ); + } ); + + test( 'a logged-in administrator sees the full content with no gate (AC-022)', async ( { + page, + } ) => { + const id = createPost( 'Gate admin', gatedBlock( 'form_e2e_7', 'G' ) ); + await loginAsAdmin( page ); + await page.goto( `/?p=${ id }` ); + + await expect( page.getByText( 'G-LOCKED' ) ).toBeVisible(); + await expect( + page.locator( '.beehiiv-gated-content__gate' ) + ).toHaveCount( 0 ); + } ); +} ); diff --git a/tests/js/gated-content-boot.test.js b/tests/js/gated-content-boot.test.js index 15c9105..8fe204c 100644 --- a/tests/js/gated-content-boot.test.js +++ b/tests/js/gated-content-boot.test.js @@ -22,12 +22,14 @@ function run( cookie ) { document.body.innerHTML = `
-
+
+
`; + // The script sits at the end of the gate, after the locked content. const script = document.createElement( 'script' ); script.textContent = BOOT_JS; - document.querySelector( '.card' ).appendChild( script ); + document.querySelector( '.gate' ).appendChild( script ); } describe( 'gated content boot script', () => { @@ -38,7 +40,7 @@ describe( 'gated content boot script', () => { it( 'adds the form loader for a locked reader', () => { run(); const loader = document.querySelector( - '.card script[data-beehiiv-form]' + '.beehiiv-gated-content__card script[data-beehiiv-form]' ); expect( loader ).not.toBeNull(); @@ -57,11 +59,17 @@ describe( 'gated content boot script', () => { expect( document.querySelector( '.gate' ).classList ).toContain( 'is-unlocked' ); - expect( document.querySelector( '.card' ).classList ).toContain( - 'is-hidden' + const locked = document.querySelector( + '.beehiiv-gated-content__locked' ); + + // The boot script also clears the attributes that hide the content. + expect( locked.hasAttribute( 'aria-hidden' ) ).toBe( false ); + expect( locked.hasAttribute( 'inert' ) ).toBe( false ); expect( - document.querySelector( '.card script[data-beehiiv-form]' ) + document.querySelector( + '.beehiiv-gated-content__card script[data-beehiiv-form]' + ) ).toBeNull(); } ); } ); diff --git a/tests/js/gated-content-view.test.js b/tests/js/gated-content-view.test.js index 483bc4a..959ef2e 100644 --- a/tests/js/gated-content-view.test.js +++ b/tests/js/gated-content-view.test.js @@ -125,15 +125,50 @@ describe( 'gated content view', () => { expect( locked().hasAttribute( 'aria-hidden' ) ).toBe( false ); } ); - it( 'hides the card at once for a returning reader', () => { - document.cookie = 'beehiiv_gate_unlocked=1; Path=/'; + it( 'unlocks once: a repeat message does not announce or refocus again', () => { + jest.useFakeTimers(); + gate().dataset.unlockedMessage = 'Content unlocked.'; + load(); + const msg = { + type: 'BEEHIIV_SUBSCRIBER_FORM_SUBMITTED', + externalEmbedId: 'form_1', + }; + post( msg ); + post( msg ); + jest.advanceTimersByTime( 100 ); + + expect( document.querySelectorAll( '[role="status"]' ) ).toHaveLength( + 1 + ); + jest.useRealTimers(); + } ); + + it( 'moves focus and announces only for the gate that was submitted', () => { + jest.useFakeTimers(); + document.body.innerHTML = + GATE_HTML + + GATE_HTML.replace( 'form_1', 'form_2' ).replace( + '
{ + g.dataset.unlockedMessage = 'Content unlocked.'; + } ); load(); + post( { + type: 'BEEHIIV_SUBSCRIBER_FORM_SUBMITTED', + externalEmbedId: 'form_1', + } ); + jest.advanceTimersByTime( 100 ); - expect( - document - .querySelector( '.beehiiv-gated-content__card' ) - .classList.contains( 'is-hidden' ) - ).toBe( true ); + const [ first ] = document.querySelectorAll( '[data-beehiiv-gate]' ); + expect( document.querySelectorAll( '[role="status"]' ) ).toHaveLength( + 1 + ); + expect( document.activeElement ).toBe( + first.querySelector( '.beehiiv-gated-content__locked' ) + ); + jest.useRealTimers(); } ); it( 'still works when the script runs before the gate is in the page', () => { diff --git a/tests/phpunit/GatedContentRenderTest.php b/tests/phpunit/GatedContentRenderTest.php index 77f1815..b510902 100644 --- a/tests/phpunit/GatedContentRenderTest.php +++ b/tests/phpunit/GatedContentRenderTest.php @@ -15,6 +15,13 @@ */ class GatedContentRenderTest extends WP_UnitTestCase { + /** + * Block names registered by this class for the current test. + * + * @var string[] + */ + private $registered_here = []; + public function set_up(): void { parent::set_up(); wp_set_current_user( 0 ); @@ -23,12 +30,13 @@ public function set_up(): void { } public function tear_down(): void { - foreach ( [ 'beehiiv/gated-content', 'beehiiv/gated-content-divider' ] as $name ) { - if ( WP_Block_Type_Registry::get_instance()->is_registered( $name ) ) { - unregister_block_type( $name ); - } + // Remove only the blocks this class registered, never the plugin's own. + foreach ( $this->registered_here as $name ) { + unregister_block_type( $name ); } + $this->registered_here = []; + parent::tear_down(); } @@ -52,6 +60,7 @@ private function register_test_blocks(): void { 'render_callback' => [ \Beehiiv\Blocks\GatedContent::class, 'render' ], ] ); + $this->registered_here[] = 'beehiiv/gated-content'; } if ( ! $registry->is_registered( 'beehiiv/gated-content-divider' ) ) { @@ -62,6 +71,7 @@ private function register_test_blocks(): void { 'render_callback' => '__return_empty_string', ] ); + $this->registered_here[] = 'beehiiv/gated-content-divider'; } } From d4dc6733e64ab8b2f8943eeec554368ff8b01c18 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 22:21:08 +0800 Subject: [PATCH 30/34] fix(ci): match the render callback signature in the PHPUnit block registration --- tests/phpunit/GatedContentRenderTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/GatedContentRenderTest.php b/tests/phpunit/GatedContentRenderTest.php index b510902..8255dcc 100644 --- a/tests/phpunit/GatedContentRenderTest.php +++ b/tests/phpunit/GatedContentRenderTest.php @@ -57,7 +57,11 @@ private function register_test_blocks(): void { 'cardBackgroundColor' => [ 'type' => 'string', 'default' => '' ], 'cardTextColor' => [ 'type' => 'string', 'default' => '' ], ], - 'render_callback' => [ \Beehiiv\Blocks\GatedContent::class, 'render' ], + // WordPress calls ( $attributes, $content, $block ). The plugin's render.php + // passes only the attributes and the block on to the renderer. + 'render_callback' => static function ( $attributes, $content, $block ) { + return \Beehiiv\Blocks\GatedContent::render( $attributes, $block ); + }, ] ); $this->registered_here[] = 'beehiiv/gated-content'; From c83b39f84d003cf857bd591a679e89802420c053 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 3 Nov 2026 22:24:11 +0800 Subject: [PATCH 31/34] chore(i18n): regenerate the translation template; tidy the multi-gate JS test --- languages/beehiiv.pot | 251 ++++++++++++++++++++++------ tests/js/gated-content-view.test.js | 15 +- 2 files changed, 207 insertions(+), 59 deletions(-) diff --git a/languages/beehiiv.pot b/languages/beehiiv.pot index 62edea6..bbe3736 100644 --- a/languages/beehiiv.pot +++ b/languages/beehiiv.pot @@ -9,7 +9,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-10-16T15:42:07+00:00\n" +"POT-Creation-Date: 2026-11-03T14:23:22+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "X-Generator: WP-CLI 2.12.0\n" "X-Domain: beehiiv\n" @@ -19,7 +19,7 @@ msgstr "" #: beehiiv.php #: includes/Admin/Menu.php:32 #: includes/Blocks/Registry.php:45 -#: src/js/editor/post-settings/index.js:314 +#: src/js/editor/post-settings/index.js:329 msgid "beehiiv" msgstr "" @@ -32,56 +32,115 @@ msgstr "" msgid "beehiiv Settings" msgstr "" -#: includes/Admin/Registrar.php:63 +#: includes/Admin/Registrar.php:77 #: src/js/editor/post-settings/components/newsletter-publication-select.js:92 msgid "Publication" msgstr "" -#: includes/Admin/Registrar.php:70 +#: includes/Admin/Registrar.php:84 msgid "Default publication" msgstr "" -#: includes/Admin/Registrar.php:81 +#: includes/Admin/Registrar.php:95 msgid "Default post template" msgstr "" -#: includes/Admin/Registrar.php:97 -msgid "Default beehiiv publication and post template for newsletters sent from this site." +#: includes/Admin/Registrar.php:114 +msgid "Subscriber gating" msgstr "" #: includes/Admin/Registrar.php:121 +msgid "Default gate headline" +msgstr "" + +#: includes/Admin/Registrar.php:130 +msgid "Default gate text" +msgstr "" + +#: includes/Admin/Registrar.php:139 +msgid "Default subscribe form ID" +msgstr "" + +#: includes/Admin/Registrar.php:148 +msgid "Unlock cookie lifetime" +msgstr "" + +#: includes/Admin/Registrar.php:162 +msgid "Defaults for gated content blocks. A gated content block can override the headline and text." +msgstr "" + +#: includes/Admin/Registrar.php:166 +msgid "Gated content stays in the page source. Gating prompts a subscription; it does not protect content." +msgstr "" + +#: includes/Admin/Registrar.php:188 +msgid "Plain text. Leave blank to show no headline." +msgstr "" + +#: includes/Admin/Registrar.php:209 +msgid "Plain text. Leave blank to show no text." +msgstr "" + +#: includes/Admin/Registrar.php:231 +msgid "Used by every gated content block that has no form ID of its own." +msgstr "" + +#: includes/Admin/Registrar.php:233 +#: src/js/blocks/subscribe-form/edit.js:44 +msgid "Paste the form ID from your beehiiv embed code (the value of data-beehiiv-form)." +msgstr "" + +#: includes/Admin/Registrar.php:235 +msgid "The form must be Inline and set to Show a success message, not Redirect." +msgstr "" + +#: includes/Admin/Registrar.php:260 +msgid "days" +msgstr "" + +#. translators: 1: minimum days, 2: maximum days, 3: default days. +#: includes/Admin/Registrar.php:266 +#, php-format +msgid "How long a reader stays unlocked after subscribing. Whole days from %1$d to %2$d. Anything else uses %3$d." +msgstr "" + +#: includes/Admin/Registrar.php:288 +msgid "Default beehiiv publication and post template for newsletters sent from this site." +msgstr "" + +#: includes/Admin/Registrar.php:311 #: src/js/admin/settings/index.js:259 #: src/js/editor/post-settings/components/newsletter-publication-select.js:78 msgid "Select a publication" msgstr "" -#: includes/Admin/Registrar.php:155 +#: includes/Admin/Registrar.php:345 #: src/js/admin/settings/index.js:25 #: src/js/editor/post-settings/components/newsletter-publication-select.js:115 msgid "Refresh publications" msgstr "" -#: includes/Admin/Registrar.php:160 +#: includes/Admin/Registrar.php:350 msgid "Preselected on new posts. Editors can choose a different publication for each post in the beehiiv sidebar." msgstr "" -#: includes/Admin/Registrar.php:190 +#: includes/Admin/Registrar.php:380 #: src/js/admin/settings/index.js:138 msgid "No default template" msgstr "" -#: includes/Admin/Registrar.php:225 +#: includes/Admin/Registrar.php:415 #: src/js/admin/settings/index.js:21 #: src/js/editor/post-settings/components/newsletter-template-select.js:201 msgid "Refresh templates" msgstr "" -#: includes/Admin/Registrar.php:230 +#: includes/Admin/Registrar.php:420 #: src/js/admin/settings/index.js:59 msgid "This publication has no post templates. Create a template in beehiiv, then refresh." msgstr "" -#: includes/Admin/Registrar.php:239 +#: includes/Admin/Registrar.php:429 msgid "Default newsletter template for this site. Refresh after changing templates in beehiiv." msgstr "" @@ -143,34 +202,34 @@ msgstr "" msgid "Read more in the documentation on the %s." msgstr "" -#: includes/Admin/Views/settings-page.php:38 +#: includes/Admin/Views/settings-page.php:39 #: src/js/blocks/advertisement/edit.js:157 msgid "Learn more about plans." msgstr "" #. translators: 1: Max plan, 2: Enterprise plan, 3: pricing link. -#: includes/Admin/Views/settings-page.php:53 +#: includes/Admin/Views/settings-page.php:54 #, php-format msgid "Your connected beehiiv account doesn't have access to send newsletters. This integration requires the %1$s or %2$s plan. %3$s" msgstr "" -#: includes/Admin/Views/settings-page.php:54 -#: includes/Admin/Views/settings-page.php:65 +#: includes/Admin/Views/settings-page.php:55 +#: includes/Admin/Views/settings-page.php:66 msgid "Max" msgstr "" -#: includes/Admin/Views/settings-page.php:55 -#: includes/Admin/Views/settings-page.php:66 +#: includes/Admin/Views/settings-page.php:56 +#: includes/Admin/Views/settings-page.php:67 msgid "Enterprise" msgstr "" #. translators: 1: Max plan, 2: Enterprise plan, 3: pricing link. -#: includes/Admin/Views/settings-page.php:64 +#: includes/Admin/Views/settings-page.php:65 #, php-format msgid "The beehiiv integration is available to publications on the %1$s and %2$s plans. %3$s" msgstr "" -#: includes/Admin/Views/settings-page.php:84 +#: includes/Admin/Views/settings-page.php:91 msgid "Save settings" msgstr "" @@ -186,6 +245,10 @@ msgstr "" msgid "%1$s – %2$s" msgstr "" +#: includes/Blocks/GatedContent.php:154 +msgid "Content unlocked." +msgstr "" + #: includes/Connection/Manager.php:57 msgid "Connected" msgstr "" @@ -553,24 +616,70 @@ msgstr "" msgid "Opportunities are cached. Refresh to pull the latest from beehiiv." msgstr "" -#: src/js/blocks/subscribe-form/edit.js:22 -msgid "The subscribe form is only rendered on the front-end and cannot be previewed here. View the page to see your form. If the form does not appear, make sure the entered form ID is valid." +#: src/js/blocks/gated-content-divider/edit.js:30 +msgid "Ignored. Only the first divider is used. Delete this one." msgstr "" -#: src/js/blocks/subscribe-form/edit.js:26 -msgid "Add a subscribe form ID in the block settings sidebar to display your form." +#: src/js/blocks/gated-content-divider/edit.js:34 +msgid "Preview ends here. Content below is gated." msgstr "" -#: src/js/blocks/subscribe-form/edit.js:35 -msgid "Form settings" +#: src/js/blocks/gated-content/edit.js:40 +msgid "Gate settings" +msgstr "" + +#: src/js/blocks/gated-content/edit.js:46 +msgid "No beehiiv publication is set. Choose one in beehiiv settings." msgstr "" +#: src/js/blocks/gated-content/edit.js:55 #: src/js/blocks/subscribe-form/edit.js:39 msgid "Subscribe form ID" msgstr "" -#: src/js/blocks/subscribe-form/edit.js:44 -msgid "Paste the form ID from your beehiiv embed code (the value of data-beehiiv-form)." +#: src/js/blocks/gated-content/edit.js:61 +msgid "Leave blank to use the default form ID from the beehiiv settings page. Form must be Inline and set to Show a success message, not Redirect." +msgstr "" + +#: src/js/blocks/gated-content/edit.js:67 +msgid "Headline" +msgstr "" + +#: src/js/blocks/gated-content/edit.js:72 +msgid "Leave blank to use the default from the beehiiv settings page. If that is blank too, no headline is shown." +msgstr "" + +#: src/js/blocks/gated-content/edit.js:78 +#: src/js/blocks/gated-content/edit.js:108 +msgid "Text" +msgstr "" + +#: src/js/blocks/gated-content/edit.js:83 +msgid "Leave blank to use the default from the beehiiv settings page. If that is blank too, no text is shown." +msgstr "" + +#: src/js/blocks/gated-content/edit.js:89 +msgid "Gated content stays in the page source. Use gating to prompt a subscription, not to protect private content." +msgstr "" + +#: src/js/blocks/gated-content/edit.js:96 +msgid "Gate card colors" +msgstr "" + +#: src/js/blocks/gated-content/edit.js:100 +msgid "Background" +msgstr "" + +#: src/js/blocks/subscribe-form/edit.js:22 +msgid "The subscribe form is only rendered on the front-end and cannot be previewed here. View the page to see your form. If the form does not appear, make sure the entered form ID is valid." +msgstr "" + +#: src/js/blocks/subscribe-form/edit.js:26 +msgid "Add a subscribe form ID in the block settings sidebar to display your form." +msgstr "" + +#: src/js/blocks/subscribe-form/edit.js:35 +msgid "Form settings" msgstr "" #: src/js/blocks/subscribe-form/edit.js:51 @@ -759,59 +868,63 @@ msgstr "" msgid "Not included in beehiiv newsletter" msgstr "" -#: src/js/editor/post-settings/index.js:157 +#: src/js/editor/post-settings/index.js:162 msgid "Newsletter title" msgstr "" -#: src/js/editor/post-settings/index.js:158 +#: src/js/editor/post-settings/index.js:163 msgid "Used as the email subject line. Leave empty to use the post title." msgstr "" -#: src/js/editor/post-settings/index.js:170 +#: src/js/editor/post-settings/index.js:175 msgid "Newsletter subtitle" msgstr "" -#: src/js/editor/post-settings/index.js:171 +#: src/js/editor/post-settings/index.js:176 msgid "Shown as the beehiiv post subtitle. Leave empty for no subtitle." msgstr "" -#: src/js/editor/post-settings/index.js:182 +#: src/js/editor/post-settings/index.js:187 msgid "Show title and subtitle in email" msgstr "" -#: src/js/editor/post-settings/index.js:183 +#: src/js/editor/post-settings/index.js:188 msgid "Shows the newsletter title and subtitle in the beehiiv email. Off by default." msgstr "" -#: src/js/editor/post-settings/index.js:229 +#: src/js/editor/post-settings/index.js:234 msgid "The newsletter can only be sent once and cannot be undone." msgstr "" -#: src/js/editor/post-settings/index.js:252 +#: src/js/editor/post-settings/index.js:257 msgid "Snippet newsletter" msgstr "" -#: src/js/editor/post-settings/index.js:255 +#: src/js/editor/post-settings/index.js:260 msgid "Send a snippet newsletter with a \"Read More\" button to the full post." msgstr "" -#: src/js/editor/post-settings/index.js:263 +#: src/js/editor/post-settings/index.js:268 msgid "Insert the \"More\" block in your post to mark where the snippet ends." msgstr "" -#: src/js/editor/post-settings/index.js:278 +#: src/js/editor/post-settings/index.js:285 +msgid "Pick a post template for this post to send a test email." +msgstr "" + +#: src/js/editor/post-settings/index.js:293 msgid "Test email" msgstr "" -#: src/js/editor/post-settings/index.js:357 +#: src/js/editor/post-settings/index.js:372 msgid "Yes" msgstr "" -#: src/js/editor/post-settings/index.js:358 +#: src/js/editor/post-settings/index.js:373 msgid "No" msgstr "" -#: src/js/editor/post-settings/index.js:365 +#: src/js/editor/post-settings/index.js:380 msgid "Send to newsletter:" msgstr "" @@ -826,6 +939,8 @@ msgid "Places a beehiiv advertisement in the newsletter. Outputs nothing on the msgstr "" #: src/js/blocks/advertisement/block.json +#: src/js/blocks/gated-content-divider/block.json +#: src/js/blocks/gated-content/block.json #: src/js/blocks/subscribe-form/block.json msgctxt "block keyword" msgid "beehiiv" @@ -841,22 +956,60 @@ msgctxt "block keyword" msgid "ad" msgstr "" -#: src/js/blocks/subscribe-form/block.json +#: src/js/blocks/gated-content-divider/block.json msgctxt "block title" -msgid "beehiiv Subscribe Form" +msgid "beehiiv Gate Divider" msgstr "" -#: src/js/blocks/subscribe-form/block.json +#: src/js/blocks/gated-content-divider/block.json msgctxt "block description" -msgid "Newsletter subscribe form powered by beehiiv." +msgid "Marks where the preview ends inside a beehiiv Gated Content block." msgstr "" -#: src/js/blocks/subscribe-form/block.json +#: src/js/blocks/gated-content-divider/block.json +#: src/js/blocks/gated-content/block.json msgctxt "block keyword" -msgid "newsletter" +msgid "gate" +msgstr "" + +#: src/js/blocks/gated-content-divider/block.json +msgctxt "block keyword" +msgid "divider" +msgstr "" + +#: src/js/blocks/gated-content/block.json +msgctxt "block title" +msgid "beehiiv Gated Content" msgstr "" +#: src/js/blocks/gated-content/block.json +msgctxt "block description" +msgid "Shows a preview, then blurs and locks the rest until the reader subscribes through a beehiiv form." +msgstr "" + +#: src/js/blocks/gated-content/block.json +msgctxt "block keyword" +msgid "paywall" +msgstr "" + +#: src/js/blocks/gated-content/block.json #: src/js/blocks/subscribe-form/block.json msgctxt "block keyword" msgid "subscribe" msgstr "" + +#: src/js/blocks/gated-content/block.json +#: src/js/blocks/subscribe-form/block.json +msgctxt "block keyword" +msgid "newsletter" +msgstr "" + +#: src/js/blocks/subscribe-form/block.json +msgctxt "block title" +msgid "beehiiv Subscribe Form" +msgstr "" + +#: src/js/blocks/subscribe-form/block.json +msgctxt "block description" +msgid "Newsletter subscribe form powered by beehiiv." +msgstr "" diff --git a/tests/js/gated-content-view.test.js b/tests/js/gated-content-view.test.js index 959ef2e..7badaf0 100644 --- a/tests/js/gated-content-view.test.js +++ b/tests/js/gated-content-view.test.js @@ -3,13 +3,14 @@ * @jest-environment jsdom */ -const GATE_HTML = ` +const gateHtml = ( formId ) => `
-
+
`; +const GATE_HTML = gateHtml( 'form_1' ); function load() { jest.resetModules(); @@ -43,8 +44,7 @@ describe( 'gated content view', () => { } ); it( 'unlocks every gate when any gate form is submitted', () => { - document.body.innerHTML = - GATE_HTML + GATE_HTML.replace( 'form_1', 'form_2' ); + document.body.innerHTML = gateHtml( 'form_1' ) + gateHtml( 'form_2' ); load(); post( { type: 'BEEHIIV_SUBSCRIBER_FORM_SUBMITTED', @@ -145,12 +145,7 @@ describe( 'gated content view', () => { it( 'moves focus and announces only for the gate that was submitted', () => { jest.useFakeTimers(); - document.body.innerHTML = - GATE_HTML + - GATE_HTML.replace( 'form_1', 'form_2' ).replace( - '
{ g.dataset.unlockedMessage = 'Content unlocked.'; } ); From 85c68f394556a6ccdaf65cc7eccb4de3c6c40ff0 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Mon, 9 Nov 2026 19:22:25 +0800 Subject: [PATCH 32/34] Keep publication fields on the first settings save and fix stale E2E specs Options::sanitize() ignored the publication fields unless the section flag was present. WordPress runs the callback twice on the first save, when no option row exists, and the second run has no flag. The first save therefore stored a blank publication and template. Submitted fields are now always applied, and a shown section still clears missing ones. E2E specs now send the section flag as the real form does, expect the gate defaults and the gateFormId key, and no longer expect a required publication select. The gated-content spec makes sure a theme is active, because a tests site with a missing theme renders blank front-end pages. --- includes/Admin/Options.php | 18 +++++++++---- tests/e2e/specs/gated-content-block.spec.js | 2 ++ .../specs/publication-template-config.spec.js | 10 +++++-- .../specs/settings-storage-retrieval.spec.js | 2 ++ .../specs/settings-validation-cache.spec.js | 12 ++++++++- tests/e2e/utils/wp-cli.js | 18 +++++++++++++ tests/phpunit/SubscriberGateDefaultsTest.php | 26 +++++++++++++++++++ 7 files changed, 80 insertions(+), 8 deletions(-) diff --git a/includes/Admin/Options.php b/includes/Admin/Options.php index 091d080..d0cc629 100644 --- a/includes/Admin/Options.php +++ b/includes/Admin/Options.php @@ -164,11 +164,19 @@ public static function sanitize( $input ): array { // The Publication section marks itself in the submission. When it was shown, // a missing field means it was cleared or not offered, so it resets to blank, // as before. When it was hidden, the stored values stay. - if ( ! empty( $input[ self::PUBLICATION_SECTION_FLAG ] ) ) { - foreach ( [ 'publication_id', 'post_template_id' ] as $key ) { - $current[ $key ] = isset( $input[ $key ] ) - ? sanitize_text_field( (string) $input[ $key ] ) - : ''; + // + // A submitted field is always applied, flag or not. WordPress runs this + // callback twice on the first save, when no option row exists yet (once in + // update_option() and again in add_option()). The second run receives the + // first run's output, which has no flag. Applying present fields keeps that + // second run from discarding them. + $publication_section_shown = ! empty( $input[ self::PUBLICATION_SECTION_FLAG ] ); + + foreach ( [ 'publication_id', 'post_template_id' ] as $key ) { + if ( isset( $input[ $key ] ) ) { + $current[ $key ] = sanitize_text_field( (string) $input[ $key ] ); + } elseif ( $publication_section_shown ) { + $current[ $key ] = ''; } } diff --git a/tests/e2e/specs/gated-content-block.spec.js b/tests/e2e/specs/gated-content-block.spec.js index 643ee6f..7cf19b2 100644 --- a/tests/e2e/specs/gated-content-block.spec.js +++ b/tests/e2e/specs/gated-content-block.spec.js @@ -4,6 +4,7 @@ const { wpCli, ensurePluginActive, ensurePrettyPermalinks, + ensureActiveTheme, } = require( '../utils/wp-cli' ); /** @@ -57,6 +58,7 @@ test.describe( 'Gated Content block', () => { test.beforeAll( () => { ensurePluginActive(); ensurePrettyPermalinks(); + ensureActiveTheme(); } ); test.afterAll( () => { diff --git a/tests/e2e/specs/publication-template-config.spec.js b/tests/e2e/specs/publication-template-config.spec.js index 65a7989..c63c30b 100644 --- a/tests/e2e/specs/publication-template-config.spec.js +++ b/tests/e2e/specs/publication-template-config.spec.js @@ -245,7 +245,11 @@ test.describe( 'Publication and Template Configuration', () => { await page.goto( SETTINGS_PATH ); const select = page.locator( PUB_SELECT ); - await expect( select ).toHaveAttribute( 'required', '' ); + + // The select is no longer `required`: the form also saves the + // Subscriber gating defaults, and a site with no publication must + // still be able to save those. + await expect( select ).not.toHaveAttribute( 'required' ); const firstOption = select.locator( 'option' ).first(); await expect( firstOption ).toHaveAttribute( 'value', '' ); @@ -523,7 +527,9 @@ test.describe( 'Publication and Template Configuration', () => { await page.locator( '#submit' ).click(); await page.waitForURL( /page=beehiiv/ ); - expect( readSettingsOption() ).toEqual( { + // The saved option also carries the gate defaults, so match only + // the two fields this form saves. + expect( readSettingsOption() ).toMatchObject( { publication_id: pubId, post_template_id: tplId, } ); diff --git a/tests/e2e/specs/settings-storage-retrieval.spec.js b/tests/e2e/specs/settings-storage-retrieval.spec.js index 4138737..03ebdc2 100644 --- a/tests/e2e/specs/settings-storage-retrieval.spec.js +++ b/tests/e2e/specs/settings-storage-retrieval.spec.js @@ -45,6 +45,8 @@ const EXPECTED_KEYS = [ // Added by PRD-06.8.02 (per-post publication selector). 'defaultPublicationId', 'publications', + // Added by the gated content block (default subscribe form ID). + 'gateFormId', ].sort(); let fixturePostId; diff --git a/tests/e2e/specs/settings-validation-cache.spec.js b/tests/e2e/specs/settings-validation-cache.spec.js index d20c35e..07b84d8 100644 --- a/tests/e2e/specs/settings-validation-cache.spec.js +++ b/tests/e2e/specs/settings-validation-cache.spec.js @@ -164,6 +164,8 @@ test.describe( 'Settings validation & cache management', () => { 'qa-e2e-pub-xss'; const response = await submitSettingsForm( page, nonceFields, { + // The real form sends this flag with the Publication section. + 'beehiiv_settings[_publication_section]': '1', 'beehiiv_settings[publication_id]': rawPublicationId, 'beehiiv_settings[post_template_id]': '', } ); @@ -182,6 +184,8 @@ test.describe( 'Settings validation & cache management', () => { const rawTemplateId = 'boldqa-e2e-tmpl-xss'; const response = await submitSettingsForm( page, nonceFields, { + // The real form sends this flag with the Publication section. + 'beehiiv_settings[_publication_section]': '1', 'beehiiv_settings[publication_id]': '', 'beehiiv_settings[post_template_id]': rawTemplateId, } ); @@ -199,11 +203,13 @@ test.describe( 'Settings validation & cache management', () => { // Establish a known-good baseline through a real, valid save first. let nonceFields = await readSettingsFormNonceFields( page ); await submitSettingsForm( page, nonceFields, { + // The real form sends this flag with the Publication section. + 'beehiiv_settings[_publication_section]': '1', 'beehiiv_settings[publication_id]': 'qa-e2e-pub-baseline', 'beehiiv_settings[post_template_id]': 'qa-e2e-tmpl-baseline', } ); const baseline = readSettingsOption(); - expect( baseline ).toEqual( { + expect( baseline ).toMatchObject( { publication_id: 'qa-e2e-pub-baseline', post_template_id: 'qa-e2e-tmpl-baseline', } ); @@ -233,6 +239,8 @@ test.describe( 'Settings validation & cache management', () => { const nonceFields = await readSettingsFormNonceFields( page ); const response = await submitSettingsForm( page, nonceFields, { + // The real form sends this flag with the Publication section. + 'beehiiv_settings[_publication_section]': '1', 'beehiiv_settings[publication_id]': publicationId, 'beehiiv_settings[post_template_id]': '', } ); @@ -252,6 +260,8 @@ test.describe( 'Settings validation & cache management', () => { const nonceFields = await readSettingsFormNonceFields( page ); const response = await submitSettingsForm( page, nonceFields, { + // The real form sends this flag with the Publication section. + 'beehiiv_settings[_publication_section]': '1', 'beehiiv_settings[publication_id]': '', 'beehiiv_settings[post_template_id]': '', } ); diff --git a/tests/e2e/utils/wp-cli.js b/tests/e2e/utils/wp-cli.js index c01317c..1ac6540 100644 --- a/tests/e2e/utils/wp-cli.js +++ b/tests/e2e/utils/wp-cli.js @@ -71,9 +71,27 @@ function ensurePrettyPermalinks() { } } +/** + * Ensures a real theme is active. + * + * A tests site created before `themes` was added to `.wp-env.json` can keep + * `stylesheet` set to `default`, a theme that is not installed. Every + * front-end request then returns an empty page (404 for posts), so any spec + * that loads a post on the site fails with "element(s) not found". Admin + * pages and REST calls still work, so only front-end specs notice. + * @param {string} slug + */ +function ensureActiveTheme( slug = 'twentytwentyfive' ) { + const active = wpCliSafe( 'theme list --status=active --field=name' ); + if ( ! active ) { + wpCli( `theme activate ${ slug }` ); + } +} + module.exports = { wpCli, wpCliSafe, ensurePluginActive, ensurePrettyPermalinks, + ensureActiveTheme, }; diff --git a/tests/phpunit/SubscriberGateDefaultsTest.php b/tests/phpunit/SubscriberGateDefaultsTest.php index e3dbe60..6e90a4d 100644 --- a/tests/phpunit/SubscriberGateDefaultsTest.php +++ b/tests/phpunit/SubscriberGateDefaultsTest.php @@ -179,6 +179,32 @@ public function test_shown_publication_section_clears_missing_fields(): void { $this->assertArrayNotHasKey( Options::PUBLICATION_SECTION_FLAG, $saved ); } + /** + * The first save, with no option row yet, keeps the submitted publication fields. + * + * WordPress runs the sanitize callback twice then (update_option() and add_option()), + * and the second run gets the first run's output, which has no section flag. + */ + public function test_first_save_keeps_the_publication_fields(): void { + // The sanitize callback only runs through update_option() once the setting is registered. + Options::register(); + delete_option( Config::OPTION_NAME ); + + update_option( + Config::OPTION_NAME, + array( + Options::PUBLICATION_SECTION_FLAG => '1', + 'publication_id' => 'pub_1', + 'post_template_id' => 'tpl_1', + ) + ); + + $saved = Options::get(); + + $this->assertSame( 'pub_1', $saved['publication_id'] ); + $this->assertSame( 'tpl_1', $saved['post_template_id'] ); + } + /** * The default form ID is plain text, trimmed, and blank when unset. */ From 42a586f2ffc32ea5aa0b8798030896c6d5f72cc1 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Mon, 9 Nov 2026 22:28:22 +0800 Subject: [PATCH 33/34] Disable the post template dropdown until a publication is selected The settings page left the post template select enabled when no publication was chosen, which did not match the PRD. It is now disabled on page load when there is no publication, and the settings script disables it again when the publication is cleared. A disabled field is not submitted, and the shown Publication section already saves a missing template as blank. The publication spec no longer expects this to fail and now checks the field both ways. One PHPUnit case used a lone paragraph as the nothing-to-gate example, but a block with no divider is fully gated. It now uses a divider with nothing after it. --- includes/Admin/Registrar.php | 1 + src/js/admin/settings/index.js | 3 ++ .../specs/publication-template-config.spec.js | 36 ++++++++----------- tests/phpunit/GatedContentRenderTest.php | 4 ++- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/includes/Admin/Registrar.php b/includes/Admin/Registrar.php index d006abd..b7bfc91 100644 --- a/includes/Admin/Registrar.php +++ b/includes/Admin/Registrar.php @@ -375,6 +375,7 @@ static function ( $row ) { id="beehiiv_post_template_id" class="beehiiv-settings-select" name="" + >