Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e92bf22
feat(admin-interface): add Subscriber gating defaults to settings page
phpbits Nov 2, 2026
415943a
feat(custom-blocks): add gated content and gate divider blocks
phpbits Nov 2, 2026
59ca990
fix(custom-blocks): stop gated content render loop with several previ…
phpbits Nov 2, 2026
dcc0833
fix(custom-blocks): hide gate card at once for returning readers and …
phpbits Nov 2, 2026
c1a5063
fix(custom-blocks): init gate after DOM ready and restyle gate card o…
phpbits Nov 2, 2026
c7b3988
feat(custom-blocks): add card background and text color options to ga…
phpbits Nov 2, 2026
969e477
fix(custom-blocks): apply card text color to the gate headline
phpbits Nov 2, 2026
8c3776d
fix(custom-blocks): keep gate on repeat renders, allow a divider per …
phpbits Nov 2, 2026
79d5591
feat(custom-blocks): show a missing-publication notice with a setting…
phpbits Nov 2, 2026
bbffdbd
feat(admin-interface): add a default subscribe form ID to the Subscri…
phpbits Nov 2, 2026
a9efe06
style(custom-blocks): use a dotted outline on the gated block in the …
phpbits Nov 2, 2026
5a57477
feat(gated-content): remove default headline and text; blank hides them
phpbits Nov 3, 2026
76e4b30
feat(gated-content): start new blocks with paragraph, divider, paragraph
phpbits Nov 3, 2026
416cadf
refactor(gated-content): rely on the block template only; drop empty-…
phpbits Nov 3, 2026
e9b5bdb
feat(gated-content): gate every gated block on a page; one subscribe …
phpbits Nov 3, 2026
5f88fe1
fix(gated-content): center the beehiiv form inside the gate card
phpbits Nov 3, 2026
cc7fc82
Revert "fix(gated-content): center the beehiiv form inside the gate c…
phpbits Nov 3, 2026
027cbd8
feat(gated-content): size the gate card to the form width
phpbits Nov 3, 2026
237a8cc
style(gated-content): set gate card min width to 400px
phpbits Nov 3, 2026
670843e
style(gated-content): set gate card min width to 500px
phpbits Nov 3, 2026
693b9b5
feat(gated-content): gate the whole block when there is no divider
phpbits Nov 3, 2026
511e424
fix(gated-content): follow the theme base color for the fade and card
phpbits Nov 3, 2026
0b76661
fix(gated-content): address code review findings
phpbits Nov 3, 2026
17155f3
refactor(gated-content): render the headline as a styled paragraph; d…
phpbits Nov 3, 2026
8574cf6
fix(gated-content): size the card from the form, not from the headlin…
phpbits Nov 3, 2026
34aa3ff
fix(gated-content): open the gate before first paint, announce unlock…
phpbits Nov 3, 2026
ff8ce7a
fix(gated-content): lint clean; static boot script with tests; reserv…
phpbits Nov 3, 2026
c2fe08c
fix(ci): register gate blocks in PHPUnit without a build; pin the E2E…
phpbits Nov 3, 2026
c022211
fix(gated-content): resolve review items; add E2E spec, readme and ch…
phpbits Nov 3, 2026
d4dc673
fix(ci): match the render callback signature in the PHPUnit block reg…
phpbits Nov 3, 2026
c83b39f
chore(i18n): regenerate the translation template; tidy the multi-gate…
phpbits Nov 3, 2026
85c68f3
Keep publication fields on the first settings save and fix stale E2E …
phpbits Nov 9, 2026
42a586f
Disable the post template dropdown until a publication is selected
phpbits Nov 9, 2026
59a9d57
Ignore stale template requests on the settings page
phpbits Nov 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ jobs:

e2e:
name: E2E tests
runs-on: ubuntu-latest
# Playwright does not support Chromium on Ubuntu 26.04 yet. Pin the runner.
runs-on: ubuntu-24.04
timeout-minutes: 60

steps:
Expand Down
135 changes: 122 additions & 13 deletions includes/Admin/Options.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,40 @@ final class Options {
private const DEFAULTS = [
'publication_id' => '',
'post_template_id' => '',
'gate_headline' => '',
'gate_text' => '',
'gate_form_id' => '',
'gate_cookie_days' => '',
];

/**
* Form key that the Publication section posts when it is shown.
*
* @var string
*/
public const PUBLICATION_SECTION_FLAG = '_publication_section';

/**
* Lowest unlock cookie lifetime, in days.
*
* @var int
*/
public const GATE_COOKIE_DAYS_MIN = 1;

/**
* Highest unlock cookie lifetime, in days.
*
* @var int
*/
public const GATE_COOKIE_DAYS_MAX = 365;

/**
* Built-in unlock cookie lifetime, in days.
*
* @var int
*/
public const GATE_COOKIE_DAYS_DEFAULT = 30;

/**
* Register the option with the Settings API.
*
Expand Down Expand Up @@ -62,34 +94,111 @@ public static function get(): array {
return wp_parse_args( $saved, self::DEFAULTS );
}

/**
* Gate defaults with every fallback applied.
*
* A blank headline or text stays empty, and the gate card leaves it out.
* An invalid cookie lifetime falls back to the default on read; the
* fallback is never written back to the stored option.
*
* @return array{headline: string, text: string, form_id: string, cookie_days: int}
* @since 1.2.0
*/
public static function get_gate_settings(): array {
$settings = self::get();

return [
'headline' => trim( (string) $settings['gate_headline'] ),
'text' => trim( (string) $settings['gate_text'] ),
'form_id' => trim( (string) $settings['gate_form_id'] ),
'cookie_days' => self::resolve_gate_cookie_days( $settings['gate_cookie_days'] ),
];
}

/**
* Resolve a stored cookie lifetime to a whole number of days from 1 to 365.
*
* Anything else (blank, text, decimal, zero, negative, over 365) becomes 30.
*
* @param mixed $value Stored or submitted value.
* @return int
* @since 1.2.0
*/
public static function resolve_gate_cookie_days( $value ): int {
if ( is_int( $value ) ) {
$value = (string) $value;
}

if ( ! is_string( $value ) || ! preg_match( '/^\s*\d+\s*$/', $value ) ) {
return self::GATE_COOKIE_DAYS_DEFAULT;
}

$days = (int) trim( $value );

if ( $days < self::GATE_COOKIE_DAYS_MIN || $days > self::GATE_COOKIE_DAYS_MAX ) {
return self::GATE_COOKIE_DAYS_DEFAULT;
}

return $days;
}

/**
* Sanitize settings before saving.
*
* The settings form posts the Publication fields and the Subscriber gating
* fields together, and the Publication fields are absent when that section
* is hidden. Only keys present in the submission change; every other stored
* value is kept.
*
* @param mixed $input Raw option value from the form.
* @return array<string, mixed>
* @since 1.0.0
*/
public static function sanitize( $input ): array {
$current = self::get();

if ( ! is_array( $input ) ) {
return self::get();
return $current;
}

// The Publication section marks itself in the submission. When it was shown,
// a missing field means it was cleared or not offered, so it resets to blank,
// as before. When it was hidden, the stored values stay.
//
// A submitted field is always applied, flag or not. WordPress runs this
// callback twice on the first save, when no option row exists yet (once in
// update_option() and again in add_option()). The second run receives the
// first run's output, which has no flag. Applying present fields keeps that
// second run from discarding them.
$publication_section_shown = ! empty( $input[ self::PUBLICATION_SECTION_FLAG ] );

foreach ( [ 'publication_id', 'post_template_id' ] as $key ) {
if ( isset( $input[ $key ] ) ) {
$current[ $key ] = sanitize_text_field( (string) $input[ $key ] );
} elseif ( $publication_section_shown ) {
$current[ $key ] = '';
}
}

// Plain text only: tags are removed and surrounding whitespace trimmed.
// The form ID has no built-in default: blank means no site-wide form.
foreach ( [ 'gate_headline', 'gate_text', 'gate_form_id' ] as $key ) {
if ( isset( $input[ $key ] ) ) {
$current[ $key ] = trim( sanitize_text_field( (string) $input[ $key ] ) );
}
}

$publication_id = isset( $input['publication_id'] )
? sanitize_text_field( (string) $input['publication_id'] )
: '';
$post_template_id = isset( $input['post_template_id'] )
? sanitize_text_field( (string) $input['post_template_id'] )
: '';
// Stored as submitted (digits only); the 1 to 365 rule applies on read.
if ( isset( $input['gate_cookie_days'] ) ) {
$current['gate_cookie_days'] = sanitize_text_field( (string) $input['gate_cookie_days'] );
}

// Saving settings clears the cached template list for the selected
// publication so the dropdown reflects any beehiiv-side changes.
if ( '' !== $publication_id ) {
Cache::delete_post_templates( $publication_id );
if ( '' !== $current['publication_id'] ) {
Cache::delete_post_templates( $current['publication_id'] );
}

return [
'publication_id' => $publication_id,
'post_template_id' => $post_template_id,
];
return $current;
}
}
Loading
Loading