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
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ const RetentionOfferSidebar: React.FC<{
<TextField
error={Boolean(errors.displayTitle)}
hint={errors.displayTitle}
placeholder='Before you go...'
placeholder='Before you go'
title='Display title'
value={formState.displayTitle}
onChange={(e) => {
Expand All @@ -239,7 +239,7 @@ const RetentionOfferSidebar: React.FC<{
onKeyDown={() => clearError('displayTitle')}
/>
<TextArea
placeholder='We&#39;d hate to see you go! How about a special offer to stay?'
placeholder='We&#39;d hate to see you leave. How about a special offer to stay?'
title='Display description'
value={formState.displayDescription}
onChange={(e) => {
Expand Down Expand Up @@ -434,11 +434,26 @@ const EditRetentionOfferModal: React.FC<{id: string}> = ({id}) => {
const shouldCreateInactiveDraft = !formState.enabled && !editableRetentionOffer && hasFormChangesFromDefault(formState, defaultState);

const createRetentionOffer = async (status: 'active' | 'archived') => {
// Generate a random 8-character hex string
const hash = Array.from(crypto.getRandomValues(new Uint8Array(4)), b => b.toString(16).padStart(2, '0')).join('');
const hash = crypto.getRandomValues(new Uint16Array(1))[0].toString(16).padStart(4, '0');

let offerDesc: string;
if (formTerms.type === 'free_months') {
const monthText = formTerms.amount === 1 ? 'free month' : 'free months';
offerDesc = `${formTerms.amount} ${monthText}`;
} else {
let durationText: string;
if (formTerms.duration === 'once') {
durationText = 'next payment';
} else if (formTerms.duration === 'repeating') {
durationText = `for ${formTerms.durationInMonths} ${formTerms.durationInMonths === 1 ? 'month' : 'months'}`;
} else {
durationText = 'forever';
}
offerDesc = `${formTerms.amount}% off ${durationText}`;
}
Comment on lines +444 to +453
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix singular/plural for repeating duration text.

For a 1‑month repeating offer, the name currently becomes “for 1 months.” Use a singular label when durationInMonths === 1.

📝 Proposed fix
-                    } else if (formTerms.duration === 'repeating') {
-                        durationText = `for ${formTerms.durationInMonths} months`;
+                    } else if (formTerms.duration === 'repeating') {
+                        const months = formTerms.durationInMonths;
+                        const monthLabel = months === 1 ? 'month' : 'months';
+                        durationText = `for ${months} ${monthLabel}`;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let durationText: string;
if (formTerms.duration === 'once') {
durationText = 'next payment';
} else if (formTerms.duration === 'repeating') {
durationText = `for ${formTerms.durationInMonths} months`;
} else {
durationText = 'forever';
}
offerDesc = `${formTerms.amount}% off ${durationText}`;
}
let durationText: string;
if (formTerms.duration === 'once') {
durationText = 'next payment';
} else if (formTerms.duration === 'repeating') {
const months = formTerms.durationInMonths;
const monthLabel = months === 1 ? 'month' : 'months';
durationText = `for ${months} ${monthLabel}`;
} else {
durationText = 'forever';
}
offerDesc = `${formTerms.amount}% off ${durationText}`;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/admin-x-settings/src/components/settings/growth/offers/edit-retention-offer-modal.tsx`
around lines 446 - 455, The repeating-duration text uses plural "months"
indiscriminately; update the logic around formTerms.duration === 'repeating'
(and the durationInMonths value) to choose "month" when
formTerms.durationInMonths === 1 and "months" otherwise, e.g. build durationText
using a conditional on formTerms.durationInMonths so offerDesc becomes `for 1
month` when durationInMonths === 1 and `for N months` for other values; ensure
the change is applied where durationText is computed before offerDesc is set.


await addOffer({
name: `Special offer ${hash}`,
name: `Retention ${offerDesc} (${hash})`,
code: hash,
display_title: displayTitle,
display_description: displayDescription,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -670,8 +670,8 @@ test.describe('Offers Modal', () => {
});

const createdOffer = (lastApiRequests.addOffer?.body as {offers: Array<{name: string; code: string}>})?.offers?.[0];
expect(createdOffer?.name).toMatch(/^Special offer [a-f0-9]{8}$/);
expect(createdOffer?.code).toMatch(/^[a-f0-9]{8}$/);
expect(createdOffer?.name).toMatch(/^Retention 35% off forever \([a-f0-9]{4}\)$/);
expect(createdOffer?.code).toMatch(/^[a-f0-9]{4}$/);
});

test('Edits existing retention offer when only display fields change', async ({page}) => {
Expand Down
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.64.8",
"version": "2.64.9",
"license": "MIT",
"repository": "https://github.com/TryGhost/Ghost",
"author": "Ghost Foundation",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const PaidAccountActions = () => {
<p className={oldPriceClassName}>
{label}
</p>
<FreeMonthsLabel nextPayment={nextPayment} />
<FreeMonthsLabel nextPayment={nextPayment} subscription={subscription} />
</>
);
}
Expand Down Expand Up @@ -202,9 +202,11 @@ function FreeTrialLabel({subscription}) {
}

// TODO: Add i18n once copy is finalized
function FreeMonthsLabel({nextPayment}) {
const months = nextPayment.discount.amount;
const label = months === 1 ? '1 month free' : `${months} months free`;
function FreeMonthsLabel({nextPayment, subscription}) {
const months = nextPayment?.discount?.amount ?? 0;
const renewalDate = getDateString(subscription?.current_period_end);
const monthsText = months === 1 ? '1 month free' : `${months} months free`;
const label = renewalDate ? `${monthsText} - Renews ${renewalDate}` : monthsText;

return (
<p className="gh-portal-account-discountcontainer" data-testid="offer-label">
Expand Down
26 changes: 19 additions & 7 deletions apps/portal/src/components/pages/account-plan-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -251,13 +251,24 @@ function PlansOrProductSection({selectedPlan, onPlanSelect, onPlanCheckout, chan
}

// TODO: Add i18n once copy is finalized
function getOfferMessage(offer, originalPrice, currency, amountOff) {
function getOfferMessage(offer, originalPrice, currency, amountOff, subscription) {
if (offer.type === 'free_months') {
const months = offer.amount;
const monthLabel = months === 1 ? '1 month' : `${months} months`;
const dayLabel = months * 30;
const monthLabel = months === 1 ? '1 free month' : `${months} free months`;

if (subscription?.current_period_end) {
const date = new Date(subscription.current_period_end);
const originalDay = date.getUTCDate();
let targetMonth = date.getUTCMonth() + months;
let targetYear = date.getUTCFullYear() + Math.floor(targetMonth / 12);
targetMonth = targetMonth % 12;
const daysInTargetMonth = new Date(Date.UTC(targetYear, targetMonth + 1, 0)).getUTCDate();
const newDate = new Date(Date.UTC(targetYear, targetMonth, Math.min(originalDay, daysInTargetMonth)));
const newBillingDate = newDate.toLocaleDateString('en-GB', {year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC'});
return `Enjoy ${monthLabel} on us. Your next billing date will be ${newBillingDate}.`;
}

return `Enjoy ${monthLabel} on us. Your next billing date will be pushed back by ${dayLabel} days.`;
return `Enjoy ${monthLabel} on us.`;
}

if (offer.duration === 'forever') {
Expand All @@ -281,8 +292,9 @@ function getOfferMessage(offer, originalPrice, currency, amountOff) {

// TODO: Add i18n once copy is finalized
const RetentionOfferSection = ({offer, product, price, onAcceptOffer, onDeclineOffer}) => {
const {brandColor, action} = useContext(AppContext);
const {brandColor, action, member} = useContext(AppContext);
const isAcceptingOffer = action === 'applyOffer:running';
const subscription = getMemberSubscription({member});

const originalPrice = formatNumber(price.amount / 100);
const currency = getCurrencySymbol(price.currency);
Expand All @@ -291,9 +303,9 @@ const RetentionOfferSection = ({offer, product, price, onAcceptOffer, onDeclineO
const discountText = offer.type === 'free_months' ? `${amountOff} free` : `${amountOff} off`;
const cadenceLabel = offer.cadence === 'month' ? 'Monthly' : 'Yearly';
const productCadenceLabel = `${product.name} - ${cadenceLabel}`;
const displayDescription = offer.display_description || 'We\'d hate to see you go! How about a special offer to stay?';
const displayDescription = offer.display_description || 'We\'d hate to see you leave. How about a special offer to stay?';

const offerMessage = getOfferMessage(offer, originalPrice, currency, amountOff);
const offerMessage = getOfferMessage(offer, originalPrice, currency, amountOff, subscription);

// TODO: Add i18n once copy is finalized
return (
Expand Down
2 changes: 1 addition & 1 deletion apps/portal/src/utils/fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export const member = {
getSubscriptionData({
amount: 1500,
startDate: '2019-05-01T11:42:40.000Z',
currentPeriodEnd: '2021-06-05T11:42:40.000Z'
currentPeriodEnd: new Date().toISOString()
})
]
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ describe('Account Plan Page', () => {
fireEvent.click(cancelButton);

expect(queryByText('1 month free')).toBeInTheDocument();
expect(queryByText('Enjoy 1 month on us. Your next billing date will be pushed back by 30 days.')).toBeInTheDocument();
expect(queryByText('Enjoy 1 free month on us. Your next billing date will be 5 Nov 2022.')).toBeInTheDocument();
});

test('renders forever percent retention offers', async () => {
Expand Down