@financial-times/n-conversion-forms 46.0.1 → 47.0.1

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.
@@ -34,12 +34,12 @@ Basic.args = {
34
34
  ],
35
35
  };
36
36
 
37
- export const UKPaperVoucherAutoReniewingDeliveryOptions = (args) => (
37
+ export const UKPaperVoucherAutoRenewingDeliveryOptions = (args) => (
38
38
  <div className="ncf">
39
39
  <DeliveryOption {...args} />
40
40
  </div>
41
41
  );
42
- UKPaperVoucherAutoReniewingDeliveryOptions.args = {
42
+ UKPaperVoucherAutoRenewingDeliveryOptions.args = {
43
43
  country: 'GBR',
44
44
  options: [
45
45
  {
@@ -1,7 +1,9 @@
1
1
  import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import classNames from 'classnames';
4
- import { Period, Monthly } from '@financial-times/n-pricing';
4
+ import { Period, Price } from '@financial-times/n-pricing';
5
+
6
+ import { getDurationFromISO8601Value, is52WeeksOrLonger } from '../helpers';
5
7
 
6
8
  export function PaymentTerm({
7
9
  fieldId = 'paymentTermField',
@@ -17,159 +19,40 @@ export function PaymentTerm({
17
19
  isNonRenewingSubscriptionTermType = false,
18
20
  }) {
19
21
  /**
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
22
+ * Capitalises a string.
23
+ * @param {string} Input string to be capitalised
24
24
  * @returns {string}
25
25
  */
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
- };
26
+ const capitalise = (string) =>
27
+ Boolean(string) ? string[0].toUpperCase() + string.slice(1) : string;
31
28
 
32
29
  /**
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}
30
+ * Creates the JSX for a single payment-term option, including the input,
31
+ * title, discount messaging, and descriptive pricing copy.
32
+ *
33
+ * @param {Object} option - Payment term configuration
34
+ * @param {string} option.value - ISO 8601 duration of the subscription term of the offer
35
+ * @param {string} option.name - term name, e.g. "monthly", "annual", "2 yearly
36
+ * @param {string} option.price - Formatted display price
37
+ * @param {string|number} [option.amount] - Price expressed in numerical terms
38
+ * @param {string} [option.symbol] - Currency symbol, e.g. £
39
+ * @param {string} [option.monthlyPrice] - Precomputed monthly equivalent price (can be with or without currency symbol)
40
+ * @param {boolean} [option.discount] - Whether the option should display discount messaging
41
+ * @param {boolean} [option.bestOffer] - Whether the option should show "Best offer" instead of standard discount copy
42
+ * @param {boolean} [option.selected] - Whether the option is selected by default
43
+ * @param {boolean} [option.isTrial] - Whether the option is a trial offer
44
+ * @param {boolean} [option.subscriptionAutoRenewTerm] - Whether the option is for an auto-renewing subscription
45
+ * @param {number} [option.trialAmount] - Amount used for trial pricing
46
+ * @param {string} [option.trialDuration] - Human-readable trial duration copy
47
+ * @param {string} [option.trialPrice] - Formatted trial price
48
+ * @param {string} [option.displayName] - Override label for the term title
49
+ * @param {string} [option.title] - Fallback title for legacy or non-period terms
50
+ * @param {string} [option.subTitle] - Optional subtitle shown alongside the term title
51
+ * @param {string} [option.chargeOnText] - Optional charge timing copy for non-period offers
52
+ * @param {boolean} [option.b2cPartnership] - Whether the option is part of a B2C partnership offer
53
+ * @param {string} [option.b2cDiscountCopy] - Partnership-specific discount copy
54
+ * @returns {React.ReactElement} A rendered payment term option
37
55
  */
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
56
  const createPaymentTerm = (option) => {
174
57
  const className = classNames([
175
58
  'ncf__payment-term__item',
@@ -187,6 +70,161 @@ export function PaymentTerm({
187
70
  ...(option.selected && { defaultChecked: true }),
188
71
  };
189
72
 
73
+ /**
74
+ * Determines whether input is a valid ISO 8601 duration value that can be decoded by the Period class.
75
+ * @returns {boolean}
76
+ */
77
+ const isValidPeriod = () => {
78
+ try {
79
+ // Period should throw an error if it is not properly provided
80
+ // in order to validate it, we just send in case type is string
81
+ new Period(typeof option.value === 'string' ? option.value : '');
82
+ return true;
83
+ } catch (error) {
84
+ return false;
85
+ }
86
+ };
87
+
88
+ /**
89
+ * Compute monthly price for given term name, if the term if 90 days or longer.
90
+ * @returns {string | undefined}
91
+ */
92
+ const getCalculatedMonthlyPriceIfEligible = () => {
93
+ const priceObject = new Price(
94
+ {
95
+ value: option.amount,
96
+ symbol: option.symbol,
97
+ },
98
+ {
99
+ period: option.value,
100
+ }
101
+ );
102
+ return priceObject.getMonthlyEquivalentIf90DaysOrLonger()?.amount.value;
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-renewing 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 null if the term is shorter than 90 days
177
+ * @returns {string | null}
178
+ */
179
+ const getEquivalentMonthlyPrice = () => {
180
+ const calculatedMonthlyPrice = getCalculatedMonthlyPriceIfEligible();
181
+
182
+ if (!calculatedMonthlyPrice) {
183
+ return null;
184
+ }
185
+
186
+ const hasValidMonthlyPrice =
187
+ option.monthlyPrice !== null &&
188
+ option.monthlyPrice !== undefined &&
189
+ option.monthlyPrice !== '' &&
190
+ option.monthlyPrice !== '0';
191
+
192
+ if (hasValidMonthlyPrice) {
193
+ return isNaN(option.monthlyPrice)
194
+ ? option.monthlyPrice
195
+ : `${option.symbol}${option.monthlyPrice}`;
196
+ }
197
+
198
+ return calculatedMonthlyPrice;
199
+ };
200
+
201
+ /**
202
+ * Returns elements that include the text describing the monthly equivalent of the subscription, if available.
203
+ * @returns {React.ReactElement | null}
204
+ */
205
+ const getEquivalentMonthlyPriceText = () => {
206
+ const equivalentMonthlyPrice = getEquivalentMonthlyPrice();
207
+
208
+ if (!equivalentMonthlyPrice) {
209
+ return null;
210
+ }
211
+ return (
212
+ <span className="ncf__payment-term__equivalent-price">
213
+ That’s equivalent to{' '}
214
+ <span className="ncf__payment-term__monthly-price">
215
+ {equivalentMonthlyPrice}
216
+ </span>{' '}
217
+ per month
218
+ </span>
219
+ );
220
+ };
221
+
222
+ /**
223
+ * Creates the standard discount badge for an option.
224
+ * Displays either "Best offer" or "Save X off RRP" when a discount exists.
225
+ *
226
+ * @returns {React.ReactElement} The discount element, or false when no discount should be shown
227
+ */
190
228
  const createDiscount = () => {
191
229
  return (
192
230
  option.discount && (
@@ -199,6 +237,11 @@ export function PaymentTerm({
199
237
  );
200
238
  };
201
239
 
240
+ /**
241
+ * Creates B2C partnership discount copy for eligible annual offers.
242
+ *
243
+ * @returns {React.ReactElement} The B2C discount element, or false when the option is not eligible
244
+ */
202
245
  const createB2cDiscountCopy = () => {
203
246
  return (
204
247
  option.name === 'annual' &&
@@ -211,70 +254,63 @@ export function PaymentTerm({
211
254
  );
212
255
  };
213
256
 
257
+ /**
258
+ * Creates the description shown beneath the term title.
259
+ * This may include trial copy, price-per-period copy, equivalent monthly price,
260
+ * renewal messaging, or fallback non-period pricing text.
261
+ *
262
+ * @returns {React.ReactElement} The description block for the payment term
263
+ */
214
264
  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}
265
+ if (option.isTrial) {
266
+ return (
267
+ <div className="ncf__payment-term__description">
268
+ {option.trialDuration || '4 weeks'} for{' '}
269
+ <span className="ncf__payment-term__trial-price">
270
+ {option.trialPrice}
271
+ </span>
272
+ <br />
273
+ {getTrialPriceExplanatoryText()}
274
+ </div>
275
+ );
276
+ }
277
+
278
+ if (isValidPeriod(option.value)) {
279
+ return (
280
+ <div className="ncf__payment-term__description">
281
+ {getPriceText()}
282
+ {getEquivalentMonthlyPriceText()}
283
+ {option.subscriptionAutoRenewTerm && getRenewalPeriodText()}
284
+ </div>
285
+ );
286
+ }
287
+
288
+ return (
289
+ <div>
290
+ <span className={largePrice ? 'ncf__payment-term__large-price' : ''}>
291
+ {option.price}
220
292
  </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>
293
+ {option.chargeOnText && (
294
+ <p className="ncf__payment-term__charge-on-text">
295
+ {option.chargeOnText}
296
+ </p>
266
297
  )}
267
- </React.Fragment>
298
+ </div>
268
299
  );
269
300
  };
270
301
 
302
+ /**
303
+ * Builds the display name shown as the payment term title.
304
+ * May prepend trial copy, use the capitalised term name for auto-renewing terms,
305
+ * derive a human-readable period for valid non-renewing terms, or fall back to
306
+ * a legacy title when no valid period is available.
307
+ *
308
+ * @returns {string} The formatted display name for the payment term
309
+ */
271
310
  const getTermDisplayName = () => {
272
311
  const showTrialCopyInTitle =
273
312
  option.isTrial && !isPrintOrBundle && !isDigitalEdition;
274
313
 
275
- const title =
276
- option.name && nameMap[option.name] ? nameMap[option.name].title : '';
277
-
278
314
  let termDisplayName = '';
279
315
  if (showTrialCopyInTitle) {
280
316
  const termName = option.displayName
@@ -284,13 +320,16 @@ export function PaymentTerm({
284
320
  }
285
321
 
286
322
  const getTermPeriod = () => {
287
- // annual, quarterly and monthly
288
- if (nameMap[option.name]) {
289
- return title;
323
+ if (option.subscriptionAutoRenewTerm && option.name) {
324
+ return capitalise(option.name);
290
325
  }
326
+
291
327
  // custom offer with period provided
292
- if (isValidPeriod(option.value)) {
293
- return getTimeFromPeriod(option.value);
328
+ if (!option.subscriptionAutoRenewTerm && isValidPeriod(option.value)) {
329
+ return getDurationFromISO8601Value({
330
+ iso8601Value: option.value,
331
+ excludeAmountWhenSingular: false,
332
+ });
294
333
  }
295
334
  // custom legacy cases, where period is not provided
296
335
  return option.title;
@@ -399,12 +438,14 @@ PaymentTerm.propTypes = {
399
438
  isB2cPartnership: PropTypes.bool,
400
439
  discount: PropTypes.string,
401
440
  isTrial: PropTypes.bool,
441
+ subscriptionAutoRenewTerm: PropTypes.bool,
402
442
  name: PropTypes.string.isRequired,
403
443
  price: PropTypes.string.isRequired,
404
444
  selected: PropTypes.bool,
405
445
  trialDuration: PropTypes.string,
406
446
  trialPrice: PropTypes.string,
407
447
  amount: PropTypes.string,
448
+ symbol: PropTypes.string,
408
449
  trialAmount: PropTypes.number,
409
450
  value: PropTypes.string.isRequired,
410
451
  monthlyPrice: PropTypes.string,