diff --git a/package-lock.json b/package-lock.json index 269e29bf..f72381c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,10 +17,10 @@ "@sendbird/chat": "^4.11.3", "@tanstack/react-query": "^5.28.14", "@tanstack/react-table": "^8.15.0", - "@types/react-router": "^5.1.20", "clsx": "^2.1.0", "downshift": "^9.0.0", "html2canvas": "^1.4.1", + "lodash": "^4.17.21", "moment": "^2.30.1", "qrcode.react": "^3.1.0", "react": "^18.2.0", @@ -56,8 +56,11 @@ "@testing-library/react": "^14.2.1", "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", + "@types/lodash": "^4.17.0", "@types/react": "^18.2.56", "@types/react-dom": "^18.2.19", + "@types/react-router-dom": "^5.3.3", + "@types/react-transition-group": "^4.4.10", "@typescript-eslint/eslint-plugin": "^6.12.0", "@typescript-eslint/parser": "^6.0.0", "@vitejs/plugin-react": "^4.2.1", @@ -4183,7 +4186,8 @@ "node_modules/@types/history": { "version": "4.7.11", "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", - "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==" + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -4274,6 +4278,12 @@ "dev": true, "peer": true }, + "node_modules/@types/lodash": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", + "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==", + "dev": true + }, "node_modules/@types/mdast": { "version": "3.0.15", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", @@ -4348,8 +4358,29 @@ "version": "5.1.20", "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, "dependencies": { "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", + "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==", + "dev": true, + "dependencies": { "@types/react": "*" } }, @@ -13072,8 +13103,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.camelcase": { "version": "4.3.0", @@ -21502,7 +21532,8 @@ "@types/history": { "version": "4.7.11", "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", - "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==" + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true }, "@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -21586,6 +21617,12 @@ "dev": true, "peer": true }, + "@types/lodash": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", + "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==", + "dev": true + }, "@types/mdast": { "version": "3.0.15", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", @@ -21660,11 +21697,32 @@ "version": "5.1.20", "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, "requires": { "@types/history": "^4.7.11", "@types/react": "*" } }, + "@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "requires": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "@types/react-transition-group": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", + "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -28084,8 +28142,7 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash.camelcase": { "version": "4.3.0", diff --git a/package.json b/package.json index 0a538847..d69b74bd 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "clsx": "^2.1.0", "downshift": "^9.0.0", "html2canvas": "^1.4.1", + "lodash": "^4.17.21", "moment": "^2.30.1", "qrcode.react": "^3.1.0", "react": "^18.2.0", @@ -60,8 +61,11 @@ "@testing-library/react": "^14.2.1", "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", + "@types/lodash": "^4.17.0", "@types/react": "^18.2.56", "@types/react-dom": "^18.2.19", + "@types/react-router-dom": "^5.3.3", + "@types/react-transition-group": "^4.4.10", "@typescript-eslint/eslint-plugin": "^6.12.0", "@typescript-eslint/parser": "^6.0.0", "@vitejs/plugin-react": "^4.2.1", diff --git a/src/App.tsx b/src/App.tsx index 1f9fdeb4..db49e35e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,13 @@ +import { QueryParamProvider } from 'use-query-params'; +import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5'; + +import AppContent from './routes/AppContent'; + const App = () => { - return
Deriv P2P
; + return ( + + + + ); }; export default App; diff --git a/src/components/AdvertiserName/AdvertiserName.scss b/src/components/AdvertiserName/AdvertiserName.scss new file mode 100644 index 00000000..8356229c --- /dev/null +++ b/src/components/AdvertiserName/AdvertiserName.scss @@ -0,0 +1,16 @@ +.p2p-advertiser-name { + display: grid; + grid-gap: 1.6rem; + grid-template-columns: min-content auto max-content; + + @include mobile { + grid-template-columns: min-content auto; + } + + &__details { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.4rem; + } +} diff --git a/src/components/AdvertiserName/AdvertiserName.tsx b/src/components/AdvertiserName/AdvertiserName.tsx new file mode 100644 index 00000000..abac9a8b --- /dev/null +++ b/src/components/AdvertiserName/AdvertiserName.tsx @@ -0,0 +1,49 @@ +import { TAdvertiserStats } from 'types'; + +import { useSettings } from '@deriv/api-v2'; +import { LabelPairedEllipsisVerticalLgRegularIcon } from '@deriv/quill-icons'; +import { Text, useDevice } from '@deriv-com/ui'; + +import { UserAvatar } from '@/components'; +import { getCurrentRoute } from '@/utils'; + +import AdvertiserNameBadges from './AdvertiserNameBadges'; +import AdvertiserNameStats from './AdvertiserNameStats'; +import AdvertiserNameToggle from './AdvertiserNameToggle'; + +import './AdvertiserName.scss'; + +const AdvertiserName = ({ advertiserStats }: { advertiserStats: DeepPartial }) => { + const { + data: { email }, + } = useSettings(); + const { isDesktop } = useDevice(); + const isMyProfile = getCurrentRoute() === 'my-profile'; + + const name = advertiserStats?.name || email; + + return ( +
+ +
+
+ + {name} + + {(advertiserStats?.should_show_name || !isMyProfile) && ( + + ({advertiserStats?.fullName}) + + )} +
+ + +
+ {isDesktop && isMyProfile && } + {isDesktop && !isMyProfile && } +
+ ); +}; +AdvertiserName.displayName = 'AdvertiserName'; + +export default AdvertiserName; diff --git a/src/components/AdvertiserName/AdvertiserNameBadges.scss b/src/components/AdvertiserName/AdvertiserNameBadges.scss new file mode 100644 index 00000000..f27263e8 --- /dev/null +++ b/src/components/AdvertiserName/AdvertiserNameBadges.scss @@ -0,0 +1,5 @@ +.p2p-advertiser-name-badges { + display: flex; + gap: 0.4rem; + padding: 0.4rem 0; +} diff --git a/src/components/AdvertiserName/AdvertiserNameBadges.tsx b/src/components/AdvertiserName/AdvertiserNameBadges.tsx new file mode 100644 index 00000000..65489753 --- /dev/null +++ b/src/components/AdvertiserName/AdvertiserNameBadges.tsx @@ -0,0 +1,34 @@ +import { TAdvertiserStats } from 'types'; + +import { Badge } from '@/components'; + +import './AdvertiserNameBadges.scss'; + +/** + * This component is used to show an advertiser's badge, for instance: + * +100 Trades, ID verified, Address not verified, etc + * + * Use cases are usually in My Profile page and Advertiser page used under the advertiser's name + */ +const AdvertiserNameBadges = ({ advertiserStats }: { advertiserStats: DeepPartial }) => { + const { isAddressVerified, isIdentityVerified, totalOrders } = advertiserStats || {}; + + return ( +
+ {(totalOrders || 0) >= 100 && } + + +
+ ); +}; +AdvertiserNameBadges.displayName = 'AdvertiserNameBadges'; + +export default AdvertiserNameBadges; diff --git a/src/components/AdvertiserName/AdvertiserNameStats.scss b/src/components/AdvertiserName/AdvertiserNameStats.scss new file mode 100644 index 00000000..c1111c0f --- /dev/null +++ b/src/components/AdvertiserName/AdvertiserNameStats.scss @@ -0,0 +1,35 @@ +.p2p-advertiser-name-stats { + display: flex; + align-items: center; + + @include mobile { + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 2rem; + } + + & div { + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0 0.8rem; + border-left: 1px solid #f2f3f4; + + @include mobile { + border-left: none; + padding: 0; + } + + &:first-child { + padding-left: 0; + padding-right: 0.8rem; + border-left: none; + } + } + + &__rating { + display: flex; + gap: 0.5rem; + } +} diff --git a/src/components/AdvertiserName/AdvertiserNameStats.tsx b/src/components/AdvertiserName/AdvertiserNameStats.tsx new file mode 100644 index 00000000..e626ded8 --- /dev/null +++ b/src/components/AdvertiserName/AdvertiserNameStats.tsx @@ -0,0 +1,99 @@ +import clsx from 'clsx'; +import { TAdvertiserStats } from 'types'; + +import { Text, useDevice } from '@deriv-com/ui'; + +import { OnlineStatusIcon, OnlineStatusLabel, StarRating } from '@/components'; +import { getCurrentRoute } from '@/utils'; + +import ThumbUpIcon from '../../public/ic-thumb-up.svg'; +import BlockedUserOutlineIcon from '../../public/ic-user-blocked-outline.svg'; + +import './AdvertiserNameStats.scss'; + +/** + * This component is to show an advertiser's stats, in UI its commonly used under an advertiser's name + * Example: + * Joined 2d | Not rated yet | x x x x x (5 ratings) + * + * Use cases are to show this in My Profile and Advertiser page + */ +const AdvertiserNameStats = ({ advertiserStats }: { advertiserStats: DeepPartial }) => { + const { isMobile } = useDevice(); + const isMyProfile = getCurrentRoute() === 'my-profile'; + + const { + blocked_by_count: blockedByCount, + daysSinceJoined, + is_online: isOnline, + last_online_time: lastOnlineTime, + rating_average: ratingAverage, + rating_count: ratingCount, + recommended_average: recommendedAverage, + } = advertiserStats || {}; + + return ( +
+
+ {!isMyProfile && ( +
+ + +
+ )} + + Joined {daysSinceJoined && daysSinceJoined > 0 ? `${daysSinceJoined}d` : 'Today'} + +
+ {!ratingAverage && ( +
+ + Not rated yet + +
+ )} + {ratingAverage && ( + <> +
+
+ {isMobile && ( + + ({ratingAverage}) + + )} + + + ({ratingCount} ratings) + +
+
+
+ + + {recommendedAverage || 0}% + +
+ + )} + {isMyProfile && ( +
+ + + {blockedByCount || 0} + +
+ )} +
+ ); +}; +AdvertiserNameStats.displayName = 'AdvertiserNameStats'; + +export default AdvertiserNameStats; diff --git a/src/components/AdvertiserName/AdvertiserNameToggle.scss b/src/components/AdvertiserName/AdvertiserNameToggle.scss new file mode 100644 index 00000000..9b501886 --- /dev/null +++ b/src/components/AdvertiserName/AdvertiserNameToggle.scss @@ -0,0 +1,20 @@ +.p2p-advertiser-name-toggle { + display: flex; + gap: 0.8rem; + + @include mobile { + padding: 1.4rem 1.6rem; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #f2f3f4; + } + + &__label { + display: flex; + flex-direction: column; + + &-real-name { + font-size: 1rem; + } + } +} diff --git a/src/components/AdvertiserName/AdvertiserNameToggle.tsx b/src/components/AdvertiserName/AdvertiserNameToggle.tsx new file mode 100644 index 00000000..410c5392 --- /dev/null +++ b/src/components/AdvertiserName/AdvertiserNameToggle.tsx @@ -0,0 +1,46 @@ +import { memo, useEffect, useState } from 'react'; +import { TAdvertiserStats } from 'types'; + +import { Text, ToggleSwitch } from '@deriv-com/ui'; + +import { api } from '@/hooks'; + +import './AdvertiserNameToggle.scss'; + +type TAdvertiserNameToggle = { + advertiserInfo: DeepPartial; + onToggle?: (shouldShowRealName: boolean) => void; +}; +const AdvertiserNameToggle = memo(({ advertiserInfo, onToggle }: TAdvertiserNameToggle) => { + const [shouldShowRealName, setShouldShowRealName] = useState(false); + const { mutate: advertiserUpdate } = api.advertiser.useUpdate(); + + useEffect(() => { + setShouldShowRealName(advertiserInfo?.should_show_name || false); + }, [advertiserInfo?.should_show_name]); + + const onToggleShowRealName = () => { + advertiserUpdate({ + show_name: !shouldShowRealName ? 1 : 0, + }); + setShouldShowRealName(!shouldShowRealName); + onToggle?.(!shouldShowRealName); + }; + + return ( +
+
+ + Show my real name + + + {advertiserInfo?.fullName} + +
+ +
+ ); +}); +AdvertiserNameToggle.displayName = 'AdvertiserNameToggle'; + +export default AdvertiserNameToggle; diff --git a/src/components/AdvertiserName/__tests__/AdvertiserName.spec.tsx b/src/components/AdvertiserName/__tests__/AdvertiserName.spec.tsx new file mode 100644 index 00000000..ad1334cb --- /dev/null +++ b/src/components/AdvertiserName/__tests__/AdvertiserName.spec.tsx @@ -0,0 +1,30 @@ +import { APIProvider, AuthProvider } from '@deriv/api-v2'; +import { render, screen } from '@testing-library/react'; + +import AdvertiserName from '../AdvertiserName'; + +const wrapper = ({ children }: { children: JSX.Element }) => ( + + {children} + +); + +const mockProps = { + advertiserStats: { + fullName: 'Jane Doe', + name: 'Jane', + should_show_name: true, + }, +}; + +jest.mock('@deriv-com/ui', () => ({ + ...jest.requireActual('@deriv-com/ui'), + useDevice: jest.fn(() => ({ isMobile: false })), +})); + +describe('AdvertiserName', () => { + it('should render full name', () => { + render(, { wrapper }); + expect(screen.queryByText(/Jane Doe/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/AdvertiserName/__tests__/AdvertiserNameBadges.spec.tsx b/src/components/AdvertiserName/__tests__/AdvertiserNameBadges.spec.tsx new file mode 100644 index 00000000..ccc9549f --- /dev/null +++ b/src/components/AdvertiserName/__tests__/AdvertiserNameBadges.spec.tsx @@ -0,0 +1,66 @@ +import { APIProvider, AuthProvider } from '@deriv/api-v2'; +import { render, screen } from '@testing-library/react'; + +import AdvertiserNameBadges from '../AdvertiserNameBadges'; + +const wrapper = ({ children }: { children: JSX.Element }) => ( + + {children} + +); +const mockUseAdvertiserStats = { + data: { + isAddressVerified: false, + isIdentityVerified: false, + totalOrders: 10, + }, + isLoading: false, +}; + +jest.mock('../../../hooks', () => ({ + ...jest.requireActual('../../../hooks'), + useAdvertiserStats: jest.fn(() => mockUseAdvertiserStats), +})); + +const mockProps = { + advertiserStats: { + isAddressVerified: false, + isIdentityVerified: false, + totalOrders: 20, + }, +}; + +describe('AdvertiserNameBadges', () => { + it('should render not verified badges', () => { + render(, { wrapper }); + expect(screen.queryAllByText('not verified')).toHaveLength(2); + }); + it('should render verified badges', () => { + mockProps.advertiserStats = { + isAddressVerified: true, + isIdentityVerified: true, + totalOrders: 20, + }; + render(, { wrapper }); + expect(screen.queryAllByText('verified')).toHaveLength(2); + }); + it('should render verified/not verified badges', () => { + mockProps.advertiserStats = { + isAddressVerified: true, + isIdentityVerified: false, + totalOrders: 20, + }; + render(, { wrapper }); + expect(screen.getByText('verified')).toBeInTheDocument(); + expect(screen.getByText('not verified')).toBeInTheDocument(); + }); + it('should render trade badge with 100+ orders', () => { + mockProps.advertiserStats = { + isAddressVerified: true, + isIdentityVerified: true, + totalOrders: 100, + }; + render(, { wrapper }); + expect(screen.getByText('100+')).toBeInTheDocument(); + }); +}); diff --git a/src/components/AdvertiserName/__tests__/AdvertiserNameStats.spec.tsx b/src/components/AdvertiserName/__tests__/AdvertiserNameStats.spec.tsx new file mode 100644 index 00000000..3187b9b5 --- /dev/null +++ b/src/components/AdvertiserName/__tests__/AdvertiserNameStats.spec.tsx @@ -0,0 +1,43 @@ +import { APIProvider, AuthProvider } from '@deriv/api-v2'; +import { render, screen } from '@testing-library/react'; + +import AdvertiserNameStats from '../AdvertiserNameStats'; + +const wrapper = ({ children }: { children: JSX.Element }) => ( + + {children} + +); + +jest.mock('@deriv-com/ui', () => ({ + ...jest.requireActual('@deriv-com/ui'), + useDevice: jest.fn(() => ({ isMobile: false })), +})); + +describe('AdvertiserNameStats', () => { + it('should render correct advertiser stats', () => { + const mockUseAdvertiserStats = { + advertiserStats: { + blocked_by_count: 1, + daysSinceJoined: 22, + rating_average: 4.4, + rating_count: 29, + recommended_average: 3.3, + }, + }; + render(, { wrapper }); + expect(screen.queryByText('Joined 22d')).toBeInTheDocument(); + expect(screen.queryByText('(29 ratings)')).toBeInTheDocument(); + }); + it('should render correct advertiser stats based on availability', () => { + const mockUseAdvertiserStats = { + advertiserStats: { + blocked_by_count: 1, + daysSinceJoined: 22, + rating_count: 29, + }, + }; + render(, { wrapper }); + expect(screen.queryByText('Not rated yet')).toBeInTheDocument(); + }); +}); diff --git a/src/components/AdvertiserName/__tests__/AdvertiserNameToggle.spec.tsx b/src/components/AdvertiserName/__tests__/AdvertiserNameToggle.spec.tsx new file mode 100644 index 00000000..3535b44f --- /dev/null +++ b/src/components/AdvertiserName/__tests__/AdvertiserNameToggle.spec.tsx @@ -0,0 +1,52 @@ +import { APIProvider, AuthProvider } from '@deriv/api-v2'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import AdvertiserNameToggle from '../AdvertiserNameToggle'; + +const wrapper = ({ children }: { children: JSX.Element }) => ( + + {children} + +); +const mockProps = { + advertiserInfo: { + fullName: 'Jane Doe', + should_show_name: false, + }, + onToggle: jest.fn(), +}; +const mockUseAdvertiserUpdateMutate = jest.fn(); + +jest.mock('@deriv/api-v2', () => ({ + ...jest.requireActual('@deriv/api-v2'), + p2p: { + advertiser: { + useUpdate: jest.fn(() => ({ + mutate: mockUseAdvertiserUpdateMutate, + })), + }, + }, +})); + +describe('AdvertiserNameToggle', () => { + it('should render full name in toggle', () => { + render(, { wrapper }); + expect(screen.getByText('Jane Doe')).toBeInTheDocument(); + }); + it('should switch full name settings', async () => { + render(, { wrapper }); + const labelBtn = screen.getByRole('checkbox'); + await userEvent.click(labelBtn); + + expect(mockUseAdvertiserUpdateMutate).toBeCalledWith({ + show_name: 1, + }); + expect(screen.getByText('Jane Doe')).toBeInTheDocument(); + await userEvent.click(labelBtn); + + expect(mockUseAdvertiserUpdateMutate).toBeCalledWith({ + show_name: 0, + }); + }); +}); diff --git a/src/components/AdvertiserName/index.ts b/src/components/AdvertiserName/index.ts new file mode 100644 index 00000000..d23e58f8 --- /dev/null +++ b/src/components/AdvertiserName/index.ts @@ -0,0 +1,2 @@ +export { default as AdvertiserName } from './AdvertiserName'; +export { default as AdvertiserNameToggle } from './AdvertiserNameToggle'; diff --git a/src/components/AdvertsTableRow/AdvertsTableRow.scss b/src/components/AdvertsTableRow/AdvertsTableRow.scss new file mode 100644 index 00000000..483225ed --- /dev/null +++ b/src/components/AdvertsTableRow/AdvertsTableRow.scss @@ -0,0 +1,19 @@ +.p2p-adverts-table-row { + width: 100%; + display: grid; + grid-template-columns: 2fr 1.4fr 1.4fr 2.4fr 0.8fr; + padding: 1.6rem; + align-items: center; + + @include mobile { + grid-template-columns: 2fr 0.8fr; + } + + &--advertiser { + grid-template-columns: repeat(4, 1fr); + + @include mobile { + grid-template-columns: repeat(2, 1fr); + } + } +} diff --git a/src/components/AdvertsTableRow/AdvertsTableRow.tsx b/src/components/AdvertsTableRow/AdvertsTableRow.tsx new file mode 100644 index 00000000..c043923c --- /dev/null +++ b/src/components/AdvertsTableRow/AdvertsTableRow.tsx @@ -0,0 +1,201 @@ +/* eslint-disable camelcase */ +import { Fragment, memo, useEffect, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import clsx from 'clsx'; +import { TAdvertsTableRowRenderer } from 'types'; + +import { useExchangeRateSubscription } from '@deriv/api-v2'; +import { LabelPairedChevronRightMdRegularIcon } from '@deriv/quill-icons'; +import { Button, Text, useDevice } from '@deriv-com/ui'; + +import { Badge, BuySellForm, PaymentMethodLabel, StarRating, UserAvatar } from '@/components'; +import { ADVERTISER_URL, BUY_SELL } from '@/constants'; +import { api } from '@/hooks'; +import { useIsAdvertiser } from '@/hooks/custom-hooks'; +import { generateEffectiveRate, getCurrentRoute } from '@/utils'; + +import './AdvertsTableRow.scss'; + +const BASE_CURRENCY = 'USD'; + +const AdvertsTableRow = memo((props: TAdvertsTableRowRenderer) => { + const [isModalOpen, setIsModalOpen] = useState(false); + const { data: exchangeRateValue, subscribe } = useExchangeRateSubscription(); + const { isDesktop, isMobile } = useDevice(); + const history = useHistory(); + const isBuySellPage = getCurrentRoute() === 'buy-sell'; + + const isAdvertiser = useIsAdvertiser(); + const { data: paymentMethods } = api.paymentMethods.useGet(); + const { data: advertiserPaymentMethods } = api.advertiserPaymentMethods.useGet(isAdvertiser); + const { data } = api.advertiser.useGetInfo() || {}; + const { daily_buy = 0, daily_buy_limit = 0, daily_sell = 0, daily_sell_limit = 0 } = data || {}; + + const { + account_currency, + advertiser_details, + counterparty_type, + effective_rate, + local_currency, + max_order_amount_limit_display, + min_order_amount_limit_display, + payment_method_names, + price_display, + rate, + rate_type, + } = props; + + useEffect(() => { + if (local_currency) { + subscribe({ + base_currency: BASE_CURRENCY, + target_currency: local_currency, + }); + } + }, [local_currency, subscribe]); + + const exchangeRate = exchangeRateValue?.rates?.[local_currency ?? '']; + const Container = isMobile ? 'div' : Fragment; + + const { completed_orders_count, id, is_online, name, rating_average, rating_count } = advertiser_details || {}; + + const { displayEffectiveRate, effectiveRate } = generateEffectiveRate({ + exchangeRate, + localCurrency: local_currency, + marketRate: Number(effective_rate), + price: Number(price_display), + rate, + rateType: rate_type, + }); + const hasRating = !!rating_average && !!rating_count; + const isBuyAdvert = counterparty_type === BUY_SELL.BUY; + const isMyAdvert = data?.id === id; + const ratingAverageDecimal = rating_average ? Number(rating_average).toFixed(1) : null; + const textColor = isMobile ? 'less-prominent' : 'general'; + + return ( +
+ + {isBuySellPage && ( +
history.push(`${ADVERTISER_URL}/${id}`)} + > + +
+
+ + {name} + + +
+
+ {hasRating ? ( + <> + + {ratingAverageDecimal} + + + + ({rating_count}) + + + ) : ( + + Not rated yet + + )} +
+
+
+ )} + + {isMobile && ( + + Rate (1 USD) + + )} + + + {isMobile && 'Limits:'} {min_order_amount_limit_display}-{max_order_amount_limit_display}{' '} + {account_currency} + + + {displayEffectiveRate} {local_currency} + + +
+ {payment_method_names ? ( + payment_method_names.map((method: string, idx: number) => ( + + )) + ) : ( + + )} +
+
+
+ {!isMyAdvert && ( +
+ {isMobile && isBuySellPage && ( + + )} + +
+ )} + {isModalOpen && ( + setIsModalOpen(false)} + paymentMethods={paymentMethods} + /> + )} +
+ ); +}); + +AdvertsTableRow.displayName = 'AdvertsTableRow'; +export default AdvertsTableRow; diff --git a/src/components/AdvertsTableRow/index.ts b/src/components/AdvertsTableRow/index.ts new file mode 100644 index 00000000..499d97db --- /dev/null +++ b/src/components/AdvertsTableRow/index.ts @@ -0,0 +1 @@ +export { default as AdvertsTableRow } from './AdvertsTableRow'; diff --git a/src/components/Badge/Badge.scss b/src/components/Badge/Badge.scss new file mode 100644 index 00000000..b8c6c7e1 --- /dev/null +++ b/src/components/Badge/Badge.scss @@ -0,0 +1,40 @@ +.p2p-badge { + display: flex; + justify-content: center; + align-items: center; + gap: 0.2rem; + width: fit-content; + padding: 0.2rem 0.6rem; + border-radius: 3.4rem; + color: #ffffff; + + &--general { + background-image: linear-gradient(#e6e9e9, #f2f3f4); + } + + &--gold { + background: linear-gradient(90deg, #f7931a 0%, #ffc71b 104.41%); + } + + &--green { + background: linear-gradient(90deg, #1db193 0%, #09da7a 104.41%); + } + + &--success { + background-image: linear-gradient(90deg, #00a8af 0%, #04cfd8 104.41%); + } + + &--warning { + background-image: linear-gradient(#f7931a, #ffc71b); + } + + &__label { + font-size: 1rem; + line-height: 1.4rem; + } + + &__status { + font-size: 1rem; + line-height: 1.4rem; + } +} diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx new file mode 100644 index 00000000..9fc84263 --- /dev/null +++ b/src/components/Badge/Badge.tsx @@ -0,0 +1,50 @@ +import { memo } from 'react'; +import clsx from 'clsx'; + +import { Text } from '@deriv-com/ui'; + +import './Badge.scss'; + +type TBadgeProps = { + label?: string; + status?: string; + tradeCount?: number | undefined; + variant?: 'general' | 'success' | 'warning'; +}; + +const Badge = ({ label, status, tradeCount, variant }: TBadgeProps) => { + const textColor = variant === 'general' ? 'less-prominent' : 'white'; + + if (tradeCount) { + return ( +
= 100 && tradeCount < 250, + 'p2p-badge--green': tradeCount >= 250, + })} + > + + {`${tradeCount >= 250 ? '250+' : '100+'}`} + +
+ ); + } + return ( +
+ + {label} + + + {status} + +
+ ); +}; + +export default memo(Badge); diff --git a/src/components/Badge/__tests__/Badge.spec.tsx b/src/components/Badge/__tests__/Badge.spec.tsx new file mode 100644 index 00000000..879ada83 --- /dev/null +++ b/src/components/Badge/__tests__/Badge.spec.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/react'; + +import Badge from '../Badge'; + +describe('Badge', () => { + it('should render the label and statuses correctly', () => { + render(); + expect(screen.getByText('Trades')).toBeInTheDocument(); + expect(screen.getByText('100+')).toBeInTheDocument(); + }); + it('should render the correct trade count', () => { + render(); + expect(screen.getByText('100+')).toBeInTheDocument(); + render(); + expect(screen.getByText('250+')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Badge/index.ts b/src/components/Badge/index.ts new file mode 100644 index 00000000..a4b8b972 --- /dev/null +++ b/src/components/Badge/index.ts @@ -0,0 +1 @@ +export { default as Badge } from './Badge'; diff --git a/src/components/BuySellForm/BuySellAmount/BuySellAmount.scss b/src/components/BuySellForm/BuySellAmount/BuySellAmount.scss new file mode 100644 index 00000000..597d2c9f --- /dev/null +++ b/src/components/BuySellForm/BuySellAmount/BuySellAmount.scss @@ -0,0 +1,16 @@ +.p2p-buy-sell-amount { + &__input-wrapper { + display: grid; + grid-template-columns: repeat(2, 1fr); + + @include mobile { + display: flex; + flex-direction: column; + gap: 1.6rem; + } + + .deriv-input__field { + font-size: 1.2rem; + } + } +} diff --git a/src/components/BuySellForm/BuySellAmount/BuySellAmount.tsx b/src/components/BuySellForm/BuySellAmount/BuySellAmount.tsx new file mode 100644 index 00000000..2f284158 --- /dev/null +++ b/src/components/BuySellForm/BuySellAmount/BuySellAmount.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import { Input, Text, useDevice } from '@deriv-com/ui'; +import { FormatUtils } from '@deriv-com/utils'; + +import { LightDivider } from '@/components'; +import { floatingPointValidator } from '@/utils'; + +import './BuySellAmount.scss'; + +type TBuySellAmountProps = { + accountCurrency: string; + amount: string; + calculatedRate: string; + control: ReturnType['control']; + isBuy: boolean; + isDisabled: boolean; + localCurrency: string; + maxLimit: string; + minLimit: string; +}; +const BuySellAmount = ({ + accountCurrency, + amount, + calculatedRate, + control, + isBuy, + isDisabled, + localCurrency, + maxLimit, + minLimit, +}: TBuySellAmountProps) => { + const { isMobile } = useDevice(); + const labelSize = isMobile ? 'sm' : 'xs'; + const [inputValue, setInputValue] = useState(minLimit); + const [buySellAmount, setBuySellAmount] = useState(amount); + + useEffect(() => { + setBuySellAmount( + FormatUtils.formatMoney(Number(inputValue) * Number(calculatedRate), { + currency: localCurrency, + }) + ); + }, [calculatedRate, inputValue, localCurrency]); + + return ( +
+ + {`Enter ${isBuy ? 'sell' : 'buy'} amount`} + +
+ ( +
+ { + setInputValue(event.target.value); + onChange(event); + }} + onKeyDown={event => { + if (!floatingPointValidator(event.key)) { + event.preventDefault(); + } + }} + rightPlaceholder={ + + {accountCurrency} + + } + step='any' + type='number' + value={value} + /> +
+ )} + rules={{ + max: { + message: `Maximum is ${maxLimit}${accountCurrency}`, + value: maxLimit, + }, + min: { + message: `Minimum is ${minLimit}${accountCurrency}`, + value: minLimit, + }, + required: 'Enter a valid amount', + }} + /> + {isMobile && } +
+ {`You'll ${isBuy ? 'receive' : 'send'}`} + + {buySellAmount} {localCurrency} + +
+
+
+ ); +}; + +export default BuySellAmount; diff --git a/src/components/BuySellForm/BuySellAmount/index.ts b/src/components/BuySellForm/BuySellAmount/index.ts new file mode 100644 index 00000000..a76c6b77 --- /dev/null +++ b/src/components/BuySellForm/BuySellAmount/index.ts @@ -0,0 +1 @@ +export { default as BuySellAmount } from './BuySellAmount'; diff --git a/src/components/BuySellForm/BuySellData/BuySellData.scss b/src/components/BuySellForm/BuySellData/BuySellData.scss new file mode 100644 index 00000000..51c7bd1f --- /dev/null +++ b/src/components/BuySellForm/BuySellData/BuySellData.scss @@ -0,0 +1,12 @@ +.p2p-buy-sell-data { + &__details { + display: grid; + grid-auto-flow: column; + margin-bottom: 1.6rem; + + @include mobile { + grid-auto-flow: row; + gap: 1.6rem; + } + } +} diff --git a/src/components/BuySellForm/BuySellData/BuySellData.tsx b/src/components/BuySellForm/BuySellData/BuySellData.tsx new file mode 100644 index 00000000..f1fae85b --- /dev/null +++ b/src/components/BuySellForm/BuySellData/BuySellData.tsx @@ -0,0 +1,90 @@ +import { THooks } from 'types'; + +import { Text, useDevice } from '@deriv-com/ui'; + +import { PaymentMethodWithIcon } from '@/components'; +import { formatTime } from '@/utils'; + +import './BuySellData.scss'; + +type TBuySellDataProps = { + accountCurrency: string; + expiryPeriod: number; + instructions?: string; + isBuy: boolean; + localCurrency: string; + name: string; + paymentMethodNames?: string[]; + paymentMethods: THooks.PaymentMethods.Get; + rate: string; +}; + +type TType = THooks.AdvertiserPaymentMethods.Get[number]['type']; +const BuySellData = ({ + accountCurrency, + expiryPeriod, + instructions = '', + isBuy, + localCurrency, + name, + paymentMethodNames, + paymentMethods, + rate, +}: TBuySellDataProps) => { + const { isMobile } = useDevice(); + const labelSize = isMobile ? 'sm' : 'xs'; + const valueSize = isMobile ? 'md' : 'sm'; + const paymentMethodTypes = paymentMethods?.reduce((acc: Record, curr) => { + if (curr.display_name && curr.type) { + acc[curr.display_name] = curr.type; + } + return acc; + }, {}); + + return ( +
+
+
+ + {isBuy ? 'Buyer' : 'Seller'} + + {name} +
+
+ {`Rate (1 ${accountCurrency})`} + + {rate} + {localCurrency} + +
+
+
+ + Payment methods + + {paymentMethodNames?.length + ? paymentMethodNames.map(method => ( + + )) + : '-'} +
+
+ {`${isBuy ? 'Buyer' : 'Seller'}'s instructions`} + {instructions} +
+
+ + Orders must be completed in + + {formatTime(expiryPeriod)} +
+
+ ); +}; + +export default BuySellData; diff --git a/src/components/BuySellForm/BuySellData/index.ts b/src/components/BuySellForm/BuySellData/index.ts new file mode 100644 index 00000000..55475fa5 --- /dev/null +++ b/src/components/BuySellForm/BuySellData/index.ts @@ -0,0 +1 @@ +export { default as BuySellData } from './BuySellData'; diff --git a/src/components/BuySellForm/BuySellForm.scss b/src/components/BuySellForm/BuySellForm.scss new file mode 100644 index 00000000..f7a2821e --- /dev/null +++ b/src/components/BuySellForm/BuySellForm.scss @@ -0,0 +1,39 @@ +.p2p-buy-sell-form { + width: 44rem; + height: unset; + border-radius: 8px; + + @include mobile { + width: 100vw; + height: 100vh; + box-shadow: none; + } + + & .deriv-modal__body { + padding: 0; + } + + &--is-buy { + height: 64.9rem; + & .deriv-modal__body { + max-height: calc(100vh - 13rem); + overflow: auto; + } + @include mobile { + height: unset; + max-height: unset; + } + } + + &__full-page-modal { + position: absolute; + top: -4rem; + left: 0; + z-index: 1; + height: calc(100vh - 8rem); + + & .p2p-mobile-wrapper__body { + overflow: auto; + } + } +} diff --git a/src/components/BuySellForm/BuySellForm.tsx b/src/components/BuySellForm/BuySellForm.tsx new file mode 100644 index 00000000..edf3b03e --- /dev/null +++ b/src/components/BuySellForm/BuySellForm.tsx @@ -0,0 +1,283 @@ +/* eslint-disable camelcase */ +import { useEffect, useState } from 'react'; +import { Control, Controller, FieldValues, useForm } from 'react-hook-form'; +import { useHistory } from 'react-router-dom'; +import { TAdvertType, THooks } from 'types'; + +import { InlineMessage, Text, TextArea, useDevice } from '@deriv-com/ui'; + +import { BUY_SELL, ORDERS_URL, RATE_TYPE, VALID_SYMBOLS_PATTERN } from '@/constants'; +import { api } from '@/hooks'; +import { + getPaymentMethodObjects, + getTextFieldError, + removeTrailingZeros, + roundOffDecimal, + setDecimalPlaces, +} from '@/utils'; + +import { LightDivider } from '../LightDivider'; + +import { BuySellAmount } from './BuySellAmount'; +import { BuySellData } from './BuySellData'; +import BuySellFormDisplayWrapper from './BuySellFormDisplayWrapper'; +import { BuySellPaymentSection } from './BuySellPaymentSection'; + +import './BuySellForm.scss'; + +type TPayload = Omit['mutate']>[0], 'payment_method_ids'> & { + payment_method_ids?: number[]; +}; + +type TBuySellFormProps = { + advert: TAdvertType; + advertiserBuyLimit: number; + advertiserPaymentMethods: THooks.AdvertiserPaymentMethods.Get; + advertiserSellLimit: number; + balanceAvailable: number; + displayEffectiveRate: string; + effectiveRate: number; + isModalOpen: boolean; + onRequestClose: () => void; + paymentMethods: THooks.PaymentMethods.Get; +}; + +const getAdvertiserMaxLimit = ( + isBuy: boolean, + advertiserBuyLimit: number, + advertiserSellLimit: number, + maxOrderAmountLimitDisplay: string +) => { + if (isBuy) { + if (advertiserBuyLimit < Number(maxOrderAmountLimitDisplay)) return roundOffDecimal(advertiserBuyLimit); + } else if (advertiserSellLimit < Number(maxOrderAmountLimitDisplay)) return roundOffDecimal(advertiserSellLimit); + return maxOrderAmountLimitDisplay; +}; + +const BuySellForm = ({ + advert, + advertiserBuyLimit, + advertiserPaymentMethods, + advertiserSellLimit, + balanceAvailable, + displayEffectiveRate, + effectiveRate, + isModalOpen, + onRequestClose, + paymentMethods, +}: TBuySellFormProps) => { + const { data: orderCreatedInfo, isSuccess, mutate } = api.order.useCreate(); + const [selectedPaymentMethods, setSelectedPaymentMethods] = useState([]); + + const { + account_currency, + advertiser_details, + description, + id, + local_currency, + max_order_amount_limit_display, + min_order_amount_limit, + min_order_amount_limit_display, + order_expiry_period, + payment_method_names, + rate_type, + type, + } = advert; + + const avertiserPaymentMethodObjects = getPaymentMethodObjects(advertiserPaymentMethods); + + const paymentMethodObjects = getPaymentMethodObjects(paymentMethods); + + const availablePaymentMethods = payment_method_names?.map(paymentMethod => { + const isAvailable = advertiserPaymentMethods?.some(method => method.display_name === paymentMethod); + return { + ...(isAvailable ? avertiserPaymentMethodObjects[paymentMethod] : paymentMethodObjects[paymentMethod]), + isAvailable, + }; + }); + + const history = useHistory(); + const { isMobile } = useDevice(); + const isBuy = type === BUY_SELL.BUY; + + const shouldDisableField = + !isBuy && + (parseFloat(balanceAvailable.toString()) === 0 || + parseFloat(balanceAvailable.toString()) < min_order_amount_limit); + + const { + control, + formState: { isValid }, + getValues, + handleSubmit, + } = useForm({ + defaultValues: { + amount: min_order_amount_limit ?? 1, + bank_details: '', + contact_details: '', + }, + mode: 'all', + }); + + const onSubmit = () => { + //TODO: error handling after implementation of exchange rate + const rateValue = rate_type === RATE_TYPE.FIXED ? null : effectiveRate; + const payload: TPayload = { + advert_id: id, + amount: Number(getValues('amount')), + }; + if (rateValue) { + payload.rate = rateValue; + } + + if (isBuy && selectedPaymentMethods.length) { + payload.payment_method_ids = selectedPaymentMethods; + } + + if (isBuy && !selectedPaymentMethods.length) { + payload.payment_info = getValues('bank_details'); + } + + mutate(payload); + }; + + const calculatedRate = removeTrailingZeros(roundOffDecimal(effectiveRate, setDecimalPlaces(effectiveRate, 6))); + const initialAmount = removeTrailingZeros((min_order_amount_limit * Number(calculatedRate)).toString()); + + const onSelectPaymentMethodCard = (paymentMethodId: number) => { + if (selectedPaymentMethods.includes(paymentMethodId)) { + setSelectedPaymentMethods(selectedPaymentMethods.filter(method => method !== paymentMethodId)); + } else { + setSelectedPaymentMethods([...selectedPaymentMethods, paymentMethodId]); + } + }; + + useEffect(() => { + if (isSuccess && orderCreatedInfo) { + history.push(`${ORDERS_URL}/${orderCreatedInfo.id}`); + onRequestClose(); + } + }, [isSuccess, orderCreatedInfo, history, onRequestClose]); + + return ( +
+ + {rate_type === RATE_TYPE.FLOAT && !shouldDisableField && ( +
+ + + If the market rate changes from the rate shown here, we won’t be able to process your + order. + + +
+ )} + + + {isBuy && payment_method_names?.length > 0 && ( + + )} + } + isBuy={isBuy} + isDisabled={shouldDisableField} + localCurrency={local_currency} + maxLimit={getAdvertiserMaxLimit( + isBuy, + advertiserBuyLimit, + advertiserSellLimit, + max_order_amount_limit_display + )} + minLimit={min_order_amount_limit_display} + /> + {isBuy && !payment_method_names?.length && ( + { + return ( +
+