Skip to content

Commit 1b4ab08

Browse files
Added retention offer card UI to cancellation flow (TryGhost#26034)
ref https://linear.app/ghost/issue/FEA-548/finalise-uxui-copy-of-the-member-retention-flow-portal Updated the retention step in Portal to render a rich retention offer card showing the target product and billing cadence, discounted price vs original price, and renewal message derived from offer duration. Co-authored-by: Michael Barrett <mike@ghost.org>
1 parent 0b81238 commit 1b4ab08

1 file changed

Lines changed: 77 additions & 57 deletions

File tree

apps/portal/src/components/pages/account-plan-page.js

Lines changed: 77 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import CloseButton from '../common/close-button';
55
import BackButton from '../common/back-button';
66
import {MultipleProductsPlansSection} from '../common/plans-section';
77
import {getDateString} from '../../utils/date-time';
8-
import {formatNumber, getAvailablePrices, getCurrencySymbol, getFilteredPrices, getMemberActivePrice, getMemberActiveProduct, getMemberSubscription, getPriceFromSubscription, getProductFromPrice, getSubscriptionFromId, getUpgradeProducts, hasMultipleProductsFeature, isComplimentaryMember, isPaidMember} from '../../utils/helpers';
8+
import {formatNumber, getAvailablePrices, getCurrencySymbol, getFilteredPrices, getMemberActivePrice, getMemberActiveProduct, getMemberSubscription, getPriceFromSubscription, getProductFromId, getProductFromPrice, getSubscriptionFromId, getUpdatedOfferPrice, getUpgradeProducts, hasMultipleProductsFeature, isComplimentaryMember, isPaidMember} from '../../utils/helpers';
99
import Interpolate from '@doist/react-interpolate';
1010
import {t} from '../../utils/i18n';
1111

@@ -39,35 +39,15 @@ export const AccountPlanPageStyles = `
3939
padding: 6px 12px;
4040
}
4141
42-
.gh-portal-retention-offer {
43-
text-align: center;
42+
.gh-portal-retention-offer-price {
43+
display: flex;
44+
align-items: center;
45+
gap: 6px;
46+
margin-top: 20px;
4447
}
4548
46-
.gh-portal-retention-offer-message {
47-
font-size: 1.5rem;
48-
color: var(--grey4);
49-
margin: 0 0 24px;
50-
line-height: 1.5;
51-
}
52-
53-
.gh-portal-retention-offer-card {
54-
background: var(--grey14);
55-
border-radius: 8px;
56-
padding: 32px 24px;
57-
margin-bottom: 24px;
58-
}
59-
60-
.gh-portal-retention-offer-discount {
61-
font-size: 2.4rem;
62-
font-weight: 700;
63-
color: var(--grey1);
64-
line-height: 1.1;
65-
}
66-
67-
.gh-portal-retention-offer-duration {
68-
font-size: 1.5rem;
69-
color: var(--grey5);
70-
margin-top: 4px;
49+
.gh-portal-retention-offer-price .gh-portal-offer-oldprice {
50+
margin: 4px 0 0;
7151
}
7252
`;
7353

@@ -295,47 +275,79 @@ function formatOfferDuration(offer) {
295275
return '';
296276
}
297277

298-
const RetentionOfferSection = ({offer, onAcceptOffer, onDeclineOffer}) => {
278+
const RetentionOfferSection = ({offer, product, price, onAcceptOffer, onDeclineOffer}) => {
299279
const {brandColor, action} = useContext(AppContext);
300280
const isAcceptingOffer = action === 'applyOffer:running';
301281

282+
const originalPrice = formatNumber(price.amount / 100);
283+
const discountedPrice = formatNumber(getUpdatedOfferPrice({offer, price}));
284+
const currencySymbol = getCurrencySymbol(price.currency);
285+
302286
const discountText = formatOfferDiscount(offer);
303287
const durationText = formatOfferDuration(offer);
288+
const intervalLabel = offer.cadence === 'month' ? 'month' : 'year';
289+
290+
let offerMessage = `${discountText} ${durationText}.`;
291+
292+
if (offer.duration !== 'forever') {
293+
offerMessage += ` Renews at ${currencySymbol}${originalPrice}/${intervalLabel}.`;
294+
}
304295

305296
// TODO: Add i18n once copy is finalized
306297
return (
307-
<div className="gh-portal-logged-out-form-container gh-portal-retention-offer">
308-
<p className="gh-portal-retention-offer-message">
298+
<div className="gh-portal-logged-out-form-container gh-portal-offer gh-portal-retention-offer">
299+
<p className="gh-portal-text-center">
309300
{'We\'d hate to see you go! How about a special offer to stay?'}
310301
</p>
311-
<div className="gh-portal-retention-offer-card">
312-
<div className="gh-portal-retention-offer-discount">{discountText}</div>
313-
<div className="gh-portal-retention-offer-duration">{durationText}</div>
302+
303+
<div className="gh-portal-offer-bar">
304+
<div className="gh-portal-offer-title">
305+
<h4>{product.name} - {offer.cadence === 'month' ? 'Monthly' : 'Yearly'}</h4>
306+
<h5 className="gh-portal-discount-label">{discountText}</h5>
307+
</div>
308+
309+
<div className="gh-portal-offer-details">
310+
<div className="gh-portal-retention-offer-price">
311+
<div className="gh-portal-product-price">
312+
<span className="currency-sign">{currencySymbol}</span>
313+
<span className="amount">{discountedPrice}</span>
314+
</div>
315+
<div className="gh-portal-offer-oldprice">
316+
{currencySymbol}{originalPrice}
317+
</div>
318+
</div>
319+
<p className="footnote">
320+
{offerMessage}
321+
</p>
322+
</div>
323+
324+
<ActionButton
325+
dataTestId={'accept-retention-offer'}
326+
onClick={onAcceptOffer}
327+
isRunning={isAcceptingOffer}
328+
disabled={isAcceptingOffer}
329+
isPrimary={true}
330+
brandColor={brandColor}
331+
label="Accept offer"
332+
style={{
333+
width: '100%',
334+
height: '40px',
335+
marginTop: '28px'
336+
}}
337+
/>
314338
</div>
315-
<ActionButton
316-
dataTestId={'accept-retention-offer'}
317-
onClick={onAcceptOffer}
318-
isRunning={isAcceptingOffer}
319-
disabled={isAcceptingOffer}
320-
isPrimary={true}
321-
brandColor={brandColor}
322-
label="Accept offer"
323-
style={{
324-
width: '100%',
325-
height: '40px'
326-
}}
327-
/>
339+
328340
<ActionButton
329341
dataTestId={'decline-retention-offer'}
330342
onClick={onDeclineOffer}
331343
isPrimary={false}
332344
isDestructive={true}
333345
classes={'gh-portal-btn-text'}
334346
brandColor={brandColor}
335-
label="Continue to cancellation"
347+
label="No thanks, I want to cancel"
336348
style={{
337349
width: '100%',
338-
marginTop: '24px',
350+
marginTop: '32px',
339351
marginBottom: '24px'
340352
}}
341353
/>
@@ -382,7 +394,7 @@ const PlansContainer = ({
382394
pendingOffer, onPlanSelect, onPlanCheckout, onConfirm, onCancelSubscription,
383395
onAcceptRetentionOffer, onDeclineRetentionOffer
384396
}) => {
385-
const {member} = useContext(AppContext);
397+
const {member, site} = useContext(AppContext);
386398
// Plan upgrade flow for free member or complimentary member
387399
if (!isPaidMember({member}) || isComplimentaryMember({member})) {
388400
return (
@@ -404,13 +416,21 @@ const PlansContainer = ({
404416

405417
// Retention offer flow - shown before cancellation confirmation
406418
if (confirmationType === 'offerRetention' && pendingOffer) {
407-
return (
408-
<RetentionOfferSection
409-
offer={pendingOffer}
410-
onAcceptOffer={onAcceptRetentionOffer}
411-
onDeclineOffer={onDeclineRetentionOffer}
412-
/>
413-
);
419+
const offerProduct = getProductFromId({site, productId: pendingOffer.tier.id});
420+
const offerPrice = pendingOffer.cadence === 'month' ? offerProduct?.monthlyPrice : offerProduct?.yearlyPrice;
421+
422+
// Skip retention offer if product or price data is invalid
423+
if (offerProduct && offerPrice) {
424+
return (
425+
<RetentionOfferSection
426+
offer={pendingOffer}
427+
product={offerProduct}
428+
price={offerPrice}
429+
onAcceptOffer={onAcceptRetentionOffer}
430+
onDeclineOffer={onDeclineRetentionOffer}
431+
/>
432+
);
433+
}
414434
}
415435

416436
// Plan confirmation flow for cancel/update flows

0 commit comments

Comments
 (0)