diff --git a/.changeset/gold-panthers-swim.md b/.changeset/gold-panthers-swim.md new file mode 100644 index 0000000000..aa2fc46e72 --- /dev/null +++ b/.changeset/gold-panthers-swim.md @@ -0,0 +1,5 @@ +--- +'@alfalab/core-components-account-select': major +--- + +Добавлен новый компонент AccountSelect diff --git a/.vscode/settings.json b/.vscode/settings.json index fc42e3bbed..46ca07ed3e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,6 +12,7 @@ "eslint.workingDirectories": [ "packages/accordion", "packages/action-button", + "packages/account-select", "packages/alert", "packages/amount", "packages/amount-input", diff --git a/packages/account-select/package.json b/packages/account-select/package.json new file mode 100644 index 0000000000..952e0d17dd --- /dev/null +++ b/packages/account-select/package.json @@ -0,0 +1,33 @@ +{ + "name": "@alfalab/core-components-account-select", + "version": "0.0.0", + "description": "", + "keywords": [], + "license": "MIT", + "sideEffects": [ + "**/*.css" + ], + "main": "index.js", + "module": "./esm/index.js", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.1 || ^18.0.0", + "react-dom": "^16.9.0 || ^17.0.1 || ^18.0.0" + }, + "dependencies": { + "@alfalab/core-components-select": "^18.0.1", + "@alfalab/core-components-popover": "^7.1.0", + "@alfalab/core-components-form-control": "^13.0.1", + "@alfalab/core-components-product-cover": "^2.0.1", + "@alfalab/core-components-mq": "^5.0.1", + "@maskito/core": "^1.7.0", + "@maskito/react": "^1.7.0", + "classnames": "^2.5.1", + "tslib": "^2.4.0" + }, + "themesVersion": "14.0.1", + "varsVersion": "10.0.0" +} diff --git a/packages/account-select/src/Component.responsive.tsx b/packages/account-select/src/Component.responsive.tsx new file mode 100644 index 0000000000..e82030ea5b --- /dev/null +++ b/packages/account-select/src/Component.responsive.tsx @@ -0,0 +1,21 @@ +import React, { forwardRef } from 'react'; + +import { useIsDesktop } from '@alfalab/core-components-mq'; + +import { AccountSelectDesktop } from './desktop'; +import { AccountSelectMobile } from './mobile'; +import type { AccountSelectResonsiveProps } from './types'; + +export const AccountSelectResponsive = forwardRef( + ({ breakpoint, client, ...restProps }, ref) => { + const isDesktop = useIsDesktop(breakpoint); + + if (isDesktop || client === 'desktop') { + return ; + } + + return ; + }, +); + +AccountSelectResponsive.displayName = 'AccountSelectResponsive'; diff --git a/packages/account-select/src/components/custom-field/index.tsx b/packages/account-select/src/components/custom-field/index.tsx new file mode 100644 index 0000000000..d6c544acd3 --- /dev/null +++ b/packages/account-select/src/components/custom-field/index.tsx @@ -0,0 +1,61 @@ +import React, { useCallback } from 'react'; + +import { FormControlDesktop } from '@alfalab/core-components-form-control/desktop'; +import { FormControlMobile } from '@alfalab/core-components-form-control/mobile'; +import { Field, FieldProps, OptionShape } from '@alfalab/core-components-select/shared'; + +import { ADD_CARD_KEY } from '../../constants'; +import { MultiStepCardInput } from '../multi-step-card-input'; + +export interface CustomFieldProps extends FieldProps { + view?: 'desktop' | 'mobile'; +} + +export const CustomField = ({ + innerProps, + selected, + label, + onSubmit, + onInput, + needCvv, + needExpiryDate, + expiryAsDate, + view = 'desktop', + cardImage, + leftAddons, + ...restProps +}: CustomFieldProps) => { + const valueRenderer = useCallback( + ({ selected: selectedOption }: { selected?: OptionShape }) => { + if (selectedOption?.key === ADD_CARD_KEY) { + return ( + + ); + } + + return selectedOption?.content; + }, + [onSubmit, onInput, cardImage, needCvv, needExpiryDate, expiryAsDate], + ); + + const FormControlComponent = view === 'mobile' ? FormControlMobile : FormControlDesktop; + + return ( + + ); +}; diff --git a/packages/account-select/src/components/multi-step-card-input/index.module.css b/packages/account-select/src/components/multi-step-card-input/index.module.css new file mode 100644 index 0000000000..e49423fbac --- /dev/null +++ b/packages/account-select/src/components/multi-step-card-input/index.module.css @@ -0,0 +1,44 @@ +@import '@alfalab/core-components-vars/src/index.css'; + +.multistepInput { + transition: width 0.3s ease-in-out; + border: none; + outline: none; + background: transparent; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + + &:focus { + outline: none; + } +} + +.cardNumberInput { + width: 19ch; +} + +.cardNumberInput:not(:focus) { + width: 5ch; +} + +.expiryInput { + width: 7ch; +} + +.cvvInput { + width: 4ch; +} + +.multistepCardInputWrapper { + border: none; + background: transparent; + padding: 0; + display: flex; + gap: 24px; + align-items: center; +} diff --git a/packages/account-select/src/components/multi-step-card-input/index.tsx b/packages/account-select/src/components/multi-step-card-input/index.tsx new file mode 100644 index 0000000000..95622a4c30 --- /dev/null +++ b/packages/account-select/src/components/multi-step-card-input/index.tsx @@ -0,0 +1,240 @@ +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { useMaskito } from '@maskito/react'; +import cn from 'classnames'; + +import { ProductCover } from '@alfalab/core-components-product-cover'; + +import { CARD_MASK, CVV_MASK, EXPIRY_MASK } from '../../constants'; +import { useAccountSelectContext } from '../../context'; +import { CardAddingProps, CardData } from '../../types'; +import { formatCardNumber, getMaskedCardNumber } from '../../utils/formaters'; +import { parseDate } from '../../utils/parse-date'; +import { validateCardNumber, validateCvv, validateExpiry } from '../../utils/validate'; + +import styles from './index.module.css'; + +type MultiStepCardInputProps = Pick< + CardAddingProps, + 'onSubmit' | 'onInput' | 'cardImage' | 'needCvv' | 'needExpiryDate' | 'expiryAsDate' +>; + +export const MultiStepCardInput: React.FC = memo( + ({ + onSubmit, + onInput, + cardImage, + needCvv = true, + needExpiryDate = true, + expiryAsDate = true, + }) => { + const [step, setStep] = useState(1); + const [cardNumber, setCardNumber] = useState(''); + const [cardExpiry, setCardExpiry] = useState(''); + const [cardCvv, setCardCvv] = useState(''); + const [isCardNumberFocused, setIsCardNumberFocused] = useState(false); + + const { setError } = useAccountSelectContext(); + + const numberRef = useRef(null); + const expiryRef = useRef(null); + const cvvRef = useRef(null); + + const numberMaskRef = useMaskito({ options: CARD_MASK }); + const expiryMaskRef = useMaskito({ + options: { + ...EXPIRY_MASK, + postprocessors: [ + (state) => { + const { + value, + selection: [, to], + } = state; + if (to >= 5 && !validateExpiry(value)) { + setError('Введена неверная дата'); + } else { + setError(null); + } + return state; + }, + ], + }, + }); + const cvvMaskRef = useMaskito({ options: CVV_MASK }); + + const numberRefCallback = useCallback( + (element: HTMLInputElement | null) => { + (numberRef as React.MutableRefObject).current = element; + numberMaskRef(element); + }, + [numberMaskRef], + ); + + const expiryRefCallback = useCallback( + (element: HTMLInputElement | null) => { + (expiryRef as React.MutableRefObject).current = element; + expiryMaskRef(element); + }, + [expiryMaskRef], + ); + + const cvvRefCallback = useCallback( + (element: HTMLInputElement | null) => { + (cvvRef as React.MutableRefObject).current = element; + cvvMaskRef(element); + }, + [cvvMaskRef], + ); + + useEffect(() => { + onInput?.({ + number: cardNumber, + ...(needExpiryDate && { expiryDate: cardExpiry }), + ...(needCvv && cardCvv && { cvv: cardCvv }), + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cardNumber, cardExpiry, cardCvv, needExpiryDate, needCvv]); + + useEffect(() => { + if (step === 1) { + numberRef.current?.focus(); + } + }, [step]); + + const handleCardNumberFocus = () => { + setIsCardNumberFocused(true); + setTimeout(() => { + const { current } = numberRef; + + if (current) { + const { length } = current.value; + + current.setSelectionRange(length, length); + } + }, 0); + }; + + const handleCardNumberBlur = (e: React.FocusEvent) => { + setError(validateCardNumber(e.target.value) ? null : 'Номер карты введён неверно'); + setIsCardNumberFocused(false); + }; + + const handleExpiryBlur = (e: React.FocusEvent) => { + setError(validateExpiry(e.target.value) ? null : 'Срок действия карты введён неверно'); + }; + + const handleCvvBlur = (e: React.FocusEvent) => { + setError(validateCvv(e.target.value) ? null : 'Нужно заполнить CVV'); + }; + + const handleCardNumberChange = ({ + target: { value }, + }: React.ChangeEvent) => { + const cleanValue = value.replace(/\s/g, ''); + + setCardNumber(cleanValue); + + if (validateCardNumber(cleanValue)) { + if (needExpiryDate) { + setStep(2); + setTimeout(() => expiryRef.current?.focus(), 100); + } else { + numberRef.current?.blur(); + onSubmit?.({ + number: cleanValue, + }); + } + } + }; + + const handleExpiryChange = ({ target: { value } }: React.ChangeEvent) => { + setCardExpiry(value); + + if (validateExpiry(value)) { + if (needCvv) { + setStep(3); + setTimeout(() => cvvRef.current?.focus(), 100); + } else { + expiryRef.current?.blur(); + onSubmit?.({ + number: cardNumber, + expiryDate: expiryAsDate ? parseDate(value as string) : value, + }); + } + } + }; + + const handleCvvChange = ({ target: { value } }: React.ChangeEvent) => { + setCardCvv(value); + + if (validateCvv(value)) { + cvvRef.current?.blur(); + onSubmit?.({ + number: cardNumber, + expiryDate: expiryAsDate ? parseDate(cardExpiry as string) : cardExpiry, + cvv: value, + }); + } + }; + + const getDisplayCardNumber = () => { + if (isCardNumberFocused || !validateCardNumber(cardNumber)) { + return formatCardNumber(cardNumber); + } + + return getMaskedCardNumber(cardNumber); + }; + + return ( +
e.stopPropagation()} + aria-hidden='true' + > + = 16 ? Number(cardNumber) : undefined} + size={32} + {...cardImage} + /> + + {needExpiryDate && step >= 2 && ( + + )} + {needCvv && step >= 3 && ( + + )} +
+ ); + }, +); diff --git a/packages/account-select/src/constants.ts b/packages/account-select/src/constants.ts new file mode 100644 index 0000000000..cc98629d4f --- /dev/null +++ b/packages/account-select/src/constants.ts @@ -0,0 +1,35 @@ +import { MaskitoOptions } from '@maskito/core'; + +export const ADD_CARD_KEY = '#ADD_NEW_CARD'; + +export const CARD_MASK: MaskitoOptions = { + mask: [ + /\d/, + /\d/, + /\d/, + /\d/, + ' ', + /\d/, + /\d/, + /\d/, + /\d/, + ' ', + /\d/, + /\d/, + /\d/, + /\d/, + ' ', + /\d/, + /\d/, + /\d/, + /\d/, + ], +}; + +export const EXPIRY_MASK: MaskitoOptions = { + mask: [/\d/, /\d/, '/', /\d/, /\d/], +}; + +export const CVV_MASK: MaskitoOptions = { + mask: [/\d/, /\d/, /\d/], +}; diff --git a/packages/account-select/src/context.ts b/packages/account-select/src/context.ts new file mode 100644 index 0000000000..076ab71274 --- /dev/null +++ b/packages/account-select/src/context.ts @@ -0,0 +1,9 @@ +import { createContext, useContext } from 'react'; + +export const AccountSelectContext = createContext<{ + setError: (error: string | null) => void; +}>({ + setError: () => {}, +}); + +export const useAccountSelectContext = () => useContext(AccountSelectContext); diff --git a/packages/account-select/src/desktop/Component.desktop.tsx b/packages/account-select/src/desktop/Component.desktop.tsx new file mode 100644 index 0000000000..969a02b26e --- /dev/null +++ b/packages/account-select/src/desktop/Component.desktop.tsx @@ -0,0 +1,92 @@ +import React, { forwardRef, useMemo, useState } from 'react'; + +import { Popover } from '@alfalab/core-components-popover'; +import { + AnyObject, + Arrow, + BaseOption, + BaseSelect, + BaseSelectChangePayload, + Optgroup as DefaultOptgroup, + OptionProps, + OptionsList as DefaultOptionsList, +} from '@alfalab/core-components-select/shared'; + +import { CustomField } from '../components/custom-field'; +import { ADD_CARD_KEY } from '../constants'; +import { AccountSelectContext } from '../context'; +import { AccountSelectProps } from '../types'; + +import styles from './index.module.css'; + +const DefaultOption = (props: OptionProps) => ( + +
{props.option.content}
+
+); + +export const AccountSelectDesktop = forwardRef( + ( + { + OptionsList = DefaultOptionsList, + Optgroup = DefaultOptgroup, + Option = DefaultOption, + closeOnSelect = true, + options, + cardAddingProps, + dataTestId, + onChange, + ...restProps + }, + ref, + ) => { + const [error, setError] = useState(null); + const { content, ...restCardAddingProps } = cardAddingProps || {}; + const enhancedOptions = useMemo(() => { + if (!content) return options; + + return [ + { + key: ADD_CARD_KEY, + content, + value: ADD_CARD_KEY, + }, + ...options, + ]; + }, [content, options]); + + const contextValue = useMemo(() => ({ setError }), [setError]); + + const handleChange = (payload: BaseSelectChangePayload) => { + setError(null); + onChange?.(payload); + }; + + return ( + + + + ); + }, +); diff --git a/packages/account-select/src/desktop/index.module.css b/packages/account-select/src/desktop/index.module.css new file mode 100644 index 0000000000..3269d0e93a --- /dev/null +++ b/packages/account-select/src/desktop/index.module.css @@ -0,0 +1,9 @@ +@import '@alfalab/core-components-vars/src/index.css'; + +.accountSelect { + min-width: 360px; +} + +.optionContent { + padding-left: var(--gap-12); +} diff --git a/packages/account-select/src/desktop/index.ts b/packages/account-select/src/desktop/index.ts new file mode 100644 index 0000000000..a715771d36 --- /dev/null +++ b/packages/account-select/src/desktop/index.ts @@ -0,0 +1 @@ +export { AccountSelectDesktop } from './Component.desktop'; diff --git a/packages/account-select/src/docs/Component.docs.mdx b/packages/account-select/src/docs/Component.docs.mdx new file mode 100644 index 0000000000..7ed960c6f3 --- /dev/null +++ b/packages/account-select/src/docs/Component.docs.mdx @@ -0,0 +1,19 @@ +import { Meta, Markdown } from '@storybook/addon-docs'; +import { ComponentHeader, Tabs } from 'storybook/blocks'; +import * as Stories from './Component.stories'; + +import Description from './description.mdx'; +import Development from './development.mdx'; + + + + + +} + + development={} +/> \ No newline at end of file diff --git a/packages/account-select/src/docs/Component.stories.tsx b/packages/account-select/src/docs/Component.stories.tsx new file mode 100644 index 0000000000..b81fd40213 --- /dev/null +++ b/packages/account-select/src/docs/Component.stories.tsx @@ -0,0 +1,166 @@ +import React, { useState } from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { boolean, text } from '@storybook/addon-knobs'; +import { AccountSelectDesktop } from '../desktop'; +import { PureCell } from '@alfalab/core-components-pure-cell'; +import { ProductCover } from '@alfalab/core-components-product-cover'; +import { PlusMIcon } from '@alfalab/icons-glyph/PlusMIcon'; +import { AccountSelectMobile } from '../mobile'; +import { CardData } from '../types'; +import { Amount } from '@alfalab/core-components-amount'; +import { Typography } from '@alfalab/core-components-typography'; + +const baseCard = { + baseUrl: 'https://online.alfabank.ru/cards-images/cards/', + layers: 'BACKGROUND,LOGO,PAYMENT_SYSTEM', + cardId: 'RM', +}; + +const meta: Meta = { + title: 'Components/AccountSelect', + component: AccountSelectDesktop, + id: 'AccountSelect', +}; + +type Story = StoryObj; + +const getOptions = (platform: 'desktop' | 'mobile' = 'desktop') => [ + { + key: '1', + content: ( + + + + + + + + Карта с преимуществами + + + + + + ), + }, + { + key: '2', + content: ( + + + + + + + + Карта с кредитным лимитом + + + + + + ), + }, +]; + +export const account_select_desktop: Story = { + name: 'AccountSelectDesktop', + render: () => { + const [cardImage, setCardImage] = useState(undefined); + const handleInput = (data: CardData) => { + if (data.number.startsWith('111111')) { + setCardImage(baseCard); + } + }; + const handleSubmit = (data: CardData) => { + console.log(data); + }; + + return ( + { + console.log(e); + }} + label={text('label', 'Элемент')} + fieldProps={{ leftAddons: }} + cardAddingProps={{ + content: ( + + + + + + + + Новая карта + + + + + ), + onInput: handleInput, + onSubmit: handleSubmit, + needCvv: boolean('needCvv', true), + needExpiryDate: boolean('needExpiryDate', true), + expiryAsDate: boolean('expiryAsDate', true), + cardImage, + }} + options={getOptions()} + /> + ); + }, +}; + +export const account_select_mobile: Story = { + name: 'AccountSelectMobile', + render: () => ( + + + + + + + + Новая карта + + + + + ), + needCvv: boolean('needCvv', true), + needExpiryDate: boolean('needExpiryDate', true), + expiryAsDate: boolean('expiryAsDate', true), + }} + /> + ), +}; + +export default meta; diff --git a/packages/account-select/src/docs/description.mdx b/packages/account-select/src/docs/description.mdx new file mode 100644 index 0000000000..d9b463171e --- /dev/null +++ b/packages/account-select/src/docs/description.mdx @@ -0,0 +1,386 @@ +## Выбор счёта + +```jsx live +render(() => { + const options = [ + { + key: '1', + content: ( + + + + + Экспресс-счёт ··1234 + + + + + + ), + }, + { + key: '2', + content: ( + + + + + Экспресс-счёт ··1234 + + + + + + ), + }, + { + key: '3', + content: ( + + + + + Экспресс-счёт ··4567 + + + + + + ), + }, + ]; + return ; +}); +``` + +## Выбор карты + +```jsx live +render(() => { + const options = [ + { + key: '1', + content: ( + + + + + + + + Карта с преимуществами + + + + + + ), + }, + { + key: '2', + content: ( + + + + + + + + Карта с кредитным лимитом + + + + + + ), + }, + ]; + return ( + }} + options={options} + /> + ); +}); +``` + +## Добавление новой карты отправителя + +Для карты отправителя можно использовать механику с запросом CVV или без него. +После ввода 6-го символа определяется банк и платёжная система. При необходимости, можно настроить отображение различных рубашек карт для банков РФ. В примере для карты начинающейся с номера 1234 56 будет определён банк и изменится рубашка. + +```jsx live +render(() => { + const options = [ + { + key: '1', + content: ( + + + + + + + + Карта с преимуществами + + + + + + ), + }, + { + key: '2', + content: ( + + + + + + + + Карта с кредитным лимитом + + + + + + ), + }, + ]; + + const [cvv, setCvv] = React.useState(true); + const [expiry, setExpiry] = React.useState(true); + const [cardImage, setCardImage] = React.useState(undefined); + const handleInput = (data) => { + if (data.number.startsWith('123456')) { + setCardImage({ + baseUrl: 'https://online.alfabank.ru/cards-images/cards/', + layers: 'BACKGROUND,LOGO,PAYMENT_SYSTEM', + cardId: 'RM', + }); + } + }; + + return ( +
+ }} + options={options} + cardAddingProps={{ + content: ( + + + + + + + + Новая карта + + + + + ), + needCvv: cvv, + needExpiryDate: expiry, + onInput: handleInput, + cardImage, + }} + /> + + { + setCvv((prevState) => !prevState); + }} + /> + + { + setExpiry((prevState) => !prevState); + }} + /> +
+ ); +}); +``` + +## Добавление новой карты получателя + +```jsx live +render(() => { + const options = [ + { + key: '1', + content: ( + + + + + + + + Карта с преимуществами + + + + + + ), + }, + { + key: '2', + content: ( + + + + + + + + Карта с кредитным лимитом + + + + + + ), + }, + ]; + + const [cardImage, setCardImage] = React.useState(undefined); + const handleInput = (data) => { + if (data.number.startsWith('123456')) { + setCardImage({ + baseUrl: 'https://online.alfabank.ru/cards-images/cards/', + layers: 'BACKGROUND,LOGO,PAYMENT_SYSTEM', + cardId: 'RM', + }); + } + }; + + return ( +
+ }} + options={options} + cardAddingProps={{ + content: ( + + + + + + + + Новая карта + + + + + ), + needCvv: false, + needExpiryDate: false, + onInput: handleInput, + cardImage, + }} + /> +
+ ); +}); +``` diff --git a/packages/account-select/src/docs/development.mdx b/packages/account-select/src/docs/development.mdx new file mode 100644 index 0000000000..3688b1c91f --- /dev/null +++ b/packages/account-select/src/docs/development.mdx @@ -0,0 +1,20 @@ +import { ArgsTabs } from 'storybook/blocks'; +import { AccountSelectMobile } from '../mobile'; +import { AccountSelectDesktop } from '../desktop'; + +## Подключение + +```jsx +import { AccountSelectResponsive } from '@alfalab/core-components/account-select'; +import { AccountSelectDesktop } from '@alfalab/core-components/account-select/desktop'; +import { AccountSelectMobile } from '@alfalab/core-components/account-select/mobile'; +``` + +## Свойства + + diff --git a/packages/account-select/src/index.ts b/packages/account-select/src/index.ts new file mode 100644 index 0000000000..849b74eef4 --- /dev/null +++ b/packages/account-select/src/index.ts @@ -0,0 +1,2 @@ +export { AccountSelectResponsive as AccountSelect } from './Component.responsive'; +export type { AccountSelectProps } from './types'; diff --git a/packages/account-select/src/mobile/Component.mobile.tsx b/packages/account-select/src/mobile/Component.mobile.tsx new file mode 100644 index 0000000000..b1d3d81a34 --- /dev/null +++ b/packages/account-select/src/mobile/Component.mobile.tsx @@ -0,0 +1,62 @@ +import React, { forwardRef, useMemo, useState } from 'react'; + +import { SelectMobile } from '@alfalab/core-components-select/mobile'; +import { BaseSelectChangePayload } from '@alfalab/core-components-select/shared'; + +import { CustomField, CustomFieldProps } from '../components/custom-field'; +import { ADD_CARD_KEY } from '../constants'; +import { AccountSelectContext } from '../context'; +import { AccountSelectProps } from '../types'; + +const MobileCustomField = (props: CustomFieldProps) => ; + +export const AccountSelectMobile = forwardRef( + ( + { cardAddingProps, options, closeOnSelect = true, dataTestId, block = true, onChange, ...restProps }, + ref + ) => { + const [error, setError] = useState(null); + const { content, ...restCardAddingProps } = cardAddingProps ?? {}; + + const enhancedOptions = useMemo(() => { + if (!content) return options; + + return [ + { + key: ADD_CARD_KEY, + content, + value: ADD_CARD_KEY, + }, + ...options, + ]; + }, [content, options]); + + const contextValue = useMemo(() => ({ setError }), [setError]); + + const handleChange = (payload: BaseSelectChangePayload) => { + setError(null); + onChange?.(payload); + }; + + return ( + + + + ); + }, +); diff --git a/packages/account-select/src/mobile/index.ts b/packages/account-select/src/mobile/index.ts new file mode 100644 index 0000000000..2faf8121dd --- /dev/null +++ b/packages/account-select/src/mobile/index.ts @@ -0,0 +1 @@ +export { AccountSelectMobile } from './Component.mobile'; diff --git a/packages/account-select/src/types.ts b/packages/account-select/src/types.ts new file mode 100644 index 0000000000..a6754b13cc --- /dev/null +++ b/packages/account-select/src/types.ts @@ -0,0 +1,83 @@ +import { BankCardImageProps } from '@alfalab/core-components-product-cover/typings'; +import { BaseSelectProps } from '@alfalab/core-components-select/shared'; + +export interface CardData { + number: string; + expiryDate?: string | Date; + cvv?: string; +} + +export interface CardAddingProps { + /** + * Идентификатор для тестирования + */ + dataTestId?: string; + + /** + * Контент для элемента добавления новой карты + */ + content: React.ReactNode; + + /** + * Обработчик ввода для новой карты + */ + onInput?: (value: CardData) => void; + + /** + * Обработчик отправки новой карты + */ + onSubmit?: (value: CardData) => void; + + /** + * Данные карты для отображения + */ + cardImage?: Pick< + BankCardImageProps, + 'baseUrl' | 'layers' | 'cardId' | 'backgroundColor' | 'borderColor' + >; + + /** + * Нужно ли отображать поле для ввода CVV + */ + needCvv?: boolean; + + /** + * Нужно ли отображать поле для ввода срока действия карты + */ + needExpiryDate?: boolean; + + /** + * Нужно ли отправлять срок действия карты в формате Date + */ + expiryAsDate?: boolean; +} + +export interface AccountSelectProps + extends Omit< + BaseSelectProps, + | 'autocomplete' + | 'Field' + | 'nativeSelect' + | 'searchProps' + | 'showSearch' + | 'Search' + | 'valueRenderer' + > { + /** + * Пропсы для добавления новой карты + */ + cardAddingProps?: CardAddingProps; +} + +export interface AccountSelectResonsiveProps extends AccountSelectProps { + /** + * Контрольная точка, с нее начинается desktop версия + * @default 1024 + */ + breakpoint?: number; + + /** + * Версия, которая будет использоваться при серверном рендеринге + */ + client?: 'desktop' | 'mobile'; +} diff --git a/packages/account-select/src/utils/formaters/index.test.ts b/packages/account-select/src/utils/formaters/index.test.ts new file mode 100644 index 0000000000..43ba3243b9 --- /dev/null +++ b/packages/account-select/src/utils/formaters/index.test.ts @@ -0,0 +1,69 @@ +import { getMaskedCardNumber, formatCardNumber } from './index'; + +describe('getMaskedCardNumber', () => { + it('should return original value if less than 16 characters', () => { + expect(getMaskedCardNumber('123456789')).toBe('123456789'); + }); + + it('should return original value if empty', () => { + expect(getMaskedCardNumber('')).toBe(''); + }); + + it('should return masked number with last 4 digits', () => { + expect(getMaskedCardNumber('1234567890123456')).toBe('··3456'); + }); + + it('should handle spaces in card number', () => { + expect(getMaskedCardNumber('1234 5678 9012 3456')).toBe('··3456'); + }); + + it('should handle card number with multiple spaces', () => { + expect(getMaskedCardNumber('1234 5678 9012 3456')).toBe('··3456'); + }); + + const testCases = [ + ['4111111111111111', '··1111'], + ['5500000000000004', '··0004'], + ['2201382000000013', '··0013'], + ['6759649826438453', '··8453'], + ]; + + it.each(testCases)('should mask card number %s correctly', (input, expected) => { + expect(getMaskedCardNumber(input)).toBe(expected); + }); +}); + +describe('formatCardNumber', () => { + it('should format card number with spaces every 4 digits', () => { + expect(formatCardNumber('1234567890123456')).toBe('1234 5678 9012 3456'); + }); + + it('should handle already formatted card number', () => { + expect(formatCardNumber('1234 5678 9012 3456')).toBe('1234 5678 9012 3456'); + }); + + it('should handle short card number', () => { + expect(formatCardNumber('1234')).toBe('1234'); + expect(formatCardNumber('12345')).toBe('1234 5'); + }); + + it('should handle empty string', () => { + expect(formatCardNumber('')).toBe(''); + }); + + it('should remove existing spaces and reformat', () => { + expect(formatCardNumber('1234 5678 9012 3456')).toBe('1234 5678 9012 3456'); + }); + + const testCases = [ + ['4111111111111111', '4111 1111 1111 1111'], + ['5500000000000004', '5500 0000 0000 0004'], + ['220138200000001', '2201 3820 0000 001'], + ['67596498264384', '6759 6498 2643 84'], + ['123', '123'], + ]; + + it.each(testCases)('should format card number %s correctly', (input, expected) => { + expect(formatCardNumber(input)).toBe(expected); + }); +}); diff --git a/packages/account-select/src/utils/formaters/index.ts b/packages/account-select/src/utils/formaters/index.ts new file mode 100644 index 0000000000..4a2acdc2b2 --- /dev/null +++ b/packages/account-select/src/utils/formaters/index.ts @@ -0,0 +1,13 @@ +export const getMaskedCardNumber = (value: string) => { + if (!value || value.length < 16) return value; + const cleanValue = value.replace(/\s/g, ''); + const lastFour = cleanValue.slice(-4); + + return `··${lastFour}`; +}; + +export const formatCardNumber = (value: string) => { + const cleanValue = value.replace(/\s/g, ''); + + return cleanValue.replace(/(\d{4})(?=\d)/g, '$1 '); +}; diff --git a/packages/account-select/src/utils/parse-date/index.test.ts b/packages/account-select/src/utils/parse-date/index.test.ts new file mode 100644 index 0000000000..53d6ef97f4 --- /dev/null +++ b/packages/account-select/src/utils/parse-date/index.test.ts @@ -0,0 +1,86 @@ +import { parseDate } from './index'; + +describe('parseDate', () => { + it('should parse valid date string correctly', () => { + const result = parseDate('12/25'); + expect(result.getFullYear()).toBe(2025); + expect(result.getMonth()).toBe(11); // Декабрь (0-индексирован) + expect(result.getDate()).toBe(31); // Последний день декабря + }); + + it('should handle single digit month and year', () => { + const result = parseDate('01/23'); + expect(result.getFullYear()).toBe(2023); + expect(result.getMonth()).toBe(0); // Январь + expect(result.getDate()).toBe(31); // Последний день января + }); + + it('should create date with last day of month', () => { + const february = parseDate('02/24'); // 2024 - високосный год + expect(february.getDate()).toBe(29); + + const februaryNonLeap = parseDate('02/23'); // 2023 - не високосный + expect(februaryNonLeap.getDate()).toBe(28); + + const april = parseDate('04/25'); + expect(april.getDate()).toBe(30); // Апрель имеет 30 дней + }); + + describe('Error cases', () => { + it('should throw error for empty string', () => { + expect(() => parseDate('')).toThrow('Неверный формат даты. Ожидается формат "ММ/ГГ"'); + }); + + it('should throw error for string without slash', () => { + expect(() => parseDate('1225')).toThrow( + 'Неверный формат даты. Ожидается формат "ММ/ГГ"', + ); + }); + + it('should throw error for invalid month', () => { + expect(() => parseDate('00/25')).toThrow('Неверный месяц. Должен быть от 01 до 12'); + expect(() => parseDate('13/25')).toThrow('Неверный месяц. Должен быть от 01 до 12'); + }); + + it('should throw error for invalid year', () => { + expect(() => parseDate('12/100')).toThrow('Неверный год. Должен быть от 00 до 99'); + }); + }); + + const validTestCases: Array<[string, number, number, number]> = [ + ['01/25', 2025, 0, 31], // Январь + ['02/24', 2024, 1, 29], // Февраль високосный + ['02/23', 2023, 1, 28], // Февраль обычный + ['03/25', 2025, 2, 31], // Март + ['04/25', 2025, 3, 30], // Апрель + ['05/25', 2025, 4, 31], // Май + ['06/25', 2025, 5, 30], // Июнь + ['07/25', 2025, 6, 31], // Июль + ['08/25', 2025, 7, 31], // Август + ['09/25', 2025, 8, 30], // Сентябрь + ['10/25', 2025, 9, 31], // Октябрь + ['11/25', 2025, 10, 30], // Ноябрь + ['12/25', 2025, 11, 31], // Декабрь + ]; + + it.each(validTestCases)( + 'should parse %s to correct date', + (input, expectedYear, expectedMonth, expectedDate) => { + const result = parseDate(input); + expect(result.getFullYear()).toBe(expectedYear); + expect(result.getMonth()).toBe(expectedMonth); + expect(result.getDate()).toBe(expectedDate); + }, + ); + + const invalidTestCases: Array<[string, string]> = [ + ['', 'Неверный формат даты. Ожидается формат "ММ/ГГ"'], + ['1225', 'Неверный формат даты. Ожидается формат "ММ/ГГ"'], + ['00/25', 'Неверный месяц. Должен быть от 01 до 12'], + ['13/25', 'Неверный месяц. Должен быть от 01 до 12'], + ]; + + it.each(invalidTestCases)('should throw error for %s', (input, expectedError) => { + expect(() => parseDate(input)).toThrow(expectedError); + }); +}); diff --git a/packages/account-select/src/utils/parse-date/index.ts b/packages/account-select/src/utils/parse-date/index.ts new file mode 100644 index 0000000000..079da9cfbf --- /dev/null +++ b/packages/account-select/src/utils/parse-date/index.ts @@ -0,0 +1,26 @@ +/** + * Преобразует строку в формате "ММ/ГГ" в объект Date + * @param expiryString - строка в формате "ММ/ГГ" (например, "12/25") + * @returns объект Date с последним днем указанного месяца и года + */ +export const parseDate = (expiryString: string): Date => { + if (!expiryString || !expiryString.includes('/')) { + throw new Error('Неверный формат даты. Ожидается формат "ММ/ГГ"'); + } + + const [monthStr, yearStr] = expiryString.split('/'); + const month = parseInt(monthStr, 10) - 1; // Месяцы в JS начинаются с 0 + const year = parseInt(yearStr, 10) + 2000; // Добавляем 2000 для получения полного года + + // Проверяем валидность месяца и года + if (month < 0 || month > 11) { + throw new Error('Неверный месяц. Должен быть от 01 до 12'); + } + + if (year < 2000 || year > 2099) { + throw new Error('Неверный год. Должен быть от 00 до 99'); + } + + // Создаем дату на последний день указанного месяца + return new Date(year, month + 1, 0); +}; diff --git a/packages/account-select/src/utils/validate/index.test.ts b/packages/account-select/src/utils/validate/index.test.ts new file mode 100644 index 0000000000..38637f5a0a --- /dev/null +++ b/packages/account-select/src/utils/validate/index.test.ts @@ -0,0 +1,129 @@ +import { validateCardNumber, validateExpiry, validateCvv } from './index'; + +describe('validateCardNumber', () => { + it('should return true for valid 16-digit card number', () => { + expect(validateCardNumber('1234567890123456')).toBe(true); + }); + + it('should return false for card number shorter than 16 digits', () => { + expect(validateCardNumber('123456789012345')).toBe(false); + expect(validateCardNumber('12345')).toBe(false); + expect(validateCardNumber('')).toBe(false); + }); + + it('should return false for card number longer than 16 digits', () => { + expect(validateCardNumber('12345678901234567')).toBe(false); + expect(validateCardNumber('123456789012345678')).toBe(false); + }); + + const validCardNumbers = [ + '4111111111111111', + '5500000000000004', + '2201382000000013', + '6759649826438453', + '0000000000000000', + '9999999999999999', + ]; + + it.each(validCardNumbers)('should validate card number %s as true', (cardNumber) => { + expect(validateCardNumber(cardNumber)).toBe(true); + }); + + const invalidCardNumbers = ['411111111111111', '41111111111111111', '411', '']; + + it.each(invalidCardNumbers)('should validate card number %s as false', (cardNumber) => { + expect(validateCardNumber(cardNumber)).toBe(false); + }); +}); + +describe('validateExpiry', () => { + it('should return true for valid future expiry', () => { + const futureYear = (new Date().getFullYear() % 100) + 2; + expect(validateExpiry(`12/${futureYear.toString().padStart(2, '0')}`)).toBe(true); + }); + + it('should return false for past years', () => { + expect(validateExpiry('12/20')).toBe(false); + expect(validateExpiry('01/19')).toBe(false); + }); + + it('should return false for invalid format', () => { + expect(validateExpiry('1/24')).toBe(false); + expect(validateExpiry('12/2')).toBe(false); + expect(validateExpiry('12/234')).toBe(false); + expect(validateExpiry('123/24')).toBe(false); + expect(validateExpiry('')).toBe(false); + expect(validateExpiry('1224')).toBe(false); + }); + + it('should return false for invalid month', () => { + expect(validateExpiry('00/25')).toBe(false); + expect(validateExpiry('13/25')).toBe(false); + expect(validateExpiry('99/25')).toBe(false); + }); + + const invalidTestCases = ['12/20', '00/25', '13/25', '1/24', '12/2', '']; + + it.each(invalidTestCases)('should validate expiry %s as false', (expiry) => { + expect(validateExpiry(expiry)).toBe(false); + }); +}); + +describe('validateCvv', () => { + it('should return true for valid 3-digit string CVV', () => { + expect(validateCvv('123')).toBe(true); + expect(validateCvv('000')).toBe(true); + expect(validateCvv('999')).toBe(true); + }); + + it('should return true for valid 3-digit number CVV', () => { + expect(validateCvv(123)).toBe(true); + expect(validateCvv(0)).toBe(false); + expect(validateCvv(999)).toBe(true); + expect(validateCvv(42)).toBe(false); + }); + + it('should return false for invalid string CVV', () => { + expect(validateCvv('12')).toBe(false); + expect(validateCvv('1234')).toBe(false); + expect(validateCvv('')).toBe(false); + expect(validateCvv('abc')).toBe(false); + }); + + it('should return false for invalid number CVV', () => { + expect(validateCvv(12)).toBe(false); + expect(validateCvv(1234)).toBe(false); + expect(validateCvv(1)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(validateCvv(undefined)).toBe(false); + }); + + const validTestCases: Array<[string | number, boolean]> = [ + ['123', true], + ['000', true], + ['999', true], + [123, true], + [999, true], + ]; + + it.each(validTestCases)('should validate CVV %s as %s', (cvv, expected) => { + expect(validateCvv(cvv)).toBe(expected); + }); + + const invalidTestCases: Array<[string | number | undefined, boolean]> = [ + ['12', false], + ['1234', false], + ['', false], + ['abc', false], + [12, false], + [1234, false], + [1, false], + [undefined, false], + ]; + + it.each(invalidTestCases)('should validate CVV %s as %s', (cvv, expected) => { + expect(validateCvv(cvv)).toBe(expected); + }); +}); diff --git a/packages/account-select/src/utils/validate/index.ts b/packages/account-select/src/utils/validate/index.ts new file mode 100644 index 0000000000..431e3eb37b --- /dev/null +++ b/packages/account-select/src/utils/validate/index.ts @@ -0,0 +1,30 @@ +const isNumericString = (str: string) => /^\d+$/.test(str); + +export const validateCardNumber = (value: string) => { + const trimmedValue = value.replace(/\s/g, ''); + + return trimmedValue.length === 16 && isNumericString(trimmedValue); +}; + +export const validateExpiry = (value: string) => { + if (value.length !== 5) return false; + const [month, year] = value.split('/'); + const currentYear = new Date().getFullYear() % 100; + const currentMonth = new Date().getMonth() + 1; + + const monthNum = parseInt(month, 10); + const yearNum = parseInt(year, 10); + + return ( + monthNum >= 1 && + monthNum <= 12 && + yearNum >= currentYear && + (yearNum > currentYear || monthNum >= currentMonth) + ); +}; + +export const validateCvv = (value: string | number | undefined) => { + if (typeof value === 'number') return value.toString().length === 3; + + return value?.length === 3 && isNumericString(value); +}; diff --git a/packages/account-select/tsconfig.build.json b/packages/account-select/tsconfig.build.json new file mode 100644 index 0000000000..325f59da05 --- /dev/null +++ b/packages/account-select/tsconfig.build.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@alfalab/core-components-env/tsconfig.base.json", + "include": ["src", "src/**/*.json"], + "exclude": ["**/*.stories.*", "**/*.test.*"], + "compilerOptions": { + "rootDir": "src", + "outDir": "ts-dist", + "paths": { + "@alfalab/core-components-account-select": ["./src"], + "@alfalab/core-components-account-select/*": ["./src/*"], + "@alfalab/core-components-form-control": ["../form-control/src"], + "@alfalab/core-components-form-control/*": ["../form-control/src/*"], + "@alfalab/core-components-mq": ["../mq/src"], + "@alfalab/core-components-mq/*": ["../mq/src/*"], + "@alfalab/core-components-popover": ["../popover/src"], + "@alfalab/core-components-popover/*": ["../popover/src/*"], + "@alfalab/core-components-product-cover": ["../product-cover/src"], + "@alfalab/core-components-product-cover/*": ["../product-cover/src/*"], + "@alfalab/core-components-select": ["../select/src"], + "@alfalab/core-components-select/*": ["../select/src/*"] + } + }, + "references": [ + { "path": "../form-control/tsconfig.build.json" }, + { "path": "../mq/tsconfig.build.json" }, + { "path": "../popover/tsconfig.build.json" }, + { "path": "../product-cover/tsconfig.build.json" }, + { "path": "../select/tsconfig.build.json" } + ] +} diff --git a/packages/account-select/tsconfig.json b/packages/account-select/tsconfig.json new file mode 100644 index 0000000000..6ae852c007 --- /dev/null +++ b/packages/account-select/tsconfig.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@alfalab/core-components-env/tsconfig.base.json", + "include": ["src", "src/**/*.json"], + "compilerOptions": { + "rootDir": "src", + "outDir": "no-dist", + "paths": { + "@alfalab/core-components-account-select": ["./src"], + "@alfalab/core-components-account-select/*": ["./src/*"], + "@alfalab/core-components-form-control": ["../form-control/src"], + "@alfalab/core-components-form-control/*": ["../form-control/src/*"], + "@alfalab/core-components-mq": ["../mq/src"], + "@alfalab/core-components-mq/*": ["../mq/src/*"], + "@alfalab/core-components-popover": ["../popover/src"], + "@alfalab/core-components-popover/*": ["../popover/src/*"], + "@alfalab/core-components-product-cover": ["../product-cover/src"], + "@alfalab/core-components-product-cover/*": ["../product-cover/src/*"], + "@alfalab/core-components-screenshot-utils": ["../screenshot-utils/src"], + "@alfalab/core-components-screenshot-utils/*": ["../screenshot-utils/src/*"], + "@alfalab/core-components-select": ["../select/src"], + "@alfalab/core-components-select/*": ["../select/src/*"], + "@alfalab/core-components-test-utils": ["../test-utils/src"], + "@alfalab/core-components-test-utils/*": ["../test-utils/src/*"] + } + }, + "references": [ + { "path": "../form-control/tsconfig.build.json" }, + { "path": "../mq/tsconfig.build.json" }, + { "path": "../popover/tsconfig.build.json" }, + { "path": "../product-cover/tsconfig.build.json" }, + { "path": "../screenshot-utils/tsconfig.build.json" }, + { "path": "../select/tsconfig.build.json" }, + { "path": "../test-utils/tsconfig.build.json" } + ] +} \ No newline at end of file diff --git a/tsconfig.test.json b/tsconfig.test.json index 2526e6b68e..9c41f83abd 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -5,6 +5,8 @@ "paths": { "@alfalab/core-components-accordion": ["./packages/accordion/src"], "@alfalab/core-components-accordion/*": ["./packages/accordion/src/*"], + "@alfalab/core-components-account-select": ["./packages/account-select/src"], + "@alfalab/core-components-account-select/*": ["./packages/account-select/src/*"], "@alfalab/core-components-action-button": ["./packages/action-button/src"], "@alfalab/core-components-action-button/*": ["./packages/action-button/src/*"], "@alfalab/core-components-alert": ["./packages/alert/src"],