diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0900814..53c7040 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,7 @@ jobs: - { series: "8.3", patch: "8.3.36" } - { series: "8.4", patch: "8.4.27" } - { series: "8.5", patch: "8.5.12" } - wp-version: ["6.8", "latest"] + wp-version: ["6.8", "6.9", "7.0", "latest"] steps: - name: Checkout @@ -62,7 +62,7 @@ jobs: run: npm run env:start - name: Run PHPUnit - run: npx wp-env run tests-cli -- bash -c "cd wp-content/plugins/$(basename "$GITHUB_WORKSPACE") && composer test" + run: npx wp-env run tests-cli -- bash -c "cd wp-content/plugins/$(basename "$GITHUB_WORKSPACE") && composer test:unit" - name: Generate PHPUnit dashboard if: always() && hashFiles('tests/phpunit/test-results/junit.xml') != '' @@ -82,7 +82,8 @@ jobs: e2e: name: E2E tests - runs-on: ubuntu-latest + # Playwright does not support Ubuntu 26.04, which ubuntu-latest now uses. + runs-on: ubuntu-24.04 timeout-minutes: 60 steps: diff --git a/README.md b/README.md index ffbb062..3205aaa 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,34 @@ define( 'BEEHIIV_SSLVERIFY', false ); // optional; local private CA only Post template for API payloads uses the plugin default `post_template_id`; omit `post_template_id` from the request when unset (`Newsletter\PostSettingsBuilder`). +## WordPress VIP runtime config + +On WordPress VIP, the platform defines `VIP_BEEHIIV_CONFIG` before the plugin loads. It is a plain PHP array built from the config form in the VIP Dashboard. The plugin reads it in `Beehiiv\OAuth\Config`. The field is declared in `vip-manifest.yaml`. + +| Key | Type | Required | Effect | +| -------------- | ---- | -------- | ---------------------------------------------------------------------- | +| `api_base_url` | url | No | Overrides the beehiiv API base URL. Empty or invalid values are ignored. | + +The plugin resolves the API base URL in this order: `VIP_BEEHIIV_CONFIG['api_base_url']`, then the `BEEHIIV_API_BASE_URL` constant from `wp-config.php`, then the production default. The plugin never fatals on missing or malformed config. It falls back to the next source. + +Valid example: + +```php +define( 'VIP_BEEHIIV_CONFIG', [ + 'api_base_url' => 'https://api.example.test/v2', +] ); +``` + +Incomplete example (setup in progress, the field saved blank). The plugin ignores the blank value and uses the default API URL: + +```php +define( 'VIP_BEEHIIV_CONFIG', [ + 'api_base_url' => '', +] ); +``` + +Test fixtures for the valid, minimal, incomplete and invalid states live in `tests/phpunit/fixtures/`. + ## Linting ```bash diff --git a/beehiiv.php b/beehiiv.php index d26973f..5711e93 100644 --- a/beehiiv.php +++ b/beehiiv.php @@ -2,7 +2,7 @@ /** * Plugin Name: beehiiv * Description: Official beehiiv WordPress plugin. Publish posts as newsletters and grow your audience with beehiiv. - * Version: 1.1.0 + * Version: 1.2.0 * Author: beehiiv * Text Domain: beehiiv * Domain Path: /languages @@ -18,7 +18,7 @@ defined( 'ABSPATH' ) || exit; if ( ! defined( 'BEEHIIV_VERSION' ) ) { - define( 'BEEHIIV_VERSION', '1.1.0' ); + define( 'BEEHIIV_VERSION', '1.2.0' ); } if ( ! defined( 'BEEHIIV_PLUGIN_FILE' ) ) { diff --git a/composer.json b/composer.json index 60dea71..9b0199e 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,12 @@ } }, "scripts": { - "test": "phpunit -c phpunit.xml.dist" + "test": [ + "@test:unit", + "@test:e2e" + ], + "test:unit": "phpunit -c phpunit.xml.dist", + "test:e2e": "npx playwright test" }, "minimum-stability": "stable", "require": { diff --git a/includes/API/Cache.php b/includes/API/Cache.php index f10543c..eb4798f 100644 --- a/includes/API/Cache.php +++ b/includes/API/Cache.php @@ -94,7 +94,7 @@ public static function set_publications( array $items ): void { /** * Clear the cached publications list so the next read fetches it from beehiiv. * - * @since x.x.x + * @since 1.1.0 * * @return void */ diff --git a/includes/API/Resources/Posts.php b/includes/API/Resources/Posts.php index 4b0aa92..1ea6d5f 100644 --- a/includes/API/Resources/Posts.php +++ b/includes/API/Resources/Posts.php @@ -84,20 +84,22 @@ public static function create( string $publication_id, array $payload ): array { /** * Fetch a single post from a beehiiv publication. * - * @param string $publication_id Publication ID. - * @param string $post_id beehiiv post ID. - * @return array{success: bool, post: array, error: string} - * @since x.x.x + * @param string $publication_id Publication ID. + * @param string $post_id beehiiv post ID. + * @param string|null $expand Optional `expand` value, for example `free_email_content`. + * @return array{success: bool, post: array, error: string, status_code: int|null} + * @since 1.1.0 */ - public static function get( string $publication_id, string $post_id ): array { + public static function get( string $publication_id, string $post_id, ?string $expand = null ): array { $publication_id = trim( $publication_id ); $post_id = trim( $post_id ); if ( '' === $publication_id || '' === $post_id ) { return [ - 'success' => false, - 'post' => [], - 'error' => 'Publication ID or post ID is empty.', + 'success' => false, + 'post' => [], + 'error' => 'Publication ID or post ID is empty.', + 'status_code' => null, ]; } @@ -106,15 +108,16 @@ public static function get( string $publication_id, string $post_id ): array { rawurlencode( $publication_id ), rawurlencode( $post_id ) ); - $response = Client::request( $path ); + $response = Client::request( $path, null === $expand ? [] : [ 'expand' => $expand ] ); $wp_error = Client::get_last_wp_error(); if ( null !== $wp_error ) { return [ - 'success' => false, - 'post' => [], - 'error' => $wp_error, + 'success' => false, + 'post' => [], + 'error' => $wp_error, + 'status_code' => null, ]; } @@ -122,16 +125,18 @@ public static function get( string $publication_id, string $post_id ): array { if ( null !== $status_code && $status_code > 299 ) { return [ - 'success' => false, - 'post' => [], - 'error' => self::format_error_message( $response, $status_code ), + 'success' => false, + 'post' => [], + 'error' => self::format_error_message( $response, $status_code ), + 'status_code' => $status_code, ]; } return [ - 'success' => true, - 'post' => isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : [], - 'error' => '', + 'success' => true, + 'post' => isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : [], + 'error' => '', + 'status_code' => $status_code, ]; } @@ -274,6 +279,51 @@ public static function get_creation_state( string $publication_id, string $post_ return $result; } + /** + * Read a post together with its rendered free email content. + * + * @link https://developers.beehiiv.com/api-reference/posts/show + * + * @param string $publication_id Publication ID. + * @param string $post_id beehiiv post ID. + * @return array{ + * success: bool, + * email_html: string, + * subject: string, + * preview_text: string, + * error: string, + * status_code: int|null + * } + * @since 1.2.0 + */ + public static function get_email_content( string $publication_id, string $post_id ): array { + $fetched = self::get( $publication_id, $post_id, 'free_email_content' ); + $result = [ + 'success' => false, + 'email_html' => '', + 'subject' => '', + 'preview_text' => '', + 'error' => $fetched['error'], + 'status_code' => $fetched['status_code'], + ]; + + if ( ! $fetched['success'] ) { + return $result; + } + + $data = $fetched['post']; + $html = $data['content']['free']['email'] ?? ''; + + $result['success'] = true; + $result['email_html'] = is_string( $html ) ? $html : ''; + $result['subject'] = self::first_non_empty( [ $data['email_settings']['email_subject_line'] ?? '' ] ); + $result['preview_text'] = self::first_non_empty( + [ $data['email_settings']['email_preview_text'] ?? '', $data['subtitle'] ?? '' ] + ); + + return $result; + } + /** * Send a test email of a beehiiv post to one or more addresses. * @@ -398,6 +448,23 @@ public static function update( string $publication_id, string $post_id, array $p ]; } + /** + * First value that is a non-empty string, or an empty string. + * + * @param array $values Candidate values in priority order. + * @return string + * @since 1.2.0 + */ + private static function first_non_empty( array $values ): string { + foreach ( $values as $value ) { + if ( is_string( $value ) && '' !== trim( $value ) ) { + return $value; + } + } + + return ''; + } + /** * Build a log-friendly error string from an API error response. * diff --git a/includes/Admin/Options.php b/includes/Admin/Options.php index a48d048..ca549f7 100644 --- a/includes/Admin/Options.php +++ b/includes/Admin/Options.php @@ -25,8 +25,9 @@ final class Options { * @var array */ private const DEFAULTS = [ - 'publication_id' => '', - 'post_template_id' => '', + 'publication_id' => '', + 'post_template_id' => '', + 'require_template_choice' => false, ]; /** @@ -81,6 +82,9 @@ public static function sanitize( $input ): array { ? sanitize_text_field( (string) $input['post_template_id'] ) : ''; + // An unchecked checkbox is missing from the form, so absence means off. + $require_template_choice = ! empty( $input['require_template_choice'] ); + // Saving settings clears the cached template list for the selected // publication so the dropdown reflects any beehiiv-side changes. if ( '' !== $publication_id ) { @@ -88,8 +92,9 @@ public static function sanitize( $input ): array { } return [ - 'publication_id' => $publication_id, - 'post_template_id' => $post_template_id, + 'publication_id' => $publication_id, + 'post_template_id' => $post_template_id, + 'require_template_choice' => $require_template_choice, ]; } } diff --git a/includes/Admin/Registrar.php b/includes/Admin/Registrar.php index 8b831b4..3b1fd93 100644 --- a/includes/Admin/Registrar.php +++ b/includes/Admin/Registrar.php @@ -86,6 +86,17 @@ private static function register_page_settings_section( string $page_slug ): voi 'label_for' => 'beehiiv_post_template_id', ] ); + + add_settings_field( + 'beehiiv_require_template_choice', + __( 'Template choice', 'beehiiv' ), + [ self::class, 'render_require_template_choice_field' ], + $page_slug, + self::PAGE_SETTINGS_SECTION_ID, + [ + 'label_for' => 'beehiiv_require_template_choice', + ] + ); } /** @@ -244,4 +255,33 @@ class="button button-secondary beehiiv-refresh-button"

+ + +

+ +

+ '', 'readonly' => true, ], + Meta::BEEHIIV_POST_STATE => [ + 'type' => 'string', + 'default' => '', + 'readonly' => true, + ], Meta::NEWSLETTER_ERROR => [ 'type' => 'string', 'default' => '', @@ -298,7 +303,7 @@ private static function enqueue_build_style( ?string $version = null ): void { * @param array $handler Route handler. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response|\WP_HTTP_Response|\WP_Error|mixed - * @since x.x.x + * @since 1.1.0 */ public static function drop_readonly_meta_from_request( $response, $handler, $request ) { if ( ! $request instanceof \WP_REST_Request ) { @@ -359,6 +364,7 @@ public static function prepare_rest_post( $response, $post, $request ) { Meta::BEEHIIV_POST_ID, Meta::BEEHIIV_SCHEDULED_AT, Meta::BEEHIIV_PUBLICATION_ID, + Meta::BEEHIIV_POST_STATE, Meta::SEND_TO_NEWSLETTER, Meta::NEWSLETTER_ERROR, Meta::NEWSLETTER_ERROR_TYPE, @@ -404,6 +410,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'] ?? '' ) ), + 'requireTemplateChoice' => ! empty( $settings['require_template_choice'] ), 'canPublishPosts' => current_user_can( 'publish_posts' ), 'defaultPublicationId' => $default_pub_id, 'publications' => $is_connected ? PublicationResolver::get_editor_publications() : [], diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index 32a1f94..3fabc24 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -11,6 +11,7 @@ use Beehiiv\API\Resources\AdvertisementOpportunities; use Beehiiv\Editor\Meta; use Beehiiv\Newsletter\Converters\ColumnsBlockConverter; +use Beehiiv\Newsletter\Converters\GroupBlockConverter; use Beehiiv\Newsletter\Converters\MediaTextBlockConverter; use Beehiiv\Newsletter\Converters\TableBlockConverter; use Beehiiv\Newsletter\Converters\ListBlockConverter; @@ -32,15 +33,21 @@ * - `core/quote` — `convert_quote_block()` * - `core/pullquote` — `convert_pullquote_block()` * - `core/embed` — `convert_embed_block()` + * - `core/columns` — `convert_columns_block_to_blocks()` (beehiiv `columns` block, up to 3 columns) * - `core/media-text` — `convert_media_text_block()` (beehiiv `columns` block) * - `core/buttons` / inner `core/button` — `convert_buttons_block()`, `convert_button_block()` * - `core/separator` — beehiiv `content_break` * - `core/more` — snippet newsletters only; beehiiv Read More `button` via `convert_more_block()` * - `beehiiv/advertisement` — beehiiv `advertisement` block via `convert_advertisement_block()` * - * Layout blocks (`core/group`, `core/columns`, etc.) are skipped at the top level. - * `convert_columns_block()` exists for internal reuse (e.g. `core/media-text`) and is - * not wired into the main block walk yet. Snippet mode stops at `core/more`. + * - `core/group` — `convert_group_block()` (beehiiv `section` block holding the converted inner + * blocks; only for posts whose first sync used sections, see `PostSettingsBuilder`; a Group + * inside a Group is omitted because beehiiv sections cannot hold sections) + * + * `core/group` is skipped at the top level for posts first synced before section support. + * A `core/columns` block with more than 3 columns is omitted. A `core/columns` block + * nested inside a column is not converted to a columns block; its inner blocks are + * emitted at the top level instead. Snippet mode stops at `core/more`. * * @since 1.0.0 */ @@ -49,11 +56,13 @@ final class BlockConverter { /** * Convert a WordPress post's blocks to beehiiv blocks. * - * @param WP_Post $post_object Post object. + * @param WP_Post $post_object Post object. + * @param bool $convert_groups Whether `core/group` blocks become beehiiv sections. When false, + * Group blocks are omitted. * @return array> * @since 1.0.0 */ - public static function convert_supported_blocks( WP_Post $post_object ): array { + public static function convert_supported_blocks( WP_Post $post_object, bool $convert_groups = false ): array { $beehiiv_blocks = []; $wp_blocks = parse_blocks( $post_object->post_content ); @@ -73,7 +82,8 @@ public static function convert_supported_blocks( WP_Post $post_object ): array { $converted_blocks = self::convert_blocks( $wp_blocks, $post_object, - $is_send_newsletter_snippet + $is_send_newsletter_snippet, + $convert_groups ); return array_values( @@ -89,13 +99,15 @@ public static function convert_supported_blocks( WP_Post $post_object ): array { * @param array> $wp_blocks Parsed blocks. * @param WP_Post $post_object Post object. * @param bool $is_send_newsletter_snippet Whether snippet mode is enabled. + * @param bool $convert_groups Whether Group blocks become sections. * @return array> * @since 1.0.0 */ private static function convert_blocks( array $wp_blocks, WP_Post $post_object, - bool $is_send_newsletter_snippet + bool $is_send_newsletter_snippet, + bool $convert_groups = false ): array { $beehiiv_blocks = []; @@ -111,6 +123,16 @@ private static function convert_blocks( break; } + if ( 'core/group' === $block_name ) { + $section_block = $convert_groups ? self::convert_group_block( $wp_block, $post_object ) : []; + + if ( ! empty( $section_block ) ) { + $beehiiv_blocks[] = $section_block; + } + + continue; + } + if ( ! SupportedBlocks::is_supported( $block_name, $is_send_newsletter_snippet ) ) { continue; } @@ -127,6 +149,11 @@ private static function convert_blocks( continue; } + if ( 'core/columns' === $block_name ) { + $beehiiv_blocks = array_merge( $beehiiv_blocks, self::convert_columns_block_to_blocks( $wp_block ) ); + continue; + } + $beehiiv_block = self::convert_block( $wp_block ); if ( ! empty( $beehiiv_block ) ) { @@ -137,6 +164,48 @@ private static function convert_blocks( return $beehiiv_blocks; } + /** + * Convert a core/group block to a beehiiv section block. + * + * The Group's inner blocks are converted with the same rules as top-level blocks and + * placed in the section in their original order. A Group nested inside a Group is + * omitted with its inner blocks, because a beehiiv section cannot hold another section + * and sending one makes beehiiv reject the whole post. The Group's background colour, text + * colour, padding, margin and border go in the section's `visual_settings`; every other setting is + * left at beehiiv defaults. Returns an empty array when no inner block converts, so no empty section + * is sent. Snippet-only blocks are omitted inside a section. + * + * @param array $wp_block Parsed core/group block. + * @param WP_Post $post_object Post object. + * @return array + * @since 1.2.0 + */ + private static function convert_group_block( array $wp_block, WP_Post $post_object ): array { + $inner_blocks = self::convert_blocks( + $wp_block['innerBlocks'] ?? [], + $post_object, + false, + false + ); + + if ( empty( $inner_blocks ) ) { + return []; + } + + $section = [ + 'type' => 'section', + 'blocks' => $inner_blocks, + ]; + + $visual_settings = GroupBlockConverter::build_visual_settings( $wp_block ); + + if ( ! empty( $visual_settings ) ) { + $section['visual_settings'] = $visual_settings; + } + + return $section; + } + /** * Convert a single supported WordPress block. * @@ -335,7 +404,7 @@ public static function convert_paragraph_block( array $wp_block ): array { $block_background_color = FormattedTextParser::resolve_paragraph_block_background_color( $inner_html, $attrs ); $parsed = FormattedTextParser::parse( $inline_html, $block_text_color ); - if ( '' === trim( $parsed['plaintext'] ) ) { + if ( '' === preg_replace( '/[\s\x{00A0}]+/u', '', $parsed['plaintext'] ) ) { return []; } @@ -520,64 +589,116 @@ public static function convert_list_block( array $wp_block ): array { /** * Convert a core/columns block to a beehiiv columns block. * - * Not included in the top-level newsletter block walk yet. Intended for - * internal reuse by `convert_media_text_block()` and future direct columns support. + * Returns an empty array when the block is not converted to a columns block, + * including when it has more columns than beehiiv accepts. * * @param array $wp_block Parsed block. * @return array * @since 1.0.0 */ public static function convert_columns_block( array $wp_block ): array { + foreach ( self::convert_columns_block_to_blocks( $wp_block ) as $beehiiv_block ) { + if ( 'columns' === ( $beehiiv_block['type'] ?? '' ) ) { + return $beehiiv_block; + } + } + + return []; + } + + /** + * Convert a core/columns block to beehiiv blocks. + * + * A block with up to 3 columns becomes one beehiiv columns block. A block with + * more columns is omitted entirely, and the row is never split. A core/columns + * block nested inside a column is not converted to a columns block: its inner + * blocks follow the outer columns block at the top level. + * + * @param array $wp_block Parsed block. + * @return array> + * @since 1.2.0 + */ + public static function convert_columns_block_to_blocks( array $wp_block ): array { if ( 'core/columns' !== ( $wp_block['blockName'] ?? '' ) ) { return []; } $attrs = $wp_block['attrs'] ?? []; $inner_html = (string) ( $wp_block['innerHTML'] ?? '' ); - $inner_blocks = $wp_block['innerBlocks'] ?? []; - $stack_on_mobile = ColumnsBlockConverter::resolve_stack_on_mobile( $attrs, $inner_html ); $parent_vertical_align = isset( $attrs['verticalAlignment'] ) && is_string( $attrs['verticalAlignment'] ) ? $attrs['verticalAlignment'] : null; - $beehiiv_columns = []; + $column_blocks = []; + $converted_columns = []; + $nested_blocks = []; - foreach ( $inner_blocks as $column_block ) { + foreach ( $wp_block['innerBlocks'] ?? [] as $column_block ) { if ( 'core/column' !== ( $column_block['blockName'] ?? '' ) ) { continue; } - $column_inner_blocks = $column_block['innerBlocks'] ?? []; - $column_blocks = self::convert_layout_block_inner_blocks( $column_inner_blocks ); + $column_blocks[] = $column_block; + $converted_columns[] = self::convert_layout_block_inner_blocks( + $column_block['innerBlocks'] ?? [], + $nested_blocks + ); + } - if ( empty( $column_blocks ) ) { - continue; - } + if ( count( $column_blocks ) > ColumnsBlockConverter::MAX_COLUMNS ) { + return []; + } - $beehiiv_column = ColumnsBlockConverter::build_beehiiv_column( - $column_blocks, - $column_block, - $parent_vertical_align - ); + $kept_blocks = []; + $kept_blocks_by_column = []; - if ( ! empty( $beehiiv_column ) ) { - $beehiiv_columns[] = $beehiiv_column; + foreach ( $column_blocks as $index => $column_block ) { + if ( ! empty( $converted_columns[ $index ] ) ) { + $kept_blocks[] = $column_block; + $kept_blocks_by_column[] = $converted_columns[ $index ]; } } - return ColumnsBlockConverter::build_beehiiv_columns_block( $beehiiv_columns, $stack_on_mobile ); + $widths = ColumnsBlockConverter::resolve_row_widths( $kept_blocks ); + $columns = []; + + foreach ( $kept_blocks as $index => $column_block ) { + $columns[] = ColumnsBlockConverter::build_beehiiv_column_from_definition( + $kept_blocks_by_column[ $index ], + $widths[ $index ], + ColumnsBlockConverter::resolve_column_vertical_alignment( + $column_block['attrs'] ?? [], + (string) ( $column_block['innerHTML'] ?? '' ), + $parent_vertical_align + ) + ); + } + + $columns_block = ColumnsBlockConverter::build_beehiiv_columns_block( + $columns, + ColumnsBlockConverter::resolve_stack_on_mobile( $attrs, $inner_html ) + ); + + return array_values( array_filter( array_merge( [ $columns_block ], $nested_blocks ) ) ); } /** * Convert nested inner blocks inside a layout block to beehiiv blocks. * * Used by layout blocks such as columns and media-text. Unsupported blocks are - * skipped; snippet-only blocks are omitted. + * skipped; snippet-only blocks are omitted. A nested core/columns block is never + * converted inside a column. When `$nested_blocks` is passed, its inner blocks are + * added to it for the caller to emit at the top level. Without it, the nested block + * is omitted. * - * @param array> $wp_blocks Parsed inner blocks. + * @param array> $wp_blocks Parsed inner blocks. + * @param array>|null $nested_blocks Receives the inner blocks of nested columns. * @return array> * @since 1.0.0 */ - private static function convert_layout_block_inner_blocks( array $wp_blocks ): array { + private static function convert_layout_block_inner_blocks( + array $wp_blocks, + ?array &$nested_blocks = null + ): array { $beehiiv_blocks = []; foreach ( $wp_blocks as $wp_block ) { @@ -587,6 +708,22 @@ private static function convert_layout_block_inner_blocks( array $wp_blocks ): a $block_name = (string) $wp_block['blockName']; + if ( 'core/columns' === $block_name ) { + if ( null !== $nested_blocks ) { + foreach ( $wp_block['innerBlocks'] ?? [] as $nested_column ) { + if ( 'core/column' === ( $nested_column['blockName'] ?? '' ) ) { + $nested_converted = self::convert_layout_block_inner_blocks( + $nested_column['innerBlocks'] ?? [], + $nested_blocks + ); + $nested_blocks = array_merge( $nested_blocks, $nested_converted ); + } + } + } + + continue; + } + if ( 'core/buttons' === $block_name ) { $beehiiv_blocks = array_merge( $beehiiv_blocks, self::convert_buttons_block( $wp_block ) ); continue; @@ -617,7 +754,8 @@ private static function convert_layout_block_inner_blocks( array $wp_blocks ): a * Maps attrs.head into rows[0] with headerRow when the Header section is enabled * in the block editor. attrs.body supplies remaining rows. When attrs are empty * (common for query-sourced table attributes in PHP parse_blocks), rows are read - * from innerHTML instead. The table is omitted when every cell is empty. + * from innerHTML instead. The table is omitted when every cell is empty. A block + * background colour is sent as `visual_settings.background_color`. * Footer rows are omitted; beehiiv has no table footer support. * * @param array $wp_block Parsed block. @@ -674,11 +812,21 @@ public static function convert_table_block( array $wp_block ): array { return []; } - return [ + $table_block = [ 'type' => 'table', 'rows' => $rows, 'headerRow' => $header_row, ]; + + $background_color = FormattedTextParser::resolve_table_block_background_color( $inner_html, $attrs ); + + if ( null !== $background_color && '' !== $background_color ) { + $table_block['visual_settings'] = [ + 'background_color' => $background_color, + ]; + } + + return $table_block; } /** @@ -958,8 +1106,9 @@ public static function convert_embed_block( array $wp_block ): array { * Convert a core/media-text block to a beehiiv columns block. * * Maps the media side to an image column and the content side to converted - * inner blocks. Blocks with video media are omitted entirely. Respects media - * position, column widths, vertical alignment, and mobile stacking. + * inner blocks. Blocks with video media, or with no valid image, are omitted + * entirely. Respects media position, column widths, vertical alignment, and + * mobile stacking. * * @param array $wp_block Parsed block. * @return array @@ -979,7 +1128,12 @@ public static function convert_media_text_block( array $wp_block ): array { $media_width = MediaTextBlockConverter::resolve_media_width( $attrs, $inner_html ); $content_width = max( 1, min( 100, 100 - $media_width ) ); - $media_blocks = self::convert_media_text_media_blocks( $attrs, $inner_html ); + $media_blocks = self::convert_media_text_media_blocks( $attrs, $inner_html ); + + if ( empty( $media_blocks ) ) { + return []; + } + $content_blocks = self::convert_layout_block_inner_blocks( $inner_blocks ); if ( empty( $content_blocks ) && '' !== $inner_html ) { diff --git a/includes/Newsletter/Converters/ColumnsBlockConverter.php b/includes/Newsletter/Converters/ColumnsBlockConverter.php index 1c8f510..9ddf1c1 100644 --- a/includes/Newsletter/Converters/ColumnsBlockConverter.php +++ b/includes/Newsletter/Converters/ColumnsBlockConverter.php @@ -16,6 +16,13 @@ */ final class ColumnsBlockConverter { + /** + * Most columns a single beehiiv columns block accepts. + * + * @var int + */ + public const MAX_COLUMNS = 3; + /** * Build a beehiiv columns block from converted column definitions. * @@ -219,6 +226,118 @@ public static function resolve_column_width( array $attrs, string $inner_html ): return null; } + /** + * Resolve the width of every column in one row. + * + * Percent (or unitless) widths are rounded to a whole percent one column at a + * time, and a column with no width stays unset. When the widths use px, em, rem + * or another non-percent unit, each width becomes that column's share of the + * combined widths. If any column in such a row has no usable width, or the row + * mixes units, no column gets a width. + * + * @param array> $column_blocks Parsed core/column blocks in the row. + * @return array Width per column, in the same order. + * @since 1.2.0 + */ + public static function resolve_row_widths( array $column_blocks ): array { + $parsed = []; + + foreach ( $column_blocks as $column_block ) { + $parsed[] = self::parse_raw_width( + self::resolve_column_raw_width( + $column_block['attrs'] ?? [], + (string) ( $column_block['innerHTML'] ?? '' ) + ) + ); + } + + $units = []; + + foreach ( $parsed as $width ) { + if ( null !== $width ) { + $units[ $width['unit'] ] = true; + } + } + + $only_percent = array_diff_key( $units, [ '%' => true ] ); + + if ( empty( $only_percent ) ) { + return array_map( + static function ( $width ): ?int { + return null === $width ? null : self::parse_width_percentage( $width['value'] ); + }, + $parsed + ); + } + + $none = array_fill( 0, count( $parsed ), null ); + + if ( 1 !== count( $units ) || in_array( null, $parsed, true ) ) { + return $none; + } + + $total = array_sum( array_column( $parsed, 'value' ) ); + + if ( $total <= 0 ) { + return $none; + } + + return array_map( + static function ( array $width ) use ( $total ): ?int { + $percentage = (int) round( $width['value'] / $total * 100 ); + + return self::is_valid_width_percentage( $percentage ) ? $percentage : null; + }, + $parsed + ); + } + + /** + * Read a column's raw width text from attrs or saved HTML. + * + * @param array $attrs Parsed core/column attrs. + * @param string $inner_html Saved column HTML. + * @return string|null Raw width such as 33.33% or 200px, or null when unset. + * @since 1.2.0 + */ + private static function resolve_column_raw_width( array $attrs, string $inner_html ): ?string { + if ( isset( $attrs['width'] ) && ( is_string( $attrs['width'] ) || is_numeric( $attrs['width'] ) ) ) { + return trim( (string) $attrs['width'] ); + } + + $processor = new \WP_HTML_Tag_Processor( $inner_html ); + + if ( $processor->next_tag( 'DIV' ) ) { + $style = $processor->get_attribute( 'style' ); + + if ( is_string( $style ) && preg_match( '/flex-basis\s*:\s*([^;]+)/i', $style, $matches ) ) { + return trim( $matches[1] ); + } + } + + return null; + } + + /** + * Split a raw width into its number and unit. + * + * @param string|null $raw Raw width text. + * @return array{value: float, unit: string}|null Null when empty or not a number with an optional unit. + * @since 1.2.0 + */ + private static function parse_raw_width( ?string $raw ): ?array { + if ( null === $raw || ! preg_match( '/^(\d*\.?\d+)\s*([a-z%]*)$/i', trim( $raw ), $matches ) ) { + return null; + } + + $unit = strtolower( $matches[2] ); + + return [ + 'value' => (float) $matches[1], + 'unit' => '' === $unit ? '%' : $unit, + ]; + } + /** * Parse a width value into an integer percentage. * diff --git a/includes/Newsletter/Converters/GroupBlockConverter.php b/includes/Newsletter/Converters/GroupBlockConverter.php new file mode 100644 index 0000000..454feb2 --- /dev/null +++ b/includes/Newsletter/Converters/GroupBlockConverter.php @@ -0,0 +1,485 @@ + + */ + private const BORDER_STYLES = [ 'solid', 'dashed', 'dotted' ]; + + /** + * Corner names in WordPress and the matching beehiiv setting suffix. + * + * @var array + */ + private const CORNERS = [ + 'topLeft' => 'top_left', + 'topRight' => 'top_right', + 'bottomLeft' => 'bottom_left', + 'bottomRight' => 'bottom_right', + ]; + + /** + * Pixels in one `rem` or `em`, used to turn relative padding into whole pixels. + * + * @var int + */ + private const PIXELS_PER_REM = 16; + + /** + * Padding sides, in the order beehiiv names them. + * + * @var array + */ + private const SIDES = [ 'top', 'bottom', 'left', 'right' ]; + + /** + * Cached slug-to-size map for theme spacing presets. + * + * @var array|null + */ + private static ?array $spacing_preset_map = null; + + /** + * Build the beehiiv section `visual_settings` for a Group block. + * + * @param array $wp_block Parsed core/group block. + * @return array Empty when the Group sets no mapped styling. + * @since 1.2.0 + */ + public static function build_visual_settings( array $wp_block ): array { + $attrs = is_array( $wp_block['attrs'] ?? null ) ? $wp_block['attrs'] : []; + $inner_html = (string) ( $wp_block['innerHTML'] ?? '' ); + $settings = []; + + $background_color = FormattedTextParser::resolve_group_block_background_color( $inner_html, $attrs ); + + if ( null !== $background_color && '' !== $background_color ) { + $settings['background_color'] = $background_color; + } + + $text_color = FormattedTextParser::resolve_group_block_text_color( $inner_html, $attrs ); + + if ( null !== $text_color && '' !== $text_color ) { + $settings['text_color'] = $text_color; + } + + foreach ( self::resolve_spacing( $attrs, 'padding' ) as $side => $pixels ) { + $settings[ 'inner_spacing_' . $side ] = $pixels; + } + + foreach ( self::resolve_spacing( $attrs, 'margin' ) as $side => $pixels ) { + $settings[ 'outer_spacing_' . $side ] = $pixels; + } + + return array_merge( $settings, self::resolve_border( $attrs, $inner_html ) ); + } + + /** + * Resolve a Group block's border to beehiiv border settings. + * + * A border colour, style or width that differs between sides cannot be sent, because + * beehiiv takes one colour and one style; widths and radii can be sent per side or + * corner. A border with a colour or width but no style is sent as `solid`, as WordPress + * draws it. + * + * @param array $attrs Parsed block attributes. + * @param string $inner_html Saved Group HTML including the `
` wrapper. + * @return array + */ + private static function resolve_border( array $attrs, string $inner_html ): array { + $border = $attrs['style']['border'] ?? []; + $border = is_array( $border ) ? $border : []; + + $settings = self::resolve_border_widths( $border ); + $color = FormattedTextParser::resolve_group_block_border_color( $inner_html, $attrs ) + ?? self::shared_side_value( $border, 'color' ); + + if ( null !== $color && '' !== $color ) { + $settings['border_color'] = $color; + } + + $style = self::resolve_border_style( $border ); + + if ( null === $style && isset( $border['style'] ) ) { + $style = false; + } + + if ( null === $style && ( isset( $settings['border_color'] ) || self::has_border_width( $settings ) ) ) { + $style = 'solid'; + } + + if ( is_string( $style ) ) { + $settings['border_style'] = $style; + } + + return array_merge( $settings, self::resolve_border_radius( $border ) ); + } + + /** + * Resolve border widths, one value for every side or one per side. + * + * @param array $border Group `style.border` attributes. + * @return array + */ + private static function resolve_border_widths( array $border ): array { + if ( isset( $border['width'] ) ) { + $pixels = self::to_pixels( $border['width'] ); + + return null === $pixels ? [] : [ 'border_width' => $pixels ]; + } + + $widths = []; + + foreach ( self::SIDES as $side ) { + if ( ! isset( $border[ $side ]['width'] ) ) { + continue; + } + + $pixels = self::to_pixels( $border[ $side ]['width'] ); + + if ( null !== $pixels ) { + $widths[ 'border_width_' . $side ] = $pixels; + } + } + + return $widths; + } + + /** + * Resolve the border style, from one value or from sides that all agree. + * + * @param array $border Group `style.border` attributes. + * @return string|null A style beehiiv accepts, or null when none is set or it is not accepted. + */ + private static function resolve_border_style( array $border ): ?string { + $style = isset( $border['style'] ) && is_string( $border['style'] ) + ? $border['style'] + : self::shared_side_value( $border, 'style' ); + + $style = null === $style ? null : strtolower( trim( $style ) ); + + return in_array( $style, self::BORDER_STYLES, true ) ? $style : null; + } + + /** + * Resolve border radius, one value for every corner or one per corner. + * + * @param array $border Group `style.border` attributes. + * @return array + */ + private static function resolve_border_radius( array $border ): array { + $radius = $border['radius'] ?? null; + + if ( is_string( $radius ) || is_int( $radius ) || is_float( $radius ) ) { + $pixels = self::to_pixels( $radius ); + + return null === $pixels ? [] : [ 'border_radius' => $pixels ]; + } + + if ( ! is_array( $radius ) ) { + return []; + } + + $corners = []; + + foreach ( self::CORNERS as $corner => $suffix ) { + if ( ! isset( $radius[ $corner ] ) ) { + continue; + } + + $pixels = self::to_pixels( $radius[ $corner ] ); + + if ( null !== $pixels ) { + $corners[ 'border_radius_' . $suffix ] = $pixels; + } + } + + return $corners; + } + + /** + * Whether any border width was resolved. + * + * @param array $settings Border settings resolved so far. + * @return bool + */ + private static function has_border_width( array $settings ): bool { + foreach ( array_keys( $settings ) as $key ) { + if ( 0 === strpos( (string) $key, 'border_width' ) ) { + return true; + } + } + + return false; + } + + /** + * Read a per-side value that every set side shares. + * + * @param array $border Group `style.border` attributes. + * @param string $key Per-side key, `color` or `style`. + * @return string|null The shared value, or null when no side sets it or the sides differ. + */ + private static function shared_side_value( array $border, string $key ): ?string { + $values = []; + + foreach ( self::SIDES as $side ) { + $value = $border[ $side ][ $key ] ?? null; + + if ( ! is_string( $value ) ) { + continue; + } + + $value = 'color' === $key ? FormattedTextParser::resolve_color_reference( $value ) : trim( $value ); + + if ( null !== $value && '' !== $value ) { + $values[] = $value; + } + } + + $values = array_unique( $values ); + + return 1 === count( $values ) ? $values[0] : null; + } + + /** + * Resolve each side of a Group block's padding or margin to whole pixels. + * + * A side whose value cannot be turned into pixels (percentages, viewport units, + * `auto`, negative values, `clamp()` presets) is left out so beehiiv keeps its default + * for that side. + * + * @param array $attrs Parsed block attributes. + * @param string $property Spacing property, `padding` or `margin`. + * @return array Pixels keyed by side. + */ + private static function resolve_spacing( array $attrs, string $property ): array { + $spacing = $attrs['style']['spacing'][ $property ] ?? null; + + if ( is_string( $spacing ) || is_int( $spacing ) ) { + $spacing = array_fill_keys( self::SIDES, $spacing ); + } + + if ( ! is_array( $spacing ) ) { + return []; + } + + $resolved = []; + + foreach ( self::SIDES as $side ) { + if ( ! isset( $spacing[ $side ] ) ) { + continue; + } + + $pixels = self::to_pixels( $spacing[ $side ] ); + + if ( null !== $pixels ) { + $resolved[ $side ] = $pixels; + } + } + + return $resolved; + } + + /** + * Convert one padding or margin value to whole pixels. + * + * @param mixed $value Spacing value: a number, a CSS length, or a spacing preset. + * @return int|null Pixels, or null when the value cannot be converted. + */ + private static function to_pixels( $value ): ?int { + if ( is_int( $value ) || is_float( $value ) ) { + return max( 0, (int) round( $value ) ); + } + + if ( ! is_string( $value ) ) { + return null; + } + + $value = trim( $value ); + $slug = self::extract_spacing_preset_slug( $value ); + + if ( null !== $slug ) { + $size = self::get_spacing_preset_map()[ $slug ] ?? null; + + return null === $size ? null : self::css_length_to_pixels( $size ); + } + + return self::css_length_to_pixels( $value ); + } + + /** + * Convert a CSS length (`40px`, `2.5rem`, `1em`, `12`) or a fluid size to whole pixels. + * + * Themes often define spacing as `clamp()`, `min()` or `max()`. A newsletter has no + * responsive spacing, so a fluid size is sent at its widest value: `clamp()` at its + * maximum, `min()` at its smallest fixed argument, `max()` at its largest. An argument + * that is not a fixed length (a `vw` unit, `calc()`) is ignored. + * + * @param string $length CSS length or fluid size. + * @return int|null Pixels, or null for any other unit or function. + */ + private static function css_length_to_pixels( string $length ): ?int { + $length = trim( $length ); + + if ( preg_match( '/^(clamp|min|max)\((.*)\)$/is', $length, $call ) ) { + return self::fluid_size_to_pixels( strtolower( $call[1] ), self::split_arguments( $call[2] ) ); + } + + if ( ! preg_match( '/^(\d+(?:\.\d+)?)\s*(px|rem|em)?$/i', trim( $length ), $matches ) ) { + return null; + } + + $number = (float) $matches[1]; + $unit = strtolower( $matches[2] ?? 'px' ); + + if ( 'px' !== $unit ) { + $number *= self::PIXELS_PER_REM; + } + + return (int) round( $number ); + } + + /** + * Resolve a `clamp()`, `min()` or `max()` size to whole pixels. + * + * @param string $name Function name: `clamp`, `min` or `max`. + * @param array $arguments Top-level arguments of the function. + * @return int|null Pixels, or null when no argument is a fixed length. + */ + private static function fluid_size_to_pixels( string $name, array $arguments ): ?int { + if ( 'clamp' === $name ) { + // clamp( minimum, preferred, maximum ): the maximum is the widest size. + return 3 === count( $arguments ) ? self::css_length_to_pixels( $arguments[2] ) : null; + } + + $values = array_filter( + array_map( array( self::class, 'css_length_to_pixels' ), $arguments ), + static function ( $pixels ) { + return null !== $pixels; + } + ); + + if ( empty( $values ) ) { + return null; + } + + return 'min' === $name ? min( $values ) : max( $values ); + } + + /** + * Split a CSS function's arguments on top-level commas. + * + * @param string $arguments Text between the function's outer parentheses. + * @return array + */ + private static function split_arguments( string $arguments ): array { + $parts = []; + $depth = 0; + $start = 0; + $total = strlen( $arguments ); + + for ( $index = 0; $index < $total; $index++ ) { + $char = $arguments[ $index ]; + + if ( '(' === $char ) { + ++$depth; + } elseif ( ')' === $char ) { + --$depth; + } elseif ( ',' === $char && 0 === $depth ) { + $parts[] = trim( substr( $arguments, $start, $index - $start ) ); + $start = $index + 1; + } + } + + $parts[] = trim( substr( $arguments, $start ) ); + + return $parts; + } + + /** + * Read the preset slug from `var:preset|spacing|40` or `var(--wp--preset--spacing--40)`. + * + * @param string $value Padding value. + * @return string|null Preset slug, or null when the value is not a spacing preset. + */ + private static function extract_spacing_preset_slug( string $value ): ?string { + if ( preg_match( '/^var:preset\|spacing\|([a-z0-9-]+)$/i', $value, $matches ) ) { + return $matches[1]; + } + + if ( preg_match( '/^var\(--wp--preset--spacing--([a-z0-9-]+)\)$/i', $value, $matches ) ) { + return $matches[1]; + } + + return null; + } + + /** + * Build a cached slug-to-size map from theme spacing presets. + * + * @return array + */ + private static function get_spacing_preset_map(): array { + if ( null !== self::$spacing_preset_map ) { + return self::$spacing_preset_map; + } + + $map = []; + $sizes = wp_get_global_settings( array( 'spacing', 'spacingSizes' ) ); + $sizes = is_array( $sizes ) ? $sizes : []; + + // Apply origins in WordPress's own order, so a later origin overrides an earlier one with the same slug. + // A theme can turn WordPress's default sizes off, and then they are not offered at all. + $sources = false === wp_get_global_settings( array( 'spacing', 'defaultSpacingSizes' ) ) + ? array( 'theme', 'custom' ) + : array( 'default', 'theme', 'custom' ); + + foreach ( $sources as $source ) { + if ( empty( $sizes[ $source ] ) || ! is_array( $sizes[ $source ] ) ) { + continue; + } + + foreach ( $sizes[ $source ] as $entry ) { + if ( + ! is_array( $entry ) + || empty( $entry['slug'] ) + || ! isset( $entry['size'] ) + || ! is_string( $entry['slug'] ) + || ! is_string( $entry['size'] ) + ) { + continue; + } + + $map[ $entry['slug'] ] = $entry['size']; + } + } + + self::$spacing_preset_map = $map; + + return self::$spacing_preset_map; + } +} diff --git a/includes/Newsletter/EmailPreview.php b/includes/Newsletter/EmailPreview.php new file mode 100644 index 0000000..5b8e824 --- /dev/null +++ b/includes/Newsletter/EmailPreview.php @@ -0,0 +1,201 @@ + true ]; + } + + if ( ! Sender::is_draft_linked( $post_id ) ) { + return new WP_Error( + 'beehiiv_preview_failed', + __( "Something went wrong and the preview couldn't load. Try again.", 'beehiiv' ) + ); + } + + $beehiiv_post_id = (string) get_post_meta( $post_id, Meta::BEEHIIV_POST_ID, true ); + } else { + $synced = Sender::sync_preview_draft( $post_id ); + + if ( is_wp_error( $synced ) ) { + return self::classify_sync_error( $synced ); + } + + $beehiiv_post_id = $synced['beehiiv_post_id']; + } + + $publication_id = PublicationResolver::get_linked_publication_id( $post_id ); + $result = Posts::get_email_content( $publication_id, $beehiiv_post_id ); + + if ( ! $result['success'] ) { + return self::classify_api_error( (string) $result['error'], $result['status_code'] ); + } + + if ( '' === trim( $result['email_html'] ) ) { + return [ 'processing' => true ]; + } + + return [ + 'html' => $result['email_html'], + 'subject' => self::resolve_subject( $post_id, $result['subject'] ), + 'preview_text' => self::resolve_preview_text( $post_id, $result['preview_text'] ), + 'from_name' => PublicationResolver::get_publication_name( $publication_id ), + ]; + } + + /** + * Subject line: what beehiiv holds, else the post's newsletter title, else the post title. + * + * @param int $post_id Post ID. + * @param string $fetched Subject returned by beehiiv. + * @return string + * @since 1.2.0 + */ + private static function resolve_subject( int $post_id, string $fetched ): string { + if ( '' !== trim( $fetched ) ) { + return $fetched; + } + + $newsletter_title = get_post_meta( $post_id, Meta::NEWSLETTER_TITLE, true ); + + if ( is_string( $newsletter_title ) && '' !== trim( $newsletter_title ) ) { + return html_entity_decode( trim( $newsletter_title ) ); + } + + return html_entity_decode( get_the_title( $post_id ) ); + } + + /** + * Preview text: what beehiiv holds, else the post's newsletter subtitle, else nothing. + * + * @param int $post_id Post ID. + * @param string $fetched Preview text returned by beehiiv. + * @return string + * @since 1.2.0 + */ + private static function resolve_preview_text( int $post_id, string $fetched ): string { + if ( '' !== trim( $fetched ) ) { + return $fetched; + } + + $subtitle = get_post_meta( $post_id, Meta::NEWSLETTER_SUBTITLE, true ); + + return is_string( $subtitle ) ? html_entity_decode( trim( $subtitle ) ) : ''; + } + + /** + * Turn a draft sync failure into a classified preview error. + * + * @param WP_Error $error Error from {@see Sender::sync_preview_draft()}. + * @return WP_Error + * @since 1.2.0 + */ + private static function classify_sync_error( WP_Error $error ): WP_Error { + $code = $error->get_error_code(); + + if ( in_array( $code, [ 'beehiiv_draft_create_failed', 'beehiiv_draft_update_failed' ], true ) ) { + return self::classify_api_error( $error->get_error_message(), null ); + } + + if ( 0 === strpos( $code, 'beehiiv_post_' ) || 0 === strpos( $code, 'beehiiv_blocks_' ) + || 0 === strpos( $code, 'beehiiv_advertisement_' ) ) { + return new WP_Error( 'beehiiv_preview_content', Sender::format_save_error_message( $error ) ); + } + + return $error; + } + + /** + * Classify a beehiiv API failure. + * + * @param string $error Error string from the API client. + * @param int|null $status_code HTTP status code when known. + * @return WP_Error + * @since 1.2.0 + */ + private static function classify_api_error( string $error, ?int $status_code ): WP_Error { + if ( null === $status_code && preg_match( '/^HTTP (\d+):/i', trim( $error ), $matches ) ) { + $status_code = (int) $matches[1]; + } + + if ( 401 === $status_code || 403 === $status_code ) { + return new WP_Error( + 'beehiiv_preview_connection', + __( + // phpcs:ignore Generic.Files.LineLength.MaxExceeded,Generic.Files.LineLength.TooLong -- Single string for translators / i18n tools. + 'beehiiv refused the request. Reconnect your account in beehiiv settings and make sure it has access to read posts.', + 'beehiiv' + ) + ); + } + + if ( 402 === $status_code ) { + return new WP_Error( + 'beehiiv_preview_plan', + __( 'Email preview needs beehiiv Send API access (Max and Enterprise plans).', 'beehiiv' ) + ); + } + + if ( preg_match( '/^HTTP (\d+):\s*(.+)$/is', trim( $error ), $matches ) && 429 !== (int) $matches[1] ) { + return new WP_Error( + 'beehiiv_preview_failed', + sprintf( + /* translators: %s: error message from beehiiv. */ + __( "beehiiv couldn't prepare the preview: %s", 'beehiiv' ), + trim( $matches[2] ) + ) + ); + } + + $mapped = Sender::format_api_error_message( $error ); + + return new WP_Error( + 'beehiiv_preview_failed', + '' !== $mapped ? $mapped : __( "Something went wrong and the preview couldn't load. Try again.", 'beehiiv' ) + ); + } +} diff --git a/includes/Newsletter/FormattedTextParser.php b/includes/Newsletter/FormattedTextParser.php index 389f675..ce62a9e 100644 --- a/includes/Newsletter/FormattedTextParser.php +++ b/includes/Newsletter/FormattedTextParser.php @@ -212,6 +212,119 @@ public static function resolve_paragraph_block_background_color( string $inner_h return self::resolve_wrapper_color_from_html_tag( $inner_html, 'p', 'background' ); } + /** + * Resolve a Group block's background colour from attrs and the `
` wrapper. + * + * @param string $inner_html Saved Group HTML including the `
` wrapper. + * @param array $attrs Parsed block attributes. + * @return string|null + * @since 1.2.0 + */ + public static function resolve_group_block_background_color( string $inner_html, array $attrs ): ?string { + $from_attrs = self::resolve_block_background_color_from_attrs( $attrs ); + + if ( null !== $from_attrs ) { + return $from_attrs; + } + + return self::resolve_wrapper_color_from_html_tag( $inner_html, 'div', 'background' ); + } + + /** + * Resolve a Group block's text colour from attrs and the `
` wrapper. + * + * @param string $inner_html Saved Group HTML including the `
` wrapper. + * @param array $attrs Parsed block attributes. + * @return string|null + * @since 1.2.0 + */ + public static function resolve_group_block_text_color( string $inner_html, array $attrs ): ?string { + $from_attrs = self::resolve_block_text_color_from_attrs( $attrs ); + + if ( null !== $from_attrs ) { + return $from_attrs; + } + + return self::resolve_wrapper_color_from_html_tag( $inner_html, 'div', 'text' ); + } + + /** + * Resolve a colour reference to a colour value. + * + * Palette variables (`var:preset|color|slug`, as the block editor stores them on a split + * border, or `var(--wp--preset--color--slug)`) become the palette colour's value. Any other + * value is returned as given. + * + * @param string $value Colour value or palette variable. + * @return string|null Null when the value is empty, transparent, or names an unknown palette colour. + * @since 1.2.0 + */ + public static function resolve_color_reference( string $value ): ?string { + $value = trim( $value ); + + if ( '' === $value || self::is_transparent_color( $value ) ) { + return null; + } + + if ( + preg_match( '/^var:preset\|color\|([a-z0-9-]+)$/i', $value, $matches ) + || preg_match( '/^var\(--wp--preset--color--([a-z0-9-]+)\)$/i', $value, $matches ) + ) { + return self::get_color_preset_map()[ $matches[1] ] ?? null; + } + + return $value; + } + + /** + * Resolve a Group block's border colour from attrs and the `
` wrapper. + * + * Reads a custom colour, then a palette slug, then the saved wrapper's inline style and + * `has-{slug}-border-color` class. + * + * @param string $inner_html Saved Group HTML including the `
` wrapper. + * @param array $attrs Parsed block attributes. + * @return string|null + * @since 1.2.0 + */ + public static function resolve_group_block_border_color( string $inner_html, array $attrs ): ?string { + $custom = $attrs['style']['border']['color'] ?? null; + + if ( is_string( $custom ) ) { + $resolved = self::resolve_color_reference( $custom ); + + if ( null !== $resolved ) { + return $resolved; + } + } + + if ( ! empty( $attrs['borderColor'] ) && is_string( $attrs['borderColor'] ) ) { + $from_slug = self::get_color_preset_map()[ trim( $attrs['borderColor'] ) ] ?? null; + + if ( null !== $from_slug ) { + return $from_slug; + } + } + + $attributes = self::get_first_tag_attributes( $inner_html, 'div' ); + + if ( null === $attributes ) { + return null; + } + + if ( '' !== $attributes['style'] ) { + $color = self::extract_css_color( html_entity_decode( $attributes['style'], ENT_QUOTES ), 'border-color' ); + + if ( null !== $color && ! self::is_transparent_color( $color ) ) { + return $color; + } + } + + $slug = self::extract_preset_slug( $attributes['class'], 'border-color' ); + + return null === $slug ? null : ( self::get_color_preset_map()[ $slug ] ?? null ); + } + /** * Resolve block-level list-item text colour from attrs and the `
  • ` tag. * @@ -269,6 +382,32 @@ public static function resolve_list_block_background_color( string $inner_html, return self::resolve_list_wrapper_color_from_html( $inner_html, $attrs, 'background' ); } + /** + * Resolve a table block's background colour from attrs or saved HTML. + * + * Reads the `` tag first, then the `
    ` wrapper. + * + * @param string $inner_html Saved table HTML. + * @param array $attrs Parsed block attrs. + * @return string|null + * @since 1.2.0 + */ + public static function resolve_table_block_background_color( string $inner_html, array $attrs ): ?string { + $from_attrs = self::resolve_block_background_color_from_attrs( $attrs ); + + if ( null !== $from_attrs ) { + return $from_attrs; + } + + $from_table = self::resolve_wrapper_color_from_html_tag( $inner_html, 'table', 'background' ); + + if ( null !== $from_table ) { + return $from_table; + } + + return self::resolve_wrapper_color_from_html_tag( $inner_html, 'figure', 'background' ); + } + /** * Walk HTML and collect formatted text segments. * @@ -1083,7 +1222,11 @@ private static function get_color_preset_map(): array { $map = []; $palette = wp_get_global_settings( array( 'color', 'palette' ) ); - $sources = array( 'theme', 'default', 'custom' ); + // Apply origins in WordPress's own order, so a later origin overrides an earlier one with the same slug. + // A theme can turn WordPress's default palette off, and then its colours are not offered at all. + $sources = false === wp_get_global_settings( array( 'color', 'defaultPalette' ) ) + ? array( 'theme', 'custom' ) + : array( 'default', 'theme', 'custom' ); $palette = is_array( $palette ) ? $palette : []; foreach ( $sources as $source ) { diff --git a/includes/Newsletter/PostSettingsBuilder.php b/includes/Newsletter/PostSettingsBuilder.php index df74b14..3970b72 100644 --- a/includes/Newsletter/PostSettingsBuilder.php +++ b/includes/Newsletter/PostSettingsBuilder.php @@ -59,6 +59,13 @@ public static function get_post_settings( int $post_id, bool $for_update = false $post_template_id = self::resolve_post_template_id( $post_id, $publication_id ); + if ( '' === $post_template_id && self::is_template_choice_required( $post_id ) ) { + return new WP_Error( + 'beehiiv_post_template_choice_required', + sprintf( 'An editor must choose a post template before post ID: %d is sent.', $post_id ) + ); + } + if ( '' === $post_template_id && PublicationResolver::get_default_publication_id() !== $publication_id ) { return new WP_Error( 'beehiiv_post_template_required', @@ -94,7 +101,10 @@ public static function get_post_settings( int $post_id, bool $for_update = false ); } - $beehiiv_blocks = BlockConverter::convert_supported_blocks( $post_object ); + $beehiiv_blocks = BlockConverter::convert_supported_blocks( + $post_object, + self::should_convert_groups( $post_id ) + ); if ( empty( $beehiiv_blocks ) ) { return new WP_Error( @@ -166,6 +176,77 @@ public static function get_post_settings( int $post_id, bool $for_update = false return apply_filters( 'beehiiv_newsletter_post_settings', $settings, $post_id, $post_object ); } + /** + * Build the create payload for a Preview draft from the saved post. + * + * Uses the real send's payload builder without scheduling, then forces draft status + * after the public settings filter so a draft can never become a real send. + * + * @param int $post_id Post ID. + * @param string|null $publication_id Publication the draft is for. Defaults to the post's resolved publication. + * @return array|WP_Error + * @since 1.2.0 + */ + public static function build_draft_payload( int $post_id, ?string $publication_id = null ) { + $settings = self::get_post_settings( $post_id, true, $publication_id ); + + if ( is_wp_error( $settings ) ) { + return $settings; + } + + if ( ! is_array( $settings ) ) { + return new WP_Error( + 'beehiiv_draft_invalid_settings', + __( 'Something went wrong preparing the email draft. Try again.', 'beehiiv' ) + ); + } + + $settings['status'] = 'draft'; + unset( $settings['scheduled_at'] ); + + return $settings; + } + + /** + * Build the update payload for a linked Preview draft. + * + * Carries the content fields only; never a status, send time or template, so the update + * cannot confirm the draft. + * + * @param int $post_id Post ID. + * @param string|null $publication_id Publication the draft lives in. + * @return array|WP_Error + * @since 1.2.0 + */ + public static function build_draft_update_payload( int $post_id, ?string $publication_id = null ) { + $settings = self::build_draft_payload( $post_id, $publication_id ); + + if ( is_wp_error( $settings ) ) { + return $settings; + } + + $payload = array_intersect_key( + $settings, + array_flip( + [ + 'title', + 'blocks', + 'thumbnail_image_url', + 'email_settings', + 'web_settings', + 'social_share', + 'content_tags', + 'seo_settings', + ] + ) + ); + + // A subtitle cleared in WordPress is removed from the draft by sending it empty. + $payload['subtitle'] = $settings['subtitle'] ?? ''; + + return $payload; + } + /** * Build a beehiiv update request for a linked newsletter. * @@ -264,12 +345,39 @@ public static function get_update_payload( int $post_id ) { return $update['payload']; } + /** + * Whether Group blocks become beehiiv sections for this post. + * + * A post that has no beehiiv post yet is on its first sync, so Groups convert. After + * that, only a post whose first sync converted them keeps converting them; a post + * synced before section support keeps omitting Groups so its beehiiv layout does not change. + * + * @param int $post_id Post ID. + * @return bool + * @since 1.2.0 + */ + private static function should_convert_groups( int $post_id ): bool { + $beehiiv_post_id = get_post_meta( $post_id, Meta::BEEHIIV_POST_ID, true ); + + if ( ! is_string( $beehiiv_post_id ) || '' === trim( $beehiiv_post_id ) ) { + return true; + } + + // A Preview draft is replaced by a new post when the newsletter is sent, so it has no + // older layout to keep. Without this, refreshing a preview would drop every Group. + if ( Sender::is_draft_linked( $post_id ) ) { + return true; + } + + return (bool) get_post_meta( $post_id, Meta::BEEHIIV_GROUP_SECTIONS, true ); + } + /** * Whether a non-empty subtitle was last synced to the linked beehiiv post. * * @param int $post_id Post ID. * @return bool - * @since x.x.x + * @since 1.1.0 */ private static function has_synced_subtitle( int $post_id ): bool { $synced = get_post_meta( $post_id, Meta::BEEHIIV_SYNCED_SUBTITLE, true ); @@ -285,7 +393,7 @@ private static function has_synced_subtitle( int $post_id ): bool { * * @param WP_Post $post Post object. * @return array - * @since x.x.x + * @since 1.1.0 */ private static function get_content_tags( WP_Post $post ): array { $names = wp_get_post_tags( $post->ID, [ 'fields' => 'names' ] ); @@ -441,7 +549,7 @@ private static function collect_advertisement_ad_ids( array $blocks ): array { * which is the only publication that has a default template. Empty when * neither applies. * - * @since x.x.x + * @since 1.1.0 * * @param int $post_id Post ID. * @param string $publication_id Publication ID. @@ -457,6 +565,11 @@ public static function resolve_post_template_id( int $post_id, string $publicati return $post_template_id; } + // With the required-template rule on, a never-sent post gets no automatic template. + if ( self::is_template_choice_required( $post_id ) ) { + return ''; + } + if ( '' !== $publication_id && PublicationResolver::get_default_publication_id() === $publication_id ) { return self::get_site_post_template_id(); } @@ -464,10 +577,26 @@ public static function resolve_post_template_id( int $post_id, string $publicati return ''; } + /** + * Whether an editor must choose this post's template before it can be sent. + * + * True when the "require template choice" setting is on and the post has never + * been sent to beehiiv. Applies to every user role. + * + * @param int $post_id Post ID. + * @return bool + * @since 1.2.0 + */ + public static function is_template_choice_required( int $post_id ): bool { + $settings = Options::get(); + + return ! empty( $settings['require_template_choice'] ) && ! Sender::has_beehiiv_post_id( $post_id ); + } + /** * Email subject line for the post: its newsletter title, or the post title when empty. * - * @since x.x.x + * @since 1.1.0 * * @param int $post_id Post ID. * @param string $post_title Decoded WordPress post title. @@ -485,7 +614,7 @@ private static function get_newsletter_title( int $post_id, string $post_title ) /** * Subtitle for the beehiiv post: its newsletter subtitle, or empty for none. * - * @since x.x.x + * @since 1.1.0 * * @param int $post_id Post ID. * @@ -501,7 +630,7 @@ private static function get_newsletter_subtitle( int $post_id ): string { /** * Whether the beehiiv email shows the title and subtitle: the post's choice, hidden when never set. * - * @since x.x.x + * @since 1.1.0 * * @param int $post_id Post ID. * diff --git a/includes/Newsletter/PreviewEligibility.php b/includes/Newsletter/PreviewEligibility.php new file mode 100644 index 0000000..3151579 --- /dev/null +++ b/includes/Newsletter/PreviewEligibility.php @@ -0,0 +1,102 @@ +post_type ) { + return new WP_Error( + 'beehiiv_post_not_found', + __( 'This post no longer exists. Save or reload the editor and try again.', 'beehiiv' ) + ); + } + + if ( ! current_user_can( 'publish_posts', $post_id ) ) { + return new WP_Error( + 'beehiiv_preview_forbidden', + __( "You don't have permission to preview this newsletter.", 'beehiiv' ) + ); + } + + $is_linked = '' !== trim( (string) get_post_meta( $post_id, Meta::BEEHIIV_POST_ID, true ) ); + $is_draft_linked = $is_linked && 'draft' === get_post_meta( $post_id, Meta::BEEHIIV_POST_STATE, true ); + + if ( $is_linked && ! $is_draft_linked ) { + return new WP_Error( + 'beehiiv_newsletter_confirmed', + __( 'This newsletter is already scheduled or sent, so it can no longer be previewed.', 'beehiiv' ) + ); + } + + $is_opted_in = rest_sanitize_boolean( get_post_meta( $post_id, Meta::SEND_TO_NEWSLETTER, true ) ); + + if ( ! $is_opted_in && ! $is_draft_linked ) { + return new WP_Error( + 'beehiiv_preview_not_opted_in', + __( 'Turn on "Send to newsletter" to preview this email.', 'beehiiv' ) + ); + } + + if ( ! Manager::is_connected() ) { + return new WP_Error( + 'beehiiv_not_connected', + __( 'Connect your beehiiv account in beehiiv settings to preview.', 'beehiiv' ) + ); + } + + $publication_id = PublicationResolver::resolve_for_post( $post_id ); + + if ( '' === $publication_id ) { + return new WP_Error( + 'beehiiv_missing_publication', + __( 'Choose a publication in beehiiv settings, then try again.', 'beehiiv' ) + ); + } + + if ( '' === PostSettingsBuilder::resolve_post_template_id( $post_id, $publication_id ) ) { + return new WP_Error( + 'beehiiv_missing_post_template', + __( 'Choose a default post template in beehiiv settings to preview.', 'beehiiv' ) + ); + } + + if ( ! Workspace::can_write_posts() ) { + return new WP_Error( + 'beehiiv_preview_plan', + __( 'Email preview needs beehiiv Send API access (Max and Enterprise plans).', 'beehiiv' ) + ); + } + + return true; + } +} diff --git a/includes/Newsletter/PublicationResolver.php b/includes/Newsletter/PublicationResolver.php index 3113f89..a772157 100644 --- a/includes/Newsletter/PublicationResolver.php +++ b/includes/Newsletter/PublicationResolver.php @@ -20,7 +20,7 @@ * publication; once its beehiiv post exists, every later action targets the * publication that beehiiv post was created in. * - * @since x.x.x + * @since 1.1.0 */ final class PublicationResolver { @@ -28,7 +28,7 @@ final class PublicationResolver { * Site-wide default publication ID from the plugin settings. * * @return string - * @since x.x.x + * @since 1.1.0 */ public static function get_default_publication_id(): string { $settings = Options::get(); @@ -40,7 +40,7 @@ public static function get_default_publication_id(): string { * Publications connected to the beehiiv account (cached list). * * @return array - * @since x.x.x + * @since 1.1.0 */ public static function get_connected_publications(): array { return Publications::get_publications(); @@ -53,7 +53,7 @@ public static function get_connected_publications(): array { * unavailable, so a beehiiv outage does not block sending. * * @return array - * @since x.x.x + * @since 1.1.0 */ public static function get_editor_publications(): array { $publications = self::get_connected_publications(); @@ -81,7 +81,7 @@ public static function get_editor_publications(): array { * * @param string $publication_id Publication ID. * @return bool|null Null when the connected list is unavailable. - * @since x.x.x + * @since 1.1.0 */ public static function is_connected_publication( string $publication_id ): ?bool { $publications = self::get_connected_publications(); @@ -104,7 +104,7 @@ public static function is_connected_publication( string $publication_id ): ?bool * * @param string $publication_id Publication ID. * @return string - * @since x.x.x + * @since 1.1.0 */ public static function get_publication_name( string $publication_id ): string { foreach ( self::get_connected_publications() as $publication ) { @@ -128,7 +128,7 @@ public static function get_publication_name( string $publication_id ): string { * * @param int $post_id Post ID. * @return array{publication_id: string, is_default: bool, fallback_from: string} - * @since x.x.x + * @since 1.1.0 */ public static function resolve_target( int $post_id ): array { $default_id = self::get_default_publication_id(); @@ -165,7 +165,7 @@ public static function resolve_target( int $post_id ): array { * * @param int $post_id Post ID. * @return string - * @since x.x.x + * @since 1.1.0 */ public static function get_linked_publication_id( int $post_id ): string { $linked_id = trim( (string) get_post_meta( $post_id, Meta::BEEHIIV_LINKED_PUBLICATION_ID, true ) ); @@ -181,7 +181,7 @@ public static function get_linked_publication_id( int $post_id ): string { * * @param int $post_id Post ID. * @return string - * @since x.x.x + * @since 1.1.0 */ public static function resolve_for_post( int $post_id ): string { $beehiiv_post_id = trim( (string) get_post_meta( $post_id, Meta::BEEHIIV_POST_ID, true ) ); diff --git a/includes/Newsletter/Sender.php b/includes/Newsletter/Sender.php index accbe8a..85c6c39 100644 --- a/includes/Newsletter/Sender.php +++ b/includes/Newsletter/Sender.php @@ -10,6 +10,7 @@ use Beehiiv\API\Resources\Posts; use Beehiiv\Connection\Manager; use Beehiiv\Editor\Meta; +use WP_Error; use WP_Post; use WP_REST_Request; @@ -40,14 +41,29 @@ final class Sender { * * Keeps a publication or template move from racing beehiiv's own send. * - * @since x.x.x + * @since 1.1.0 */ private const SENT_SAFETY_MARGIN = 60; + /** + * Option name prefix for the per-post preview lock. + * + * @since 1.2.0 + */ + private const PREVIEW_LOCK_PREFIX = 'beehiiv_preview_lock_'; + + /** + * Seconds after which a preview lock counts as abandoned. Longer than the slowest sync + * (a delete and a create, each with a 30 second request timeout). + * + * @since 1.2.0 + */ + private const PREVIEW_LOCK_SECONDS = 180; + /** * Meta keys that lock once beehiiv has sent the newsletter. * - * @since x.x.x + * @since 1.1.0 */ private const LOCKED_AFTER_SEND_META_KEYS = [ Meta::BEEHIIV_PUBLICATION_ID, @@ -83,7 +99,7 @@ public static function init(): void { * Applies to every save path (block editor, REST, PHP). Changes are ignored * rather than rejected so a full editor save does not fail. * - * @since x.x.x + * @since 1.1.0 * * @param mixed $check Whether to allow updating metadata for the given type. * @param int $post_id Post ID. @@ -122,10 +138,12 @@ public static function guard_locked_newsletter_meta( $check, $post_id, $meta_key * * @param int $post_id Post ID. * @return bool - * @since x.x.x + * @since 1.1.0 */ public static function is_newsletter_published( int $post_id ): bool { - return self::has_beehiiv_post_id( $post_id ) && self::send_time_has_passed( $post_id ); + return self::has_beehiiv_post_id( $post_id ) + && ! self::is_draft_linked( $post_id ) + && self::send_time_has_passed( $post_id ); } /** @@ -133,7 +151,7 @@ public static function is_newsletter_published( int $post_id ): bool { * * @param int $post_id Post ID. * @return bool - * @since x.x.x + * @since 1.1.0 */ private static function send_time_has_passed( int $post_id ): bool { $scheduled_at = get_post_meta( $post_id, Meta::BEEHIIV_SCHEDULED_AT, true ); @@ -159,7 +177,7 @@ private static function send_time_has_passed( int $post_id ): bool { * @param string $meta_key Meta key. * @param string $value Value. * @return void - * @since x.x.x + * @since 1.1.0 */ private static function update_locked_meta( int $post_id, string $meta_key, string $value ): void { self::$bypass_meta_lock = true; @@ -174,7 +192,7 @@ private static function update_locked_meta( int $post_id, string $meta_key, stri * @param string $publication_id Publication the beehiiv post was created in. * @param string $post_template_id Template it was created with. * @return void - * @since x.x.x + * @since 1.1.0 */ private static function record_linked_publication( int $post_id, @@ -191,7 +209,7 @@ private static function record_linked_publication( * * @param int $post_id Post ID. * @return void - * @since x.x.x + * @since 1.1.0 */ private static function clear_linked_publication( int $post_id ): void { delete_post_meta( $post_id, Meta::BEEHIIV_LINKED_PUBLICATION_ID ); @@ -380,10 +398,274 @@ public static function cancel_scheduled_newsletter( int $post_id ): void { self::clear_linked_publication( $post_id ); delete_post_meta( $post_id, Meta::BEEHIIV_NEWSLETTER_SENT ); delete_post_meta( $post_id, Meta::BEEHIIV_SYNCED_SUBTITLE ); + delete_post_meta( $post_id, Meta::BEEHIIV_POST_STATE ); update_post_meta( $post_id, Meta::SEND_TO_NEWSLETTER, true ); self::clear_error( $post_id ); } + /** + * Create or update the beehiiv draft that Preview shows, from the saved post. + * + * The draft is linked to the post in a distinct draft state, built with the same + * payload builder as a real send, and never confirmed here. A draft whose publication + * or template no longer matches the post's choice is recreated, because the update API + * cannot move a post or change its template. Failures are returned, never stored as + * the post's newsletter error, and the Send toggle is left as is. + * + * @param int $post_id Post ID. + * @return array{beehiiv_post_id: string, created: bool}|WP_Error + * @since 1.2.0 + */ + public static function sync_preview_draft( int $post_id ) { + if ( ! self::acquire_preview_lock( $post_id ) ) { + return new WP_Error( + 'beehiiv_preview_in_progress', + __( 'A preview of this post is already being prepared. Try again in a moment.', 'beehiiv' ) + ); + } + + try { + return self::sync_preview_draft_locked( $post_id ); + } finally { + self::release_preview_lock( $post_id ); + } + } + + /** + * Take the per-post preview lock so two simultaneous previews cannot both create a draft. + * + * Uses an option insert, which is atomic in the database. A lock older than + * {@see PREVIEW_LOCK_SECONDS} is treated as abandoned and taken over. + * + * @param int $post_id Post ID. + * @return bool Whether the lock was acquired. + * @since 1.2.0 + */ + private static function acquire_preview_lock( int $post_id ): bool { + $key = self::PREVIEW_LOCK_PREFIX . $post_id; + + self::purge_stale_preview_locks(); + // The purge bypasses the options cache, so drop any cached copy of this lock. + wp_cache_delete( $key, 'options' ); + + if ( add_option( $key, time(), '', false ) ) { + return true; + } + + $taken_at = (int) get_option( $key, 0 ); + + if ( $taken_at > time() - self::PREVIEW_LOCK_SECONDS ) { + return false; + } + + // Abandoned lock: replace it, then re-check that this request holds it. + delete_option( $key ); + + return add_option( $key, time(), '', false ); + } + + /** + * Whether a preview sync of this post is running right now. + * + * @param int $post_id Post ID. + * @return bool + * @since 1.2.0 + */ + public static function is_preview_in_progress( int $post_id ): bool { + $taken_at = (int) get_option( self::PREVIEW_LOCK_PREFIX . $post_id, 0 ); + + return $taken_at > time() - self::PREVIEW_LOCK_SECONDS; + } + + /** + * Remove preview locks left behind by requests that died before releasing them. + * + * @return void + * @since 1.2.0 + */ + private static function purge_stale_preview_locks(): void { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Housekeeping of this feature's own lock rows. + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND CAST( option_value AS UNSIGNED ) < %d", + $wpdb->esc_like( self::PREVIEW_LOCK_PREFIX ) . '%', + time() - self::PREVIEW_LOCK_SECONDS + ) + ); + } + + /** + * Release the per-post preview lock. + * + * @param int $post_id Post ID. + * @return void + * @since 1.2.0 + */ + private static function release_preview_lock( int $post_id ): void { + delete_option( self::PREVIEW_LOCK_PREFIX . $post_id ); + } + + /** + * Create or update the Preview draft; the caller holds the preview lock. + * + * @param int $post_id Post ID. + * @return array{beehiiv_post_id: string, created: bool}|WP_Error + * @since 1.2.0 + */ + private static function sync_preview_draft_locked( int $post_id ) { + // One definition of "can this post be previewed" for the whole feature. + $eligibility = PreviewEligibility::check( $post_id ); + + if ( is_wp_error( $eligibility ) ) { + return $eligibility; + } + + $target = PublicationResolver::resolve_target( $post_id ); + $publication_id = $target['publication_id']; + + if ( '' === $publication_id ) { + return new WP_Error( 'beehiiv_no_publication', self::no_post_publication_message() ); + } + + if ( self::is_draft_linked( $post_id ) ) { + $linked_publication_id = PublicationResolver::get_linked_publication_id( $post_id ); + + if ( self::draft_needs_recreate( $post_id, $linked_publication_id, $publication_id ) ) { + if ( ! self::delete_linked_draft( $post_id ) ) { + return new WP_Error( + 'beehiiv_draft_update_failed', + 'The previous draft could not be replaced in beehiiv.' + ); + } + } else { + $beehiiv_post_id = self::get_beehiiv_post_id( $post_id ); + $payload = PostSettingsBuilder::build_draft_update_payload( $post_id, $linked_publication_id ); + + if ( is_wp_error( $payload ) ) { + return $payload; + } + + $result = Posts::update( $linked_publication_id, $beehiiv_post_id, $payload ); + + if ( ! $result['success'] ) { + return new WP_Error( 'beehiiv_draft_update_failed', $result['error'] ); + } + + return [ + 'beehiiv_post_id' => $beehiiv_post_id, + 'created' => false, + ]; + } + } + + $payload = PostSettingsBuilder::build_draft_payload( $post_id, $publication_id ); + + if ( is_wp_error( $payload ) ) { + return $payload; + } + + $result = Posts::create( $publication_id, $payload ); + + if ( ! $result['success'] ) { + return new WP_Error( 'beehiiv_draft_create_failed', $result['error'] ); + } + + self::record_linked_publication( $post_id, $publication_id, (string) ( $payload['post_template_id'] ?? '' ) ); + update_post_meta( $post_id, Meta::BEEHIIV_POST_ID, $result['post_id'] ); + update_post_meta( $post_id, Meta::BEEHIIV_POST_STATE, 'draft' ); + + return [ + 'beehiiv_post_id' => $result['post_id'], + 'created' => true, + ]; + } + + /** + * Whether the post's linked beehiiv post is a Preview draft. + * + * @param int $post_id Post ID. + * @return bool + * @since 1.2.0 + */ + public static function is_draft_linked( int $post_id ): bool { + return self::has_beehiiv_post_id( $post_id ) + && 'draft' === get_post_meta( $post_id, Meta::BEEHIIV_POST_STATE, true ); + } + + /** + * Whether a linked draft must be recreated to match the post's publication or template. + * + * @param int $post_id Post ID. + * @param string $linked_publication_id Publication the draft lives in. + * @param string $target_publication_id Publication the post now targets. + * @return bool + * @since 1.2.0 + */ + private static function draft_needs_recreate( + int $post_id, + string $linked_publication_id, + string $target_publication_id + ): bool { + if ( $linked_publication_id !== $target_publication_id ) { + return true; + } + + $linked_template_id = get_post_meta( $post_id, Meta::BEEHIIV_LINKED_POST_TEMPLATE_ID, true ); + $linked_template_id = is_string( $linked_template_id ) ? trim( $linked_template_id ) : ''; + $chosen_template_id = PostSettingsBuilder::resolve_post_template_id( $post_id, $target_publication_id ); + + return '' !== $linked_template_id && '' !== $chosen_template_id && $linked_template_id !== $chosen_template_id; + } + + /** + * Delete the linked Preview draft in beehiiv and clear the link. + * + * Never records a newsletter error; a failed delete leaves the link in place. + * + * @param int $post_id Post ID. + * @return bool Whether the link was cleared. + * @since 1.2.0 + */ + private static function delete_linked_draft( int $post_id ): bool { + if ( ! self::is_draft_linked( $post_id ) ) { + return false; + } + + $publication_id = PublicationResolver::get_linked_publication_id( $post_id ); + + if ( Manager::is_connected() && '' !== $publication_id ) { + $result = Posts::delete( $publication_id, self::get_beehiiv_post_id( $post_id ) ); + + if ( ! $result['success'] ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( sprintf( 'beehiiv draft delete failed for post ID %d: %s', $post_id, $result['error'] ) ); + return false; + } + } + + delete_post_meta( $post_id, Meta::BEEHIIV_POST_ID ); + delete_post_meta( $post_id, Meta::BEEHIIV_POST_STATE ); + delete_post_meta( $post_id, Meta::BEEHIIV_SCHEDULED_AT ); + self::clear_linked_publication( $post_id ); + + return true; + } + + /** + * Linked beehiiv post ID, or an empty string. + * + * @param int $post_id Post ID. + * @return string + * @since 1.2.0 + */ + private static function get_beehiiv_post_id( int $post_id ): string { + $beehiiv_post_id = get_post_meta( $post_id, Meta::BEEHIIV_POST_ID, true ); + + return is_string( $beehiiv_post_id ) ? trim( $beehiiv_post_id ) : ''; + } + /** * Create or schedule a beehiiv newsletter when post meta and status allow. * @@ -400,6 +682,35 @@ public static function maybe_send_post_newsletter( WP_Post $post, ?WP_REST_Reque return; } + if ( self::is_draft_linked( $post->ID ) ) { + // Turning the toggle off removes the draft. + if ( ! self::is_send_to_newsletter_enabled( $post->ID, $request ) ) { + self::delete_linked_draft( $post->ID ); + return; + } + + // Saves of a post that is not published or scheduled never touch the draft. + if ( ! self::can_sync_newsletter( $post ) ) { + return; + } + + // Publishing or scheduling replaces the draft with a real send, so the email + // is never left behind as an unsent draft. A failed cleanup is reported and retried. + self::clear_error( $post->ID ); + + if ( ! self::delete_linked_draft( $post->ID ) ) { + self::record_error( + $post->ID, + 'send', + __( + "We couldn't replace the email draft in beehiiv. Try saving the post again.", + 'beehiiv' + ) + ); + return; + } + } + if ( self::has_beehiiv_post_id( $post->ID ) ) { self::maybe_update_post_newsletter( $post ); return; @@ -536,6 +847,8 @@ public static function send( int $post_id ): void { (string) ( $beehiiv_post_data['post_template_id'] ?? '' ) ); update_post_meta( $post_id, Meta::BEEHIIV_POST_ID, $result['post_id'] ); + // This first sync converted Group blocks to sections; later syncs of this post must too. + update_post_meta( $post_id, Meta::BEEHIIV_GROUP_SECTIONS, true ); update_post_meta( $post_id, Meta::SEND_TO_NEWSLETTER, false ); delete_post_meta( $post_id, Meta::BEEHIIV_NEWSLETTER_SENT ); self::persist_synced_subtitle( $post_id, $beehiiv_post_data ); @@ -559,6 +872,10 @@ public static function send( int $post_id ): void { * @since 1.0.0 */ public static function update( int $post_id ): void { + if ( self::is_draft_linked( $post_id ) ) { + return; + } + self::clear_error( $post_id ); if ( ! Manager::is_connected() ) { @@ -699,7 +1016,7 @@ public static function update( int $post_id ): void { * @param int $post_id Post ID. * @param string $linked_publication_id Publication the beehiiv post lives in. * @return string Target publication ID, or empty when no move is needed. - * @since x.x.x + * @since 1.1.0 */ private static function get_move_target_publication( int $post_id, string $linked_publication_id ): string { if ( self::is_newsletter_published( $post_id ) ) { @@ -814,7 +1131,7 @@ private static function recreate_linked_post( * @param int $post_id Post ID. * @param array $payload Payload that was sent. * @return void - * @since x.x.x + * @since 1.1.0 */ private static function persist_synced_subtitle( int $post_id, array $payload ): void { if ( ! array_key_exists( 'subtitle', $payload ) ) { @@ -898,7 +1215,7 @@ public static function has_beehiiv_post_id( int $post_id ): bool { * * @param int $post_id Post ID. * @return bool - * @since x.x.x + * @since 1.1.0 */ public static function is_newsletter_sent( int $post_id ): bool { return rest_sanitize_boolean( get_post_meta( $post_id, Meta::BEEHIIV_NEWSLETTER_SENT, true ) ); @@ -912,7 +1229,7 @@ public static function is_newsletter_sent( int $post_id ): bool { * * @param int $post_id Post ID. * @return bool - * @since x.x.x + * @since 1.1.0 */ private static function should_confirm_sent_state( int $post_id ): bool { $scheduled_at = get_post_meta( $post_id, Meta::BEEHIIV_SCHEDULED_AT, true ); @@ -935,7 +1252,7 @@ private static function should_confirm_sent_state( int $post_id ): bool { * * @param array $beehiiv_post Post data from beehiiv. * @return bool - * @since x.x.x + * @since 1.1.0 */ private static function is_sent_beehiiv_post( array $beehiiv_post ): bool { if ( isset( $beehiiv_post['status'] ) && 'archived' === $beehiiv_post['status'] ) { @@ -1017,6 +1334,8 @@ public static function format_save_error_message( \WP_Error $error ): string { 'Choose a default post template in beehiiv settings, or pick one for this post in the beehiiv sidebar.', 'beehiiv' ); + case 'beehiiv_post_template_choice_required': + return __( 'Choose a post template before sending this post to beehiiv.', 'beehiiv' ); case 'beehiiv_post_template_required': return __( "Pick a post template for this post's publication in the beehiiv sidebar.", 'beehiiv' ); case 'beehiiv_publication_missing': @@ -1092,7 +1411,7 @@ private static function no_publication_message(): string { * User-facing message when a post has no publication and there is no default. * * @return string - * @since x.x.x + * @since 1.1.0 */ private static function no_post_publication_message(): string { return __( 'Choose a publication for this post in the beehiiv sidebar.', 'beehiiv' ); @@ -1104,7 +1423,7 @@ private static function no_post_publication_message(): string { * @param string $removed_publication_id Publication no longer connected. * @param string $default_publication_id Default publication used instead. * @return string - * @since x.x.x + * @since 1.1.0 */ private static function publication_fallback_message( string $removed_publication_id, @@ -1309,7 +1628,9 @@ private static function is_send_to_newsletter_enabled( int $post_id, ?WP_REST_Re * @since 1.0.0 */ private static function should_cancel_newsletter_for_status_change( string $new_status, WP_Post $post ): bool { - return self::has_beehiiv_post_id( $post->ID ) && self::is_non_public_status( $new_status ); + return self::has_beehiiv_post_id( $post->ID ) + && ! self::is_draft_linked( $post->ID ) + && self::is_non_public_status( $new_status ); } /** @@ -1326,7 +1647,7 @@ private static function should_resend_newsletter_for_status_change( string $old_status, WP_Post $post ): bool { - if ( self::has_beehiiv_post_id( $post->ID ) ) { + if ( self::has_beehiiv_post_id( $post->ID ) && ! self::is_draft_linked( $post->ID ) ) { return false; } diff --git a/includes/Newsletter/SeoMetadata.php b/includes/Newsletter/SeoMetadata.php index f9217ca..534e463 100644 --- a/includes/Newsletter/SeoMetadata.php +++ b/includes/Newsletter/SeoMetadata.php @@ -19,7 +19,7 @@ * otherwise from the post title and excerpt. Social titles and descriptions fall * back to the search title and description when the SEO plugin leaves them empty. * - * @since x.x.x + * @since 1.1.0 */ final class SeoMetadata { @@ -37,7 +37,7 @@ final class SeoMetadata { * twitter_title: string, * twitter_description: string * } - * @since x.x.x + * @since 1.1.0 */ public static function resolve( WP_Post $post ): array { try { @@ -53,7 +53,7 @@ public static function resolve( WP_Post $post ): array { * @param array $values SEO plugin values. * @param WP_Post $post Post object. * @return array - * @since x.x.x + * @since 1.1.0 */ private static function build( array $values, WP_Post $post ): array { $title = '' !== ( $values['title'] ?? '' ) ? $values['title'] : self::clean( get_the_title( $post ) ); @@ -77,7 +77,7 @@ private static function build( array $values, WP_Post $post ): array { * @param array $settings Resolved values. * @param WP_Post $post Post object. * @return array - * @since x.x.x + * @since 1.1.0 */ private static function filter( array $settings, WP_Post $post ): array { /** @@ -85,7 +85,7 @@ private static function filter( array $settings, WP_Post $post ): array { * * Use this to supply values from an SEO plugin the beehiiv plugin does not read natively. * - * @since x.x.x + * @since 1.1.0 * * @param array $settings Resolved `seo_settings` values. * @param WP_Post $post WordPress post object. @@ -112,7 +112,7 @@ private static function filter( array $settings, WP_Post $post ): array { * * @param WP_Post $post Post object. * @return string - * @since x.x.x + * @since 1.1.0 */ private static function excerpt( WP_Post $post ): string { if ( '' !== trim( $post->post_excerpt ) ) { @@ -129,7 +129,7 @@ private static function excerpt( WP_Post $post ): string { * * @param WP_Post $post Post object. * @return array - * @since x.x.x + * @since 1.1.0 */ private static function from_seo_plugin( WP_Post $post ): array { if ( function_exists( 'YoastSEO' ) ) { @@ -148,7 +148,7 @@ private static function from_seo_plugin( WP_Post $post ): array { * * @param WP_Post $post Post object. * @return array - * @since x.x.x + * @since 1.1.0 */ private static function from_yoast( WP_Post $post ): array { $meta = YoastSEO()->meta->for_post( $post->ID ); @@ -172,7 +172,7 @@ private static function from_yoast( WP_Post $post ): array { * * @param WP_Post $post Post object. * @return array - * @since x.x.x + * @since 1.1.0 */ private static function from_rank_math( WP_Post $post ): array { $keys = [ @@ -207,7 +207,7 @@ private static function from_rank_math( WP_Post $post ): array { * @param string $value Preferred value. * @param string $fallback Fallback value. * @return string - * @since x.x.x + * @since 1.1.0 */ private static function first_filled( string $value, string $fallback ): string { return '' !== $value ? $value : $fallback; @@ -218,7 +218,7 @@ private static function first_filled( string $value, string $fallback ): string * * @param string $value Raw value. * @return string - * @since x.x.x + * @since 1.1.0 */ private static function clean( string $value ): string { return trim( html_entity_decode( wp_strip_all_tags( $value ), ENT_QUOTES, 'UTF-8' ) ); diff --git a/includes/Newsletter/SupportedBlocks.php b/includes/Newsletter/SupportedBlocks.php index ba93a96..695c6cc 100644 --- a/includes/Newsletter/SupportedBlocks.php +++ b/includes/Newsletter/SupportedBlocks.php @@ -7,13 +7,18 @@ namespace Beehiiv\Newsletter; +use Beehiiv\Newsletter\Converters\ColumnsBlockConverter; + defined( 'ABSPATH' ) || exit; /** * Single source of truth for which WordPress blocks are included in newsletters. * - * Layout blocks such as `core/group` and `core/columns` are not supported. They - * and all of their inner blocks are omitted from the newsletter payload. + * `core/group` is supported as a beehiiv section for posts first synced with section + * support; see `BlockConverter::convert_group_block()`. `core/columns` is supported, but a + * Columns block with more than 3 columns is omitted; see + * `BlockConverter::convert_columns_block_to_blocks()`. Other layout blocks are not + * supported. They and all of their inner blocks are omitted from the newsletter payload. * * @since 1.0.0 */ @@ -34,6 +39,8 @@ final class SupportedBlocks { 'core/pullquote', 'core/embed', 'core/media-text', + 'core/columns', + 'core/group', 'core/buttons', 'core/separator', 'beehiiv/advertisement', @@ -46,6 +53,7 @@ final class SupportedBlocks { */ public const NESTED_ONLY = [ 'core/button', + 'core/column', 'core/list-item', ]; @@ -61,7 +69,7 @@ final class SupportedBlocks { /** * Block support config for the block editor (localized to JavaScript). * - * @return array> + * @return array|int> * @since 1.0.0 */ public static function get_editor_config(): array { @@ -69,6 +77,7 @@ public static function get_editor_config(): array { 'supported' => self::BLOCKS, 'nestedOnly' => self::NESTED_ONLY, 'snippetOnly' => self::SNIPPET_ONLY, + 'maxColumns' => ColumnsBlockConverter::MAX_COLUMNS, ]; } diff --git a/includes/OAuth/Config.php b/includes/OAuth/Config.php index 15a77f5..558b3cf 100644 --- a/includes/OAuth/Config.php +++ b/includes/OAuth/Config.php @@ -37,6 +37,16 @@ final class Config { */ private const REGISTRATION_TOKEN = 'BEEHIIV_REGISTRATION_TOKEN_PLACEHOLDER'; + /** + * Constant WordPress VIP defines before the plugin loads, holding a config array. + * + * Derived from the integration slug in `vip-manifest.yaml`. Supported key: + * `api_base_url` (optional URL; empty or invalid values are ignored). + * + * @since 1.1.0 + */ + public const CONSTANT_NAME = 'VIP_BEEHIIV_CONFIG'; + /** * WP-config.php constant for registration token override. * @@ -114,9 +124,76 @@ public static function get_oauth_base_url(): string { */ public static function get_api_base_url(): string { + $vip_url = self::sanitize_vip_url( self::get_vip_config()['api_base_url'] ?? '' ); + + if ( '' !== $vip_url ) { + return $vip_url; + } + return self::get_url_constant( self::API_BASE_URL_CONST, self::DEFAULT_API_BASE_URL ); } + /** + * Config array WordPress VIP defines, or an empty array when absent or invalid. + * + * Missing or malformed config is a normal state, never an error: callers + * fall back to the wp-config constant and then the production default. + * + * @since 1.1.0 + * + * @return array + */ + public static function get_vip_config(): array { + + if ( ! defined( self::CONSTANT_NAME ) ) { + return []; + } + + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Defined by the VIP platform. + return self::parse_vip_config( constant( self::CONSTANT_NAME ) ); + } + + /** + * Normalize a raw VIP config value to an array. + * + * @since 1.1.0 + * + * @param mixed $raw Raw value of the VIP config constant. + * + * @return array + */ + public static function parse_vip_config( $raw ): array { + + return is_array( $raw ) ? $raw : []; + } + + /** + * Return a usable http(s) URL from a VIP config value, or an empty string. + * + * A missing key, a non-string value, a blank string and a non-http(s) URL + * are all treated the same: unset. + * + * @since 1.1.0 + * + * @param mixed $value Raw config value. + * + * @return string + */ + public static function sanitize_vip_url( $value ): string { + + if ( ! is_string( $value ) ) { + return ''; + } + + $url = trim( $value ); + + if ( '' === $url || ! in_array( wp_parse_url( $url, PHP_URL_SCHEME ), [ 'http', 'https' ], true ) ) { + return ''; + } + + return untrailingslashit( $url ); + } + /** * Whether outbound HTTPS requests should verify TLS certificates. * diff --git a/includes/Plugin.php b/includes/Plugin.php index c15a98f..2b44776 100644 --- a/includes/Plugin.php +++ b/includes/Plugin.php @@ -47,6 +47,7 @@ public static function init(): void { add_action( 'rest_api_init', [ REST\PublicationsController::class, 'register_routes' ] ); add_action( 'rest_api_init', [ REST\AdvertisementOpportunitiesController::class, 'register_routes' ] ); add_action( 'rest_api_init', [ REST\TestSendController::class, 'register_routes' ] ); + add_action( 'rest_api_init', [ REST\EmailPreviewController::class, 'register_routes' ] ); } /** diff --git a/includes/REST/EmailPreviewController.php b/includes/REST/EmailPreviewController.php new file mode 100644 index 0000000..654edb9 --- /dev/null +++ b/includes/REST/EmailPreviewController.php @@ -0,0 +1,109 @@ + WP_REST_Server::CREATABLE, + 'callback' => [ self::class, 'preview' ], + 'permission_callback' => [ self::class, 'permissions_check' ], + 'args' => [ + 'post_id' => [ + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ], + 'fetch_only' => [ + 'required' => false, + 'type' => 'boolean', + 'default' => false, + 'sanitize_callback' => 'rest_sanitize_boolean', + ], + ], + ] + ); + } + + /** + * Requires publish rights and edit access to the referenced post. + * + * @since 1.2.0 + * + * @param WP_REST_Request $request REST request. + * @return bool + */ + public static function permissions_check( WP_REST_Request $request ): bool { + + $post_id = absint( $request->get_param( 'post_id' ) ); + + return $post_id + && current_user_can( 'edit_post', $post_id ) + && current_user_can( 'publish_posts' ); + } + + /** + * Sync the linked draft and return the rendered email HTML. + * + * @since 1.2.0 + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response + */ + public static function preview( WP_REST_Request $request ): WP_REST_Response { + + $result = EmailPreview::render( + absint( $request->get_param( 'post_id' ) ), + (bool) $request->get_param( 'fetch_only' ) + ); + + if ( is_wp_error( $result ) ) { + return new WP_REST_Response( + [ + 'code' => $result->get_error_code(), + 'message' => $result->get_error_message(), + ], + 400 + ); + } + + // 202: beehiiv is still preparing the email; the editor asks again shortly. + return new WP_REST_Response( $result, isset( $result['processing'] ) ? 202 : 200 ); + } +} diff --git a/includes/REST/PublicationsController.php b/includes/REST/PublicationsController.php index 285466a..2ddaac1 100644 --- a/includes/REST/PublicationsController.php +++ b/includes/REST/PublicationsController.php @@ -19,21 +19,21 @@ /** * Exposes connected publications so the settings screen and editor can refresh their lists. * - * @since x.x.x + * @since 1.1.0 */ final class PublicationsController { /** * REST namespace. * - * @since x.x.x + * @since 1.1.0 */ private const NAMESPACE = 'beehiiv/v1'; /** * Register REST routes. * - * @since x.x.x + * @since 1.1.0 * * @return void */ @@ -62,7 +62,7 @@ public static function register_routes(): void { * * Settings screen requires manage_options; the editor's Publication selector requires publish_posts. * - * @since x.x.x + * @since 1.1.0 * * @return bool */ @@ -74,7 +74,7 @@ public static function permissions_check(): bool { /** * Return the publications connected to the beehiiv account. * - * @since x.x.x + * @since 1.1.0 * * @param WP_REST_Request $request REST request. * diff --git a/languages/beehiiv.pot b/languages/beehiiv.pot index 62edea6..37432d4 100644 --- a/languages/beehiiv.pot +++ b/languages/beehiiv.pot @@ -2,14 +2,14 @@ # This file is distributed under the GPL-2.0-or-later. msgid "" msgstr "" -"Project-Id-Version: beehiiv 1.1.0\n" +"Project-Id-Version: beehiiv 1.2.0\n" "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/beehiiv\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "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-12T16:55:02+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:339 msgid "beehiiv" msgstr "" @@ -45,46 +45,58 @@ msgstr "" msgid "Default post template" msgstr "" -#: includes/Admin/Registrar.php:97 +#: includes/Admin/Registrar.php:92 +msgid "Template choice" +msgstr "" + +#: includes/Admin/Registrar.php:108 msgid "Default beehiiv publication and post template for newsletters sent from this site." msgstr "" -#: includes/Admin/Registrar.php:121 +#: includes/Admin/Registrar.php:132 #: 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:166 #: 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:171 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:201 #: src/js/admin/settings/index.js:138 msgid "No default template" msgstr "" -#: includes/Admin/Registrar.php:225 +#: includes/Admin/Registrar.php:236 #: src/js/admin/settings/index.js:21 -#: src/js/editor/post-settings/components/newsletter-template-select.js:201 +#: src/js/editor/post-settings/components/newsletter-template-select.js:251 msgid "Refresh templates" msgstr "" -#: includes/Admin/Registrar.php:230 +#: includes/Admin/Registrar.php:241 #: 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:250 msgid "Default newsletter template for this site. Refresh after changing templates in beehiiv." msgstr "" +#: includes/Admin/Registrar.php:277 +msgid "Require editors to choose a post template" +msgstr "" + +#: includes/Admin/Registrar.php:282 +msgid "When on, editors must pick a template for each new post. The default template is not applied automatically to those posts, and sending is blocked until a template is chosen." +msgstr "" + #: includes/Admin/Views/connection.php:30 msgid "Connection" msgstr "" @@ -200,121 +212,182 @@ msgstr "" msgid "%1$s (%2$s)" msgstr "" -#: includes/Newsletter/PostSettingsBuilder.php:618 -#: includes/Newsletter/PostSettingsBuilder.php:667 -#: includes/Newsletter/Sender.php:1046 +#: includes/Newsletter/EmailPreview.php:56 +#: includes/Newsletter/EmailPreview.php:198 +#: src/js/editor/post-settings/components/email-preview.js:141 +msgid "Something went wrong and the preview couldn't load. Try again." +msgstr "" + +#: includes/Newsletter/EmailPreview.php:168 +msgid "beehiiv refused the request. Reconnect your account in beehiiv settings and make sure it has access to read posts." +msgstr "" + +#: includes/Newsletter/EmailPreview.php:179 +#: includes/Newsletter/PreviewEligibility.php:96 +msgid "Email preview needs beehiiv Send API access (Max and Enterprise plans)." +msgstr "" + +#. translators: %s: error message from beehiiv. +#: includes/Newsletter/EmailPreview.php:188 +#, php-format +msgid "beehiiv couldn't prepare the preview: %s" +msgstr "" + +#: includes/Newsletter/PostSettingsBuilder.php:200 +msgid "Something went wrong preparing the email draft. Try again." +msgstr "" + +#: includes/Newsletter/PostSettingsBuilder.php:741 +#: includes/Newsletter/PostSettingsBuilder.php:790 +#: includes/Newsletter/Sender.php:1365 msgid "That send date has already passed. Pick a future date and time in the newsletter schedule." msgstr "" -#: includes/Newsletter/PostSettingsBuilder.php:642 -#: includes/Newsletter/PostSettingsBuilder.php:697 -#: includes/Newsletter/PostSettingsBuilder.php:738 -#: includes/Newsletter/Sender.php:1057 +#: includes/Newsletter/PostSettingsBuilder.php:765 +#: includes/Newsletter/PostSettingsBuilder.php:820 +#: includes/Newsletter/PostSettingsBuilder.php:861 +#: includes/Newsletter/Sender.php:1376 msgid "That send date isn't valid. Open the newsletter schedule and choose a different date and time." msgstr "" -#: includes/Newsletter/PostSettingsBuilder.php:685 -#: includes/Newsletter/Sender.php:1051 +#: includes/Newsletter/PostSettingsBuilder.php:808 +#: includes/Newsletter/Sender.php:1370 msgid "The newsletter can't send before this post publishes. Choose a later send time, or schedule the post first." msgstr "" -#: includes/Newsletter/Sender.php:359 +#: includes/Newsletter/PreviewEligibility.php:40 +#: includes/Newsletter/Sender.php:808 +#: includes/Newsletter/Sender.php:936 +#: includes/Newsletter/Sender.php:1363 +#: includes/Newsletter/TestSender.php:125 +msgid "This post no longer exists. Save or reload the editor and try again." +msgstr "" + +#: includes/Newsletter/PreviewEligibility.php:47 +msgid "You don't have permission to preview this newsletter." +msgstr "" + +#: includes/Newsletter/PreviewEligibility.php:57 +msgid "This newsletter is already scheduled or sent, so it can no longer be previewed." +msgstr "" + +#: includes/Newsletter/PreviewEligibility.php:66 +msgid "Turn on \"Send to newsletter\" to preview this email." +msgstr "" + +#: includes/Newsletter/PreviewEligibility.php:73 +msgid "Connect your beehiiv account in beehiiv settings to preview." +msgstr "" + +#: includes/Newsletter/PreviewEligibility.php:82 +#: includes/Newsletter/Sender.php:1407 +#: includes/Newsletter/TestSender.php:168 +msgid "Choose a publication in beehiiv settings, then try again." +msgstr "" + +#: includes/Newsletter/PreviewEligibility.php:89 +msgid "Choose a default post template in beehiiv settings to preview." +msgstr "" + +#: includes/Newsletter/Sender.php:377 msgid "We couldn't cancel the scheduled newsletter in beehiiv. Try saving the post again, or cancel it directly in beehiiv." msgstr "" -#: includes/Newsletter/Sender.php:497 -#: includes/Newsletter/Sender.php:619 -#: includes/Newsletter/Sender.php:1044 -#: includes/Newsletter/TestSender.php:125 -msgid "This post no longer exists. Save or reload the editor and try again." +#: includes/Newsletter/Sender.php:423 +msgid "A preview of this post is already being prepared. Try again in a moment." msgstr "" -#: includes/Newsletter/Sender.php:1015 +#: includes/Newsletter/Sender.php:705 +msgid "We couldn't replace the email draft in beehiiv. Try saving the post again." +msgstr "" + +#: includes/Newsletter/Sender.php:1332 msgid "Choose a default post template in beehiiv settings, or pick one for this post in the beehiiv sidebar." msgstr "" -#: includes/Newsletter/Sender.php:1021 +#: includes/Newsletter/Sender.php:1338 +#: src/js/editor/post-settings/components/newsletter-status-notices.js:137 +#: src/js/editor/post-settings/components/newsletter-template-select.js:195 +msgid "Choose a post template before sending this post to beehiiv." +msgstr "" + +#: includes/Newsletter/Sender.php:1340 msgid "Pick a post template for this post's publication in the beehiiv sidebar." msgstr "" -#: includes/Newsletter/Sender.php:1025 +#: includes/Newsletter/Sender.php:1344 msgid "Add a title and body content before sending this newsletter." msgstr "" -#: includes/Newsletter/Sender.php:1027 +#: includes/Newsletter/Sender.php:1346 msgid "This post doesn't include any blocks beehiiv can send. Add supported content and try again." msgstr "" -#: includes/Newsletter/Sender.php:1032 +#: includes/Newsletter/Sender.php:1351 #: src/js/editor/post-settings/components/incomplete-advertisement-notice.js:25 msgid "Select an advertisement for the Advertisement block, or remove the block, to send this newsletter." msgstr "" -#: includes/Newsletter/Sender.php:1038 +#: includes/Newsletter/Sender.php:1357 msgid "The selected advertisement is no longer available in beehiiv. Choose a different advertisement or remove the Advertisement block, then try again." msgstr "" -#: includes/Newsletter/Sender.php:1075 +#: includes/Newsletter/Sender.php:1394 msgid "Connect your beehiiv account in beehiiv settings to send this newsletter." msgstr "" -#: includes/Newsletter/Sender.php:1088 -#: includes/Newsletter/TestSender.php:168 -msgid "Choose a publication in beehiiv settings, then try again." -msgstr "" - -#: includes/Newsletter/Sender.php:1098 +#: includes/Newsletter/Sender.php:1417 msgid "Choose a publication for this post in the beehiiv sidebar." msgstr "" #. translators: 1: removed publication name, 2: default publication name. -#: includes/Newsletter/Sender.php:1115 +#: includes/Newsletter/Sender.php:1434 #, php-format msgid "The publication \"%1$s\" is no longer connected, so this newsletter was sent to the default publication \"%2$s\" instead." msgstr "" -#: includes/Newsletter/Sender.php:1142 -#: includes/Newsletter/Sender.php:1177 +#: includes/Newsletter/Sender.php:1461 +#: includes/Newsletter/Sender.php:1496 msgid "Post not found." msgstr "" -#: includes/Newsletter/Sender.php:1146 +#: includes/Newsletter/Sender.php:1465 msgid "Your beehiiv connection expired. Reconnect in beehiiv settings." msgstr "" #. translators: %s: error message from the beehiiv API. -#: includes/Newsletter/Sender.php:1155 +#: includes/Newsletter/Sender.php:1474 #, php-format msgid "beehiiv rejected this newsletter: %s" msgstr "" -#: includes/Newsletter/Sender.php:1161 +#: includes/Newsletter/Sender.php:1480 #: includes/Newsletter/TestSender.php:631 msgid "Too many requests. Try again in a moment." msgstr "" -#: includes/Newsletter/Sender.php:1165 +#: includes/Newsletter/Sender.php:1484 msgid "beehiiv is temporarily unavailable. Try again later." msgstr "" -#: includes/Newsletter/Sender.php:1169 -#: includes/Newsletter/Sender.php:1181 +#: includes/Newsletter/Sender.php:1488 +#: includes/Newsletter/Sender.php:1500 msgid "Something went wrong. Try saving the post again." msgstr "" -#: includes/Newsletter/Sender.php:1185 +#: includes/Newsletter/Sender.php:1504 msgid "beehiiv took too long to respond. Try again." msgstr "" -#: includes/Newsletter/Sender.php:1189 +#: includes/Newsletter/Sender.php:1508 msgid "Couldn't reach beehiiv. Try again." msgstr "" -#: includes/Newsletter/Sender.php:1206 +#: includes/Newsletter/Sender.php:1525 msgid "Something went wrong and the newsletter wasn't sent. Try saving the post again." msgstr "" -#: includes/Newsletter/Sender.php:1223 +#: includes/Newsletter/Sender.php:1542 msgid "Something went wrong and the newsletter couldn't be updated. Try saving the post again." msgstr "" @@ -492,12 +565,12 @@ msgstr "" #: src/js/admin/settings/index.js:302 #: src/js/blocks/advertisement/edit.js:219 #: src/js/editor/post-settings/components/newsletter-publication-select.js:114 -#: src/js/editor/post-settings/components/newsletter-template-select.js:200 +#: src/js/editor/post-settings/components/newsletter-template-select.js:250 msgid "Refreshing…" msgstr "" #: src/js/admin/settings/index.js:213 -#: src/js/editor/post-settings/components/newsletter-template-select.js:208 +#: src/js/editor/post-settings/components/newsletter-template-select.js:258 msgid "Templates updated from beehiiv." msgstr "" @@ -582,6 +655,63 @@ msgstr "" msgid "beehiiv Subscribe Form" msgstr "" +#: src/js/editor/post-settings/components/email-preview.js:84 +msgid "This newsletter is already scheduled or sent, so it can't be previewed." +msgstr "" + +#: src/js/editor/post-settings/components/email-preview.js:132 +msgid "beehiiv is still preparing this email. Try again in a moment." +msgstr "" + +#: src/js/editor/post-settings/components/email-preview.js:172 +msgid "Save the post to preview your latest edits." +msgstr "" + +#: src/js/editor/post-settings/components/email-preview.js:185 +msgid "Preview email" +msgstr "" + +#: src/js/editor/post-settings/components/email-preview.js:190 +#: src/js/editor/post-settings/index.js:298 +msgid "Email preview" +msgstr "" + +#: src/js/editor/post-settings/components/email-preview.js:198 +msgid "Preparing your email preview…" +msgstr "" + +#: src/js/editor/post-settings/components/email-preview.js:211 +msgid "Try again" +msgstr "" + +#: src/js/editor/post-settings/components/email-preview.js:229 +msgid "Desktop" +msgstr "" + +#: src/js/editor/post-settings/components/email-preview.js:240 +msgid "Mobile" +msgstr "" + +#: src/js/editor/post-settings/components/email-preview.js:246 +msgid "Refresh preview" +msgstr "" + +#: src/js/editor/post-settings/components/email-preview.js:265 +msgid "From:" +msgstr "" + +#: src/js/editor/post-settings/components/email-preview.js:267 +msgid "Subject:" +msgstr "" + +#: src/js/editor/post-settings/components/email-preview.js:270 +msgid "Preview Text:" +msgstr "" + +#: src/js/editor/post-settings/components/email-preview.js:276 +msgid "beehiiv email preview" +msgstr "" + #: src/js/editor/post-settings/components/newsletter-date-picker.js:37 msgid "Invalid date. Please choose a different date and time." msgstr "" @@ -664,44 +794,60 @@ msgstr "" msgid "Choose a publication for this post." msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:134 +#: src/js/editor/post-settings/components/newsletter-status-notices.js:141 msgid "Pick a post template for this post." msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:167 +#: src/js/editor/post-settings/components/newsletter-status-notices.js:174 msgid "Could not save this post to beehiiv:" msgstr "" -#: src/js/editor/post-settings/components/newsletter-status-notices.js:175 +#: src/js/editor/post-settings/components/newsletter-status-notices.js:182 msgid "Could not send this post to beehiiv:" msgstr "" #. translators: %s: post template name #. translators: %s: post template ID -#: src/js/editor/post-settings/components/newsletter-template-select.js:122 -#: src/js/editor/post-settings/components/newsletter-template-select.js:140 +#: src/js/editor/post-settings/components/newsletter-template-select.js:126 +#: src/js/editor/post-settings/components/newsletter-template-select.js:144 #, js-format msgid "%s (default)" msgstr "" -#: src/js/editor/post-settings/components/newsletter-template-select.js:156 -msgid "Select a template" +#. translators: %s: post template ID +#: src/js/editor/post-settings/components/newsletter-template-select.js:160 +#, js-format +msgid "%s (unavailable)" msgstr "" -#: src/js/editor/post-settings/components/newsletter-template-select.js:178 -msgid "Post template" +#: src/js/editor/post-settings/components/newsletter-template-select.js:171 +msgid "Choose a template" +msgstr "" + +#: src/js/editor/post-settings/components/newsletter-template-select.js:172 +msgid "Select a template" msgstr "" -#: src/js/editor/post-settings/components/newsletter-template-select.js:184 +#: src/js/editor/post-settings/components/newsletter-template-select.js:188 msgid "The template can't be changed once the newsletter is in beehiiv." msgstr "" -#: src/js/editor/post-settings/components/newsletter-template-select.js:212 +#. translators: %s: default post template name +#: src/js/editor/post-settings/components/newsletter-template-select.js:208 +#, js-format +msgid "This post uses the default template, \"%s\", unless you choose another." +msgstr "" + +#: src/js/editor/post-settings/components/newsletter-template-select.js:235 +msgid "Post template" +msgstr "" + +#: src/js/editor/post-settings/components/newsletter-template-select.js:262 msgid "Templates are cached. Refresh to pull the latest from beehiiv after adding, renaming, or deleting one." msgstr "" #. translators: %d: number of omitted blocks. -#: src/js/editor/post-settings/components/omitted-blocks-notice.js:27 +#: src/js/editor/post-settings/components/omitted-blocks-notice.js:28 #, js-format msgid "%d block will be omitted from this newsletter." msgid_plural "%d blocks will be omitted from this newsletter." @@ -754,64 +900,67 @@ msgstr "" msgid "A leftover test draft may still be in beehiiv. You can delete it there." msgstr "" -#: src/js/editor/post-settings/filters/with-omitted-block-indicator.js:38 -#: src/js/editor/post-settings/filters/with-omitted-block-indicator.js:42 +#: src/js/editor/post-settings/filters/with-omitted-block-indicator.js:52 msgid "Not included in beehiiv newsletter" msgstr "" -#: src/js/editor/post-settings/index.js:157 +#: src/js/editor/post-settings/index.js:166 msgid "Newsletter title" msgstr "" -#: src/js/editor/post-settings/index.js:158 +#: src/js/editor/post-settings/index.js:167 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:179 msgid "Newsletter subtitle" msgstr "" -#: src/js/editor/post-settings/index.js:171 +#: src/js/editor/post-settings/index.js:180 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:191 msgid "Show title and subtitle in email" msgstr "" -#: src/js/editor/post-settings/index.js:183 +#: src/js/editor/post-settings/index.js:192 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:239 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:262 msgid "Snippet newsletter" msgstr "" -#: src/js/editor/post-settings/index.js:255 +#: src/js/editor/post-settings/index.js:265 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:273 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:290 +msgid "Pick a post template for this post to send a test email." +msgstr "" + +#: src/js/editor/post-settings/index.js:303 msgid "Test email" msgstr "" -#: src/js/editor/post-settings/index.js:357 +#: src/js/editor/post-settings/index.js:390 msgid "Yes" msgstr "" -#: src/js/editor/post-settings/index.js:358 +#: src/js/editor/post-settings/index.js:391 msgid "No" msgstr "" -#: src/js/editor/post-settings/index.js:365 +#: src/js/editor/post-settings/index.js:398 msgid "Send to newsletter:" msgstr "" diff --git a/readme.txt b/readme.txt index eeef2b8..57c76f9 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: newsletter, email, publishing, beehiiv, subscribe Requires at least: 6.8 Tested up to: 7.0 Requires PHP: 7.4 -Stable tag: 1.1.0 +Stable tag: 1.2.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -145,6 +145,13 @@ It is visible in the editor but does not render on the front end of your site. == Changelog == += 1.2.0 = +* Added a "Preview email" button to the post editor that shows the real beehiiv email of a saved post, in desktop and mobile views. +* Added a setting that requires editors to choose a post template before sending. When it is on, no template is chosen for them. +* A post template that is no longer available in beehiiv is now labeled "(unavailable)" in the template picker. +* Group blocks now become beehiiv sections, and top-level Columns blocks become beehiiv columns. +* Added support for the WordPress VIP Integration Center, including configuration through VIP_BEEHIIV_CONFIG. + = 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/editor/post-settings/components/email-preview.js b/src/js/editor/post-settings/components/email-preview.js new file mode 100644 index 0000000..7461863 --- /dev/null +++ b/src/js/editor/post-settings/components/email-preview.js @@ -0,0 +1,290 @@ +/** + * Preview the real beehiiv email of the saved post in a WordPress modal. + */ +import { __ } from '@wordpress/i18n'; +import { useMemo, useRef, useState } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { Button, Modal, Spinner } from '@wordpress/components'; +import { update } from '@wordpress/icons'; +import apiFetch from '@wordpress/api-fetch'; + +import { useBeehiivEditorConfig } from '../hooks/use-beehiiv-editor-config'; +import renderSettingsLinkMessage from '../utils/render-settings-link-message'; + +// While beehiiv prepares a new email: up to MAX_ATTEMPTS fetches, POLL_INTERVAL_MS apart. +const MAX_ATTEMPTS = 4; +const POLL_INTERVAL_MS = 2000; + +const wait = ( ms ) => new Promise( ( resolve ) => setTimeout( resolve, ms ) ); + +const INERT_STYLE = + 'a,a *,area{pointer-events:none!important;cursor:default!important}'; + +/** + * Make the email view-only: links cannot be followed, even with the keyboard. + * + * Scripts, forms and popups are already blocked by the empty `sandbox` attribute; this + * removes link targets so the frame cannot navigate away from the email either. + * + * @param {string} html Rendered email HTML from beehiiv. + * @return {string} HTML with links made inert. + */ +export function makeEmailInert( html ) { + // DOMParser builds an inert document: nothing in it loads or runs. + const doc = new window.DOMParser().parseFromString( html, 'text/html' ); + + doc.querySelectorAll( 'a[href], area[href]' ).forEach( ( link ) => { + link.removeAttribute( 'href' ); + } ); + + const base = doc.createElement( 'base' ); + base.setAttribute( 'target', '_blank' ); + doc.head.prepend( base ); + + const style = doc.createElement( 'style' ); + style.textContent = INERT_STYLE; + doc.head.append( style ); + + return '' + doc.documentElement.outerHTML; +} + +/** + * @param {Object} props + * @param {import('../hooks/use-beehiiv-post-meta').BeehiivPostMeta} props.beehiivMeta + */ +export default function EmailPreview( { beehiivMeta } ) { + const { settingsUrl } = useBeehiivEditorConfig(); + const [ isOpen, setIsOpen ] = useState( false ); + const [ isLoading, setIsLoading ] = useState( false ); + const [ email, setEmail ] = useState( null ); + const [ device, setDevice ] = useState( 'desktop' ); + const [ error, setError ] = useState( null ); + const latestRequest = useRef( 0 ); + // Parsing a large email is costly; redo it only when the email itself changes. + const inertHtml = useMemo( + () => ( email ? makeEmailInert( email.html ) : '' ), + [ email ] + ); + + const { postId, isDirty, isNew, isSaving } = useSelect( ( select ) => { + const editor = select( editorStore ); + + return { + postId: editor.getCurrentPostId(), + isDirty: editor.isEditedPostDirty(), + isNew: editor.isEditedPostNew(), + isSaving: editor.isSavingPost(), + }; + }, [] ); + + if ( beehiivMeta.newsletterAlreadySent ) { + return ( +

    + { __( + "This newsletter is already scheduled or sent, so it can't be previewed.", + 'beehiiv' + ) } +

    + ); + } + + const needsSave = isNew || isDirty; + + const load = async () => { + const requestId = ++latestRequest.current; + const isCurrent = () => requestId === latestRequest.current; + + setIsLoading( true ); + setError( null ); + setEmail( null ); + + try { + // The first call syncs the draft; while beehiiv is still preparing the + // email, later calls only fetch it, so no request is held open. + for ( let attempt = 0; attempt < MAX_ATTEMPTS; attempt++ ) { + if ( attempt > 0 ) { + await wait( POLL_INTERVAL_MS ); + } + + const data = await apiFetch( { + path: '/beehiiv/v1/email-preview', + method: 'POST', + data: { post_id: postId, fetch_only: attempt > 0 }, + } ); + + if ( ! isCurrent() ) { + return; + } + + if ( data?.html ) { + setEmail( { + html: data.html, + subject: data.subject ?? '', + previewText: data.preview_text ?? '', + fromName: data.from_name ?? '', + } ); + return; + } + } + + setError( + __( + 'beehiiv is still preparing this email. Try again in a moment.', + 'beehiiv' + ) + ); + } catch ( apiError ) { + if ( isCurrent() ) { + setError( + apiError?.message || + __( + "Something went wrong and the preview couldn't load. Try again.", + 'beehiiv' + ) + ); + } + } finally { + if ( isCurrent() ) { + setIsLoading( false ); + } + } + }; + + const open = () => { + setIsOpen( true ); + load(); + }; + + const close = () => { + // Ignore any request still in flight; reopening runs a fresh sync. + latestRequest.current++; + setIsOpen( false ); + setIsLoading( false ); + setEmail( null ); + setError( null ); + }; + + return ( +
    + { needsSave && ( +

    + { __( + 'Save the post to preview your latest edits.', + 'beehiiv' + ) } +

    + ) } + + + + { isOpen && ( + + { isLoading && ( +

    + { ' ' } + { __( 'Preparing your email preview…', 'beehiiv' ) } +

    + ) } + + { error && ( +
    +

    + { renderSettingsLinkMessage( + error, + settingsUrl + ) } +

    + +
    + ) } + + { email && ( +
    +
    +
    + + +
    +
    + +
    + { device === 'desktop' && ( + + ) } +
    +
    { __( 'From:', 'beehiiv' ) }
    +
    { email.fromName }
    +
    { __( 'Subject:', 'beehiiv' ) }
    +
    { email.subject }
    +
    + { __( 'Preview Text:', 'beehiiv' ) } +
    +
    { email.previewText }
    +
    +