diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0900814..4314fed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -82,7 +82,8 @@ jobs: e2e: name: E2E tests - runs-on: ubuntu-latest + # Playwright does not support Chromium on Ubuntu 26.04 yet. Pin the runner. + runs-on: ubuntu-24.04 timeout-minutes: 60 steps: diff --git a/includes/Admin/Options.php b/includes/Admin/Options.php index a48d048..d0cc629 100644 --- a/includes/Admin/Options.php +++ b/includes/Admin/Options.php @@ -27,8 +27,40 @@ final class Options { private const DEFAULTS = [ 'publication_id' => '', 'post_template_id' => '', + 'gate_headline' => '', + 'gate_text' => '', + 'gate_form_id' => '', + '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. + * + * @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 +94,111 @@ public static function get(): array { return wp_parse_args( $saved, self::DEFAULTS ); } + /** + * Gate defaults with every fallback applied. + * + * 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 + */ + public static function get_gate_settings(): array { + $settings = self::get(); + + return [ + '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'] ), + ]; + } + + /** + * 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; + } + + // 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. + // + // 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 ] = ''; + } + } + + // Plain text only: tags are removed and surrounding whitespace trimmed. + // 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 ] ) ); + } } - $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'] ) - : ''; + // 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..b7bfc91 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,12 +102,189 @@ 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_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' ), + [ 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' + ) . '

'; + echo '

' . esc_html__( + 'Gated content stays in the page source. Gating prompts a subscription; it does not protect content.', + 'beehiiv' + ) . '

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

+ +

+ + +

+ +

+ + +

+ +

+ + + +

+ +

+ ', + 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' @@ -115,7 +306,6 @@ public static function render_publication_id_field(): void { id="beehiiv_publication_id" class="beehiiv-settings-select" name="" - required >

- + -
- + } + + do_settings_sections( Registrar::GATE_SETTINGS_PAGE ); + submit_button( __( 'Save settings', 'beehiiv' ) ); + ?> + diff --git a/includes/Blocks/GatedContent.php b/includes/Blocks/GatedContent.php new file mode 100644 index 0000000..e1544cc --- /dev/null +++ b/includes/Blocks/GatedContent.php @@ -0,0 +1,199 @@ + $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; + + // 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. + $split = true; + continue; + } + + $html = $inner_block->render(); + + // With no divider, the whole block is gated. + if ( $split || ! $has_divider ) { + $locked[] = $html; + } else { + $preview[] = $html; + } + } + + $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, or a logged-in user: show the + // content in full. + if ( + empty( $locked ) + || '' === $form_id + || is_user_logged_in() + ) { + return sprintf( + '
%s
', + get_block_wrapper_attributes(), + implode( '', array_merge( $preview, $locked ) ) + ); + } + + $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']; + + // Optional per-block card colors. Anything that is not a hex color is dropped. + $card_bg = sanitize_hex_color( (string) ( $attributes['cardBackgroundColor'] ?? '' ) ); + $card_color = sanitize_hex_color( (string) ( $attributes['cardTextColor'] ?? '' ) ); + $card_style = ''; + + if ( $card_bg ) { + $card_style .= 'background-color:' . $card_bg . ';'; + } + + if ( $card_color ) { + $card_style .= 'color:' . $card_color . ';'; + } + + // A blank headline or text is left out of the card. + $headline_html = '' !== $headline + ? '

' . esc_html( $headline ) . '

' + : ''; + $text_html = '' !== $text + ? '

' . esc_html( $text ) . '

' + : ''; + + $card = sprintf( + '
%2$s%3$s
', + '' !== $card_style ? ' style="' . esc_attr( $card_style ) . '"' : '', + $headline_html, + $text_html + ); + + $gate = sprintf( + '
%4$s' + . '%7$s
', + esc_attr( $form_id ), + esc_attr( self::COOKIE_NAME ), + (int) $defaults['cookie_days'], + $card, + implode( '', $locked ), + esc_attr__( 'Content unlocked.', 'beehiiv' ), + self::get_boot_script() + ); + + return sprintf( + '
%2$s%3$s
', + get_block_wrapper_attributes(), + implode( '', $preview ), + $gate + ); + } + + /** + * 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 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. + * tests/js/gated-content-boot.test.js reads BOOT_JS from this file. + * + * @return string + */ + private static function get_boot_script(): string { + return wp_get_inline_script_tag( self::BOOT_JS ); + } + + /** + * 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/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/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/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/admin/settings/index.js b/src/js/admin/settings/index.js index 82f51ee..9a84f39 100644 --- a/src/js/admin/settings/index.js +++ b/src/js/admin/settings/index.js @@ -156,6 +156,9 @@ function populateTemplateOptions( items ) { updateNoTemplatesNotice( hasTemplates ); } +// Id of the newest template request, used to ignore stale responses. +let templateRequestId = 0; + /** * Fetch templates for the selected publication via REST API. * @@ -169,8 +172,23 @@ async function loadTemplates( publicationId, refresh = false ) { return; } + // Only the latest request may change the page. An older one that finishes + // late must not re-enable the field or bring back templates for a + // publication that is no longer selected. + templateRequestId += 1; + const requestId = templateRequestId; + if ( ! publicationId ) { populateTemplateOptions( [] ); + + // No publication selected, so there are no templates to choose from. + templateSelect.disabled = true; + + if ( refreshTemplatesButton ) { + refreshTemplatesButton.disabled = true; + refreshTemplatesButton.textContent = refreshDefaultLabel; + } + return; } @@ -196,6 +214,10 @@ async function loadTemplates( publicationId, refresh = false ) { const items = await apiFetch( { path } ); + if ( requestId !== templateRequestId ) { + return; + } + populateTemplateOptions( Array.isArray( items ) ? items : [] ); if ( @@ -214,13 +236,17 @@ async function loadTemplates( publicationId, refresh = false ) { ); } } catch { - populateTemplateOptions( [] ); + if ( requestId === templateRequestId ) { + populateTemplateOptions( [] ); + } } finally { - templateSelect.disabled = false; + if ( requestId === templateRequestId ) { + templateSelect.disabled = false; - if ( refreshTemplatesButton ) { - refreshTemplatesButton.disabled = ! publicationSelect?.value; - refreshTemplatesButton.textContent = refreshDefaultLabel; + if ( refreshTemplatesButton ) { + refreshTemplatesButton.disabled = ! publicationSelect?.value; + refreshTemplatesButton.textContent = refreshDefaultLabel; + } } } } 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..181573b --- /dev/null +++ b/src/js/blocks/gated-content-divider/block.json @@ -0,0 +1,20 @@ +{ + "$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, + "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..d92a916 --- /dev/null +++ b/src/js/blocks/gated-content-divider/edit.js @@ -0,0 +1,41 @@ +import { __ } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; +import { useBlockProps } from '@wordpress/block-editor'; + +export default function Edit( { clientId } ) { + const blockProps = useBlockProps( { + className: 'beehiiv-gate-divider', + } ); + + // Only the first divider in a gated block counts. Flag any later one. + const isIgnored = useSelect( + ( select ) => { + const editor = select( 'core/block-editor' ); + const parentId = editor.getBlockRootClientId( clientId ); + const firstDivider = editor + .getBlocks( parentId ) + .find( + ( block ) => block.name === 'beehiiv/gated-content-divider' + ); + + return firstDivider ? firstDivider.clientId !== clientId : false; + }, + [ clientId ] + ); + + return ( +
+ + { isIgnored + ? __( + 'Ignored. Only the first divider is used. Delete this one.', + 'beehiiv' + ) + : __( + '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..84d7577 --- /dev/null +++ b/src/js/blocks/gated-content/block.json @@ -0,0 +1,43 @@ +{ + "$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": "" + }, + "cardBackgroundColor": { + "type": "string", + "default": "" + }, + "cardTextColor": { + "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..0ed846e --- /dev/null +++ b/src/js/blocks/gated-content/edit.js @@ -0,0 +1,125 @@ +import { __ } from '@wordpress/i18n'; +import { + InnerBlocks, + InspectorControls, + PanelColorSettings, + useBlockProps, +} from '@wordpress/block-editor'; +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'; + +// 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 } ) { + const { settingsUrl, hasPublication, gateFormId } = + useBeehiivEditorConfig(); + const { formId, headline, text, cardBackgroundColor, cardTextColor } = + attributes; + + const blockProps = useBlockProps(); + + return ( + <> + + + { settingsUrl && ! hasPublication && ( + + { renderSettingsLinkMessage( + __( + 'No beehiiv publication is set. Choose one in beehiiv settings.', + 'beehiiv' + ), + settingsUrl + ) } + + ) } + + setAttributes( { formId: value.trim() } ) + } + help={ __( + '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' + ) } + /> + + setAttributes( { headline: value } ) + } + help={ __( + 'Leave blank to use the default from the beehiiv settings page. If that is blank too, no headline is shown.', + 'beehiiv' + ) } + /> + + setAttributes( { text: value } ) + } + help={ __( + 'Leave blank to use the default from the beehiiv settings page. If that is blank too, no text is shown.', + 'beehiiv' + ) } + /> +

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

+
+ + setAttributes( { + cardBackgroundColor: value || '', + } ), + }, + { + label: __( 'Text', 'beehiiv' ), + value: cardTextColor, + onChange: ( value ) => + setAttributes( { cardTextColor: value || '' } ), + }, + ] } + /> +
+ +
+ +
+ + ); +} diff --git a/src/js/blocks/gated-content/editor.scss b/src/js/blocks/gated-content/editor.scss new file mode 100644 index 0000000..5997049 --- /dev/null +++ b/src/js/blocks/gated-content/editor.scss @@ -0,0 +1,5 @@ +.wp-block-beehiiv-gated-content { + border: 2px dotted #e0e0e0; + border-radius: 6px; + padding: 16px 18px; +} 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 }`; +} + +// Tell assistive technology the content is open, and move focus to it. The card +// that held focus is about to disappear. +function announce( gate, locked ) { + const message = gate.dataset.unlockedMessage; + + if ( message ) { + const status = document.createElement( 'p' ); + 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. + window.setTimeout( () => { + status.textContent = message; + }, 50 ); + } + + if ( locked ) { + locked.setAttribute( 'tabindex', '-1' ); + locked.focus( { preventScroll: true } ); + } +} + +// 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' ); + + gate.classList.add( 'is-unlocked' ); + + if ( locked ) { + locked.removeAttribute( 'aria-hidden' ); + locked.removeAttribute( 'inert' ); + } + + if ( announceUnlock ) { + announce( gate, locked ); + } +} + +function init() { + const gates = Array.from( + document.querySelectorAll( '[data-beehiiv-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 ) ); + return; + } + + if ( ! gates.length ) { + return; + } + + const onMessage = ( event ) => { + if ( + ! ALLOWED_ORIGINS.includes( event.origin ) || + ! event.data || + event.data.type !== SUBMITTED_TYPE + ) { + return; + } + + const source = gates.find( + ( gate ) => + gate.dataset.formId === String( event.data.externalEmbedId ) + ); + + if ( ! source ) { + return; + } + + setCookie( + source.dataset.cookieName, + parseInt( source.dataset.cookieDays, 10 ) || 30 + ); + + // 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. +if ( document.readyState === 'loading' ) { + document.addEventListener( 'DOMContentLoaded', init ); +} else { + init(); +} 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/e2e/specs/gated-content-block.spec.js b/tests/e2e/specs/gated-content-block.spec.js new file mode 100644 index 0000000..7cf19b2 --- /dev/null +++ b/tests/e2e/specs/gated-content-block.spec.js @@ -0,0 +1,183 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin } = require( '../utils/auth' ); +const { + wpCli, + ensurePluginActive, + ensurePrettyPermalinks, + ensureActiveTheme, +} = 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(); + ensureActiveTheme(); + } ); + + 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/e2e/specs/publication-template-config.spec.js b/tests/e2e/specs/publication-template-config.spec.js index 65a7989..0ed0bab 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', '' ); @@ -255,13 +259,12 @@ test.describe( 'Publication and Template Configuration', () => { test( 'AC-003: post template dropdown is disabled and empty until a publication is selected', async ( { page, } ) => { - // "Disabled" half of this AC is a known failure -- see the assertion - // below for the concrete reason (real PRD/implementation mismatch, - // not flakiness). Remove test.fail() once Registrar.php or the PRD - // is reconciled. - test.fail(); - seedConnectedAndAuthorized(); + const pubId = 'qa-e2e-pub-ac003'; + seedPublications( [ { id: pubId, name: 'QA Publication AC003' } ] ); + seedTemplates( pubId, [ + { id: 'qa-e2e-tpl-ac003', name: 'QA Template AC003' }, + ] ); setSettingsOption( '', '' ); await page.goto( SETTINGS_PATH ); @@ -269,24 +272,19 @@ test.describe( 'Publication and Template Configuration', () => { const select = page.locator( TPL_SELECT ); await expect( select ).toHaveCount( 1 ); - // "Empty" half of the AC: only the placeholder option renders when no - // publication is selected (Registrar::render_post_template_id_field() - // only queries PostTemplates::get_post_templates() when publication_id - // is non-empty). + // Disabled and empty: only the placeholder option, and no way to pick it. + await expect( select ).toBeDisabled(); await expect( select.locator( 'option' ) ).toHaveCount( 1 ); - // "Disabled" half of the AC: reading - // Registrar::render_post_template_id_field() shows the