Skip to content

Comments

MR-9: Add membership expiry warning banner for members#7

Open
zhickson wants to merge 3 commits intomainfrom
feature/MR-9-expiry-banner
Open

MR-9: Add membership expiry warning banner for members#7
zhickson wants to merge 3 commits intomainfrom
feature/MR-9-expiry-banner

Conversation

@zhickson
Copy link
Member

@zhickson zhickson commented Feb 19, 2026

Summary

This PR introduces a new membership expiry banner that helps members avoid interruptions by warning them before access lapses.

When enabled, logged-in members will see a clear banner if their membership has expired, expires today, or is due to expire soon. The warning window is configurable in plugin settings, so site owners can choose how early to remind members.

What's included

  • New optional Expiry Banner feature in Memberful settings.
  • Configurable reminder window (1-90 days, default 7).
  • Front-end banner messaging for:
    • expired memberships
    • memberships expiring today
    • memberships expiring in N days
  • Dismiss control so members can hide the banner for the current session.
  • Accessibility support (role/aria-live) and extension hooks for custom implementations.

Scope and behaviour

  • Shown only to logged-in members when criteria are met.
  • Not shown to admins.
  • No impact when feature is disabled.

Test plan

  • Turn on the expiry banner in settings.
  • Set threshold days (e.g. 7) and save.
  • Verify banner appears for eligible members with correct message states:
    • expired
    • expires today
    • expires in N days
  • Verify no banner for members outside threshold.
  • Verify no banner for admins.
  • Verify dismiss hides the banner for the current session.
  • Turn feature off and verify banner no longer appears.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added an expiry banner feature that notifies logged-in members when their membership is expiring or has expired.
    • New admin settings to enable/disable the expiry banner and configure the notification window (1–90 days before expiry, default 7 days).
    • Members can dismiss the banner, with dismissal state persisted during their session.

@coderabbitai
Copy link

coderabbitai bot commented Feb 19, 2026

Walkthrough

This PR introduces an expiry banner feature for the Memberful WordPress plugin. The implementation includes a dismissable global banner displayed to logged-in users with memberships expiring within a configurable threshold (default 7 days) or already expired, along with admin settings to enable/disable the feature and configure the day threshold.

Changes

Cohort / File(s) Summary
Plugin Bootstrap & Configuration
memberful-wp.php, src/options.php
Added require_once for new expiry_banner.php module and defined two new default options: memberful_expiry_banner_enabled (false) and memberful_expiry_banner_days (7).
Admin Settings
src/admin.php, views/options.php
Extended admin options handling to save/load expiry banner settings with days clamped to 1–90; added UI form fields for enabling the banner and configuring the day threshold.
Expiry Banner Logic
src/expiry_banner.php
New module implementing the core banner rendering functionality; includes public helper functions to retrieve soonest expiring subscription, build localised messages, parse expiry timestamps, and render the banner via wp_footer hook with filtering and capability checks.
Expiry Banner View
views/expiry-banner.php
New template rendering a fixed banner with message, dismiss button, and inline styles; includes JavaScript to persist dismissal state in sessionStorage across page loads within the session.

Sequence Diagram

sequenceDiagram
    participant Browser as User's Browser
    participant WP as WordPress
    participant Footer as wp_footer Hook
    participant Banner as expiry_banner.php
    participant Meta as User Meta
    participant View as View Template
    participant Session as SessionStorage

    Browser->>WP: Load frontend page
    WP->>Footer: Trigger wp_footer action
    Footer->>Banner: memberful_wp_render_expiry_banner()
    
    Banner->>Banner: Check if logged in & enabled
    alt Not logged in or disabled
        Banner->>WP: Early return
    else Proceed
        Banner->>Meta: Fetch user memberful_subscription meta
        Meta-->>Banner: Subscription array
        
        Banner->>Banner: memberful_wp_get_soonest_expiring_subscription()
        Banner->>Banner: Filter by threshold (default 7 days)
        Banner-->>Banner: Return soonest expiry or null
        
        alt No expiring subscription found
            Banner->>WP: Return
        else Subscription found
            Banner->>Banner: memberful_wp_expiry_banner_message()
            Banner-->>Banner: Build localised message
            
            Banner->>View: Render expiry-banner template
            View->>View: Output HTML with message & dismiss button
            View-->>Browser: Display banner
        end
    end
    
    Browser->>Session: Check sessionStorage for dismissal flag
    Session-->>Browser: Flag status
    alt Dismissed previously this session
        Browser->>View: Hide banner via JavaScript
    else Not dismissed
        Browser->>View: Show banner
        View->>Browser: User sees banner
        Browser->>View: User clicks dismiss button
        View->>Session: Store dismissal flag in sessionStorage
        View->>Browser: Hide banner
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A banner hops onto the scene so fine,
To warn when memberships near their time,
With days configurable, dismiss with care,
And filters aplenty for custom flair! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'MR-9: Add membership expiry warning banner for members' directly summarizes the main change: implementing an expiry warning banner feature for members.
Linked Issues check ✅ Passed The pull request implementation addresses all core coding requirements from MR-9, including banner rendering, expiry detection, dismissal functionality, multi-membership handling, settings configuration, and hooks for customisation.
Out of Scope Changes check ✅ Passed All changes are directly aligned with implementing the membership expiry warning banner; no unrelated modifications to unrelated features or systems are present.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/MR-9-expiry-banner

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
wordpress/wp-content/plugins/memberful-wp/src/options.php (1)

27-28: Minor style inconsistency: false vs FALSE.

The existing options in this array use uppercase FALSE/TRUE (e.g., lines 20, 26), while these new entries use lowercase false. Functionally identical in PHP, but worth aligning for consistency.

🔧 Suggested diff
-    'memberful_expiry_banner_enabled' => false,
+    'memberful_expiry_banner_enabled' => FALSE,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wordpress/wp-content/plugins/memberful-wp/src/options.php` around lines 27 -
28, The new option entries 'memberful_expiry_banner_enabled' and
'memberful_expiry_banner_days' use lowercase boolean literal false; update
'memberful_expiry_banner_enabled' to use the uppercase PHP boolean literal FALSE
to match the surrounding options style (e.g., other entries using TRUE/FALSE) so
the array remains stylistically consistent.
wordpress/wp-content/plugins/memberful-wp/views/expiry-banner.php (1)

19-21: async and defer attributes are no-ops on inline scripts.

These attributes only have effect on <script> elements with a src attribute. On inline scripts they are silently ignored by browsers. Removing them avoids confusion.

🔧 Suggested diff
-<script async defer>
+<script>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wordpress/wp-content/plugins/memberful-wp/views/expiry-banner.php` around
lines 19 - 21, The inline script in expiry-banner.php is using async and defer
on a script tag that has no src (they're no-ops); remove the async and defer
attributes from the <script> element wrapping the IIFE that references
memberful-expiry-banner and the sessionStorage key
"memberful_expiry_banner_dismissed" so the tag is a plain inline <script> and
behavior remains identical (keep the IIFE, event listener on
.memberful-expiry-banner__dismiss, and sessionStorage logic unchanged).
wordpress/wp-content/plugins/memberful-wp/src/expiry_banner.php (1)

10-17: Skipping admins — consider documenting the rationale or using a filter.

Users with manage_options are unconditionally excluded (line 15). If an admin also has a Memberful membership (e.g., for testing), they will never see the banner. This is likely intentional, but it's not overridable. Consider making this filterable for edge cases, or at least adding a brief inline comment explaining why admins are excluded.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wordpress/wp-content/plugins/memberful-wp/src/expiry_banner.php` around lines
10 - 17, The code unconditionally skips users with
current_user_can('manage_options') inside memberful_wp_render_expiry_banner
which prevents admins from ever seeing the expiry banner; update the logic to be
overridable by introducing a filter (e.g. call
apply_filters('memberful_wp_show_expiry_to_admins', false) or similar) and use
its result to decide whether to return, or if you prefer minimal change add a
clear inline comment above the current_user_can check explaining why admins are
excluded and that it is intentional; make sure the filter name and the
current_user_can('manage_options') check are referenced so callers can opt into
showing the banner to admins.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@wordpress/wp-content/plugins/memberful-wp/src/expiry_banner.php`:
- Around line 129-155: The current loop over $subscriptions incorrectly picks
the earliest expires_at inside the threshold even if that subscription is
long-ago expired; change the selection to prefer a non-expired subscription:
inside the foreach tracking $expires_at (from
memberful_wp_parse_expiry_timestamp) compute $is_expired = ($expires_at - $now)
< 0 and then maintain two candidates—$best_active (non-expired, choose the one
with smallest expires_at) and $best_expired (expired, choose the one with
largest expires_at = most recently expired). Update $best_active when a
non-expired subscription has an earlier expires_at than the current
$best_active; update $best_expired when an expired subscription has a later
expires_at than the current $best_expired. After the loop set $soonest to
$best_active if present, otherwise to $best_expired, preserving existing
calculation of days_remaining and is_expired using $now and DAY_IN_SECONDS.

In `@wordpress/wp-content/plugins/memberful-wp/views/expiry-banner.php`:
- Around line 16-18: The fixed .memberful-expiry-banner overlaps page content
because it uses position:fixed without offsetting the document flow; update the
display logic that renders .memberful-expiry-banner to also add/remove a
matching top offset to the page (e.g., set document.body.style.paddingTop =
banner.offsetHeight + 'px' when the banner is shown and clear it on dismiss) or
insert a spacer element of height equal to banner.offsetHeight immediately
before the main content; ensure this adjustment is applied after layout (or on
window resize) and is removed when the .memberful-expiry-banner is dismissed so
focus targets and skip-links are not hidden.

---

Nitpick comments:
In `@wordpress/wp-content/plugins/memberful-wp/src/expiry_banner.php`:
- Around line 10-17: The code unconditionally skips users with
current_user_can('manage_options') inside memberful_wp_render_expiry_banner
which prevents admins from ever seeing the expiry banner; update the logic to be
overridable by introducing a filter (e.g. call
apply_filters('memberful_wp_show_expiry_to_admins', false) or similar) and use
its result to decide whether to return, or if you prefer minimal change add a
clear inline comment above the current_user_can check explaining why admins are
excluded and that it is intentional; make sure the filter name and the
current_user_can('manage_options') check are referenced so callers can opt into
showing the banner to admins.

In `@wordpress/wp-content/plugins/memberful-wp/src/options.php`:
- Around line 27-28: The new option entries 'memberful_expiry_banner_enabled'
and 'memberful_expiry_banner_days' use lowercase boolean literal false; update
'memberful_expiry_banner_enabled' to use the uppercase PHP boolean literal FALSE
to match the surrounding options style (e.g., other entries using TRUE/FALSE) so
the array remains stylistically consistent.

In `@wordpress/wp-content/plugins/memberful-wp/views/expiry-banner.php`:
- Around line 19-21: The inline script in expiry-banner.php is using async and
defer on a script tag that has no src (they're no-ops); remove the async and
defer attributes from the <script> element wrapping the IIFE that references
memberful-expiry-banner and the sessionStorage key
"memberful_expiry_banner_dismissed" so the tag is a plain inline <script> and
behavior remains identical (keep the IIFE, event listener on
.memberful-expiry-banner__dismiss, and sessionStorage logic unchanged).

Comment on lines +129 to +155
foreach ( $subscriptions as $subscription ) {
if ( empty( $subscription['expires_at'] ) ) {
continue;
}

$expires_at = memberful_wp_parse_expiry_timestamp( $subscription['expires_at'] );

if ( empty( $expires_at ) ) {
continue;
}

if ( $expires_at > $threshold_timestamp ) {
continue;
}

if ( null === $soonest || $expires_at < $soonest['expires_at'] ) {
$seconds_remaining = $expires_at - $now;
$is_expired = $seconds_remaining < 0;
$days_remaining = $is_expired ? 0 : (int) ceil( $seconds_remaining / DAY_IN_SECONDS );

$soonest = array(
'expires_at' => $expires_at,
'days_remaining' => $days_remaining,
'is_expired' => $is_expired,
);
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd /tmp && find . -type f -name "*.php" 2>/dev/null | head -1 && echo "---" && rg -n 'memberful_subscription' --type=php -C 3 | head -100

Repository: TheCodeCompany/memberful-wp

Length of output: 240


🏁 Script executed:

# Check the actual file and understand the subscription data structure
find . -type f -name "expiry_banner.php" -o -name "*.php" -path "*/memberful*" | head -20

Repository: TheCodeCompany/memberful-wp

Length of output: 1427


🏁 Script executed:

# Look at the expiry_banner.php file to see the full context
cat -n wordpress/wp-content/plugins/memberful-wp/src/expiry_banner.php | head -160

Repository: TheCodeCompany/memberful-wp

Length of output: 5766


🏁 Script executed:

# Search for where subscriptions are stored/retrieved to understand data structure
rg -n 'memberful_subscription|get_user_meta.*memberful' --type=php -B 2 -A 5 | head -150

Repository: TheCodeCompany/memberful-wp

Length of output: 15291


🏁 Script executed:

# Find where memberful_subscription is updated to understand the data structure
rg -n 'update_user_meta.*memberful_subscription|memberful_subscription.*update' --type=php -B 2 -A 5

Repository: TheCodeCompany/memberful-wp

Length of output: 53


🏁 Script executed:

# Search for any webhook handlers or sync functions that update subscription data
rg -n 'webhook|sync.*subscription' --type=php -i | head -50

Repository: TheCodeCompany/memberful-wp

Length of output: 3877


🏁 Script executed:

# Look for authenticator.php which likely handles user sync from Memberful API
cat -n wordpress/wp-content/plugins/memberful-wp/src/authenticator.php | head -200

Repository: TheCodeCompany/memberful-wp

Length of output: 7362


🏁 Script executed:

# Find the Memberful_Wp_User_Subscriptions class
find . -name "*.php" -type f | xargs grep -l "class Memberful_Wp_User_Subscriptions"

Repository: TheCodeCompany/memberful-wp

Length of output: 141


🏁 Script executed:

# Look at the user subscriptions file
cat -n wordpress/wp-content/plugins/memberful-wp/src/user/subscriptions.php

Repository: TheCodeCompany/memberful-wp

Length of output: 1192


🏁 Script executed:

# Also check syncing.php to see how subscriptions are synced
cat -n wordpress/wp-content/plugins/memberful-wp/src/syncing.php | head -100

Repository: TheCodeCompany/memberful-wp

Length of output: 3702


🏁 Script executed:

# Check the parent class to understand how subscriptions are stored
cat -n wordpress/wp-content/plugins/memberful-wp/src/user/entity.php

Repository: TheCodeCompany/memberful-wp

Length of output: 1797


Correct the subscription selection logic to avoid prioritising old-expired subscriptions over active ones.

When a user has multiple subscriptions, the banner should display based on the soonest active subscription within the threshold. Currently, if a user has both an old-expired subscription (e.g., from 2 years ago) and an active subscription expiring soon, the banner will incorrectly show "Your membership has expired" for the old one.

The subscription data stored in user meta includes no 'active' or 'status' field—only expires_at. When the Memberful API syncs subscriptions, old expired ones persist in user meta. The current loop selects the subscription with the earliest expires_at timestamp within the threshold, regardless of age.

Implement the proposed fix: prioritise non-expired subscriptions within the threshold (soonest to expire), and only fall back to the most recently expired subscription if no active subscription exists.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wordpress/wp-content/plugins/memberful-wp/src/expiry_banner.php` around lines
129 - 155, The current loop over $subscriptions incorrectly picks the earliest
expires_at inside the threshold even if that subscription is long-ago expired;
change the selection to prefer a non-expired subscription: inside the foreach
tracking $expires_at (from memberful_wp_parse_expiry_timestamp) compute
$is_expired = ($expires_at - $now) < 0 and then maintain two
candidates—$best_active (non-expired, choose the one with smallest expires_at)
and $best_expired (expired, choose the one with largest expires_at = most
recently expired). Update $best_active when a non-expired subscription has an
earlier expires_at than the current $best_active; update $best_expired when an
expired subscription has a later expires_at than the current $best_expired.
After the loop set $soonest to $best_active if present, otherwise to
$best_expired, preserving existing calculation of days_remaining and is_expired
using $now and DAY_IN_SECONDS.

Comment on lines +16 to +18
<style>
.memberful-expiry-banner{position:fixed;top:var(--wp-admin--admin-bar--height,0);left:0;right:0;z-index:var(--memberful-expiry-banner-z-index,9999);display:flex;align-items:center;justify-content:center;gap:1em;padding:.5em 1em;margin:0;font:inherit;font-size:.875rem;line-height:1.4;background:var(--memberful-expiry-banner-background,#fef3cd);color:var(--memberful-expiry-banner-colour,#664d03);border-bottom:1px solid var(--memberful-expiry-banner-border-colour,#e0c882)}.memberful-expiry-banner a{color:inherit;text-decoration:underline}.memberful-expiry-banner__message{margin:0}.memberful-expiry-banner__dismiss{min-width:2rem;min-height:2rem;margin:0;padding:0;border:0;background:0 0;color:inherit;cursor:pointer;font:inherit;font-size:1.25rem;line-height:1}
</style>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fixed-position banner overlaps page content without offsetting body padding.

The banner uses position:fixed;top:… which means it will sit on top of page content. There is no corresponding body or content-area offset, so the first ~40px of page content will be hidden behind the banner. Consider adding a spacer element or adjusting body padding-top via the inline styles/script to prevent content from being obscured.

This is particularly relevant for accessibility — keyboard-focus or skip-link targets near the top of the page may be visually hidden behind the banner.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wordpress/wp-content/plugins/memberful-wp/views/expiry-banner.php` around
lines 16 - 18, The fixed .memberful-expiry-banner overlaps page content because
it uses position:fixed without offsetting the document flow; update the display
logic that renders .memberful-expiry-banner to also add/remove a matching top
offset to the page (e.g., set document.body.style.paddingTop =
banner.offsetHeight + 'px' when the banner is shown and clear it on dismiss) or
insert a spacer element of height equal to banner.offsetHeight immediately
before the main content; ensure this adjustment is applied after layout (or on
window resize) and is removed when the .memberful-expiry-banner is dismissed so
focus targets and skip-links are not hidden.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant