diff --git a/packages/api/src/hooks/p2p/entity/advert/p2p-advert/useAdvertUpdate.ts b/packages/api/src/hooks/p2p/entity/advert/p2p-advert/useAdvertUpdate.ts index 43e6f889f669..cf64af4daad0 100644 --- a/packages/api/src/hooks/p2p/entity/advert/p2p-advert/useAdvertUpdate.ts +++ b/packages/api/src/hooks/p2p/entity/advert/p2p-advert/useAdvertUpdate.ts @@ -23,6 +23,7 @@ const useAdvertUpdate = () => { } = useMutation('p2p_advert_update', { onSuccess: () => { invalidate('p2p_advert_list'); + invalidate('p2p_advertiser_adverts'); }, }); diff --git a/packages/api/src/hooks/p2p/entity/advert/p2p-advertiser-adverts/useAdvertiserAdverts.ts b/packages/api/src/hooks/p2p/entity/advert/p2p-advertiser-adverts/useAdvertiserAdverts.ts index d25b02d99ec7..a32b32f0840d 100644 --- a/packages/api/src/hooks/p2p/entity/advert/p2p-advertiser-adverts/useAdvertiserAdverts.ts +++ b/packages/api/src/hooks/p2p/entity/advert/p2p-advertiser-adverts/useAdvertiserAdverts.ts @@ -13,7 +13,7 @@ const useAdvertiserAdverts = ( options: { ...config, getNextPageParam: (lastPage, pages) => { - if (!lastPage?.p2p_advertiser_adverts?.list) return; + if (!lastPage?.p2p_advertiser_adverts?.list?.length) return; return pages.length; }, @@ -53,6 +53,8 @@ const useAdvertiserAdverts = ( }, /** The advert creation time in epoch. */ created_time: advert?.created_time ? new Date(advert.created_time) : undefined, + /** Indicates if this is block trade advert or not. */ + block_trade: Boolean(advert?.block_trade), })); }, [flatten_data]); diff --git a/packages/api/src/hooks/useExchangeRateSubscription.ts b/packages/api/src/hooks/useExchangeRateSubscription.ts index 65be3d8b6be9..906ad8910828 100644 --- a/packages/api/src/hooks/useExchangeRateSubscription.ts +++ b/packages/api/src/hooks/useExchangeRateSubscription.ts @@ -1,8 +1,9 @@ import { useCallback } from 'react'; import useSubscription from '../useSubscription'; -type TPayload = Required< - NonNullable>['subscribe']>>[0]['payload'] +type TPayload = WithRequiredProperty< + NonNullable>['subscribe']>>[0]['payload'], + 'target_currency' >; /** A custom hook that gets exchange rates from base currency to target currency */ diff --git a/packages/p2p-v2/src/components/PopoverDropdown/PopoverDropdown.scss b/packages/p2p-v2/src/components/PopoverDropdown/PopoverDropdown.scss new file mode 100644 index 000000000000..4d716a41f38a --- /dev/null +++ b/packages/p2p-v2/src/components/PopoverDropdown/PopoverDropdown.scss @@ -0,0 +1,32 @@ +.p2p-v2-popover-dropdown { + display: flex; + flex-direction: column; + position: relative; + + &__icon { + margin-left: 1rem; + } + + &__list { + display: flex; + flex-direction: column; + border-radius: 0.4rem; + width: 19.5rem; + box-shadow: 0px 32px 64px 0px #0e0e0e24; + z-index: 999; + top: 2.8rem; + position: absolute; + background: #fff; + right: 0; + + .derivs-button__color--primary:hover:not(:disabled) { + background-color: var(--general-hover); + } + + &-item { + padding: 1rem 1.6rem; + background-color: inherit; + height: inherit; + } + } +} diff --git a/packages/p2p-v2/src/components/PopoverDropdown/PopoverDropdown.tsx b/packages/p2p-v2/src/components/PopoverDropdown/PopoverDropdown.tsx new file mode 100644 index 000000000000..7ad648abab96 --- /dev/null +++ b/packages/p2p-v2/src/components/PopoverDropdown/PopoverDropdown.tsx @@ -0,0 +1,50 @@ +import React, { useRef, useState } from 'react'; +import { Button, Text } from '@deriv-com/ui'; +import { useOnClickOutside } from 'usehooks-ts'; +import { LabelPairedEllipsisVerticalMdRegularIcon } from '@deriv/quill-icons'; +import './PopoverDropdown.scss'; + +type TItem = { + label: string; + value: string; +}; + +type TPopoverDropdownProps = { + dataTestId?: string; + dropdownList: TItem[]; + onClick: (value: string) => void; +}; + +const PopoverDropdown = ({ dataTestId, dropdownList, onClick }: TPopoverDropdownProps) => { + const [visible, setVisible] = useState(false); + const ref = useRef(null); + useOnClickOutside(ref, () => setVisible(false)); + + return ( +
+ setVisible(prevState => !prevState)} + /> + {visible && ( +
+ {dropdownList.map(item => ( + + ))} +
+ )} +
+ ); +}; + +export default PopoverDropdown; diff --git a/packages/p2p-v2/src/components/PopoverDropdown/index.ts b/packages/p2p-v2/src/components/PopoverDropdown/index.ts new file mode 100644 index 000000000000..bfe8f9b5e527 --- /dev/null +++ b/packages/p2p-v2/src/components/PopoverDropdown/index.ts @@ -0,0 +1 @@ +export { default as PopoverDropdown } from './PopoverDropdown'; diff --git a/packages/p2p-v2/src/components/Table/Table.scss b/packages/p2p-v2/src/components/Table/Table.scss index 96fa5b0ed1de..ce152413d5dd 100644 --- a/packages/p2p-v2/src/components/Table/Table.scss +++ b/packages/p2p-v2/src/components/Table/Table.scss @@ -1,12 +1,19 @@ .p2p-v2-table { - overflow-y: auto; - display: flex; - flex-direction: column; - width: 100%; - position: absolute; + &__content { + overflow-y: auto; + display: flex; + flex-direction: column; + width: 100%; - @include mobile { - height: 100%; + @include mobile { + height: 100%; + } + + &-row { + &:not(:last-child) { + border-bottom: 1px solid var(--general-section-1); + } + } } scrollbar-width: thin; /* For Firefox */ @@ -35,4 +42,10 @@ background-color: var(--state-active); } } + + &__header { + display: grid; + border-bottom: 2px solid var(--general-section-1); + padding: 1.6rem; + } } diff --git a/packages/p2p-v2/src/components/Table/Table.tsx b/packages/p2p-v2/src/components/Table/Table.tsx index 69d5c6c61045..255a77e0e020 100644 --- a/packages/p2p-v2/src/components/Table/Table.tsx +++ b/packages/p2p-v2/src/components/Table/Table.tsx @@ -1,7 +1,8 @@ -import React, { memo, useRef } from 'react'; +import React, { memo, useLayoutEffect, useRef, useState } from 'react'; import clsx from 'clsx'; -import { useFetchMore } from '@/hooks'; import { ColumnDef, getCoreRowModel, getGroupedRowModel, GroupingState, useReactTable } from '@tanstack/react-table'; +import { Text } from '@deriv-com/ui'; +import { useFetchMore, useDevice } from '@/hooks'; import './Table.scss'; type TProps = { @@ -10,8 +11,7 @@ type TProps = { groupBy?: GroupingState; isFetching: boolean; loadMoreFunction: () => void; - rowClassname: string; - rowGroupRender?: (data: T) => JSX.Element; + renderHeader?: (data: string) => JSX.Element; rowRender: (data: T) => JSX.Element; tableClassname: string; }; @@ -21,10 +21,11 @@ const Table = ({ data, isFetching, loadMoreFunction, - rowClassname, + renderHeader = () =>
, rowRender, tableClassname, }: TProps) => { + const { isDesktop } = useDevice(); const table = useReactTable({ columns, data, @@ -33,6 +34,16 @@ const Table = ({ }); const tableContainerRef = useRef(null); + const headerRef = useRef(null); + const [height, setHeight] = useState(0); + + useLayoutEffect(() => { + if (headerRef?.current) { + const topPosition = headerRef.current.getBoundingClientRect().bottom; + setHeight(window.innerHeight - topPosition); + } + }, [headerRef?.current]); + const { fetchMoreOnBottomReached } = useFetchMore({ loadMore: loadMoreFunction, ref: tableContainerRef, @@ -40,16 +51,28 @@ const Table = ({ }); return ( -
fetchMoreOnBottomReached(e.target as HTMLDivElement)} - ref={tableContainerRef} - > - {table.getRowModel().rows.map(row => ( -
- {rowRender(row.original)} +
+ {isDesktop && columns.length > 0 && ( +
+ {table.getFlatHeaders().map(header => ( + + {renderHeader(header.column.columnDef.header as string)} + + ))}
- ))} + )} +
fetchMoreOnBottomReached(e.target as HTMLDivElement)} + ref={tableContainerRef} + style={{ height: isDesktop && columns.length > 0 ? `calc(${height}px - 3.6rem)` : '100%' }} + > + {table.getRowModel().rows.map(row => ( +
+ {rowRender(row.original)} +
+ ))} +
); }; diff --git a/packages/p2p-v2/src/components/index.ts b/packages/p2p-v2/src/components/index.ts index 6f9643bcc9db..3a8a0a3574d6 100644 --- a/packages/p2p-v2/src/components/index.ts +++ b/packages/p2p-v2/src/components/index.ts @@ -14,6 +14,7 @@ export * from './PaymentMethodField'; export * from './PaymentMethodForm'; export * from './PaymentMethodsFormFooter'; export * from './PaymentMethodsHeader'; +export * from './PopoverDropdown'; export * from './RadioGroup'; export * from './Search'; export * from './StarRating'; diff --git a/packages/p2p-v2/src/constants/ad-constants.ts b/packages/p2p-v2/src/constants/ad-constants.ts new file mode 100644 index 000000000000..f40b2a5dc83c --- /dev/null +++ b/packages/p2p-v2/src/constants/ad-constants.ts @@ -0,0 +1,22 @@ +export const COUNTERPARTIES_DROPDOWN_LIST = [ + { value: 'all', text: 'All' }, + { value: 'blocked', text: 'Blocked' }, +]; + +export const RATE_TYPE = { + FLOAT: 'float', + FIXED: 'fixed', +}; + +export const AD_ACTION = { + EDIT: 'edit', + CREATE: 'create', + ACTIVATE: 'activate', + DEACTIVATE: 'deactivate', + DELETE: 'delete', +}; + +export const ADVERT_TYPE = { + SELL: 'Sell', + BUY: 'Buy', +}; diff --git a/packages/p2p-v2/src/constants/counterparties-dropdown.ts b/packages/p2p-v2/src/constants/counterparties-dropdown.ts deleted file mode 100644 index ac3577b48aea..000000000000 --- a/packages/p2p-v2/src/constants/counterparties-dropdown.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const COUNTERPARTIES_DROPDOWN_LIST = [ - { value: 'all', text: 'All' }, - { value: 'blocked', text: 'Blocked' }, -]; diff --git a/packages/p2p-v2/src/constants/index.ts b/packages/p2p-v2/src/constants/index.ts index 967a89d97a21..7d69d6a3c6f5 100644 --- a/packages/p2p-v2/src/constants/index.ts +++ b/packages/p2p-v2/src/constants/index.ts @@ -1,3 +1,3 @@ -export * from './counterparties-dropdown'; +export * from './ad-constants'; export * from './payment-methods'; export * from './validation'; diff --git a/packages/p2p-v2/src/pages/index.ts b/packages/p2p-v2/src/pages/index.ts index e2678c1bbd62..4a7c8455e3dc 100644 --- a/packages/p2p-v2/src/pages/index.ts +++ b/packages/p2p-v2/src/pages/index.ts @@ -1 +1,2 @@ +export * from './my-ads'; export * from './my-profile'; diff --git a/packages/p2p-v2/src/pages/my-ads/components/AdStatus/AdStatus.scss b/packages/p2p-v2/src/pages/my-ads/components/AdStatus/AdStatus.scss new file mode 100644 index 000000000000..c17f233d461e --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/components/AdStatus/AdStatus.scss @@ -0,0 +1,38 @@ +@mixin ad-status-base($background-color) { + &:before { + content: ''; + height: 100%; + width: 100%; + opacity: 0.16; + display: block; + position: absolute; + left: 0; + top: 0; + border-radius: 1.6rem; + background-color: $background-color; + } + align-items: center; + display: flex; + justify-content: center; + padding: 0.1rem 1.2rem; + position: relative; +} + +.p2p-v2-ad-status { + &--active { + @include ad-status-base(var(--status-success)); + + width: fit-content; + + @include mobile { + margin-bottom: 0.8rem; + padding: 0.2rem 1rem; + } + } + + &--inactive { + @include ad-status-base(var(--status-danger)); + + width: fit-content; + } +} diff --git a/packages/p2p-v2/src/pages/my-ads/components/AdStatus/AdStatus.tsx b/packages/p2p-v2/src/pages/my-ads/components/AdStatus/AdStatus.tsx new file mode 100644 index 000000000000..bc49ccdfcaa3 --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/components/AdStatus/AdStatus.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Text } from '@deriv-com/ui'; +import { useDevice } from '@/hooks'; +import './AdStatus.scss'; + +type TAdStatusProps = { + isActive?: boolean; +}; + +const AdStatus = ({ isActive = false }: TAdStatusProps) => { + const { isMobile } = useDevice(); + return ( + + {isActive ? 'Active' : 'Inactive'} + + ); +}; + +export default AdStatus; diff --git a/packages/p2p-v2/src/pages/my-ads/components/AdStatus/__tests__/AdStatus.spec.tsx b/packages/p2p-v2/src/pages/my-ads/components/AdStatus/__tests__/AdStatus.spec.tsx new file mode 100644 index 000000000000..d7f84a0ada88 --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/components/AdStatus/__tests__/AdStatus.spec.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import AdStatus from '../AdStatus'; + +describe('AdStatus', () => { + it('should render the component as expected with Inactive as default', () => { + render(); + expect(screen.getByText('Inactive')).toBeInTheDocument(); + }); + it('should render active when isActive is true', () => { + render(); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); +}); diff --git a/packages/p2p-v2/src/pages/my-ads/components/AdStatus/index.ts b/packages/p2p-v2/src/pages/my-ads/components/AdStatus/index.ts new file mode 100644 index 000000000000..c746c4b34f6f --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/components/AdStatus/index.ts @@ -0,0 +1 @@ +export { default as AdStatus } from './AdStatus'; diff --git a/packages/p2p-v2/src/pages/my-ads/components/AdType/AdType.scss b/packages/p2p-v2/src/pages/my-ads/components/AdType/AdType.scss new file mode 100644 index 000000000000..67c113e16e8a --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/components/AdType/AdType.scss @@ -0,0 +1,20 @@ +.p2p-v2-ad-type { + display: flex; + flex-direction: row; + align-items: center; + + @include mobile { + justify-content: flex-end; + } + + &__badge { + align-items: center; + border-radius: 0.4rem; + border: 1px solid var(--border-normal); + display: flex; + flex-direction: row; + margin: 0.3rem 0.5rem 0.3rem 0; + padding: 0.1rem 0.8rem; + width: fit-content; + } +} diff --git a/packages/p2p-v2/src/pages/my-ads/components/AdType/AdType.tsx b/packages/p2p-v2/src/pages/my-ads/components/AdType/AdType.tsx new file mode 100644 index 000000000000..d4e9e37c0f0a --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/components/AdType/AdType.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Text } from '@deriv-com/ui'; +import './AdType.scss'; + +type TAdTypeProps = { + adPauseColor: string; + floatRate: string; +}; +const AdType = ({ adPauseColor, floatRate }: TAdTypeProps) => { + return ( +
+ + Float + + + {floatRate}% + +
+ ); +}; + +export default AdType; diff --git a/packages/p2p-v2/src/pages/my-ads/components/AdType/__tests__/AdType.spec.tsx b/packages/p2p-v2/src/pages/my-ads/components/AdType/__tests__/AdType.spec.tsx new file mode 100644 index 000000000000..260ebbb55cf3 --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/components/AdType/__tests__/AdType.spec.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import AdType from '../AdType'; + +const mockProps = { + adPauseColor: 'red', + floatRate: '1.23', +}; + +describe('AdType', () => { + it('should render the component as expected', () => { + render(); + expect(screen.getByText('Float')).toBeInTheDocument(); + expect(screen.getByText('1.23%')).toBeInTheDocument(); + }); +}); diff --git a/packages/p2p-v2/src/pages/my-ads/components/AdType/index.ts b/packages/p2p-v2/src/pages/my-ads/components/AdType/index.ts new file mode 100644 index 000000000000..73f48c3954f1 --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/components/AdType/index.ts @@ -0,0 +1 @@ +export { default as AdType } from './AdType'; diff --git a/packages/p2p-v2/src/pages/my-ads/components/ProgressIndicator/ProgressIndicator.scss b/packages/p2p-v2/src/pages/my-ads/components/ProgressIndicator/ProgressIndicator.scss new file mode 100644 index 000000000000..35cac2b2f81d --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/components/ProgressIndicator/ProgressIndicator.scss @@ -0,0 +1,28 @@ +.p2p-v2-progress-indicator { + position: relative; + + &__container { + height: 0.4rem; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + border-radius: $BORDER_RADIUS; + position: relative; + overflow: hidden; + } + &__bar { + z-index: 2; + background-color: var(--status-success); + height: 100%; + position: absolute; + left: 0; + } + &__empty { + z-index: 1; + background-color: var(--general-section-1); + width: 100%; + height: 100%; + position: absolute; + } +} diff --git a/packages/p2p-v2/src/pages/my-ads/components/ProgressIndicator/ProgressIndicator.tsx b/packages/p2p-v2/src/pages/my-ads/components/ProgressIndicator/ProgressIndicator.tsx new file mode 100644 index 000000000000..40a1dae101a0 --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/components/ProgressIndicator/ProgressIndicator.tsx @@ -0,0 +1,25 @@ +import React, { CSSProperties } from 'react'; +import clsx from 'clsx'; +import './ProgressIndicator.scss'; + +type TProgressIndicator = { + className?: string; + style?: CSSProperties; + total: number; + value: number; +}; + +const ProgressIndicator = ({ className, style, total, value }: TProgressIndicator) => { + return ( +
+
+
+
+ ); +}; + +export default ProgressIndicator; diff --git a/packages/p2p-v2/src/pages/my-ads/components/ProgressIndicator/__tests__/ProgressIndicator.spec.tsx b/packages/p2p-v2/src/pages/my-ads/components/ProgressIndicator/__tests__/ProgressIndicator.spec.tsx new file mode 100644 index 000000000000..d1877d008e38 --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/components/ProgressIndicator/__tests__/ProgressIndicator.spec.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import ProgressIndicator from '../ProgressIndicator'; + +const mockProps = { + value: 1, + total: 2, +}; + +describe('ProgressIndicator', () => { + it('should render the component as expected', () => { + render(); + expect(screen.getByTestId('dt_p2p_v2_progress_indicator')).toBeInTheDocument(); + }); +}); diff --git a/packages/p2p-v2/src/pages/my-ads/components/ProgressIndicator/index.ts b/packages/p2p-v2/src/pages/my-ads/components/ProgressIndicator/index.ts new file mode 100644 index 000000000000..ea074f1f611b --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/components/ProgressIndicator/index.ts @@ -0,0 +1 @@ +export { default as ProgressIndicator } from './ProgressIndicator'; diff --git a/packages/p2p-v2/src/pages/my-ads/components/index.ts b/packages/p2p-v2/src/pages/my-ads/components/index.ts new file mode 100644 index 000000000000..c9ddab5ca7f9 --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/components/index.ts @@ -0,0 +1,3 @@ +export * from './AdStatus'; +export * from './AdType'; +export * from './ProgressIndicator'; diff --git a/packages/p2p-v2/src/pages/my-ads/index.ts b/packages/p2p-v2/src/pages/my-ads/index.ts new file mode 100644 index 000000000000..c9de5c34d487 --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/index.ts @@ -0,0 +1 @@ +export * from './screens'; diff --git a/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAds.tsx b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAds.tsx new file mode 100644 index 000000000000..1b86d05b5a3f --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAds.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { MyAdsTable } from './MyAdsTable'; + +const MyAds = () => { + //TODO: add empty state + return ( +
+ +
+ ); +}; + +export default MyAds; diff --git a/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTable/MyAdsTable.scss b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTable/MyAdsTable.scss new file mode 100644 index 000000000000..66fcc811ccb2 --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTable/MyAdsTable.scss @@ -0,0 +1,14 @@ +.p2p-v2-my-ads-table { + width: 100%; + height: 100%; + position: relative; + & .p2p-v2-table { + &__header { + grid-template-columns: repeat(3, 1.6fr) 1.9fr 3fr 1.9fr; + } + + &__content { + height: 100%; + } + } +} diff --git a/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTable/MyAdsTable.tsx b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTable/MyAdsTable.tsx new file mode 100644 index 000000000000..ed9eb459ff16 --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTable/MyAdsTable.tsx @@ -0,0 +1,86 @@ +import React, { memo } from 'react'; +import { p2p } from '@deriv/api'; +import { Loader } from '@deriv-com/ui'; +import { Table } from '@/components'; +import { MyAdsTableRow } from '../MyAdsTableRow'; +import './MyAdsTable.scss'; + +export type TMyAdsTableRowRendererProps = Partial< + NonNullable['data']>[0] +> & { + isBarred: boolean; + isListed: boolean; + onClickIcon: (id: string, action: string) => void; +}; + +const MyAdsTableRowRenderer = memo((values: TMyAdsTableRowRendererProps) => ); +MyAdsTableRowRenderer.displayName = 'MyAdsTableRowRenderer'; + +const headerRenderer = (header: string) => {header}; + +const columns = [ + { + header: 'Ad ID', + }, + { + header: 'Limits', + }, + { + header: 'Rate (1 BTC)', + }, + { + header: 'Available amount', + }, + { + header: 'Payment methods', + }, + { + header: 'Status', + }, +]; + +const MyAdsTable = () => { + const { data = [], isFetching, isLoading, loadMoreAdverts } = p2p.advertiserAdverts.useGet(); + const { data: advertiserInfo } = p2p.advertiser.useGetInfo(); + const { mutate } = p2p.advert.useUpdate(); + + if (isLoading) return ; + + const onClickIcon = (id: string, action: string) => { + //TODO: to implement the onclick actions + switch (action) { + case 'activate': + mutate({ id, is_active: 1 }); + break; + case 'deactivate': + mutate({ id, is_active: 0 }); + break; + case 'edit': + default: + break; + } + }; + + return ( +
+ ( + + )} + tableClassname='' + /> + + ); +}; + +export default MyAdsTable; diff --git a/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTable/index.ts b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTable/index.ts new file mode 100644 index 000000000000..a77ac553858b --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTable/index.ts @@ -0,0 +1 @@ +export { default as MyAdsTable } from './MyAdsTable'; diff --git a/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTableRow/MyAdsTableRow.scss b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTableRow/MyAdsTableRow.scss new file mode 100644 index 000000000000..6c2c1f7d0c95 --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTableRow/MyAdsTableRow.scss @@ -0,0 +1,133 @@ +@mixin popoverIcons($background-color, $height, $width) { + align-items: center; + background-color: $background-color; + cursor: pointer; + display: flex; + height: $height; + justify-content: center; + width: $width; + + @include desktop { + &:hover { + background-color: var(--general-hover); + border-radius: 0.5rem; + } + } +} + +.p2p-v2-my-ads-table-row { + display: flex; + flex: 1; + flex-direction: column; + + &__line { + border-bottom: 1px solid var(--general-section-1); + padding: 1.6rem; + position: relative; + display: grid; + align-items: center; + grid-template-columns: repeat(3, 1.6fr) 1.9fr 3fr 1.9fr; + + @include mobile { + grid-template-columns: unset; + padding: 1.6rem; + width: 100%; + } + + &__type-and-status { + display: flex; + justify-content: space-between; + margin-bottom: 0.8rem; + + &__wrapper { + display: flex; + } + } + + &-details { + display: flex; + justify-content: space-between; + } + + &-methods { + display: flex; + flex-wrap: wrap; + } + } + + &__rate { + color: var(--text-profit-success); + font-weight: bold; + } + + &__available { + align-items: flex-start; + flex-flow: column; + justify-content: center; + width: 85%; + + &-progress { + margin-bottom: 0.4rem; + + @include mobile { + margin: 0.4rem 0; + } + } + } + + &__payment-method { + display: flex; + flex-wrap: wrap; + + &--label { + align-items: center; + border-radius: 0.4rem; + border: 1px solid var(--border-normal); + display: flex; + flex-direction: row; + margin: 0.25rem; + padding: 0 0.8rem; + width: fit-content; + + @include mobile { + height: 2.4rem; + margin: 0.25rem 0.5rem 0.25rem 0; + } + } + } + + &__actions { + display: flex; + align-items: center; + &-popovers { + background-color: var(--general-main-1); + display: flex; + height: 99%; + justify-content: center; + min-width: 14rem; + padding: 1.6rem; + position: absolute; + align-items: center; + right: 5.5rem; + top: 0; + .derivs-button { + background-color: #fff; + &__color--primary:hover:not(:disabled) { + background-color: var(--general-hover); + } + } + & svg { + fill: #333333; + } + + @include mobile { + display: flex; + justify-content: unset; + } + + div { + margin: auto; + } + } + } +} diff --git a/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTableRow/MyAdsTableRow.tsx b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTableRow/MyAdsTableRow.tsx new file mode 100644 index 000000000000..e095b0689cd3 --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTableRow/MyAdsTableRow.tsx @@ -0,0 +1,226 @@ +import React, { memo, useEffect, useState } from 'react'; +import clsx from 'clsx'; +import { useExchangeRateSubscription } from '@deriv/api'; +import { Button, Text, Tooltip } from '@deriv-com/ui'; +import { useDevice } from '@/hooks'; +import { generateEffectiveRate } from '@/utils/format-value'; +//TODO: Replace with quill icons once available +import DeactivateIcon from '../../../../../public/ic-archive.svg'; +import ActivateIcon from '../../../../../public/ic-unarchive.svg'; +import EditIcon from '../../../../../public/ic-edit.svg'; +import DeleteIcon from '../../../../../public/ic-delete.svg'; +import { PopoverDropdown } from '@/components'; +import { ADVERT_TYPE, RATE_TYPE } from '@/constants'; +import { formatMoney } from '@/utils/currency'; +import { AdStatus, AdType, ProgressIndicator } from '../../../components'; +import { TMyAdsTableRowRendererProps } from '../MyAdsTable/MyAdsTable'; +import './MyAdsTableRow.scss'; + +const BASE_CURRENCY = 'USD'; +//TODO: to be modified after design is updated. +const list = [ + { label: 'Edit', value: 'edit' }, + { label: 'Delete', value: 'delete' }, + { label: 'Duplicate', value: 'duplicate' }, + { label: 'Share', value: 'share' }, + { label: 'Deactivate', value: 'deactivate' }, +]; + +const MyAdsTableRow = ({ isBarred, isListed, onClickIcon, ...rest }: TMyAdsTableRowRendererProps) => { + const { isMobile } = useDevice(); + const { data: exchangeRateValue, subscribe } = useExchangeRateSubscription(); + + const { + account_currency, + amount, + amount_display, + effective_rate, + id, + is_active, + local_currency, + max_order_amount_display, + min_order_amount_display, + payment_method_names, + price_display, + rate_display, + rate_type, + remaining_amount, + remaining_amount_display, + type, + } = rest; + + useEffect(() => { + subscribe({ + base_currency: BASE_CURRENCY, + target_currency: local_currency!, + }); + }, [local_currency, subscribe]); + + const [isActionsVisible, setIsActionsVisible] = useState(false); + const isAdvertListed = isListed && !isBarred; + const adPauseColor = isAdvertListed ? 'general' : 'less-prominent'; + const amountDealt = (amount ?? 0) - (remaining_amount ?? 0); + + const exchangeRate = exchangeRateValue?.rates?.[local_currency ?? '']; + + const { displayEffectiveRate } = generateEffectiveRate({ + price: Number(price_display), + rateType: rate_type, + rate: Number(rate_display), + localCurrency: local_currency, + exchangeRate, + marketRate: Number(effective_rate), + }); + + //TODO: get the floating rate configs after integration with usep2psettings to handle disabled case. + + const advertType = type === 'buy' ? ADVERT_TYPE.BUY : ADVERT_TYPE.SELL; + + const onClickActionItem = (value: string) => { + onClickIcon(id!, value); + }; + + if (isMobile) { + return ( +
+ + {`Ad ID ${id} `} + +
+ + {advertType} {account_currency} + +
+ + onClickActionItem(value)} + /> +
+
+
+ + {`${formatMoney(account_currency!, amountDealt, true)}`} {account_currency}  + {advertType === 'Buy' ? 'Bought' : 'Sold'} + + + {amount_display} {account_currency} + +
+ +
+ + Limits + + + {`Rate (1 ${account_currency})`} + +
+
+ + {min_order_amount_display} - {max_order_amount_display} {account_currency} + + +
+ {displayEffectiveRate} {local_currency} + {rate_type === RATE_TYPE.FLOAT && ( + + )} +
+
+
+
+ {payment_method_names?.map(payment_method => ( +
+ + {payment_method} + +
+ ))} +
+
+ ); + } + + return ( +
setIsActionsVisible(true)} + onMouseLeave={() => setIsActionsVisible(false)} + > + + {advertType} {id} + + + {min_order_amount_display} - {max_order_amount_display} {account_currency} + + + {displayEffectiveRate} {local_currency} + {rate_type === RATE_TYPE.FLOAT && } + + + + {remaining_amount_display}/{amount_display} {account_currency} + +
+ {payment_method_names?.map(paymentMethod => ( +
+ + {paymentMethod} + +
+ ))} +
+
+ {isActionsVisible ? ( +
+ + + +
+ ) : ( + + )} +
+
+ ); +}; + +export default memo(MyAdsTableRow); diff --git a/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTableRow/index.ts b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTableRow/index.ts new file mode 100644 index 000000000000..35accfc01e97 --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/MyAdsTableRow/index.ts @@ -0,0 +1 @@ +export { default as MyAdsTableRow } from './MyAdsTableRow'; diff --git a/packages/p2p-v2/src/pages/my-ads/screens/MyAds/__tests__/MyAdsTableRow.spec.tsx b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/__tests__/MyAdsTableRow.spec.tsx new file mode 100644 index 000000000000..7f2f302488db --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/__tests__/MyAdsTableRow.spec.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useExchangeRateSubscription } from '@deriv/api'; +import MyAdsTableRow from '../MyAdsTableRow/MyAdsTableRow'; +import useDevice from '../../../../../hooks/useDevice'; + +const mockProps = { + account_currency: 'USD', + active_orders: 0, + advertiser_details: { + completed_orders_count: 0, + id: '34', + is_online: true, + last_online_time: 1688480346, + name: 'client CR90000212', + rating_average: null, + rating_count: 0, + recommended_average: null, + recommended_count: null, + total_completion_rate: null, + is_blocked: false, + is_favourite: false, + has_not_been_recommended: false, + is_recommended: false, + }, + amount: 22, + amount_display: '22.00', + block_trade: false, + contact_info: '', + counterparty_type: 'sell' as const, + country: 'id', + created_time: new Date(1688460999), + description: '', + effective_rate: 22, + effective_rate_display: '22.00', + id: '138', + is_active: true, + is_visible: true, + local_currency: 'IDR', + max_order_amount: 22, + max_order_amount_display: '22.00', + max_order_amount_limit: 22, + max_order_amount_limit_display: '22.00', + min_order_amount: 22, + min_order_amount_display: '22.00', + min_order_amount_limit: 22, + min_order_amount_limit_display: '22.00', + payment_info: '', + payment_method: null, + payment_method_names: ['Bank Transfer'], + price: 22, + price_display: '22.00', + rate: 22, + rate_display: '22.00', + rate_type: 'fixed' as const, + remaining_amount: 22, + remaining_amount_display: '22.00', + type: 'buy' as const, + onClickIcon: jest.fn(), + isBarred: false, + isListed: true, +}; + +jest.mock('@deriv/api', () => ({ + useExchangeRateSubscription: jest.fn(), +})); + +jest.mock('../../../../../hooks/useDevice', () => ({ + __esModule: true, + default: jest.fn(() => ({ + isMobile: false, + })), +})); + +const mockUseExchangeRate = useExchangeRateSubscription as jest.Mock; + +describe('MyAdsTableRow', () => { + beforeEach(() => { + mockUseExchangeRate.mockReturnValue({ + subscribe: jest.fn(), + unsubscribe: jest.fn(), + }); + }); + it('should render the component as expected', () => { + render(); + expect(screen.getByText('Buy 138')).toBeInTheDocument(); + expect(screen.getByText('22.00 - 22.00 USD')).toBeInTheDocument(); + expect(screen.getByText('22.00 IDR')).toBeInTheDocument(); + expect(screen.getByText('Bank Transfer')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + it('should render the mobile view as expected', () => { + (useDevice as jest.Mock).mockReturnValue({ isMobile: true }); + render(); + expect(screen.getByText('Ad ID 138')).toBeInTheDocument(); + expect(screen.getByText('Buy USD')).toBeInTheDocument(); + expect(screen.getByText('Rate (1 USD)')).toBeInTheDocument(); + expect(screen.getByText('Bank Transfer')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + it('should open the popover dropdown on clicking on menu in mobile view', async () => { + (useDevice as jest.Mock).mockReturnValue({ isMobile: true }); + render(); + const button = screen.getByTestId('dt_p2p_v2_actions_menu'); + userEvent.click(button); + await waitFor(() => { + expect(screen.getByText('Edit')).toBeInTheDocument(); + expect(screen.getByText('Deactivate')).toBeInTheDocument(); + expect(screen.getByText('Delete')).toBeInTheDocument(); + expect(screen.getByText('Duplicate')).toBeInTheDocument(); + }); + }); + //TODO: add test for onclick actions once the component is updated. +}); diff --git a/packages/p2p-v2/src/pages/my-ads/screens/MyAds/index.ts b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/index.ts new file mode 100644 index 000000000000..c8c92e262d2b --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/screens/MyAds/index.ts @@ -0,0 +1 @@ +export { default as MyAds } from './MyAds'; diff --git a/packages/p2p-v2/src/pages/my-ads/screens/MyAdsEmpty/MyAdsEmpty.tsx b/packages/p2p-v2/src/pages/my-ads/screens/MyAdsEmpty/MyAdsEmpty.tsx new file mode 100644 index 000000000000..51c4b1c0ddb6 --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/screens/MyAdsEmpty/MyAdsEmpty.tsx @@ -0,0 +1,6 @@ +import React from 'react'; + +//TODO: replace with empty line and icon +const MyAdsEmpty = () =>
empty ads
; + +export default MyAdsEmpty; diff --git a/packages/p2p-v2/src/pages/my-ads/screens/MyAdsEmpty/index.ts b/packages/p2p-v2/src/pages/my-ads/screens/MyAdsEmpty/index.ts new file mode 100644 index 000000000000..cb8fa86797ea --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/screens/MyAdsEmpty/index.ts @@ -0,0 +1 @@ +export { default as MyAdsEmpty } from './MyAdsEmpty'; diff --git a/packages/p2p-v2/src/pages/my-ads/screens/index.ts b/packages/p2p-v2/src/pages/my-ads/screens/index.ts new file mode 100644 index 000000000000..65873ee5389c --- /dev/null +++ b/packages/p2p-v2/src/pages/my-ads/screens/index.ts @@ -0,0 +1 @@ +export * from './MyAds'; diff --git a/packages/p2p-v2/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.scss b/packages/p2p-v2/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.scss index 2eac1eb19d38..6cb15d41bb67 100644 --- a/packages/p2p-v2/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.scss +++ b/packages/p2p-v2/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.scss @@ -9,9 +9,6 @@ &:last-child { position: relative; } - &-loader { - margin-top: 3rem; - } &:not(:last-child) { border-bottom: 1px solid var(--general-section-1); } diff --git a/packages/p2p-v2/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.tsx b/packages/p2p-v2/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.tsx index 249523d51827..b16609950cae 100644 --- a/packages/p2p-v2/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.tsx +++ b/packages/p2p-v2/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.tsx @@ -47,7 +47,7 @@ const MyProfileCounterpartiesTable = ({ if (data.length > 0) { setShowHeader(true); } - }, [data]); + }, [data, setShowHeader]); if (isLoading) { return ; @@ -63,7 +63,6 @@ const MyProfileCounterpartiesTable = ({ data={data} isFetching={isFetching} loadMoreFunction={loadMoreAdvertisers} - rowClassname='p2p-v2-my-profile-counterparties-table__row' rowRender={(rowData: unknown) => ( \ No newline at end of file diff --git a/packages/p2p-v2/src/public/ic-delete.svg b/packages/p2p-v2/src/public/ic-delete.svg new file mode 100644 index 000000000000..4f98deaabbac --- /dev/null +++ b/packages/p2p-v2/src/public/ic-delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/p2p-v2/src/public/ic-edit.svg b/packages/p2p-v2/src/public/ic-edit.svg new file mode 100644 index 000000000000..280c57a881d8 --- /dev/null +++ b/packages/p2p-v2/src/public/ic-edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/p2p-v2/src/public/ic-more.svg b/packages/p2p-v2/src/public/ic-more.svg new file mode 100644 index 000000000000..ea499ab0afaf --- /dev/null +++ b/packages/p2p-v2/src/public/ic-more.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/p2p-v2/src/public/ic-share.svg b/packages/p2p-v2/src/public/ic-share.svg new file mode 100644 index 000000000000..8c19276ccddc --- /dev/null +++ b/packages/p2p-v2/src/public/ic-share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/p2p-v2/src/public/ic-unarchive.svg b/packages/p2p-v2/src/public/ic-unarchive.svg new file mode 100644 index 000000000000..105fcd1fe7d7 --- /dev/null +++ b/packages/p2p-v2/src/public/ic-unarchive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/p2p-v2/src/routes/AppContent/index.tsx b/packages/p2p-v2/src/routes/AppContent/index.tsx index 757184e59ee9..2c5d48141c69 100644 --- a/packages/p2p-v2/src/routes/AppContent/index.tsx +++ b/packages/p2p-v2/src/routes/AppContent/index.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { useHistory } from 'react-router-dom'; import { useEventListener } from 'usehooks-ts'; import { CloseHeader } from '@/components'; -import { MyProfile } from '@/pages'; +import { MyAds, MyProfile } from '@/pages'; import { useActiveAccount } from '@deriv/api'; import { Loader, Tab, Tabs } from '@deriv-com/ui'; import './index.scss'; @@ -10,9 +10,13 @@ import './index.scss'; const DEFAULT_TAB = 'buy-sell'; export const routesConfiguration = [ - { Component:
Buy Sell Page
, path: 'buy-sell', title: 'Buy / Sell' }, + { Component:
Buy sell Page
, path: 'buy-sell', title: 'Buy / Sell' }, { Component:
Orders Page
, path: 'orders', title: 'Orders' }, - { Component:
My Ads Page
, path: 'my-ads', title: 'My Ads' }, + { + Component: , + path: 'my-ads', + title: 'My Ads', + }, { Component: , path: 'my-profile', title: 'My Profile' }, ]; diff --git a/packages/p2p-v2/src/utils/__tests__/currency.spec.ts b/packages/p2p-v2/src/utils/__tests__/currency.spec.ts new file mode 100644 index 000000000000..bb45ac42528b --- /dev/null +++ b/packages/p2p-v2/src/utils/__tests__/currency.spec.ts @@ -0,0 +1,110 @@ +import * as CurrencyUtils from '../currency'; +import { TCurrenciesConfig } from '../currency'; + +describe('CurrencyUtils', () => { + const website_status: { currenciesConfig: TCurrenciesConfig } = { + currenciesConfig: { + AUD: { fractionalDigits: 2, type: 'fiat' }, + EUR: { fractionalDigits: 2, type: 'fiat' }, + GBP: { fractionalDigits: 2, type: 'fiat' }, + USD: { fractionalDigits: 2, type: 'fiat', transferBetweenAccounts: { limits: { max: 2500, min: 1.0 } } }, + BTC: { fractionalDigits: 8, type: 'crypto' }, + }, + }; + beforeEach(() => { + CurrencyUtils.setCurrencies(website_status); + }); + + describe('.formatMoney()', () => { + it('works as expected', () => { + expect(CurrencyUtils.formatMoney('USD', '123.55')).toBe(`${CurrencyUtils.formatCurrency('USD')}123.55`); + expect(CurrencyUtils.formatMoney('GBP', '123.55')).toBe(`${CurrencyUtils.formatCurrency('GBP')}123.55`); + expect(CurrencyUtils.formatMoney('EUR', '123.55')).toBe(`${CurrencyUtils.formatCurrency('EUR')}123.55`); + expect(CurrencyUtils.formatMoney('AUD', '123.55')).toBe(`${CurrencyUtils.formatCurrency('AUD')}123.55`); + expect(CurrencyUtils.formatMoney('BTC', '0.005432110')).toBe( + `${CurrencyUtils.formatCurrency('BTC')}0.00543211` + ); + expect(CurrencyUtils.formatMoney('BTC', '0.005432116')).toBe( + `${CurrencyUtils.formatCurrency('BTC')}0.00543212` + ); + expect(CurrencyUtils.formatMoney('BTC', '0.00000001')).toBe( + `${CurrencyUtils.formatCurrency('BTC')}0.00000001` + ); + // don't remove trailing zeroes for now + expect(CurrencyUtils.formatMoney('BTC', '0.00010000')).toBe( + `${CurrencyUtils.formatCurrency('BTC')}0.00010000` + ); + }); + + it('works with negative values', () => { + expect(CurrencyUtils.formatMoney('USD', '-123.55')).toBe(`-${CurrencyUtils.formatCurrency('USD')}123.55`); + }); + + it('works when exclude currency', () => { + expect(CurrencyUtils.formatMoney('USD', '123.55', true)).toBe('123.55'); + }); + }); + + describe('.formatCurrency()', () => { + it('works as expected', () => { + expect(CurrencyUtils.formatCurrency('USD')).toBe(''); + }); + }); + + describe('.addComma()', () => { + it('works as expected', () => { + expect(CurrencyUtils.addComma('123')).toBe('123'); + expect(CurrencyUtils.addComma('1234567')).toBe('1,234,567'); + }); + + it('works with decimal places', () => { + expect(CurrencyUtils.addComma('1234.5678')).toBe('1,234.5678'); + expect(CurrencyUtils.addComma('1234.5678', 2)).toBe('1,234.57'); + expect(CurrencyUtils.addComma('1234', 2)).toBe('1,234.00'); + expect(CurrencyUtils.addComma('1234.45', 0)).toBe('1,234'); + expect(CurrencyUtils.addComma('1234.56', 0)).toBe('1,235'); + }); + + it('works with negative numbers', () => { + expect(CurrencyUtils.addComma('-1234')).toBe('-1,234'); + }); + + it('handles null values', () => { + expect(CurrencyUtils.addComma()).toBe('0'); + expect(CurrencyUtils.addComma(null)).toBe('0'); + expect(CurrencyUtils.addComma('')).toBe('0'); + }); + }); + + describe('.getDecimalPlaces()', () => { + it('works as expected', () => { + expect(CurrencyUtils.getDecimalPlaces('AUD')).toBe(2); + expect(CurrencyUtils.getDecimalPlaces('EUR')).toBe(2); + expect(CurrencyUtils.getDecimalPlaces('GBP')).toBe(2); + expect(CurrencyUtils.getDecimalPlaces('USD')).toBe(2); + expect(CurrencyUtils.getDecimalPlaces('BTC')).toBe(8); + }); + + it('works with dummy currencies', () => { + expect(CurrencyUtils.getDecimalPlaces('ZZZ')).toBe(2); + }); + + it('works with undefined', () => { + expect(CurrencyUtils.getDecimalPlaces()).toBe(2); + }); + }); + + describe('.isCryptocurrency()', () => { + it('works as expected', () => { + expect(CurrencyUtils.isCryptocurrency('AUD')).toBe(false); + expect(CurrencyUtils.isCryptocurrency('EUR')).toBe(false); + expect(CurrencyUtils.isCryptocurrency('GBP')).toBe(false); + expect(CurrencyUtils.isCryptocurrency('USD')).toBe(false); + expect(CurrencyUtils.isCryptocurrency('BTC')).toBe(true); + }); + + it('works with undefined currencies', () => { + expect(CurrencyUtils.isCryptocurrency('ZZZ')).toBe(false); + }); + }); +}); diff --git a/packages/p2p-v2/src/utils/__tests__/object.spec.ts b/packages/p2p-v2/src/utils/__tests__/object.spec.ts new file mode 100644 index 000000000000..47118a91954a --- /dev/null +++ b/packages/p2p-v2/src/utils/__tests__/object.spec.ts @@ -0,0 +1,165 @@ +import * as Utility from '../object'; + +describe('Utility', () => { + describe('.isEmptyObject()', () => { + it('returns true for empty objects or non-objects', () => { + [{}, 1, undefined, null, false, true, ''].forEach(value => { + expect(Utility.isEmptyObject(value)).toBe(true); + }); + }); + + it('returns false for not empty objects', () => { + expect(Utility.isEmptyObject({ not_empty: true })).toBe(false); + }); + }); + + describe('.getPropertyValue()', () => { + const obj = { + str: 'abc', + num: 123, + empty: '', + nul: null, + undef: undefined, + promise: new Promise(resolve => { + resolve('aa'); + }), + array: ['a', 'b'], + nested: { + level_2: { + level_3: 'some text', + }, + }, + }; + + it('returns correct values with correct type', () => { + expect(typeof Utility.getPropertyValue(obj, 'str')).toBe('string'); + expect(Utility.getPropertyValue(obj, 'str')).toBe('abc'); + expect(typeof Utility.getPropertyValue(obj, 'num')).toBe('number'); + expect(Utility.getPropertyValue(obj, 'num')).toBe(123); + expect(typeof Utility.getPropertyValue(obj, 'empty')).toBe('string'); + expect(Utility.getPropertyValue(obj, 'empty')).toBe(''); + expect(Utility.getPropertyValue(obj, 'nul')).toBeNull(); + expect(Utility.getPropertyValue(obj, 'undef')).toBeUndefined(); + expect(Utility.getPropertyValue(obj, 'promise')).toBeInstanceOf(Promise); + }); + + it('handles arrays correctly', () => { + expect(Array.isArray(Utility.getPropertyValue(obj, 'array'))).toBe(true); + }); + + it('handles nested objects correctly', () => { + expect(Utility.getPropertyValue(obj, 'nested')).toBeInstanceOf(Object); + expect(typeof Utility.getPropertyValue(obj, ['nested', 'level_2', 'level_3'])).toBe('string'); + expect(Utility.getPropertyValue(obj, ['nested', 'level_2', 'level_3'])).toEqual(obj.nested.level_2.level_3); + }); + + it('returns cloned array to prevent unwanted changes to the source', () => { + const cloned_array = Utility.getPropertyValue(obj, 'array'); + cloned_array[0] = 'AA'; + expect(Utility.getPropertyValue(obj, 'array')[0]).toBe('a'); + expect(cloned_array[0]).toBe('AA'); + }); + + it('returns deeply cloned object to prevent unwanted changes to the source', () => { + let cloned_obj = Utility.getPropertyValue(obj, 'nested'); + cloned_obj.level_2 = { new_prop: 'new value' }; + expect(Utility.getPropertyValue(obj, 'nested')).toEqual({ level_2: { level_3: 'some text' } }); + expect(cloned_obj).toEqual({ level_2: { new_prop: 'new value' } }); + + cloned_obj = Utility.getPropertyValue(obj, ['nested', 'level_2']); + cloned_obj.level_3 = 'new text'; + expect(Utility.getPropertyValue(obj, ['nested', 'level_2', 'level_3'])).toBe('some text'); + expect(cloned_obj.level_3).toBe('new text'); + }); + }); + + describe('.isDeepEqual()', () => { + describe('simple data types', () => { + it('null', () => { + expect(Utility.isDeepEqual(null, null)).toBe(true); + }); + it('undefined', () => { + expect(Utility.isDeepEqual(undefined, undefined)).toBe(true); + }); + it('string', () => { + expect(Utility.isDeepEqual('', '')).toBe(true); + expect(Utility.isDeepEqual('abc', 'abc')).toBe(true); + expect(Utility.isDeepEqual('abc', 'aaa')).toBe(false); + }); + it('number', () => { + expect(Utility.isDeepEqual(0, 0)).toBe(true); + expect(Utility.isDeepEqual(2.0, 2.0)).toBe(true); + expect(Utility.isDeepEqual(-1, -1)).toBe(true); + }); + it('boolean', () => { + expect(Utility.isDeepEqual(true, true)).toBe(true); + expect(Utility.isDeepEqual(false, false)).toBe(true); + expect(Utility.isDeepEqual(true, false)).toBe(false); + }); + it('special cases', () => { + expect(Utility.isDeepEqual(0, '0')).toBe(false); + expect(Utility.isDeepEqual(0, false)).toBe(false); + expect(Utility.isDeepEqual(0, null)).toBe(false); + expect(Utility.isDeepEqual(0, undefined)).toBe(false); + expect(Utility.isDeepEqual(1, true)).toBe(false); + expect(Utility.isDeepEqual(1, '1')).toBe(false); + }); + }); + + describe('arrays and objects', () => { + it('works with arrays', () => { + expect(Utility.isDeepEqual([], [])).toBe(true); + expect(Utility.isDeepEqual(0, [0])).toBe(false); + expect(Utility.isDeepEqual([1, 'b', null], [1, 'b', null])).toBe(true); // same array + expect(Utility.isDeepEqual([1, 2, 3], [1, 3, 2])).toBe(false); // different order + expect(Utility.isDeepEqual([1, 2, 3], [1, 2, 4])).toBe(false); // different value + expect(Utility.isDeepEqual([1, 2, 3], [1, 2])).toBe(false); // different length 1st + expect(Utility.isDeepEqual([1, 2], [1, 2, 3])).toBe(false); // different length 2nd + expect(Utility.isDeepEqual([1, [2, 3]], [1, [2, 3]])).toBe(true); // same multi-dimensional + expect(Utility.isDeepEqual([1, [2, 3]], [[1, 2], 3])).toBe(false); // different multi-dimensional but same when flatten + expect( + Utility.isDeepEqual( + [ + [1, 2, ['a', 'b']], + [3, 4, ['c', 'd']], + ], + [ + [1, 2, ['a', 'b']], + [3, 4, ['c', 'd']], + ] + ) + ).toBe(true); + }); + + it('works with objects', () => { + expect(Utility.isDeepEqual({}, {})).toBe(true); + expect(Utility.isDeepEqual([], {})).toBe(false); // same typeof + expect(Utility.isDeepEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true); // same but different order + expect(Utility.isDeepEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 })).toBe(false); // different length 1st + expect(Utility.isDeepEqual({ a: 1, b: 2 }, { a: 1, b: 2, c: 3 })).toBe(false); // different length 2nd + expect(Utility.isDeepEqual({ a: 1, b: { c: 3 } }, { a: 1, b: { c: 3 } })).toBe(true); // same nested + expect(Utility.isDeepEqual({ a: 1, b: { c: 3 } }, { a: 1, b: { c: 4 } })).toBe(false); // different nested + }); + }); + + describe('complex values', () => { + it('works as expected', () => { + expect( + Utility.isDeepEqual( + { a: 1, b: { c: ['c', 'cc'], d: true, e: null } }, + { a: 1, b: { c: ['c', 'cc'], d: true, e: null } } + ) + ).toBe(true); + expect( + Utility.isDeepEqual({ a: 1, b: { c: ['c', { cc: 33 }] } }, { a: 1, b: { c: ['c', { cc: 33 }] } }) + ).toBe(true); + expect( + Utility.isDeepEqual( + ['a', '1', [{ c: ['c', { cc: 33 }] }], { d: 4 }], + ['a', '1', [{ c: ['c', { cc: 33 }] }], { d: 4 }] + ) + ).toBe(true); + }); + }); + }); +}); diff --git a/packages/p2p-v2/src/utils/currency.ts b/packages/p2p-v2/src/utils/currency.ts index 90d8957df572..08a33a7e398a 100644 --- a/packages/p2p-v2/src/utils/currency.ts +++ b/packages/p2p-v2/src/utils/currency.ts @@ -1,3 +1,5 @@ +import { deepFreeze, getPropertyValue } from './object'; + /** * Converts a number into a string of US-supported currency format. For example: * 10000 => 10,000.00 @@ -11,3 +13,343 @@ export const numberToCurrencyText = (value: number) => minimumFractionDigits: 2, style: 'decimal', }).format(value); + +export type TCurrenciesConfig = { + [key: string]: { + fractionalDigits: number; + isDepositSuspended?: 0 | 1; + isSuspended?: 0 | 1; + isWithdrawalSuspended?: 0 | 1; + name?: string; + stakeDefault?: number; + transferBetweenAccounts?: { + fees?: { [key: string]: number }; + limits: { + [key: string]: unknown; + max?: number; + min: number; + } | null; + limitsDxtrade?: { [key: string]: unknown }; + limitsMt5?: { [key: string]: unknown }; + }; + type: string; + }; +}; + +let currenciesConfig: TCurrenciesConfig = {}; + +export const AMOUNT_MAX_LENGTH = 10; + +export const CURRENCY_TYPE = { + CRYPTO: 'crypto', + FIAT: 'fiat', +} as const; + +/** + * Formats a monetary value based on the given currency and formatting options. + * + * @param {string} currencyValue - The currency symbol or code. + * @param {number|string} amount - The monetary value to be formatted. + * @param {boolean} [excludeCurrency=false] - Whether to exclude the currency symbol from the formatted result. + * @param {number} [decimals=0] - The number of decimal places for the formatted result. + * @param {number} [minimumFractionDigits=0] - The minimum number of decimal places for the formatted result. + * + * @returns {string} - The formatted monetary value as a string. + * + * @example + * ``` + * const formattedAmount = formatMoney('USD', 1234.567, false, 2, 0); + * console.log(formattedAmount); // "$1,234.57" + * ``` + */ + +export const formatMoney = ( + currencyValue: string, + amount: number | string, + excludeCurrency?: boolean, + decimals = 0, + minimumFractionDigits = 0 +) => { + let money: number | string = amount; + if (money) money = String(money).replace(/,/g, ''); + const sign = money && Number(money) < 0 ? '-' : ''; + const decimal_places = decimals || getDecimalPlaces(currencyValue); + + money = isNaN(+money) ? 0 : Math.abs(+money); + if (typeof Intl !== 'undefined') { + const options = { + minimumFractionDigits: minimumFractionDigits || decimal_places, + maximumFractionDigits: decimal_places, + }; + // TODO: [use-shared-i18n] - Use a getLanguage function to determine number format. + money = new Intl.NumberFormat('en', options).format(money); + } else { + money = addComma(money, decimal_places); + } + + return sign + (excludeCurrency ? '' : formatCurrency(currencyValue)) + money; +}; + +/** + * Formats the given currency into a string that includes a span element with a class name. + * The class name is a combination of "symbols" and the lowercased currency string. + * + * @param {string} currency - The currency string to be formatted. + * @returns {string} The formatted string that includes a span element with a class name. + */ +export const formatCurrency = (currency: string) => { + return ``; +}; + +/** + * Adds commas to a numeric value for better readability. + * + * @param {number|string|null} [num=0] - The numeric value to add commas to. + * @param {number} [decimalPoints] - The number of decimal points to include. If provided, the value will be formatted with fixed decimal points. + * @param {boolean} [isCrypto] - Specifies if the number represents a cryptocurrency value. + * + * @returns {string} - The formatted numeric value with commas. + * + * @example + * ``` + * const formattedNumber = addComma(1234567.89, 2, false); + * console.log(formattedNumber); // "1,234,567.89" + * ``` + */ +export const addComma = (num?: number | string | null, decimalPoints?: number, isCrypto?: boolean) => { + let number: number | string = String(num || 0).replace(/,/g, ''); + if (typeof decimalPoints !== 'undefined') { + number = (+number).toFixed(decimalPoints); + } + if (isCrypto) { + number = parseFloat(String(number)); + } + + return number + .toString() + .replace(/(^|[^\w.])(\d{4,})/g, ($0, $1, $2) => $1 + $2.replace(/\d(?=(?:\d\d\d)+(?!\d))/g, '$&,')); +}; + +/** + * Calculates the number of decimal places for the given currency. + * + * @param {string} currency - The currency symbol or code. + * @returns {number} - The number of decimal places for the currency. + * + * @example + * ``` + * const decimalPlaces = calcDecimalPlaces('BTC'); + * console.log(decimalPlaces); // 8 + * ``` + */ +export const calcDecimalPlaces = (currency: string) => { + return isCryptocurrency(currency) ? getPropertyValue(CryptoConfig.get(), [currency, 'fractionalDigits']) : 2; +}; + +/** + * Gets the number of decimal places for the given currency. + * + * @param {string} [currency=''] - The currency symbol or code. + * @returns {number} - The number of decimal places for the currency. + * + * @example + * ``` + * const decimalPlaces = getDecimalPlaces('EUR'); + * console.log(decimalPlaces); // 2 + * ``` + */ +export const getDecimalPlaces = (currency = '') => + // need to check currenciesConfig[currency] exists instead of || in case of 0 value + currenciesConfig[currency] + ? getPropertyValue(currenciesConfig, [currency, 'fractionalDigits']) + : calcDecimalPlaces(currency); + +/** + * Sets the currencies configuration for the website. + * + * @param {Object} website_status - The website status object containing currencies configuration. + * @param {TCurrenciesConfig} website_status.currenciesConfig - The currencies configuration object. + * + * @returns {void} + * + * @example + * ``` + * const websiteStatus = { + * currenciesConfig: { + * EUR: { fractionalDigits: 2 }, + * USD: { fractionalDigits: 2 }, + * GBP: { fractionalDigits: 2 }, + * BTC: { fractionalDigits: 8 }, + * }, + * }; + * setCurrencies(websiteStatus); + * ``` + */ +export const setCurrencies = (website_status: { currenciesConfig: TCurrenciesConfig }) => { + currenciesConfig = website_status.currenciesConfig; +}; + +// (currency in crypto_config) is a back-up in case website_status doesn't include the currency config, in some cases where it's disabled +export const isCryptocurrency = (currency: string) => { + return /crypto/i.test(getPropertyValue(currenciesConfig, [currency, 'type'])) || currency in CryptoConfig.get(); +}; + +export const CryptoConfig = (() => { + let crypto_config: any; + + // TODO: [use-shared-i18n] - Use translate function shared among apps or pass in translated names externally. + const initCryptoConfig = () => + deepFreeze({ + BTC: { + displayCode: 'BTC', + name: 'Bitcoin', + minWithdrawal: 0.002, + paMaxWithdrawal: 5, + paMinWithdrawal: 0.002, + fractionalDigits: 8, + }, + BUSD: { + displayCode: 'BUSD', + name: 'Binance USD', + minWithdrawal: 0.002, + paMaxWithdrawal: 5, + paMinWithdrawal: 0.002, + fractionalDigits: 2, + }, + DAI: { + displayCode: 'DAI', + name: 'Multi-Collateral DAI', + minWithdrawal: 0.002, + paMaxWithdrawal: 5, + paMinWithdrawal: 0.002, + fractionalDigits: 2, + }, + EURS: { + displayCode: 'EURS', + name: 'STATIS Euro', + minWithdrawal: 0.002, + paMaxWithdrawal: 5, + paMinWithdrawal: 0.002, + fractionalDigits: 2, + }, + IDK: { + displayCode: 'IDK', + name: 'IDK', + minWithdrawal: 0.002, + paMaxWithdrawal: 5, + paMinWithdrawal: 0.002, + fractionalDigits: 0, + }, + PAX: { + displayCode: 'PAX', + name: 'Paxos Standard', + minWithdrawal: 0.002, + paMaxWithdrawal: 5, + paMinWithdrawal: 0.002, + fractionalDigits: 2, + }, + TUSD: { + displayCode: 'TUSD', + name: 'True USD', + minWithdrawal: 0.002, + paMaxWithdrawal: 5, + paMinWithdrawal: 0.002, + fractionalDigits: 2, + }, + USDC: { + displayCode: 'USDC', + name: 'USD Coin', + minWithdrawal: 0.002, + paMaxWithdrawal: 5, + paMinWithdrawal: 0.002, + fractionalDigits: 2, + }, + USDK: { + displayCode: 'USDK', + name: 'USDK', + minWithdrawal: 0.002, + paMaxWithdrawal: 5, + paMinWithdrawal: 0.002, + fractionalDigits: 2, + }, + eUSDT: { + displayCode: 'eUSDT', + name: 'Tether ERC20', + minWithdrawal: 0.002, + paMaxWithdrawal: 5, + paMinWithdrawal: 0.002, + fractionalDigits: 2, + }, + tUSDT: { + displayCode: 'tUSDT', + name: 'Tether TRC20', + minWithdrawal: 0.002, + paMaxWithdrawal: 5, + paMinWithdrawal: 0.002, + fractionalDigits: 2, + }, + BCH: { + displayCode: 'BCH', + name: 'Bitcoin Cash', + minWithdrawal: 0.002, + paMaxWithdrawal: 5, + paMinWithdrawal: 0.002, + fractionalDigits: 8, + }, + ETH: { + displayCode: 'ETH', + name: 'Ether', + minWithdrawal: 0.002, + paMaxWithdrawal: 5, + paMinWithdrawal: 0.002, + fractionalDigits: 8, + }, + ETC: { + displayCode: 'ETC', + name: 'Ether Classic', + minWithdrawal: 0.002, + paMaxWithdrawal: 5, + paMinWithdrawal: 0.002, + fractionalDigits: 8, + }, + LTC: { + displayCode: 'LTC', + name: 'Litecoin', + minWithdrawal: 0.002, + paMaxWithdrawal: 5, + paMinWithdrawal: 0.002, + fractionalDigits: 8, + }, + UST: { + displayCode: 'USDT', + name: 'Tether Omni', + minWithdrawal: 0.02, + paMaxWithdrawal: 2000, + paMinWithdrawal: 10, + fractionalDigits: 2, + }, + // USB: { + // displayCode: 'USB', + // name: 'Binary Coin', + // minWithdrawal: 0.02, + // paMaxWithdrawal: 2000, + // paMinWithdrawal: 10, + // fractionalDigits: 2, + // }, + }); + + return { + get: () => { + if (!crypto_config) { + crypto_config = initCryptoConfig(); + } + return crypto_config; + }, + }; +})(); + +export type TAccount = { + account_type: 'demo' | 'real'; + balance: number; + currency: string; +}; diff --git a/packages/p2p-v2/src/utils/format-value.ts b/packages/p2p-v2/src/utils/format-value.ts new file mode 100644 index 000000000000..cd1553993c54 --- /dev/null +++ b/packages/p2p-v2/src/utils/format-value.ts @@ -0,0 +1,116 @@ +import { RATE_TYPE } from '@/constants'; +import { formatMoney } from './currency'; + +/** + * Rounds off the number to the specified decimal place. + * @param {Number} number - The number to round off + * @param {Number} decimalPlace - The decimal place to round off to (default: 2) + * @returns {String} The rounded off number + */ +export const roundOffDecimal = (number: number, decimalPlace = 2): string => number.toFixed(decimalPlace); + +/** + * Sets the decimal places of the number to the specified decimal place. + * @param {Number} value - The number to set the decimal places. + * @param {Number} expectedDecimalPlace - The decimal place to set the number to. + * @returns {Number} The number with the decimal places set. + */ +export const setDecimalPlaces = (value: number, expectedDecimalPlace: number): number => { + const actualDecimalPlace = value.toString().split('.')[1]?.length; + return actualDecimalPlace > expectedDecimalPlace ? expectedDecimalPlace : actualDecimalPlace; +}; + +/** + * Calculates the percent of the number. + * @param {String} number - The number to calculate the percent of. + * @param {String} percent - The percent to calculate. + * @returns {Number} The percent of the number. + */ +export const percentOf = (number: number, percent: number): number => number + number * (percent / 100); + +type TGenerateEffectiveRate = { + exchangeRate: number; + localCurrency: string; + marketRate: number; + price: number; + rate: number; + rateType: string; +}; + +type TReturnGenerateEffectiveRate = { + displayEffectiveRate: string; + effectiveRate: number; +}; + +/** + * Calculates the effective rate. + * @param {Object} params - The parameters to calculate the effective rate. + * @param {Number} params.price - The price of the ad. + * @param {Number} params.rate - The rate of the ad. + * @param {String} params.localCurrency - The local currency of the ad. + * @param {Number} params.exchangeRate - The exchange rate of the ad. + * @param {Number} params.marketRate - The market rate of the ad. + * @param {String} params.rateType - The rate type of the ad. + * @returns {Object} The effective rate and the display effective rate. + */ +export const generateEffectiveRate = ({ + exchangeRate = 0, + localCurrency = '', + marketRate = 0, + price = 0, + rate = 0, + rateType = RATE_TYPE.FIXED, +}: Partial): TReturnGenerateEffectiveRate => { + let effectiveRate, displayEffectiveRate; + + if (rateType === RATE_TYPE.FIXED) { + effectiveRate = price; + displayEffectiveRate = formatMoney(localCurrency, effectiveRate, true); + } else { + effectiveRate = exchangeRate > 0 ? percentOf(exchangeRate, rate) : marketRate; + const decimalPlace = setDecimalPlaces(effectiveRate, 6); + displayEffectiveRate = removeTrailingZeros( + formatMoney(localCurrency, roundOffDecimal(effectiveRate, decimalPlace), true, decimalPlace) + ); + } + return { effectiveRate, displayEffectiveRate }; +}; + +/** + * Removes the trailing zeros from the number. + * @param {String} value - The number to remove the trailing zeros. + * @returns {String} The number without the trailing zeros. + */ +export const removeTrailingZeros = (value: string): string => { + const [input, unit] = value.trim().split(' '); + + if (input.indexOf('.') === -1) return formatInput(input, unit); + + let trimFrom = input.length - 1; + + do { + if (input[trimFrom] === '0') trimFrom--; + } while (input[trimFrom] === '0'); + + if (input[trimFrom] === '.') trimFrom--; + + const result = value.toString().substring(0, trimFrom + 1); + + return formatInput(result, unit); +}; + +/** + * Formats the input to the specified format. + * @param {String} input - The input to format. + * @param {String} unit - The unit to append to the input. + * @returns {String} The formatted input. + */ +export const formatInput = (input: string, unit: string): string => { + const plainInput = input.replace(/,/g, ''); + + if (parseFloat(plainInput) % 1 === 0) return `${input}.00 ${unit ? unit.trim() : ''}`; + + if (plainInput.split('.')[1].length === 1) return `${input}0 ${unit ? unit.trim() : ''}`; + + return `${input}${unit ? ` ${unit.trim()}` : ''}`; +}; diff --git a/packages/p2p-v2/src/utils/object.ts b/packages/p2p-v2/src/utils/object.ts new file mode 100644 index 000000000000..414ad6393b6f --- /dev/null +++ b/packages/p2p-v2/src/utils/object.ts @@ -0,0 +1,58 @@ +/* eslint-disable */ +const extend = require('extend'); + +export const isEmptyObject = (obj: any) => { + let is_empty = true; + if (obj && obj instanceof Object) { + Object.keys(obj).forEach(key => { + if (Object.prototype.hasOwnProperty.call(obj, key)) is_empty = false; + }); + } + return is_empty; +}; + +export const cloneObject = (obj: any) => (!isEmptyObject(obj) ? extend(true, Array.isArray(obj) ? [] : {}, obj) : obj); + +// Note that this function breaks on objects with circular references. +export const isDeepEqual = (a: any, b: any) => { + if (typeof a !== typeof b) { + return false; + } else if (Array.isArray(a)) { + return isEqualArray(a, b); + } else if (a && b && typeof a === 'object') { + return isEqualObject(a, b); + } else if (typeof a === 'number' && typeof b === 'number' && isNaN(a) && isNaN(b)) { + return true; + } + // else + return a === b; +}; + +export const isEqualArray = (arr1: any[], arr2: any[]): boolean => + arr1 === arr2 || (arr1.length === arr2.length && arr1.every((value, idx) => isDeepEqual(value, arr2[idx]))); + +export const isEqualObject = (obj1: any, obj2: any): boolean => + obj1 === obj2 || + (Object.keys(obj1).length === Object.keys(obj2).length && + Object.keys(obj1).every(key => isDeepEqual(obj1[key], obj2[key]))); + +export const getPropertyValue = (obj: any, k: string | string[]): any => { + let keys = k; + if (!Array.isArray(keys)) keys = [keys]; + if (!isEmptyObject(obj) && keys[0] in obj && keys && keys.length > 1) { + return getPropertyValue(obj[keys[0]], keys.slice(1)); + } + // else return clone of object to avoid overwriting data + return obj ? cloneObject(obj[keys[0]]) : undefined; +}; + +// Recursively freeze an object (deep freeze) +export const deepFreeze = (obj: any) => { + Object.getOwnPropertyNames(obj).forEach(key => { + const value = obj[key]; + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + deepFreeze(value); + } + }); + return Object.freeze(obj); +}; diff --git a/types/utils.d.ts b/types/utils.d.ts index 4362e2eba296..6849cf3aa5a5 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -54,6 +54,10 @@ declare global { type RequireAtLeastOne = { [K in keyof T]-?: Required> & Partial>>; }[keyof T]; + + type WithRequiredProperty = T & { + [K in Key]-?: T[K]; + }; } export {};