Skip to content

Implemented gift subscription checkout UI#27060

Merged
mike182uk merged 1 commit intomainfrom
BER-3484-add-gift-checkout
Apr 1, 2026
Merged

Implemented gift subscription checkout UI#27060
mike182uk merged 1 commit intomainfrom
BER-3484-add-gift-checkout

Conversation

@mike182uk
Copy link
Copy Markdown
Member

ref https://linear.app/ghost/issue/BER-3484

Implemented the gift page content behind the giftSubscriptions labs flag: site header, purchaser email input, monthly/yearly toggle, paid tier cards with pricing and benefits, and a Purchase gift button

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 1, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4aac682c-0d7c-418e-87ba-0c8bda2850c3

📥 Commits

Reviewing files that changed from the base of the PR and between 99a152c and 0d664e7.

📒 Files selected for processing (7)
  • apps/portal/package.json
  • apps/portal/src/components/common/products-section.js
  • apps/portal/src/components/pages/gift-page.js
  • apps/portal/src/components/popup-modal.js
  • apps/portal/src/utils/helpers.js
  • apps/portal/test/portal-links.test.js
  • apps/portal/test/utils/helpers.test.js
✅ Files skipped from review due to trivial changes (4)
  • apps/portal/package.json
  • apps/portal/test/portal-links.test.js
  • apps/portal/test/utils/helpers.test.js
  • apps/portal/src/components/pages/gift-page.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/portal/src/components/popup-modal.js
  • apps/portal/src/utils/helpers.js
  • apps/portal/src/components/common/products-section.js

Walkthrough

Updated the portal package version to 2.67.4. Replaced the static gift page with a stateful GiftPage that manages recipient email and validation, selected billing interval, derives an active interval via a new shared helper, loads and filters paid products, renders product cards with interval pricing and discounts, and stubs purchase handling (disabled when cookies are disabled). Popup modal now forces the gift page to render full-size. Tests and helper tests updated to reflect the new active-interval logic and revised gift-page copy.

Possibly related PRs

  • TryGhost/Ghost PR 27048: Adds the gift-subscriptions feature flag and related UI gating that this change relies on.
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main objective of the PR: implementing the gift subscription checkout UI with all its components (email input, monthly/yearly toggle, product cards, purchase button).
Description check ✅ Passed The description is directly related to the changeset, clearly outlining the implementation of the gift subscription checkout page with specific UI components and the linear issue reference.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch BER-3484-add-gift-checkout

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

@mike182uk mike182uk force-pushed the BER-3484-add-gift-checkout branch from 4697155 to 59c27fb Compare April 1, 2026 15:54
Copy link
Copy Markdown
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: 4

🧹 Nitpick comments (1)
apps/portal/test/portal-links.test.js (1)

347-365: Expand this smoke test beyond the subtitle.

The new gift page is stateful, so this still passes if the email field or purchase cards stop rendering. Adding a quick assertion for the purchaser email input and at least one Purchase gift button would give the flow basic coverage.

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

In `@apps/portal/test/portal-links.test.js` around lines 347 - 365, The test for
'#/portal/gift' currently only asserts the gift subtitle; extend it to assert
the presence of the purchaser email input and at least one "Purchase gift"
action so the page state is exercised; after locating popupFrame via
utils.findByTitle (and using within(popupFrame.contentDocument)), add assertions
that a purchaser email input exists (e.g., queryByLabelText / purchaser email /
or by placeholder/email role) and that a button with text like /purchase gift/i
(or a role="button" with that name) is present and in the document to ensure the
form and purchase call-to-action render correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/portal/src/components/pages/gift-page.js`:
- Around line 144-162: getAvailableProducts is currently excluding products that
only have a monthly or yearly price, causing products to be empty for
single-cadence sites; update getAvailableProducts (in
apps/portal/src/utils/helpers.js) to return products that have a price for at
least one cadence instead of requiring both monthlyPrice and yearlyPrice, and
make its filtering respect the site's portal_plans/portal_default_plan (used in
gift-page.js) so monthly-only or yearly-only products are included; ensure the
call in gift-page.js (where products = getAvailableProducts({site}).filter(p =>
p.type === 'paid')) receives products that match the site's allowed cadences so
the subsequent hasGiftSubscriptions check can work correctly.
- Around line 205-217: The handlePurchase handler currently only validates email
and returns; update handlePurchase to accept the selected product context (e.g.,
priceId and optional interval) from the card that invokes it, run
ValidateInputForm({fields: emailField}) and setEmailError as before, then call
the existing checkout flow (or implement an
initiateGiftCheckout/createCheckoutSession call) with the validated email and
the passed priceId/interval so the checkout knows which product to purchase;
also update the card renderers to pass a bound callback like () =>
handlePurchase(priceId, interval, e) (or supply priceId when wiring the onClick)
so each card provides its product context.
- Around line 12-13: The i18n-disable comment in
apps/portal/src/components/pages/gift-page.js must be removed and all
user-facing literal strings in the GiftPage component replaced with calls to the
i18n translator t('portal.<key>') (or use the existing useTranslation hook) —
update each label/button/error in functions/render methods in GiftPage to use
t() with meaningful keys, then add those keys and translations into the
portal.json namespace for each target locale under
ghost/i18n/locales/{locale}/portal.json; ensure no remaining /* eslint-disable
i18next/no-literal-string */ remains in this file so the linter enforces future
i18n.
- Around line 104-127: The global highestDiscount value is computed across all
products and can overstate savings for the plan the user actually selects;
change the logic to compute and display the discount for the product(s)
currently shown/selected instead of Math.max(...discounts). Locate where
discounts and highestDiscount are computed and replace with a per-product
discount using calculateDiscount(product.monthlyPrice?.amount,
product.yearlyPrice?.amount) for the active product (e.g., the product at the
current selection index or the single featured product), or alternatively remove
the global badge and render per-plan save labels next to each plan; update any
UI bits that reference highestDiscount (the Yearly toggle span) to use that
per-product discount value and the selectedInterval/selection state (e.g.,
selectedInterval, setSelectedInterval).

---

Nitpick comments:
In `@apps/portal/test/portal-links.test.js`:
- Around line 347-365: The test for '#/portal/gift' currently only asserts the
gift subtitle; extend it to assert the presence of the purchaser email input and
at least one "Purchase gift" action so the page state is exercised; after
locating popupFrame via utils.findByTitle (and using
within(popupFrame.contentDocument)), add assertions that a purchaser email input
exists (e.g., queryByLabelText / purchaser email / or by placeholder/email role)
and that a button with text like /purchase gift/i (or a role="button" with that
name) is present and in the document to ensure the form and purchase
call-to-action render correctly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c403ffd8-312e-4159-9d89-0af23d84b8f3

📥 Commits

Reviewing files that changed from the base of the PR and between 643a114 and 4697155.

📒 Files selected for processing (4)
  • apps/portal/package.json
  • apps/portal/src/components/pages/gift-page.js
  • apps/portal/src/components/popup-modal.js
  • apps/portal/test/portal-links.test.js

Copy link
Copy Markdown
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 (2)
apps/portal/src/components/pages/gift-page.js (2)

146-158: Consider memoizing activeInterval derivation.

The activeInterval calculation runs on every render. While functionally correct, it could be extracted or memoized for clarity:

Optional: Use a helper function
const getDefaultInterval = (portalPlans, portalDefaultPlan) => {
    if (portalDefaultPlan === 'monthly' && portalPlans?.includes('monthly')) {
        return 'month';
    }
    if (portalPlans?.includes('yearly')) {
        return 'year';
    }
    if (portalPlans?.includes('monthly')) {
        return 'month';
    }
    return 'year';
};

// Then use:
const activeInterval = selectedInterval || getDefaultInterval(portalPlans, portalDefaultPlan);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/portal/src/components/pages/gift-page.js` around lines 146 - 158, The
inline derivation of activeInterval (using selectedInterval, portalDefaultPlan,
and portalPlans) runs on every render; extract and memoize it by creating a
helper such as getDefaultInterval(portalPlans, portalDefaultPlan) and then set
const activeInterval = selectedInterval || getDefaultInterval(portalPlans,
portalDefaultPlan) (or wrap that in React.useMemo if preferred) so the logic is
centralized, clearer, and avoids recalculating on each render; update references
to activeInterval in the component to use this new value.

104-105: Discount calculation also assumes both prices exist.

Similar to GiftProductCardPrice, this code would produce incorrect results if getAvailableProducts is updated to include single-interval products. calculateDiscount would receive undefined for the missing price.

Consider guarding the calculation:

Defensive discount calculation
-    const discounts = products.map(p => calculateDiscount(p.monthlyPrice?.amount, p.yearlyPrice?.amount));
+    const discounts = products
+        .filter(p => p.monthlyPrice && p.yearlyPrice)
+        .map(p => calculateDiscount(p.monthlyPrice.amount, p.yearlyPrice.amount));
     const highestDiscount = Math.max(...discounts);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/portal/src/components/pages/gift-page.js` around lines 104 - 105, The
discount mapping assumes both prices exist; update the discount computation that
builds discounts and highestDiscount so it only calls calculateDiscount for
products with both monthlyPrice?.amount and yearlyPrice?.amount (or
alternatively ensure calculateDiscount handles undefined safely). Specifically,
in the block that defines discounts and highestDiscount, filter products (from
getAvailableProducts) to those having monthlyPrice?.amount and
yearlyPrice?.amount before mapping to calculateDiscount, and compute Math.max
over that filtered list (fallback to 0 if none), so calculateDiscount is never
invoked with undefined; mirror the same defensive check used in
GiftProductCardPrice.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/portal/src/components/pages/gift-page.js`:
- Around line 36-42: GiftProductCardPrice currently returns null if either
monthlyPrice or yearlyPrice is missing, so when getAvailableProducts is changed
to allow single-interval products this component will render nothing; update
GiftProductCardPrice to tolerate missing intervals by: determine an activePrice
based on selectedInterval but fallback to whichever of monthlyPrice or
yearlyPrice exists (or the only one present), only return null if neither price
exists, and ensure display logic uses activePrice and a label derived from
selectedInterval or the price's interval; reference the GiftProductCardPrice
function, selectedInterval, monthlyPrice, yearlyPrice and getAvailableProducts
when making the change.

---

Nitpick comments:
In `@apps/portal/src/components/pages/gift-page.js`:
- Around line 146-158: The inline derivation of activeInterval (using
selectedInterval, portalDefaultPlan, and portalPlans) runs on every render;
extract and memoize it by creating a helper such as
getDefaultInterval(portalPlans, portalDefaultPlan) and then set const
activeInterval = selectedInterval || getDefaultInterval(portalPlans,
portalDefaultPlan) (or wrap that in React.useMemo if preferred) so the logic is
centralized, clearer, and avoids recalculating on each render; update references
to activeInterval in the component to use this new value.
- Around line 104-105: The discount mapping assumes both prices exist; update
the discount computation that builds discounts and highestDiscount so it only
calls calculateDiscount for products with both monthlyPrice?.amount and
yearlyPrice?.amount (or alternatively ensure calculateDiscount handles undefined
safely). Specifically, in the block that defines discounts and highestDiscount,
filter products (from getAvailableProducts) to those having monthlyPrice?.amount
and yearlyPrice?.amount before mapping to calculateDiscount, and compute
Math.max over that filtered list (fallback to 0 if none), so calculateDiscount
is never invoked with undefined; mirror the same defensive check used in
GiftProductCardPrice.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fb2eff60-472f-4bb5-a1ce-707c8ed22ef7

📥 Commits

Reviewing files that changed from the base of the PR and between 4697155 and 59c27fb.

📒 Files selected for processing (4)
  • apps/portal/package.json
  • apps/portal/src/components/pages/gift-page.js
  • apps/portal/src/components/popup-modal.js
  • apps/portal/test/portal-links.test.js
✅ Files skipped from review due to trivial changes (2)
  • apps/portal/package.json
  • apps/portal/test/portal-links.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/portal/src/components/popup-modal.js

@mike182uk mike182uk force-pushed the BER-3484-add-gift-checkout branch from 59c27fb to 1606b8f Compare April 1, 2026 16:21
Copy link
Copy Markdown
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.

🧹 Nitpick comments (1)
apps/portal/src/components/pages/gift-page.js (1)

15-33: Consider adding explicit return for completeness.

The function can implicitly return undefined when portalPlans contains neither 'monthly' nor 'yearly'. While this case is protected by the early return at line 170-195 (since getAvailableProducts returns an empty array when neither interval is configured), an explicit return would make the function's contract clearer.

📝 Optional: Add explicit undefined return
     if (portalPlans.includes('monthly')) {
         return 'month';
     }
+    return undefined;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/portal/src/components/pages/gift-page.js` around lines 15 - 33,
getActiveInterval may implicitly return undefined when portalPlans contains
neither 'monthly' nor 'yearly'; update the function (getActiveInterval) to
include an explicit final return (e.g., return undefined or a clear default) so
the function's contract is explicit and callers don't rely on implicit behavior
— locate getActiveInterval in gift-page.js and add the explicit return at the
end after the existing checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/portal/src/components/pages/gift-page.js`:
- Around line 15-33: getActiveInterval may implicitly return undefined when
portalPlans contains neither 'monthly' nor 'yearly'; update the function
(getActiveInterval) to include an explicit final return (e.g., return undefined
or a clear default) so the function's contract is explicit and callers don't
rely on implicit behavior — locate getActiveInterval in gift-page.js and add the
explicit return at the end after the existing checks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e72e986f-ce17-453d-b502-78e9c9680847

📥 Commits

Reviewing files that changed from the base of the PR and between 59c27fb and 1606b8f.

📒 Files selected for processing (4)
  • apps/portal/package.json
  • apps/portal/src/components/pages/gift-page.js
  • apps/portal/src/components/popup-modal.js
  • apps/portal/test/portal-links.test.js
✅ Files skipped from review due to trivial changes (2)
  • apps/portal/test/portal-links.test.js
  • apps/portal/package.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/portal/src/components/popup-modal.js

@mike182uk mike182uk force-pushed the BER-3484-add-gift-checkout branch from 1606b8f to 99a152c Compare April 1, 2026 16:28
Copy link
Copy Markdown
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.

🧹 Nitpick comments (1)
apps/portal/test/utils/helpers.test.js (1)

843-875: Good test coverage for the new helper.

The tests cover the key scenarios: selection priority, default plan fallback, and handling unavailable intervals.

Consider adding an edge case for an empty portalPlans array to document the expected undefined return:

test('returns undefined when no plans are available', () => {
    expect(getActiveInterval({portalPlans: [], portalDefaultPlan: null, selectedInterval: null})).toBeUndefined();
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/portal/test/utils/helpers.test.js` around lines 843 - 875, Add a test
case to the getActiveInterval test suite to cover the edge case where
portalPlans is an empty array: call getActiveInterval({portalPlans: [],
portalDefaultPlan: null, selectedInterval: null}) and assert it returns
undefined (use toBeUndefined()); place the new test alongside the existing tests
in the describe('getActiveInterval', ...) block so the behavior for no available
plans is documented and validated.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/portal/test/utils/helpers.test.js`:
- Around line 843-875: Add a test case to the getActiveInterval test suite to
cover the edge case where portalPlans is an empty array: call
getActiveInterval({portalPlans: [], portalDefaultPlan: null, selectedInterval:
null}) and assert it returns undefined (use toBeUndefined()); place the new test
alongside the existing tests in the describe('getActiveInterval', ...) block so
the behavior for no available plans is documented and validated.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e3854f12-6cb8-4227-a4b4-2be3e6ffda41

📥 Commits

Reviewing files that changed from the base of the PR and between 1606b8f and 99a152c.

📒 Files selected for processing (7)
  • apps/portal/package.json
  • apps/portal/src/components/common/products-section.js
  • apps/portal/src/components/pages/gift-page.js
  • apps/portal/src/components/popup-modal.js
  • apps/portal/src/utils/helpers.js
  • apps/portal/test/portal-links.test.js
  • apps/portal/test/utils/helpers.test.js
✅ Files skipped from review due to trivial changes (2)
  • apps/portal/package.json
  • apps/portal/test/portal-links.test.js

ref https://linear.app/ghost/issue/BER-3484

Implemented the gift page content behind the `giftSubscriptions` labs
flag: site header, purchaser email input, monthly/yearly toggle, paid
tier cards with pricing and benefits, and a "Purchase gift" button
@mike182uk mike182uk force-pushed the BER-3484-add-gift-checkout branch from 99a152c to 0d664e7 Compare April 1, 2026 19:19
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud bot commented Apr 1, 2026

@mike182uk mike182uk merged commit f9a7c67 into main Apr 1, 2026
58 of 67 checks passed
@mike182uk mike182uk deleted the BER-3484-add-gift-checkout branch April 1, 2026 19:54
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