Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.67.6",
"version": "2.67.7",
"license": "MIT",
"repository": "https://github.com/TryGhost/Ghost",
"author": "Ghost Foundation",
Expand Down
19 changes: 19 additions & 0 deletions apps/portal/src/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,24 @@
}
}

async function checkoutGift({data, state, api}) {
try {
const {tierId, cadence, email} = data;
await api.member.checkoutGift({tierId, cadence, email});
return {
action: 'checkoutGift:success'
};
} catch (e) {
return {
action: 'checkoutGift:failed',
popupNotification: createPopupNotification({
type: 'checkoutGift:failed', autoHide: false, closeable: true, state, status: 'error',
message: t('Failed to process checkout, please try again')
})
};
}

Check warning on line 241 in apps/portal/src/actions.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ1Omvi4iWucoroco_4F&open=AZ1Omvi4iWucoroco_4F&pullRequest=27080
}

async function updateSubscription({data, state, api}) {
try {
const {plan, planId, subscriptionId, cancelAtPeriodEnd} = data;
Expand Down Expand Up @@ -678,6 +696,7 @@
editBilling,
manageBilling,
checkoutPlan,
checkoutGift,
updateNewsletterPreference,
showPopupNotification,
removeEmailFromSuppressionList,
Expand Down
40 changes: 26 additions & 14 deletions apps/portal/src/components/pages/gift-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import CloseButton from '../common/close-button';
import SiteTitleBackButton from '../common/site-title-back-button';
import InputForm from '../common/input-form';
import ActionButton from '../common/action-button';
import LoadingPage from './loading-page';
import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg';
import {getAvailableProducts, getCurrencySymbol, formatNumber, getStripeAmount, isCookiesDisabled, getActiveInterval} from '../../utils/helpers';
Expand Down Expand Up @@ -61,7 +62,7 @@
);
}

function GiftProductCard({product, selectedInterval, onPurchase, disabled}) {
function GiftProductCard({brandColor, product, selectedInterval, isDisabled, isPurchasing, onPurchase}) {

Check warning on line 65 in apps/portal/src/components/pages/gift-page.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'onPurchase' is missing in props validation

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ1Omvh2iWucoroco_4D&open=AZ1Omvh2iWucoroco_4D&pullRequest=27080

Check warning on line 65 in apps/portal/src/components/pages/gift-page.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'product' is missing in props validation

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ1Omvh2iWucoroco_3_&open=AZ1Omvh2iWucoroco_3_&pullRequest=27080

Check warning on line 65 in apps/portal/src/components/pages/gift-page.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'selectedInterval' is missing in props validation

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ1Omvh2iWucoroco_4A&open=AZ1Omvh2iWucoroco_4A&pullRequest=27080

Check warning on line 65 in apps/portal/src/components/pages/gift-page.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'brandColor' is missing in props validation

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ1Omvh2iWucoroco_3-&open=AZ1Omvh2iWucoroco_3-&pullRequest=27080

Check warning on line 65 in apps/portal/src/components/pages/gift-page.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'isDisabled' is missing in props validation

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ1Omvh2iWucoroco_4B&open=AZ1Omvh2iWucoroco_4B&pullRequest=27080

Check warning on line 65 in apps/portal/src/components/pages/gift-page.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'isPurchasing' is missing in props validation

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ1Omvh2iWucoroco_4C&open=AZ1Omvh2iWucoroco_4C&pullRequest=27080
let productDescription = product.description;

if ((!product.benefits || !product.benefits.length) && !productDescription) {
Expand All @@ -84,14 +85,15 @@
<GiftProductCardBenefits product={product} />
</div>
<div className='gh-portal-btn-product'>
<button
data-test-button='purchase-gift'
className='gh-portal-btn'
disabled={disabled}
onClick={onPurchase}
>
Purchase gift
</button>
<ActionButton
dataTestId='purchase-gift'
label='Purchase gift'
onClick={e => onPurchase(e, product)}
disabled={isDisabled}
isRunning={isPurchasing}
brandColor={brandColor}
style={{width: '100%'}}
/>
</div>
</div>
</div>
Expand Down Expand Up @@ -132,10 +134,11 @@
}

const GiftPage = () => {
const {site, member} = useContext(AppContext);
const {site, member, brandColor, action, doAction} = useContext(AppContext);
const [email, setEmail] = useState(member?.email || '');
const [emailError, setEmailError] = useState('');
const [selectedInterval, setSelectedInterval] = useState(null);
const [selectedProduct, setSelectedProduct] = useState(null);

if (!site) {
return <LoadingPage />;
Expand Down Expand Up @@ -176,7 +179,8 @@

const siteIcon = site.icon;
const siteTitle = site.title || '';
const disabled = isCookiesDisabled();
const isPurchasing = action === 'checkoutGift:running';
const isDisabled = isCookiesDisabled() || isPurchasing;

const emailField = [{
type: 'email',
Expand All @@ -190,7 +194,7 @@
errorMessage: emailError
}];

const handlePurchase = (e) => {
const handlePurchase = (e, product) => {
e.preventDefault();

const errors = ValidateInputForm({fields: emailField});
Expand All @@ -201,7 +205,13 @@
}

setEmailError('');
// TODO: implement gift checkout using priceId and email
setSelectedProduct(product.id);

doAction('checkoutGift', {
tierId: product.id,
cadence: activeInterval,
email
});
};

return (
Expand Down Expand Up @@ -243,10 +253,12 @@
{products.map(product => (
<GiftProductCard
key={product.id}
brandColor={brandColor}
product={product}
selectedInterval={activeInterval}
isDisabled={isDisabled}
isPurchasing={isPurchasing && selectedProduct === product.id}
onPurchase={handlePurchase}
disabled={disabled}
/>
))}
</div>
Expand Down
54 changes: 54 additions & 0 deletions apps/portal/src/utils/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,60 @@
});
},

async checkoutGift({tierId, cadence, email: customerEmail} = {}) {
const url = endpointFor({type: 'members', resource: 'create-stripe-checkout-session'});

let identity = null;
try {
identity = await api.member.identity();
} catch (e) {
// Not authenticated - that's fine for gift purchases
}

Check warning on line 520 in apps/portal/src/utils/api.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ1OmviWiWucoroco_4E&open=AZ1OmviWiWucoroco_4E&pullRequest=27080

const body = {
identity,
metadata: {
requestSrc: 'portal'
},
type: 'gift',
tierId,
cadence,
customerEmail
};

const response = await makeRequest({
url,
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});

let responseJson = {};
try {
responseJson = await response.json();
} catch (e) {
// response may not be JSON (e.g. HTML error page from proxy)
}

Check warning on line 547 in apps/portal/src/utils/api.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ1TAeu8Tm5LOco2aU5c&open=AZ1TAeu8Tm5LOco2aU5c&pullRequest=27080

if (!response.ok) {
const error = responseJson?.errors?.[0];

if (error) {
throw error;
}

throw new Error('Failed to process gift checkout, please try again.');
}

if (responseJson.url) {
return window.location.assign(responseJson.url);
}

throw new Error('Failed to process gift checkout, please try again.');
},

async checkoutDonation({successUrl, cancelUrl, metadata = {}, personalNote = ''} = {}) {
const identity = await api.member.identity();
const url = endpointFor({type: 'members', resource: 'create-stripe-checkout-session'});
Expand Down
44 changes: 44 additions & 0 deletions apps/portal/test/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,3 +343,47 @@ describe('verifyOTC action', () => {
});
});
});

describe('checkoutGift action', () => {
test('calls api.member.checkoutGift with correct data', async () => {
const mockApi = {
member: {
checkoutGift: vi.fn(() => Promise.resolve())
}
};

const result = await ActionHandler({
action: 'checkoutGift',
data: {tierId: 'tier_123', cadence: 'month', email: 'buyer@example.com'},
state: {},
api: mockApi
});

expect(mockApi.member.checkoutGift).toHaveBeenCalledWith({
tierId: 'tier_123',
cadence: 'month',
email: 'buyer@example.com'
});
expect(result.action).toBe('checkoutGift:success');
});

test('returns failed action with notification on error', async () => {
const mockApi = {
member: {
checkoutGift: vi.fn(() => Promise.reject(new Error('Stripe error')))
}
};

const result = await ActionHandler({
action: 'checkoutGift',
data: {tierId: 'tier_123', cadence: 'month', email: 'buyer@example.com'},
state: {},
api: mockApi
});

expect(result.action).toBe('checkoutGift:failed');
expect(result.popupNotification).toBeDefined();
expect(result.popupNotification.type).toBe('checkoutGift:failed');
expect(result.popupNotification.status).toBe('error');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const messages = {
memberNotFound: 'No member exists with this e-mail address.',
invalidType: 'Invalid checkout type.',
notConfigured: 'This site is not accepting payments at the moment.',
giftSubscriptionsNotEnabled: 'Gift subscriptions are not enabled on this site.',
invalidNewsletters: 'Cannot subscribe to invalid newsletters {newsletters}',
archivedNewsletters: 'Cannot subscribe to archived newsletters {newsletters}',
otcNotSupported: 'OTC verification not supported.',
Expand Down Expand Up @@ -598,14 +599,47 @@ module.exports = class RouterController {
}
}

/**
* @param {object} options
* @param {object} options.tier
* @param {'month'|'year'} options.cadence
* @param {string} options.email
* @param {string} options.successUrl
* @param {string} options.cancelUrl
* @param {object} options.metadata
* @param {object} [options.member]
* @param {boolean} options.isAuthenticated
* @returns
*/
async _createGiftCheckoutSession(options) {
if (!this._paymentsService.stripeAPIService.configured) {
throw new DisabledFeatureError({
message: tpl(messages.notConfigured)
});
}

try {
const paymentLink = await this._paymentsService.getGiftPaymentLink(options);

return {url: paymentLink};
} catch (err) {
logging.error(err);
this._sentry?.captureException?.(err);
throw new BadRequestError({
err,
message: tpl(messages.unableToCheckout)
});
}
}

async createCheckoutSession(req, res) {
const type = req.body.type ?? 'subscription';
const metadata = req.body.metadata ?? {};
const identity = req.body.identity;
const membersEnabled = true;

// Check this checkout type is supported
if (typeof type !== 'string' || !['subscription', 'donation'].includes(type)) {
if (typeof type !== 'string' || !['subscription', 'donation', 'gift'].includes(type)) {
throw new BadRequestError({
message: tpl(messages.invalidType)
});
Expand Down Expand Up @@ -682,6 +716,41 @@ module.exports = class RouterController {
} else if (type === 'donation') {
options.personalNote = parsePersonalNote(req.body.personalNote);
response = await this._createDonationCheckoutSession(options);
} else if (type === 'gift') {
if (!this.labsService.isSet('giftSubscriptions')) {
throw new BadRequestError({
message: tpl(messages.giftSubscriptionsNotEnabled)
});
}

if (!membersEnabled) {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}

if (typeof req.body.customerEmail !== 'string' || !isEmail(req.body.customerEmail)) {
throw new BadRequestError({
message: tpl(messages.badRequest),
context: 'A valid email address is required to purchase a gift subscription'
});
}

if (req.body.offerId) {
throw new BadRequestError({
message: tpl(messages.badRequest),
context: 'Offers cannot be applied to gift subscriptions'
});
}

const data = await this._getSubscriptionCheckoutData(req.body);

response = await this._createGiftCheckoutSession({
...options,
...data,
successUrl: this._urlUtils.getSiteUrl(),
cancelUrl: this._urlUtils.getSiteUrl()
});
}

res.writeHead(200, {
Expand Down
Loading
Loading