Merge branch 'DRVDOC-1260' into 'main'
[ProtonMail-WebClient.git] / packages / components / containers / payments / CreditCard.tsx
blob94733379996854de64d9573233789d39ae503d03
1 import type { ChangeEvent } from 'react';
2 import { useEffect, useMemo, useRef, useState } from 'react';
4 import valid from 'card-validator';
5 import { c } from 'ttag';
7 import { Input } from '@proton/atoms';
8 import Icon from '@proton/components/components/icon/Icon';
9 import Label from '@proton/components/components/label/Label';
10 import Option from '@proton/components/components/option/Option';
11 import SelectTwo from '@proton/components/components/selectTwo/SelectTwo';
12 import type { SelectChangeEvent } from '@proton/components/components/selectTwo/select';
13 import { requestAnimationFrameRateLimiter, default as useElementRect } from '@proton/components/hooks/useElementRect';
14 import { formatCreditCardNumber, isValidNumber } from '@proton/components/payments/client-extensions/credit-card-type';
15 import type { CardFieldStatus } from '@proton/components/payments/react-extensions/useCard';
16 import type { CardModel } from '@proton/payments';
17 import { rootFontSize } from '@proton/shared/lib/helpers/dom';
18 import { isNumber } from '@proton/shared/lib/helpers/validators';
19 import clsx from '@proton/utils/clsx';
21 import { DEFAULT_SEPARATOR, getFullList } from '../../helpers/countries';
23 import './CreditCard.scss';
25 const isPotentiallyCVV = (value: string, maxLength: number) => valid.cvv(value, maxLength).isPotentiallyValid;
26 const isValidMonth = (m: string) => !m || (isNumber(m) && m.length <= 2);
27 const isValidYear = (y: string) => !y || (isNumber(y) && y.length <= 4);
29 const handleExpOnChange = (newValue: string, prevMonth: string, prevYear: string) => {
30     const [newMonth = '', newYear = ''] = newValue.split('/');
32     if (newValue.includes('/')) {
33         return {
34             month: isValidMonth(newMonth) ? newMonth : prevMonth,
35             year: isValidYear(newYear) ? newYear : prevYear,
36         };
37     }
39     if (newMonth.length > 2) {
40         // User removes the '/'
41         return;
42     }
44     if (prevMonth.length === 2) {
45         // User removes the '/' and year is empty
46         const [first = ''] = newMonth;
47         return {
48             year: '',
49             month: isValidMonth(first) ? first : prevMonth,
50         };
51     }
53     const [first = '', second = ''] = newMonth;
54     return {
55         year: '',
56         month: isValidMonth(`${first}${second}`) ? `${first}${second}` : prevMonth,
57     };
60 const WarningIcon = ({ className }: { className?: string }) => {
61     return <Icon name="exclamation-circle-filled" className={clsx('shrink-0 color-danger', className)} size={4.5} />;
64 export interface Props {
65     setCardProperty: (key: keyof CardModel, value: string) => void;
66     loading?: boolean;
67     card: CardModel;
68     errors: Partial<CardModel>;
69     fieldsStatus: CardFieldStatus;
70     bigger?: boolean;
71     forceNarrow?: boolean;
74 /**
75  * The hook will focus the next field if the current field is filled and the condition is true.
76  * The codition typically should be true when the current field is valid.
77  */
78 const useAdvancer = (
79     currentElementRef: React.RefObject<HTMLInputElement>,
80     nextElementRef: React.RefObject<HTMLInputElement>,
81     currentFieldState: string,
82     condition: boolean
83 ) => {
84     const [advanced, setAdvanced] = useState(false);
86     useEffect(() => {
87         const currentElementFocused = document.activeElement === currentElementRef.current;
88         if (condition && currentElementFocused && nextElementRef.current && !advanced) {
89             nextElementRef.current.focus();
90             setAdvanced(true);
91         }
92     }, [currentFieldState, condition]);
95 const CreditCard = ({
96     card,
97     errors,
98     setCardProperty: onChange,
99     loading = false,
100     fieldsStatus,
101     bigger = false,
102     forceNarrow = false,
103 }: Props) => {
104     const narrowNumberRef = useRef<HTMLInputElement>(null);
105     const narrowExpRef = useRef<HTMLInputElement>(null);
106     const narrowCvcRef = useRef<HTMLInputElement>(null);
108     const wideNumberRef = useRef<HTMLInputElement>(null);
109     const wideExpRef = useRef<HTMLInputElement>(null);
110     const wideCvcRef = useRef<HTMLInputElement>(null);
112     const zipRef = useRef<HTMLInputElement>(null);
114     useAdvancer(narrowNumberRef, narrowExpRef, card.number, fieldsStatus?.number ?? false);
115     useAdvancer(narrowExpRef, narrowCvcRef, card.month, fieldsStatus?.month ?? false);
116     useAdvancer(narrowCvcRef, zipRef, card.cvc, fieldsStatus?.cvc ?? false);
118     useAdvancer(wideNumberRef, wideExpRef, card.number, fieldsStatus?.number ?? false);
119     useAdvancer(wideExpRef, wideCvcRef, card.month, fieldsStatus?.month ?? false);
120     useAdvancer(wideCvcRef, zipRef, card.cvc, fieldsStatus?.cvc ?? false);
122     const formContainer = useRef<HTMLDivElement>(null);
123     const formRect = useElementRect(formContainer, requestAnimationFrameRateLimiter);
125     const countries = useMemo(() => getFullList(), []);
127     const maxCvvLength = 4;
128     const handleChange =
129         (key: keyof CardModel) =>
130         ({ target }: ChangeEvent<HTMLInputElement> | ChangeEvent<HTMLSelectElement>) => {
131             const newValue = target.value;
133             // todo: if the new design is widely adopted or at least stabilized by several weeks in prod,
134             // then make this logic as part of credit card validation overall, i.e. apply it to getErrors() in useCard() hook
135             const isInvalid = key === 'cvc' && !isPotentiallyCVV(newValue, maxCvvLength);
136             if (isInvalid) {
137                 return;
138             }
140             onChange(key, newValue);
141         };
143     // translator: this is the pattern for bank card expiration MM/YY, where MM stands for Month Month and YY Year Year. Please keep the slash in the middle.
144     const patternExpiration = c('Info').t`MM/YY`;
146     // translator: this is a ZIP code used for american credit cards
147     const zipCode = c('Label, credit card').t`ZIP code`;
148     const title = card.country === 'US' ? zipCode : c('Label').t`Postal code`;
150     const commonNumberProps = {
151         id: 'ccnumber',
152         'data-testid': 'ccnumber',
153         disableChange: loading,
154         autoComplete: 'cc-number',
155         name: 'cardnumber',
156         maxLength: 23,
157     };
159     const commonExpProps = {
160         id: 'exp',
161         disableChange: loading,
162         placeholder: patternExpiration,
163         'data-testid': 'exp',
164         autoComplete: 'cc-exp',
165         maxLength: 5,
166     };
168     const commonCvcProps = {
169         autoComplete: 'cc-csc',
170         id: 'cvc',
171         name: 'cvc',
172         'data-testid': 'cvc',
173         value: card.cvc,
174         onChange: handleChange('cvc'),
175         disableChange: loading,
176         maxLength: maxCvvLength,
177     };
179     const { valueWithGaps, bankIcon, niceType, codeName } = formatCreditCardNumber(card.number);
180     const { month, year } = card;
182     // 25 x 16 = we want eq 400px width to trigger the adaptation being zoom-friendly
183     const narrowWidth = rootFontSize() * 25;
184     const isNarrow = forceNarrow || (formRect ? formRect.width < narrowWidth : false);
186     let error = null;
187     if (errors.number) {
188         error = (
189             <span data-testid="error-ccnumber" id="error-ccnumber">
190                 {errors.number}
191             </span>
192         );
193     } else if (errors.month) {
194         error = (
195             <span data-testid="error-exp" id="error-exp">
196                 {errors.month}
197             </span>
198         );
199     } else if (errors.cvc) {
200         error = (
201             <span data-testid="error-cvc" id="error-cvc">
202                 {errors.cvc}
203             </span>
204         );
205     }
207     let creditCardForm;
208     if (isNarrow) {
209         const cardNumberSuffix = (() => {
210             if (errors.number) {
211                 return <WarningIcon />;
212             }
214             if (card.number && bankIcon) {
215                 return <img src={bankIcon} title={niceType} alt={niceType} width="24" />;
216             }
218             return <Icon name="credit-card" size={4} className="mr-1" />;
219         })();
221         creditCardForm = (
222             <>
223                 <Label
224                     htmlFor={commonNumberProps.id}
225                     className="field-two-label field-two-label-container flex pt-3"
226                 >{c('Label').t`Card details`}</Label>
227                 <span id="id_desc_card_number" className="sr-only">{c('Label').t`Card number`}</span>
228                 <Input
229                     className="card-number--small"
230                     inputClassName="px-3"
231                     placeholder={c('Label').t`Card number`}
232                     aria-describedby="id_desc_card_number error-ccnumber"
233                     value={valueWithGaps}
234                     error={errors.number}
235                     onChange={({ target }) => {
236                         const val = target.value.replace(/\s/g, '');
237                         if (isValidNumber(val)) {
238                             onChange('number', val);
239                         }
240                     }}
241                     suffix={cardNumberSuffix}
242                     ref={narrowNumberRef}
243                     {...commonNumberProps}
244                 />
245                 <div className="flex">
246                     <Label htmlFor={commonExpProps.id} className="sr-only">{c('Label')
247                         .t`Expiration (${patternExpiration})`}</Label>
248                     <Input
249                         inputClassName="px-3"
250                         className="exp exp--small"
251                         aria-describedby="error-exp"
252                         value={`${month}${month.length === 2 || year.length ? '/' : ''}${year}`}
253                         error={errors.month}
254                         onChange={({ target }) => {
255                             const change = handleExpOnChange(target.value, month, year);
256                             if (change) {
257                                 onChange('month', change.month);
258                                 onChange('year', change.year);
259                             }
260                         }}
261                         ref={narrowExpRef}
262                         suffix={errors.month ? <WarningIcon /> : null}
263                         {...commonExpProps}
264                     />
265                     <Label htmlFor={commonCvcProps.id} className="sr-only">{c('Label')
266                         .t`Security code (${codeName})`}</Label>
267                     <Input
268                         placeholder={codeName}
269                         inputClassName="px-3"
270                         className="cvv cvv--small"
271                         aria-describedby="error-cvc"
272                         ref={narrowCvcRef}
273                         error={errors.cvc}
274                         suffix={errors.cvc ? <WarningIcon /> : null}
275                         {...commonCvcProps}
276                     />
277                 </div>
278             </>
279         );
280     } else {
281         creditCardForm = (
282             <>
283                 <Label
284                     htmlFor={commonNumberProps.id}
285                     className="field-two-label field-two-label-container flex pt-3"
286                 >{c('Label').t`Card details`}</Label>
287                 <span id="id_desc_card_number" className="sr-only">{c('Label').t`Card number`}</span>
288                 <Input
289                     className="card-information"
290                     inputClassName="card-number"
291                     placeholder={c('Label').t`Card number`}
292                     value={valueWithGaps}
293                     error={error}
294                     aria-describedby="id_desc_card_number error-ccnumber"
295                     onChange={({ target }) => {
296                         const val = target.value.replace(/\s/g, '');
297                         if (isValidNumber(val)) {
298                             onChange('number', val);
299                         }
300                     }}
301                     ref={wideNumberRef}
302                     prefix={
303                         <div className="ml-3 mr-1">
304                             {card.number && bankIcon ? (
305                                 <img src={bankIcon} title={niceType} alt={niceType} width="32" />
306                             ) : (
307                                 <Icon name="credit-card-detailed" size={8} />
308                             )}
309                         </div>
310                     }
311                     suffix={
312                         <div className="flex mx-0">
313                             <Label htmlFor={commonExpProps.id} className="sr-only">{c('Label')
314                                 .t`Expiration (${patternExpiration})`}</Label>
315                             <Input
316                                 unstyled
317                                 inputClassName="mr-3 py-0.5 px-3 border-left border-right"
318                                 className="exp"
319                                 aria-describedby="error-exp"
320                                 value={`${month}${month.length === 2 || year.length ? '/' : ''}${year}`}
321                                 onChange={({ target }) => {
322                                     const change = handleExpOnChange(target.value, month, year);
323                                     if (change) {
324                                         onChange('month', change.month);
325                                         onChange('year', change.year);
326                                     }
327                                 }}
328                                 ref={wideExpRef}
329                                 {...commonExpProps}
330                             />
332                             <Label htmlFor={commonCvcProps.id} className="sr-only">{c('Label')
333                                 .t`Security code (${codeName})`}</Label>
334                             <Input
335                                 unstyled
336                                 placeholder={codeName}
337                                 aria-describedby="error-cvc"
338                                 inputClassName="p-0"
339                                 className="cvv"
340                                 ref={wideCvcRef}
341                                 {...commonCvcProps}
342                             />
343                         </div>
344                     }
345                     {...commonNumberProps}
346                 />
347             </>
348         );
349     }
351     return (
352         <div
353             ref={formContainer}
354             data-testid="credit-card-form-container"
355             className={clsx([
356                 'field-two-container',
357                 bigger && 'field-two--bigger',
358                 isNarrow && 'credit-card-form--narrow',
359             ])}
360         >
361             {creditCardForm}
362             <div className="error-container mt-1 text-semibold text-sm flex gap-2">
363                 {error && (
364                     <div className="flex">
365                         <WarningIcon className="mr-1" />
366                         {error}
367                     </div>
368                 )}
369             </div>
370             <Label htmlFor="postalcode" className="field-two-label field-two-label-container flex pt-1">{c('Label')
371                 .t`Billing address`}</Label>
372             <Input
373                 placeholder={title}
374                 className="country-select justify-space-between divide-x"
375                 inputClassName="ml-1"
376                 prefixClassName="flex-1"
377                 ref={zipRef}
378                 prefix={
379                     <SelectTwo
380                         className="mx-3"
381                         unstyled
382                         onChange={
383                             loading
384                                 ? undefined
385                                 : ({ value }: SelectChangeEvent<string>) => {
386                                       if (value === DEFAULT_SEPARATOR.value) {
387                                           return;
388                                       }
389                                       onChange('country', value);
390                                   }
391                         }
392                         data-testid="country"
393                         id="country"
394                         value={card.country}
395                     >
396                         {countries.map(({ key, value, label, disabled }) => {
397                             return (
398                                 <Option
399                                     key={key}
400                                     value={value}
401                                     title={label}
402                                     disabled={disabled}
403                                     data-testid={`country-${value}`}
404                                 >
405                                     {value === DEFAULT_SEPARATOR.value ? <hr className="m-0" /> : label}
406                                 </Option>
407                             );
408                         })}
409                     </SelectTwo>
410                 }
411                 data-testid="postalCode"
412                 minLength={3}
413                 maxLength={9}
414                 autoComplete="postal-code"
415                 id="postalcode"
416                 aria-describedby="id_desc_postal"
417                 value={card.zip}
418                 onChange={handleChange('zip')}
419                 disableChange={loading}
420                 title={title}
421                 error={errors.zip}
422                 suffix={errors.zip ? <WarningIcon className="mr-2" /> : null}
423             />
424             <span className="sr-only" id="id_desc_postal">
425                 {title}
426             </span>
427             <div className="error-container mt-1 mb-3 text-semibold text-sm flex">
428                 {errors.zip && (
429                     <>
430                         <WarningIcon className="mr-1" />
431                         <span data-testid="error-postalCode">{errors.zip}</span>
432                     </>
433                 )}
434             </div>
435         </div>
436     );
439 export default CreditCard;