Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion apps/portal/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 11 additions & 5 deletions apps/portal/src/actions.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -229,24 +230,29 @@ 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
};
}

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(),
Expand Down
7 changes: 6 additions & 1 deletion apps/portal/src/components/notification.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 (
<p>
{'Gift redeemed! You\'re all set.'}
{successMessage}
</p>
);
} else if (type === 'giftRedeem' && status === 'error') {
// TODO: Add translation strings once copy has been finalised
return (
<p>
{'We couldn\'t redeem this gift for your account.'}
Expand Down
11 changes: 11 additions & 0 deletions apps/portal/src/utils/gift-redemption-notification.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {getMemberTierName, getSubscriptionExpiry} from './helpers';
import {t} from './i18n';

export function getGiftDurationLabel({cadence, duration} = {}) {
Expand All @@ -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
Expand Down
18 changes: 7 additions & 11 deletions apps/portal/src/utils/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
};

Expand Down Expand Up @@ -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) {
Expand Down
38 changes: 32 additions & 6 deletions apps/portal/test/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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()
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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'
}
}
}
};

Expand All @@ -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'
});
Expand All @@ -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
}
});
});
Expand Down
44 changes: 42 additions & 2 deletions apps/portal/test/unit/components/notification.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ describe('Notification', () => {
NotificationParser.mockReturnValue({
type: 'giftRedeem',
status: 'success',
message: 'Gift redeemed! You\'re all set.',
autoHide: true,
duration: 5000
});
Expand Down Expand Up @@ -130,7 +129,6 @@ describe('Notification', () => {
NotificationParser.mockReturnValue({
type: 'giftRedeem',
status: 'success',
message: 'Gift redeemed! You\'re all set.',
autoHide: true,
duration: 5000
});
Expand Down Expand Up @@ -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(
<AppContext.Provider value={{
site,
member: {
paid: true,
subscriptions: [{
status: 'active',
tier: {
name: 'Ultra',
expiry_at: '2027-05-29T12:00:00.000Z'
}
}]
},
brandColor: '#000000',
showPopup: true,
doAction,
notification: null
}}
>
<Notification />
</AppContext.Provider>
);

await waitFor(() => {
expect(getByText('You now have access to Ultra until 29 May 2027. Enjoy!')).toBeInTheDocument();
});
});
});
17 changes: 2 additions & 15 deletions apps/portal/test/unit/notifications.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,11 @@ describe('notifications utils', () => {
test('reads gift redemption params from action subscribe plus portal hash query', () => {
window.history.replaceState({}, '', '/?action=subscribe&success=true#/portal/account?giftRedemption=true');

expect(NotificationParser()).toMatchObject({
type: 'giftRedeem',
status: 'success',
autoHide: true,
duration: 5000,
message: 'Gift redeemed! You\'re all set.'
});
});

test('reads gift redemption params from legacy giftRedeem action', () => {
window.history.replaceState({}, '', '/#/portal/account?action=giftRedeem&success=true');

expect(NotificationParser()).toMatchObject({
expect(NotificationParser()).toEqual({
type: 'giftRedeem',
status: 'success',
autoHide: true,
duration: 5000,
message: 'Gift redeemed! You\'re all set.'
duration: 5000
});
});

Expand Down
9 changes: 6 additions & 3 deletions ghost/admin/app/helpers/parse-member-event.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,12 +270,11 @@ export default class ParseMemberEventHelper extends Helper {
const duration = event.data.duration;
const cadenceLabel = duration === 1 ? event.data.cadence : event.data.cadence + 's';

return `Purchased a gift subscription for ${formattedAmount} (${tierName}, ${duration} ${cadenceLabel})`;
return `Purchased gift subscription for ${formattedAmount} (${tierName}, ${duration} ${cadenceLabel})`;
}

if (event.type === 'gift_redemption_event') {
const tierName = event.data.tier_name;
return `Started paid subscription (${tierName}) via gift`;
return 'started paid subscription via gift';
}
}

Expand Down Expand Up @@ -358,6 +357,10 @@ export default class ParseMemberEventHelper extends Helper {
return formattedAmount;
}

if (event.type === 'gift_redemption_event') {
return event.data.tier_name;
}

return;
}

Expand Down
2 changes: 1 addition & 1 deletion ghost/admin/public/assets/icons/event-gift.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 25 additions & 3 deletions ghost/core/core/server/adapters/lib/redis/AdapterCacheRedis.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class AdapterCacheRedis extends BaseCacheAdapter {
this.refreshAheadFactor = config.refreshAheadFactor || 0;
this.getTimeoutMilliseconds = config.getTimeoutMilliseconds || null;
this.currentlyExecutingBackgroundRefreshes = new Set();
this.currentlyExecutingReads = new Map();
this._keyPrefix = config.keyPrefix || '';
this._prefixHashInitInFlight = null;
this.redisClient.on('error', this.handleRedisError);
Expand Down Expand Up @@ -240,9 +241,30 @@ class AdapterCacheRedis extends BaseCacheAdapter {
}
return result;
} else {
const data = await fetchData();
await this.set(key, data); // We don't use `internalKey` here because `set` handles it
return data;
if (!internalKey) {
return fetchData();
}
if (this.currentlyExecutingReads.has(internalKey)) {
return this.currentlyExecutingReads.get(internalKey);
}
const fetchPromise = fetchData();
const resultPromise = fetchPromise.catch((err) => {
logging.error(err);
});
fetchPromise.then(async (data) => {
try {
debug('set', internalKey);
await this.cache.set(internalKey, data);
} catch (err) {
logging.error(err);
}
}).catch(() => {
// fetchData rejection — already logged by resultPromise
}).finally(() => {
this.currentlyExecutingReads.delete(internalKey);
});
this.currentlyExecutingReads.set(internalKey, resultPromise);
return resultPromise;
}
} catch (err) {
logging.error(err);
Expand Down
Loading