Skip to content

✨ Added retention offers#26747

Merged
sagzy merged 2 commits intomainfrom
ship-retention-offers
Mar 10, 2026
Merged

✨ Added retention offers#26747
sagzy merged 2 commits intomainfrom
ship-retention-offers

Conversation

@sagzy
Copy link
Contributor

@sagzy sagzy commented Mar 10, 2026

ref https://linear.app/ghost/project/retention-offers-churn-prevention-e2474be8b6ad/overview
closes https://linear.app/ghost/issue/BER-3410

Using retention offers, publishers can offer existing paid members a discount before they cancel, in order to retain them. For example, "a month on us" or "50% off your next payment".


Co-authored-by: Michael Barrett mike@ghost.org
Co-authored-by: Sodbileg Gansukh sodbileg.gansukh@gmail.com

ref https://linear.app/ghost/project/retention-offers-churn-prevention-e2474be8b6ad/overview
closes https://linear.app/ghost/issue/BER-3410

Using retention offers, publishers can offer existing paid members a discount before they cancel, in order to retain them. For example, "a month on us" or "50% off your next payment".

---------

Co-authored-by: Michael Barrett <mike@ghost.org>
Co-authored-by: Sodbileg Gansukh <sodbileg.gansukh@gmail.com>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 10, 2026

Walkthrough

retentionOffers was moved from PRIVATE_FEATURES to GA_FEATURES, so the Admin‑X private features UI no longer lists Retention Offers. E2E test utilities now always open the Manage offers modal, archive existing offers in a loop, then create a new offer. Several test specs were updated to use the modal-driven flow and assert offer presence within the modal instead of on the static offers list.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: promoting retention offers from a private feature to a generally available feature across the codebase.
Description check ✅ Passed The description directly relates to the changeset, explaining the retention offers feature implementation and its purpose for publishers to retain paid members with discounts.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ship-retention-offers

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.

@ErisDS
Copy link
Member

ErisDS commented Mar 10, 2026

🤖 Velo CI Failure Analysis

Classification: 🟠 SOFT FAIL

  • Workflow: CI
  • Failed Step: Run Playwright tests locally
  • Run: View failed run
    What failed: CI failure - likely code issue
    Why: The failure appears to be related to code changes. Check the error output for details.
    Action:
    Review the error logs and fix the issue in your code.

Copy link
Contributor

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ghost/core/test/e2e-browser/utils/e2e-browser-utils.js`:
- Around line 265-266: The current unconditional wait on
page.getByTestId('retention-offer-item').first().waitFor() will hang when no
retention offers exist (retentionOffers empty in offers-index-retention.tsx);
fix by ensuring a retention offer is created in the test setup before calling
this utility OR make the wait resilient: replace the unconditional wait with a
check for presence (e.g. query/count the 'retention-offer-item' locator and only
waitFor() when count > 0) so the helper handles both empty and populated states
safely.
- Around line 268-278: The current code archives only the first offer by using
nth(0); change it to loop until no offers remain so all existing 'offer-item'
elements are archived. Replace the if (hasOfferItems) block with a while loop
that checks await page.getByTestId('offer-item').count() > 0, then for each
iteration click page.getByTestId('offer-item').nth(0), click the 'Archive offer'
button, interact with the confirmation modal (confirmation-modal) by clicking
the 'Archive' role button and waiting for the modal to hide, and ensure you wait
for the offer count to decrease before the next iteration to avoid race
conditions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fec6063a-7680-45c6-a1a2-96e04647c93c

📥 Commits

Reviewing files that changed from the base of the PR and between 1de575e and 9f3926b.

📒 Files selected for processing (1)
  • ghost/core/test/e2e-browser/utils/e2e-browser-utils.js

@mike182uk mike182uk force-pushed the ship-retention-offers branch from 9f3926b to 1d3bf24 Compare March 10, 2026 12:12
@mike182uk mike182uk force-pushed the ship-retention-offers branch from 1d3bf24 to c82224f Compare March 10, 2026 12:45
Copy link
Contributor

@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: 1

🧹 Nitpick comments (1)
ghost/core/test/e2e-browser/portal/offers.spec.js (1)

34-37: Extract the Manage offers modal flow into a helper.

This wait/open/assert/close sequence is repeated four times in this spec. Pulling it into a small helper will reduce selector drift the next time the offers modal changes.

Also applies to: 115-118, 193-196, 271-274

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

In `@ghost/core/test/e2e-browser/portal/offers.spec.js` around lines 34 - 37,
Extract the repeated wait/open/assert/close flow into a single helper (e.g.,
openAndAssertOffersModal) that takes offerName; the helper should perform the
sequence using sharedPage.getByTestId('offers').getByRole('button', {name:
'Manage tiers'}).waitFor({state: 'hidden'}), then click
sharedPage.getByTestId('offers').getByRole('button', {name: 'Manage offers'}),
assert await
expect(sharedPage.getByTestId('offers-modal')).toContainText(offerName), and
finally close with sharedPage.getByTestId('offers-modal').getByRole('button',
{name: 'Close'}).click(); replace the four duplicated blocks (lines around
34-37, 115-118, 193-196, 271-274) with calls to
openAndAssertOffersModal(offerName) to centralize selectors and reduce drift.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ghost/core/test/e2e-browser/portal/offers.spec.js`:
- Around line 87-88: The test is asserting redemption count too loosely by
checking toContainText('1') on the whole offer row; narrow the assertion to the
specific redemption cell instead. Locate the row via
sharedPage.getByTestId('offer-item').filter({hasText: offerName}) (existing
offerRow), then query the redemption/count cell inside that row (e.g. a test id
like 'offer-redemptions' or a specific column locator) and assert the exact text
with toHaveText('1') (or otherwise assert an exact label/value) rather than
using a substring match on the entire row.

---

Nitpick comments:
In `@ghost/core/test/e2e-browser/portal/offers.spec.js`:
- Around line 34-37: Extract the repeated wait/open/assert/close flow into a
single helper (e.g., openAndAssertOffersModal) that takes offerName; the helper
should perform the sequence using
sharedPage.getByTestId('offers').getByRole('button', {name: 'Manage
tiers'}).waitFor({state: 'hidden'}), then click
sharedPage.getByTestId('offers').getByRole('button', {name: 'Manage offers'}),
assert await
expect(sharedPage.getByTestId('offers-modal')).toContainText(offerName), and
finally close with sharedPage.getByTestId('offers-modal').getByRole('button',
{name: 'Close'}).click(); replace the four duplicated blocks (lines around
34-37, 115-118, 193-196, 271-274) with calls to
openAndAssertOffersModal(offerName) to centralize selectors and reduce drift.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e646e3f9-9af6-43ce-bc1f-52f6bd538a7d

📥 Commits

Reviewing files that changed from the base of the PR and between 1d3bf24 and c82224f.

📒 Files selected for processing (3)
  • ghost/core/test/e2e-browser/admin/tiers.spec.js
  • ghost/core/test/e2e-browser/portal/offers.spec.js
  • ghost/core/test/e2e-browser/utils/e2e-browser-utils.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • ghost/core/test/e2e-browser/admin/tiers.spec.js

Comment on lines +87 to +88
const offerRow = sharedPage.getByTestId('offer-item').filter({hasText: offerName});
await expect(offerRow).toContainText('1');
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

The redemption-count check is now too weak to prove the count changed.

toContainText('1') on the whole row can match unrelated text in the generated offerName or other numeric fields in the row, so this can go green before any redemption is recorded. Assert against the specific redemption field/cell, or at least an exact redemption label/value, instead of a bare substring on the row.

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

In `@ghost/core/test/e2e-browser/portal/offers.spec.js` around lines 87 - 88, The
test is asserting redemption count too loosely by checking toContainText('1') on
the whole offer row; narrow the assertion to the specific redemption cell
instead. Locate the row via
sharedPage.getByTestId('offer-item').filter({hasText: offerName}) (existing
offerRow), then query the redemption/count cell inside that row (e.g. a test id
like 'offer-redemptions' or a specific column locator) and assert the exact text
with toHaveText('1') (or otherwise assert an exact label/value) rather than
using a substring match on the entire row.

@ErisDS
Copy link
Member

ErisDS commented Mar 10, 2026

🤖 Velo CI Failure Analysis

Classification: 🔴 HARD FAIL

  • Workflow: CI
  • Failed Step: Load Image
  • Run: View failed run
    What failed: Operation timed out
    Why: Infrastructure issue detected: operation timed out. This is typically not caused by code changes.
    Action:
    Try re-running the workflow. If it persists, investigate the infrastructure.

@sagzy sagzy merged commit e66644b into main Mar 10, 2026
54 of 59 checks passed
@sagzy sagzy deleted the ship-retention-offers branch March 10, 2026 14:52
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.

3 participants