diff --git a/apps/portal/package.json b/apps/portal/package.json index a6718b6e10e..55f15c926d5 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.68.13", + "version": "2.68.14", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", diff --git a/apps/portal/src/actions.js b/apps/portal/src/actions.js index 662afa61dbc..35063506f32 100644 --- a/apps/portal/src/actions.js +++ b/apps/portal/src/actions.js @@ -1,5 +1,6 @@ import setupGhostApi from './utils/api'; import {chooseBestErrorMessage} from './utils/errors'; +import {getGiftRedemptionSuccessMessage} from './utils/gift-redemption-notification'; import {createNotification, createPopupNotification, getMemberEmail, getMemberName, getProductCadenceFromPrice, removePortalLinkFromUrl, getRefDomain} from './utils/helpers'; import {t} from './utils/i18n'; @@ -229,13 +230,18 @@ async function redeemGift({data, state, api}) { status: 'success', autoHide: true, closeable: true, - state + state, + message: getGiftRedemptionSuccessMessage({member}) }); + removePortalLinkFromUrl(); return { action: 'redeemGift:success', member, - page: 'accountHome', + showPopup: false, + lastPage: null, + pageQuery: '', + popupNotification: null, notification, notificationSequence: notification.count }; @@ -243,10 +249,10 @@ async function redeemGift({data, state, api}) { const integrityToken = await api.member.getIntegrityToken(); const redirectUrl = new URL(state?.site?.url || window.location.href); - const hashParams = new URLSearchParams({ + redirectUrl.search = new URLSearchParams({ giftRedemption: 'true' - }); - redirectUrl.hash = `/portal/account?${hashParams.toString()}`; + }).toString(); + redirectUrl.hash = ''; const {otc_ref: otcRef, inboxLinks} = await api.member.sendMagicLink({ email: (email || '').trim(), diff --git a/apps/portal/src/components/notification.js b/apps/portal/src/components/notification.js index aabf708c2e9..c427f1b13c9 100644 --- a/apps/portal/src/components/notification.js +++ b/apps/portal/src/components/notification.js @@ -7,6 +7,7 @@ import {ReactComponent as CloseIcon} from '../images/icons/close.svg'; import {ReactComponent as CheckmarkIcon} from '../images/icons/checkmark-fill.svg'; import {ReactComponent as WarningIcon} from '../images/icons/warning-fill.svg'; import NotificationParser, {clearURLParams} from '../utils/notifications'; +import {getGiftRedemptionSuccessMessage} from '../utils/gift-redemption-notification'; import {getPortalLink} from '../utils/helpers'; import {t} from '../utils/i18n'; @@ -108,12 +109,16 @@ const NotificationText = ({type, status, message, context}) => { ); } else if (type === 'giftRedeem' && status === 'success') { // TODO: Add translation strings once copy has been finalised + const successMessage = getGiftRedemptionSuccessMessage({member: context.member}) + || 'Gift redeemed! You\'re all set.'; + return (
- {'Gift redeemed! You\'re all set.'} + {successMessage}
); } else if (type === 'giftRedeem' && status === 'error') { + // TODO: Add translation strings once copy has been finalised return (
{'We couldn\'t redeem this gift for your account.'}
diff --git a/apps/portal/src/utils/gift-redemption-notification.js b/apps/portal/src/utils/gift-redemption-notification.js
index f14dfd64918..bc8510c6809 100644
--- a/apps/portal/src/utils/gift-redemption-notification.js
+++ b/apps/portal/src/utils/gift-redemption-notification.js
@@ -1,3 +1,4 @@
+import {getMemberTierName, getSubscriptionExpiry} from './helpers';
import {t} from './i18n';
export function getGiftDurationLabel({cadence, duration} = {}) {
@@ -12,6 +13,16 @@ export function getGiftDurationLabel({cadence, duration} = {}) {
: t('{months} months', {months: duration});
}
+export function getGiftRedemptionSuccessMessage({member} = {}) {
+ const tierName = getMemberTierName({member});
+ const expiryDate = getSubscriptionExpiry({member});
+ if (!tierName || !expiryDate) {
+ return null;
+ }
+ // TODO: Add translation strings once copy has been finalised
+ return `You now have access to ${tierName} until ${expiryDate}. Enjoy!`;
+}
+
export function getGiftRedemptionErrorMessage(error) {
const subtitle = error?.message && error.message !== 'Failed to load gift data'
? error.message
diff --git a/apps/portal/src/utils/notifications.js b/apps/portal/src/utils/notifications.js
index 4ca670ff681..57d416a5ee4 100644
--- a/apps/portal/src/utils/notifications.js
+++ b/apps/portal/src/utils/notifications.js
@@ -12,17 +12,12 @@ const getURLParam = ({searchParams, hashParams}, name) => {
return searchParams.get(name) ?? hashParams.get(name);
};
-export const handleGiftRedemptionAction = ({status}) => {
- const successStatus = JSON.parse(status);
-
+export const handleGiftRedemptionAction = ({success}) => {
return {
type: 'giftRedeem',
- status: successStatus ? 'success' : 'error',
- duration: successStatus ? 5000 : 3000,
- autoHide: successStatus,
- ...(successStatus ? {
- message: 'Gift redeemed! You\'re all set.' // TODO: Add translation strings once copy has been finalised
- } : {})
+ status: success ? 'success' : 'error',
+ duration: success ? 5000 : 3000,
+ autoHide: success
};
};
@@ -100,8 +95,9 @@ export default function NotificationParser({billingOnly = false} = {}) {
return handleStripeActions({status: stripeStatus, billingOnly});
}
- if ((giftRedemption || action === 'giftRedeem') && successStatus && !billingOnly) {
- return handleGiftRedemptionAction({status: successStatus});
+ if (giftRedemption && successStatus) {
+ const success = successStatus === 'true';
+ return handleGiftRedemptionAction({success});
}
if (action && successStatus && !billingOnly) {
diff --git a/apps/portal/test/actions.test.ts b/apps/portal/test/actions.test.ts
index 8ff8d17d1d0..17dfb0f6d8c 100644
--- a/apps/portal/test/actions.test.ts
+++ b/apps/portal/test/actions.test.ts
@@ -48,6 +48,8 @@ describe('signup action', () => {
describe('redeemGift action', () => {
test('redeems a gift directly for a logged-in member and refreshes member data', async () => {
+ window.history.replaceState({}, '', '/#/portal/gift/redeem/gift-token-123');
+
const mockApi = {
gift: {
redeem: vi.fn(() => Promise.resolve({
@@ -62,7 +64,14 @@ describe('redeemGift action', () => {
name: 'Jamie Larson',
email: 'jamie@example.com',
paid: true,
- status: 'gift'
+ status: 'gift',
+ subscriptions: [{
+ status: 'active',
+ tier: {
+ name: 'Premium',
+ expiry_at: '2027-05-29T12:00:00.000Z'
+ }
+ }]
})),
getIntegrityToken: vi.fn(),
sendMagicLink: vi.fn()
@@ -101,15 +110,23 @@ describe('redeemGift action', () => {
expect(mockApi.member.sendMagicLink).not.toHaveBeenCalled();
expect(result).toMatchObject({
action: 'redeemGift:success',
- page: 'accountHome',
+ showPopup: false,
+ lastPage: null,
+ pageQuery: '',
+ popupNotification: null,
member: {
status: 'gift'
},
notification: {
type: 'giftRedeem',
- status: 'success'
+ status: 'success',
+ message: 'You now have access to Premium until 29 May 2027. Enjoy!'
}
});
+ // Ensure the account page is no longer rendered after redemption.
+ expect(result).not.toHaveProperty('page');
+ // Redemption hash is cleared so a refresh doesn't re-trigger the redeemed-token flow.
+ expect(window.location.hash).toBe('');
});
test('sends a subscribe magic link with the gift token and redirects back to Portal account', async () => {
@@ -124,7 +141,14 @@ describe('redeemGift action', () => {
url: 'https://example.com/'
},
pageData: {
- token: 'gift-token-123'
+ token: 'gift-token-123',
+ gift: {
+ cadence: 'month',
+ duration: 3,
+ tier: {
+ name: 'Ultra'
+ }
+ }
}
};
@@ -139,12 +163,14 @@ describe('redeemGift action', () => {
api: mockApi
});
+ const expectedRedirect = 'https://example.com/?giftRedemption=true';
+
expect(mockApi.member.sendMagicLink).toHaveBeenCalledWith({
email: 'jamie@example.com',
emailType: 'subscribe',
integrityToken: 'token-123',
includeOTC: true,
- redirect: 'https://example.com/#/portal/account?giftRedemption=true',
+ redirect: expectedRedirect,
giftToken: 'gift-token-123',
name: 'Jamie Larson'
});
@@ -156,7 +182,7 @@ describe('redeemGift action', () => {
pageData: {
token: 'gift-token-123',
email: 'jamie@example.com',
- redirect: 'https://example.com/#/portal/account?giftRedemption=true'
+ redirect: expectedRedirect
}
});
});
diff --git a/apps/portal/test/unit/components/notification.test.js b/apps/portal/test/unit/components/notification.test.js
index 25a2f2654d0..8971bdfab10 100644
--- a/apps/portal/test/unit/components/notification.test.js
+++ b/apps/portal/test/unit/components/notification.test.js
@@ -91,7 +91,6 @@ describe('Notification', () => {
NotificationParser.mockReturnValue({
type: 'giftRedeem',
status: 'success',
- message: 'Gift redeemed! You\'re all set.',
autoHide: true,
duration: 5000
});
@@ -130,7 +129,6 @@ describe('Notification', () => {
NotificationParser.mockReturnValue({
type: 'giftRedeem',
status: 'success',
- message: 'Gift redeemed! You\'re all set.',
autoHide: true,
duration: 5000
});
@@ -165,4 +163,46 @@ describe('Notification', () => {
expect(container.querySelector('.gh-portal-notification')).not.toHaveClass('slideout');
});
+
+ test('derives gift redemption success message from member tier in context', async () => {
+ NotificationParser.mockReturnValue({
+ type: 'giftRedeem',
+ status: 'success',
+ autoHide: true,
+ duration: 5000
+ });
+
+ const doAction = vi.fn();
+ const site = {
+ url: 'https://example.com',
+ title: 'Example Site'
+ };
+
+ const {getByText} = render(
+