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('/')) {
34 month: isValidMonth(newMonth) ? newMonth : prevMonth,
35 year: isValidYear(newYear) ? newYear : prevYear,
39 if (newMonth.length > 2) {
40 // User removes the '/'
44 if (prevMonth.length === 2) {
45 // User removes the '/' and year is empty
46 const [first = ''] = newMonth;
49 month: isValidMonth(first) ? first : prevMonth,
53 const [first = '', second = ''] = newMonth;
56 month: isValidMonth(`${first}${second}`) ? `${first}${second}` : prevMonth,
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;
68 errors: Partial<CardModel>;
69 fieldsStatus: CardFieldStatus;
71 forceNarrow?: boolean;
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.
79 currentElementRef: React.RefObject<HTMLInputElement>,
80 nextElementRef: React.RefObject<HTMLInputElement>,
81 currentFieldState: string,
84 const [advanced, setAdvanced] = useState(false);
87 const currentElementFocused = document.activeElement === currentElementRef.current;
88 if (condition && currentElementFocused && nextElementRef.current && !advanced) {
89 nextElementRef.current.focus();
92 }, [currentFieldState, condition]);
98 setCardProperty: onChange,
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;
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);
140 onChange(key, newValue);
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 = {
152 'data-testid': 'ccnumber',
153 disableChange: loading,
154 autoComplete: 'cc-number',
159 const commonExpProps = {
161 disableChange: loading,
162 placeholder: patternExpiration,
163 'data-testid': 'exp',
164 autoComplete: 'cc-exp',
168 const commonCvcProps = {
169 autoComplete: 'cc-csc',
172 'data-testid': 'cvc',
174 onChange: handleChange('cvc'),
175 disableChange: loading,
176 maxLength: maxCvvLength,
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);
189 <span data-testid="error-ccnumber" id="error-ccnumber">
193 } else if (errors.month) {
195 <span data-testid="error-exp" id="error-exp">
199 } else if (errors.cvc) {
201 <span data-testid="error-cvc" id="error-cvc">
209 const cardNumberSuffix = (() => {
211 return <WarningIcon />;
214 if (card.number && bankIcon) {
215 return <img src={bankIcon} title={niceType} alt={niceType} width="24" />;
218 return <Icon name="credit-card" size={4} className="mr-1" />;
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>
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);
241 suffix={cardNumberSuffix}
242 ref={narrowNumberRef}
243 {...commonNumberProps}
245 <div className="flex">
246 <Label htmlFor={commonExpProps.id} className="sr-only">{c('Label')
247 .t`Expiration (${patternExpiration})`}</Label>
249 inputClassName="px-3"
250 className="exp exp--small"
251 aria-describedby="error-exp"
252 value={`${month}${month.length === 2 || year.length ? '/' : ''}${year}`}
254 onChange={({ target }) => {
255 const change = handleExpOnChange(target.value, month, year);
257 onChange('month', change.month);
258 onChange('year', change.year);
262 suffix={errors.month ? <WarningIcon /> : null}
265 <Label htmlFor={commonCvcProps.id} className="sr-only">{c('Label')
266 .t`Security code (${codeName})`}</Label>
268 placeholder={codeName}
269 inputClassName="px-3"
270 className="cvv cvv--small"
271 aria-describedby="error-cvc"
274 suffix={errors.cvc ? <WarningIcon /> : null}
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>
289 className="card-information"
290 inputClassName="card-number"
291 placeholder={c('Label').t`Card number`}
292 value={valueWithGaps}
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);
303 <div className="ml-3 mr-1">
304 {card.number && bankIcon ? (
305 <img src={bankIcon} title={niceType} alt={niceType} width="32" />
307 <Icon name="credit-card-detailed" size={8} />
312 <div className="flex mx-0">
313 <Label htmlFor={commonExpProps.id} className="sr-only">{c('Label')
314 .t`Expiration (${patternExpiration})`}</Label>
317 inputClassName="mr-3 py-0.5 px-3 border-left border-right"
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);
324 onChange('month', change.month);
325 onChange('year', change.year);
332 <Label htmlFor={commonCvcProps.id} className="sr-only">{c('Label')
333 .t`Security code (${codeName})`}</Label>
336 placeholder={codeName}
337 aria-describedby="error-cvc"
345 {...commonNumberProps}
354 data-testid="credit-card-form-container"
356 'field-two-container',
357 bigger && 'field-two--bigger',
358 isNarrow && 'credit-card-form--narrow',
362 <div className="error-container mt-1 text-semibold text-sm flex gap-2">
364 <div className="flex">
365 <WarningIcon className="mr-1" />
370 <Label htmlFor="postalcode" className="field-two-label field-two-label-container flex pt-1">{c('Label')
371 .t`Billing address`}</Label>
374 className="country-select justify-space-between divide-x"
375 inputClassName="ml-1"
376 prefixClassName="flex-1"
385 : ({ value }: SelectChangeEvent<string>) => {
386 if (value === DEFAULT_SEPARATOR.value) {
389 onChange('country', value);
392 data-testid="country"
396 {countries.map(({ key, value, label, disabled }) => {
403 data-testid={`country-${value}`}
405 {value === DEFAULT_SEPARATOR.value ? <hr className="m-0" /> : label}
411 data-testid="postalCode"
414 autoComplete="postal-code"
416 aria-describedby="id_desc_postal"
418 onChange={handleChange('zip')}
419 disableChange={loading}
422 suffix={errors.zip ? <WarningIcon className="mr-2" /> : null}
424 <span className="sr-only" id="id_desc_postal">
427 <div className="error-container mt-1 mb-3 text-semibold text-sm flex">
430 <WarningIcon className="mr-1" />
431 <span data-testid="error-postalCode">{errors.zip}</span>
439 export default CreditCard;