Skip to content

Commit 086701b

Browse files
committed
Wired up gift subscription checkout end-to-end
ref https://linear.app/ghost/issue/BER-3484 Wired up the gift subscription checkout end-to-end
1 parent 927d4da commit 086701b

11 files changed

Lines changed: 787 additions & 16 deletions

File tree

apps/portal/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tryghost/portal",
3-
"version": "2.67.5",
3+
"version": "2.67.7",
44
"license": "MIT",
55
"repository": "https://github.com/TryGhost/Ghost",
66
"author": "Ghost Foundation",

apps/portal/src/actions.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,24 @@ async function checkoutPlan({data, state, api}) {
223223
}
224224
}
225225

226+
async function checkoutGift({data, state, api}) {
227+
try {
228+
const {tierId, cadence, email} = data;
229+
await api.member.checkoutGift({tierId, cadence, email});
230+
return {
231+
action: 'checkoutGift:success'
232+
};
233+
} catch (e) {
234+
return {
235+
action: 'checkoutGift:failed',
236+
popupNotification: createPopupNotification({
237+
type: 'checkoutGift:failed', autoHide: false, closeable: true, state, status: 'error',
238+
message: t('Failed to process checkout, please try again')
239+
})
240+
};
241+
}
242+
}
243+
226244
async function updateSubscription({data, state, api}) {
227245
try {
228246
const {plan, planId, subscriptionId, cancelAtPeriodEnd} = data;
@@ -678,6 +696,7 @@ const Actions = {
678696
editBilling,
679697
manageBilling,
680698
checkoutPlan,
699+
checkoutGift,
681700
updateNewsletterPreference,
682701
showPopupNotification,
683702
removeEmailFromSuppressionList,

apps/portal/src/components/pages/gift-page.js

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import AppContext from '../../app-context';
33
import CloseButton from '../common/close-button';
44
import SiteTitleBackButton from '../common/site-title-back-button';
55
import InputForm from '../common/input-form';
6+
import ActionButton from '../common/action-button';
67
import LoadingPage from './loading-page';
78
import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg';
89
import {getAvailableProducts, getCurrencySymbol, formatNumber, getStripeAmount, isCookiesDisabled, getActiveInterval} from '../../utils/helpers';
@@ -61,7 +62,7 @@ function GiftProductCardPrice({product, selectedInterval}) {
6162
);
6263
}
6364

64-
function GiftProductCard({product, selectedInterval, onPurchase, disabled}) {
65+
function GiftProductCard({brandColor, product, selectedInterval, isDisabled, isPurchasing, onPurchase}) {
6566
let productDescription = product.description;
6667

6768
if ((!product.benefits || !product.benefits.length) && !productDescription) {
@@ -84,14 +85,15 @@ function GiftProductCard({product, selectedInterval, onPurchase, disabled}) {
8485
<GiftProductCardBenefits product={product} />
8586
</div>
8687
<div className='gh-portal-btn-product'>
87-
<button
88-
data-test-button='purchase-gift'
89-
className='gh-portal-btn'
90-
disabled={disabled}
91-
onClick={onPurchase}
92-
>
93-
Purchase gift
94-
</button>
88+
<ActionButton
89+
dataTestId='purchase-gift'
90+
label='Purchase gift'
91+
onClick={e => onPurchase(e, product)}
92+
disabled={isDisabled}
93+
isRunning={isPurchasing}
94+
brandColor={brandColor}
95+
style={{width: '100%'}}
96+
/>
9597
</div>
9698
</div>
9799
</div>
@@ -132,10 +134,11 @@ function GiftPriceSwitch({selectedInterval, setSelectedInterval, products}) {
132134
}
133135

134136
const GiftPage = () => {
135-
const {site, member} = useContext(AppContext);
137+
const {site, member, brandColor, action, doAction} = useContext(AppContext);
136138
const [email, setEmail] = useState(member?.email || '');
137139
const [emailError, setEmailError] = useState('');
138140
const [selectedInterval, setSelectedInterval] = useState(null);
141+
const [selectedProduct, setSelectedProduct] = useState(null);
139142

140143
if (!site) {
141144
return <LoadingPage />;
@@ -176,7 +179,8 @@ const GiftPage = () => {
176179

177180
const siteIcon = site.icon;
178181
const siteTitle = site.title || '';
179-
const disabled = isCookiesDisabled();
182+
const isPurchasing = action === 'checkoutGift:running';
183+
const isDisabled = isCookiesDisabled() || isPurchasing;
180184

181185
const emailField = [{
182186
type: 'email',
@@ -190,7 +194,7 @@ const GiftPage = () => {
190194
errorMessage: emailError
191195
}];
192196

193-
const handlePurchase = (e) => {
197+
const handlePurchase = (e, product) => {
194198
e.preventDefault();
195199

196200
const errors = ValidateInputForm({fields: emailField});
@@ -201,7 +205,13 @@ const GiftPage = () => {
201205
}
202206

203207
setEmailError('');
204-
// TODO: implement gift checkout using priceId and email
208+
setSelectedProduct(product.id);
209+
210+
doAction('checkoutGift', {
211+
tierId: product.id,
212+
cadence: activeInterval,
213+
email
214+
});
205215
};
206216

207217
return (
@@ -243,10 +253,12 @@ const GiftPage = () => {
243253
{products.map(product => (
244254
<GiftProductCard
245255
key={product.id}
256+
brandColor={brandColor}
246257
product={product}
247258
selectedInterval={activeInterval}
259+
isDisabled={isDisabled}
260+
isPurchasing={isPurchasing && selectedProduct === product.id}
248261
onPurchase={handlePurchase}
249-
disabled={disabled}
250262
/>
251263
))}
252264
</div>

apps/portal/src/utils/api.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,55 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
509509
});
510510
},
511511

512+
async checkoutGift({tierId, cadence, email: customerEmail} = {}) {
513+
const url = endpointFor({type: 'members', resource: 'create-stripe-checkout-session'});
514+
515+
let identity = null;
516+
try {
517+
identity = await api.member.identity();
518+
} catch (e) {
519+
// Not authenticated - that's fine for gift purchases
520+
}
521+
522+
const body = {
523+
identity,
524+
metadata: {
525+
requestSrc: 'portal'
526+
},
527+
type: 'gift',
528+
tierId,
529+
cadence,
530+
customerEmail
531+
};
532+
533+
const response = await makeRequest({
534+
url,
535+
method: 'POST',
536+
headers: {
537+
'Content-Type': 'application/json'
538+
},
539+
body: JSON.stringify(body)
540+
});
541+
542+
const responseJson = await response.json();
543+
544+
if (!response.ok) {
545+
const error = responseJson?.errors?.[0];
546+
547+
if (error) {
548+
throw error;
549+
}
550+
551+
throw new Error('Failed to process gift checkout, please try again.');
552+
}
553+
554+
if (responseJson.url) {
555+
return window.location.assign(responseJson.url);
556+
}
557+
558+
throw new Error('Failed to process gift checkout, please try again.');
559+
},
560+
512561
async checkoutDonation({successUrl, cancelUrl, metadata = {}, personalNote = ''} = {}) {
513562
const identity = await api.member.identity();
514563
const url = endpointFor({type: 'members', resource: 'create-stripe-checkout-session'});

apps/portal/test/actions.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,3 +343,47 @@ describe('verifyOTC action', () => {
343343
});
344344
});
345345
});
346+
347+
describe('checkoutGift action', () => {
348+
test('calls api.member.checkoutGift with correct data', async () => {
349+
const mockApi = {
350+
member: {
351+
checkoutGift: vi.fn(() => Promise.resolve())
352+
}
353+
};
354+
355+
const result = await ActionHandler({
356+
action: 'checkoutGift',
357+
data: {tierId: 'tier_123', cadence: 'month', email: 'buyer@example.com'},
358+
state: {},
359+
api: mockApi
360+
});
361+
362+
expect(mockApi.member.checkoutGift).toHaveBeenCalledWith({
363+
tierId: 'tier_123',
364+
cadence: 'month',
365+
email: 'buyer@example.com'
366+
});
367+
expect(result.action).toBe('checkoutGift:success');
368+
});
369+
370+
test('returns failed action with notification on error', async () => {
371+
const mockApi = {
372+
member: {
373+
checkoutGift: vi.fn(() => Promise.reject(new Error('Stripe error')))
374+
}
375+
};
376+
377+
const result = await ActionHandler({
378+
action: 'checkoutGift',
379+
data: {tierId: 'tier_123', cadence: 'month', email: 'buyer@example.com'},
380+
state: {},
381+
api: mockApi
382+
});
383+
384+
expect(result.action).toBe('checkoutGift:failed');
385+
expect(result.popupNotification).toBeDefined();
386+
expect(result.popupNotification.type).toBe('checkoutGift:failed');
387+
expect(result.popupNotification.status).toBe('error');
388+
});
389+
});

ghost/core/core/server/services/members/members-api/controllers/router-controller.js

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const messages = {
2929
memberNotFound: 'No member exists with this e-mail address.',
3030
invalidType: 'Invalid checkout type.',
3131
notConfigured: 'This site is not accepting payments at the moment.',
32+
giftSubscriptionsNotEnabled: 'Gift subscriptions are not enabled on this site.',
3233
invalidNewsletters: 'Cannot subscribe to invalid newsletters {newsletters}',
3334
archivedNewsletters: 'Cannot subscribe to archived newsletters {newsletters}',
3435
otcNotSupported: 'OTC verification not supported.',
@@ -598,14 +599,47 @@ module.exports = class RouterController {
598599
}
599600
}
600601

602+
/**
603+
* @param {object} options
604+
* @param {object} options.tier
605+
* @param {string} options.cadence
606+
* @param {string} options.successUrl Base URL for success redirect (payments service appends gift token params)
607+
* @param {string} options.cancelUrl URL to redirect to after cancelled checkout
608+
* @param {string} [options.email] Email address of the purchaser
609+
* @param {object} [options.member] Currently authenticated member
610+
* @param {boolean} options.isAuthenticated
611+
* @param {object} options.metadata Metadata to be passed to Stripe
612+
* @returns
613+
*/
614+
async _createGiftCheckoutSession(options) {
615+
if (!this._paymentsService.stripeAPIService.configured) {
616+
throw new DisabledFeatureError({
617+
message: tpl(messages.notConfigured)
618+
});
619+
}
620+
621+
try {
622+
const paymentLink = await this._paymentsService.getGiftPaymentLink(options);
623+
624+
return {url: paymentLink};
625+
} catch (err) {
626+
logging.error(err);
627+
this._sentry?.captureException?.(err);
628+
throw new BadRequestError({
629+
err,
630+
message: tpl(messages.unableToCheckout)
631+
});
632+
}
633+
}
634+
601635
async createCheckoutSession(req, res) {
602636
const type = req.body.type ?? 'subscription';
603637
const metadata = req.body.metadata ?? {};
604638
const identity = req.body.identity;
605639
const membersEnabled = true;
606640

607641
// Check this checkout type is supported
608-
if (typeof type !== 'string' || !['subscription', 'donation'].includes(type)) {
642+
if (typeof type !== 'string' || !['subscription', 'donation', 'gift'].includes(type)) {
609643
throw new BadRequestError({
610644
message: tpl(messages.invalidType)
611645
});
@@ -682,6 +716,36 @@ module.exports = class RouterController {
682716
} else if (type === 'donation') {
683717
options.personalNote = parsePersonalNote(req.body.personalNote);
684718
response = await this._createDonationCheckoutSession(options);
719+
} else if (type === 'gift') {
720+
if (!this.labsService.isSet('giftSubscriptions')) {
721+
throw new BadRequestError({
722+
message: tpl(messages.giftSubscriptionsNotEnabled)
723+
});
724+
}
725+
726+
if (req.body.offerId) {
727+
throw new BadRequestError({
728+
message: tpl(messages.badRequest),
729+
context: 'Offers cannot be applied to gift subscriptions'
730+
});
731+
}
732+
733+
const data = await this._getSubscriptionCheckoutData(req.body);
734+
735+
// Gifts require a paid tier
736+
if (!data.tier.getPrice(data.cadence)) {
737+
throw new BadRequestError({
738+
message: tpl(messages.badRequest),
739+
context: 'Gift subscriptions require a paid tier'
740+
});
741+
}
742+
743+
response = await this._createGiftCheckoutSession({
744+
...options,
745+
...data,
746+
successUrl: this._urlUtils.getSiteUrl(),
747+
cancelUrl: this._urlUtils.getSiteUrl()
748+
});
685749
}
686750

687751
res.writeHead(200, {

0 commit comments

Comments
 (0)