@financial-times/n-conversion-forms 46.0.0 → 47.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,12 @@ import PropTypes from 'prop-types';
3
3
  import classNames from 'classnames';
4
4
  import { Period, Monthly } from '@financial-times/n-pricing';
5
5
 
6
+ import {
7
+ getDurationFromISO8601Value,
8
+ is52WeeksOrLonger,
9
+ is90DaysOrLonger,
10
+ } from '../helpers';
11
+
6
12
  export function PaymentTerm({
7
13
  fieldId = 'paymentTermField',
8
14
  inputName = 'paymentTerm',
@@ -17,159 +23,40 @@ export function PaymentTerm({
17
23
  isNonRenewingSubscriptionTermType = false,
18
24
  }) {
19
25
  /**
20
- * Compute monthly price for given term name
21
- * @param {number} amount price in number format
22
- * @param {string} currency country id of the currency
23
- * @param {string} period (expressed in IS0 8601 duration format): e.g. PxY (yearly) or PxM (montly) where x is the amount of years/months
26
+ * Capitalises a string.
27
+ * @param {string} Input string to be capitalised
24
28
  * @returns {string}
25
29
  */
26
- const getMonthlyPriceFromPeriod = (amount, currency, period) => {
27
- const periodObj = new Period(period);
28
- const monthlyPrice = periodObj.calculatePrice('P1M', amount);
29
- return new Monthly({ value: monthlyPrice, currency }).getAmount('monthly');
30
- };
30
+ const capitalise = (string) =>
31
+ Boolean(string) ? string[0].toUpperCase() + string.slice(1) : string;
31
32
 
32
33
  /**
33
- * returns period converted to time if found
34
- * otherwise returns empty string to avoid show information not mapped
35
- * @param {string} period (expressed in IS0 8601 duration format): PxY (yearly), PxM (montly), PxW (weekly), of PxD (daily), where x is the amount of years/months/weeks/days
36
- * @returns {string}
34
+ * Creates the JSX for a single payment-term option, including the input,
35
+ * title, discount messaging, and descriptive pricing copy.
36
+ *
37
+ * @param {Object} option - Payment term configuration
38
+ * @param {string} option.value - ISO 8601 duration of the subscription term of the offer
39
+ * @param {string} option.name - term name, e.g. "monthly", "annual", "2 yearly
40
+ * @param {string} option.price - Formatted display price
41
+ * @param {string|number} [option.amount] - Price expressed in numerical terms
42
+ * @param {string} [option.symbol] - Currency symbol, e.g. £
43
+ * @param {string} [option.monthlyPrice] - Precomputed monthly equivalent price (can be with or without currency symbol)
44
+ * @param {boolean} [option.discount] - Whether the option should display discount messaging
45
+ * @param {boolean} [option.bestOffer] - Whether the option should show "Best offer" instead of standard discount copy
46
+ * @param {boolean} [option.selected] - Whether the option is selected by default
47
+ * @param {boolean} [option.isTrial] - Whether the option is a trial offer
48
+ * @param {boolean} [option.subscriptionAutoRenewTerm] - Whether the option is for an auto-renewing subscription
49
+ * @param {number} [option.trialAmount] - Amount used for trial pricing
50
+ * @param {string} [option.trialDuration] - Human-readable trial duration copy
51
+ * @param {string} [option.trialPrice] - Formatted trial price
52
+ * @param {string} [option.displayName] - Override label for the term title
53
+ * @param {string} [option.title] - Fallback title for legacy or non-period terms
54
+ * @param {string} [option.subTitle] - Optional subtitle shown alongside the term title
55
+ * @param {string} [option.chargeOnText] - Optional charge timing copy for non-period offers
56
+ * @param {boolean} [option.b2cPartnership] - Whether the option is part of a B2C partnership offer
57
+ * @param {string} [option.b2cDiscountCopy] - Partnership-specific discount copy
58
+ * @returns {React.ReactElement} A rendered payment term option
37
59
  */
38
- const getTimeFromPeriod = (period) => {
39
- const periodUnitCodeToWordMap = {
40
- Y: 'years',
41
- M: 'months',
42
- W: 'weeks',
43
- D: 'days',
44
- };
45
-
46
- const periodUnitCode = period.substring(period.length - 1);
47
-
48
- const freq = periodUnitCodeToWordMap[periodUnitCode] || '';
49
-
50
- const amount = period.substring(1, period.length - 1);
51
-
52
- return period ? `${amount} ${freq}` : '';
53
- };
54
-
55
- const isValidPeriod = (period) => {
56
- try {
57
- // Period should throw an error if it is not properly provided
58
- // in order to validate it, we just send in case type is string
59
- new Period(typeof period === 'string' ? period : '');
60
- return true;
61
- } catch (e) {
62
- return false;
63
- }
64
- };
65
-
66
- const nameMap = {
67
- annual: {
68
- title: 'Annual',
69
- price: (price) => (
70
- <React.Fragment>
71
- Single{' '}
72
- <span className="ncf__payment-term__price ncf__strong">{price}</span>{' '}
73
- payment
74
- </React.Fragment>
75
- ),
76
- trialPrice: (price) => (
77
- <React.Fragment>
78
- Unless you cancel during your trial you will be billed{' '}
79
- <span className="ncf__payment-term__price">{price}</span> per year
80
- after the trial period.
81
- </React.Fragment>
82
- ),
83
- monthlyPrice: (price) =>
84
- price && (
85
- <span className="ncf__payment-term__equivalent-price">
86
- That’s equivalent to{' '}
87
- <span className="ncf__payment-term__monthly-price">{price}</span>{' '}
88
- per month
89
- </span>
90
- ),
91
- renewsText: () => (
92
- <p className="ncf__payment-term__renews-text">
93
- Renews annually unless cancelled
94
- </p>
95
- ),
96
- },
97
- quarterly: {
98
- title: 'Quarterly',
99
- price: (price) => (
100
- <React.Fragment>
101
- <span className="ncf__payment-term__price">{price}</span> per quarter
102
- </React.Fragment>
103
- ),
104
- trialPrice: (price) => (
105
- <React.Fragment>
106
- Unless you cancel during your trial you will be billed{' '}
107
- <span className="ncf__payment-term__price">{price}</span> per quarter
108
- after the trial period.
109
- </React.Fragment>
110
- ),
111
- monthlyPrice: () => {},
112
- renewsText: () => (
113
- <p className="ncf__payment-term__renews-text">
114
- Renews quarterly unless cancelled
115
- </p>
116
- ),
117
- },
118
- monthly: {
119
- title: 'Monthly',
120
- price: (price) => (
121
- <React.Fragment>
122
- <span className="ncf__payment-term__price">{price}</span> per month
123
- </React.Fragment>
124
- ),
125
- trialPrice: (price) => (
126
- <React.Fragment>
127
- Unless you cancel during your trial you will be billed{' '}
128
- <span className="ncf__payment-term__price">{price}</span> per month
129
- after the trial period.
130
- </React.Fragment>
131
- ),
132
- monthlyPrice: () => {},
133
- renewsText: () => (
134
- <p className="ncf__payment-term__renews-text">
135
- {'Renews monthly unless cancelled'}
136
- </p>
137
- ),
138
- },
139
- custom: {
140
- price: (price) => (
141
- <React.Fragment>
142
- Single{' '}
143
- <span className="ncf__payment-term__price ncf__strong">{price}</span>{' '}
144
- payment
145
- </React.Fragment>
146
- ),
147
- trialPrice: (trialPrice, trialPeriod) => (
148
- <React.Fragment>
149
- Unless you cancel during your trial you will be billed{' '}
150
- <span className="ncf__payment-term__price">{trialPrice}</span> per{' '}
151
- {trialPeriod}
152
- after the trial period.
153
- </React.Fragment>
154
- ),
155
- monthlyPrice: (monthlyPrice) =>
156
- Boolean(monthlyPrice) && (
157
- <span className="ncf__payment-term__equivalent-price">
158
- That’s equivalent to{' '}
159
- <span className="ncf__payment-term__monthly-price">
160
- {monthlyPrice}
161
- </span>{' '}
162
- per month
163
- </span>
164
- ),
165
- renewsText: (renewalPeriod) =>
166
- Boolean(renewalPeriod) && (
167
- <p className="ncf__payment-term__renews-text">
168
- Renews every {renewalPeriod} unless cancelled
169
- </p>
170
- ),
171
- },
172
- };
173
60
  const createPaymentTerm = (option) => {
174
61
  const className = classNames([
175
62
  'ncf__payment-term__item',
@@ -187,6 +74,139 @@ export function PaymentTerm({
187
74
  ...(option.selected && { defaultChecked: true }),
188
75
  };
189
76
 
77
+ /**
78
+ * Determines whether input is a valid ISO 8601 duration value that can be decoded by the Period class.
79
+ * @returns {boolean}
80
+ */
81
+ const isValidPeriod = () => {
82
+ try {
83
+ // Period should throw an error if it is not properly provided
84
+ // in order to validate it, we just send in case type is string
85
+ new Period(typeof option.value === 'string' ? option.value : '');
86
+ return true;
87
+ } catch (error) {
88
+ return false;
89
+ }
90
+ };
91
+
92
+ /**
93
+ * Compute monthly price for given term name.
94
+ * @returns {string}
95
+ */
96
+ const getMonthlyPriceFromPeriod = () => {
97
+ const periodObj = new Period(option.value);
98
+ const monthlyPrice = periodObj.calculatePrice('P1M', option.amount);
99
+ return new Monthly({
100
+ value: monthlyPrice,
101
+ symbol: option.symbol,
102
+ }).getAmount('monthly');
103
+ };
104
+
105
+ /**
106
+ * Returns elements that include the text describing the price of the offer option.
107
+ * @returns {React.ReactElement}
108
+ */
109
+ const getPriceText = () => {
110
+ const isExpressedAsSinglePayment =
111
+ !option.subscriptionAutoRenewTerm ||
112
+ // With an auto-renewing annual term there is a high chance
113
+ // it will not be the same price in the second year,
114
+ // so we do not want to imply that the price will remain consistent.
115
+ // For shorter auto-renewing terms there is higher confidence that the price
116
+ // will remain consistent across subsequent terms.
117
+ (option.subscriptionAutoRenewTerm && is52WeeksOrLonger(option.value));
118
+
119
+ const isExpressedAsRecurringPayment = !isExpressedAsSinglePayment;
120
+
121
+ if (isExpressedAsSinglePayment) {
122
+ return (
123
+ <React.Fragment>
124
+ Single{' '}
125
+ <span className="ncf__payment-term__price">{option.price}</span>{' '}
126
+ payment
127
+ </React.Fragment>
128
+ );
129
+ }
130
+
131
+ if (isExpressedAsRecurringPayment) {
132
+ return (
133
+ <React.Fragment>
134
+ <span className="ncf__payment-term__price">{option.price}</span> per{' '}
135
+ {getDurationFromISO8601Value({
136
+ iso8601Value: option.value,
137
+ })}
138
+ </React.Fragment>
139
+ );
140
+ }
141
+ };
142
+
143
+ /**
144
+ * Returns elements that include the text describing the price increase following the trial period.
145
+ * @returns {React.ReactElement}
146
+ */
147
+ const getTrialPriceExplanatoryText = () => {
148
+ return (
149
+ <React.Fragment>
150
+ Unless you cancel during your trial you will be billed{' '}
151
+ <span className="ncf__payment-term__price">{option.price}</span> per{' '}
152
+ {getDurationFromISO8601Value({
153
+ iso8601Value: option.value,
154
+ })}{' '}
155
+ after the trial period.
156
+ </React.Fragment>
157
+ );
158
+ };
159
+
160
+ /**
161
+ * Returns elements that include the text describing how regularly an auto-reniewing subscription will renew.
162
+ * @returns {React.ReactElement}
163
+ */
164
+ const getRenewalPeriodText = () => {
165
+ return (
166
+ <p className="ncf__payment-term__renews-text">
167
+ Renews every{' '}
168
+ {getDurationFromISO8601Value({ iso8601Value: option.value })} unless
169
+ cancelled
170
+ </p>
171
+ );
172
+ };
173
+
174
+ /**
175
+ * Returns elements that include the text describing the monthly equivalent of the subscription.
176
+ * @returns {string}
177
+ */
178
+ const getEquivalentMonthlyPrice = () => {
179
+ if (Boolean(option.monthlyPrice) && option.monthlyPrice !== '0') {
180
+ return isNaN(option.monthlyPrice)
181
+ ? option.monthlyPrice
182
+ : `${option.symbol}${option.monthlyPrice}`;
183
+ }
184
+
185
+ return getMonthlyPriceFromPeriod();
186
+ };
187
+
188
+ /**
189
+ * Returns elements that include the text describing the monthly equivalent of the subscription.
190
+ * @returns {React.ReactElement}
191
+ */
192
+ const getEquivalentMonthlyPriceText = () => {
193
+ return (
194
+ <span className="ncf__payment-term__equivalent-price">
195
+ That’s equivalent to{' '}
196
+ <span className="ncf__payment-term__monthly-price">
197
+ {getEquivalentMonthlyPrice()}
198
+ </span>{' '}
199
+ per month
200
+ </span>
201
+ );
202
+ };
203
+
204
+ /**
205
+ * Creates the standard discount badge for an option.
206
+ * Displays either "Best offer" or "Save X off RRP" when a discount exists.
207
+ *
208
+ * @returns {React.ReactElement} The discount element, or false when no discount should be shown
209
+ */
190
210
  const createDiscount = () => {
191
211
  return (
192
212
  option.discount && (
@@ -199,6 +219,11 @@ export function PaymentTerm({
199
219
  );
200
220
  };
201
221
 
222
+ /**
223
+ * Creates B2C partnership discount copy for eligible annual offers.
224
+ *
225
+ * @returns {React.ReactElement} The B2C discount element, or false when the option is not eligible
226
+ */
202
227
  const createB2cDiscountCopy = () => {
203
228
  return (
204
229
  option.name === 'annual' &&
@@ -211,70 +236,65 @@ export function PaymentTerm({
211
236
  );
212
237
  };
213
238
 
239
+ /**
240
+ * Creates the description shown beneath the term title.
241
+ * This may include trial copy, price-per-period copy, equivalent monthly price,
242
+ * renewal messaging, or fallback non-period pricing text.
243
+ *
244
+ * @returns {React.ReactElement} The description block for the payment term
245
+ */
214
246
  const createDescription = () => {
215
- return option.isTrial ? (
216
- <div className="ncf__payment-term__description">
217
- {option.trialDuration || '4 weeks'} for{' '}
218
- <span className="ncf__payment-term__trial-price">
219
- {option.trialPrice}
247
+ if (option.isTrial) {
248
+ return (
249
+ <div className="ncf__payment-term__description">
250
+ {option.trialDuration || '4 weeks'} for{' '}
251
+ <span className="ncf__payment-term__trial-price">
252
+ {option.trialPrice}
253
+ </span>
254
+ <br />
255
+ {getTrialPriceExplanatoryText()}
256
+ </div>
257
+ );
258
+ }
259
+
260
+ if (isValidPeriod(option.value)) {
261
+ return (
262
+ <div className="ncf__payment-term__description">
263
+ {getPriceText()}
264
+
265
+ {is90DaysOrLonger(option.value) && getEquivalentMonthlyPriceText()}
266
+
267
+ {option.subscriptionAutoRenewTerm && getRenewalPeriodText()}
268
+ </div>
269
+ );
270
+ }
271
+
272
+ return (
273
+ <div>
274
+ <span className={largePrice ? 'ncf__payment-term__large-price' : ''}>
275
+ {option.price}
220
276
  </span>
221
- <br />
222
- {nameMap[option.name] &&
223
- nameMap[option.name].trialPrice(option.price)}
224
- </div>
225
- ) : (
226
- <React.Fragment>
227
- {nameMap[option.name] ? (
228
- <div className="ncf__payment-term__description">
229
- {nameMap[option.name].price(option.price)}
230
- {nameMap[option.name].monthlyPrice(option.monthlyPrice)}
231
- {isAutoRenewingSubscriptionTermType &&
232
- nameMap[option.name].renewsText()}
233
- {/* Remove this discount text temporarily in favour of monthly price */}
234
- {/* <br />Save up to 25% when you pay annually */}
235
- </div>
236
- ) : // this should cover the cases different than annual, quarterly and monthly
237
- // for those containing period on option.value, render custom template, for the rest keep legacy render
238
- isValidPeriod(option.value) ? (
239
- <div className="ncf__payment-term__description">
240
- {nameMap['custom'].price(option.price)}
241
- {nameMap['custom'].monthlyPrice(
242
- option.monthlyPrice && option.monthlyPrice !== '0'
243
- ? Number(option.monthlyPrice)
244
- : getMonthlyPriceFromPeriod(
245
- option.amount,
246
- option.currency,
247
- option.value
248
- )
249
- )}
250
- {isAutoRenewingSubscriptionTermType &&
251
- nameMap['custom'].renewsText(getTimeFromPeriod(option.value))}
252
- </div>
253
- ) : (
254
- <div>
255
- <span
256
- className={largePrice ? 'ncf__payment-term__large-price' : ''}
257
- >
258
- {option.price}
259
- </span>
260
- {option.chargeOnText && (
261
- <p className="ncf__payment-term__charge-on-text">
262
- {option.chargeOnText}
263
- </p>
264
- )}
265
- </div>
277
+ {option.chargeOnText && (
278
+ <p className="ncf__payment-term__charge-on-text">
279
+ {option.chargeOnText}
280
+ </p>
266
281
  )}
267
- </React.Fragment>
282
+ </div>
268
283
  );
269
284
  };
270
285
 
286
+ /**
287
+ * Builds the display name shown as the payment term title.
288
+ * May prepend trial copy, use the capitalised term name for auto-renewing terms,
289
+ * derive a human-readable period for valid non-renewing terms, or fall back to
290
+ * a legacy title when no valid period is available.
291
+ *
292
+ * @returns {string} The formatted display name for the payment term
293
+ */
271
294
  const getTermDisplayName = () => {
272
295
  const showTrialCopyInTitle =
273
296
  option.isTrial && !isPrintOrBundle && !isDigitalEdition;
274
297
 
275
- const title =
276
- option.name && nameMap[option.name] ? nameMap[option.name].title : '';
277
-
278
298
  let termDisplayName = '';
279
299
  if (showTrialCopyInTitle) {
280
300
  const termName = option.displayName
@@ -284,13 +304,16 @@ export function PaymentTerm({
284
304
  }
285
305
 
286
306
  const getTermPeriod = () => {
287
- // annual, quarterly and monthly
288
- if (nameMap[option.name]) {
289
- return title;
307
+ if (option.subscriptionAutoRenewTerm && option.name) {
308
+ return capitalise(option.name);
290
309
  }
310
+
291
311
  // custom offer with period provided
292
- if (isValidPeriod(option.value)) {
293
- return getTimeFromPeriod(option.value);
312
+ if (!option.subscriptionAutoRenewTerm && isValidPeriod(option.value)) {
313
+ return getDurationFromISO8601Value({
314
+ iso8601Value: option.value,
315
+ excludeAmountWhenSingular: false,
316
+ });
294
317
  }
295
318
  // custom legacy cases, where period is not provided
296
319
  return option.title;
@@ -399,12 +422,14 @@ PaymentTerm.propTypes = {
399
422
  isB2cPartnership: PropTypes.bool,
400
423
  discount: PropTypes.string,
401
424
  isTrial: PropTypes.bool,
425
+ subscriptionAutoRenewTerm: PropTypes.bool,
402
426
  name: PropTypes.string.isRequired,
403
427
  price: PropTypes.string.isRequired,
404
428
  selected: PropTypes.bool,
405
429
  trialDuration: PropTypes.string,
406
430
  trialPrice: PropTypes.string,
407
431
  amount: PropTypes.string,
432
+ symbol: PropTypes.string,
408
433
  trialAmount: PropTypes.number,
409
434
  value: PropTypes.string.isRequired,
410
435
  monthlyPrice: PropTypes.string,