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 && (
+
+ )}
+ setIsModalOpen(true)}
+ size={isMobile ? 'lg' : 'sm'}
+ textSize={isMobile ? 'md' : 'xs'}
+ >
+ {isBuyAdvert ? 'Buy' : 'Sell'} {account_currency}
+
+
+ )}
+ {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 (
+
+ );
+};
+
+export default BuySellForm;
diff --git a/src/components/BuySellForm/BuySellFormDisplayWrapper.tsx b/src/components/BuySellForm/BuySellFormDisplayWrapper.tsx
new file mode 100644
index 00000000..ce5535f6
--- /dev/null
+++ b/src/components/BuySellForm/BuySellFormDisplayWrapper.tsx
@@ -0,0 +1,61 @@
+import { PropsWithChildren } from 'react';
+import clsx from 'clsx';
+
+import { Modal, useDevice } from '@deriv-com/ui';
+
+import { FullPageMobileWrapper } from '../FullPageMobileWrapper';
+
+import { BuySellFormFooter } from './BuySellFormFooter';
+import { BuySellFormHeader } from './BuySellFormHeader';
+
+type TBuySellFormDisplayWrapperProps = {
+ accountCurrency: string;
+ isBuy: boolean;
+ isModalOpen: boolean;
+ isValid: boolean;
+ onRequestClose: () => void;
+ onSubmit: () => void;
+};
+const BuySellFormDisplayWrapper = ({
+ accountCurrency,
+ children,
+ isBuy,
+ isModalOpen,
+ isValid,
+ onRequestClose,
+ onSubmit,
+}: PropsWithChildren) => {
+ const { isMobile } = useDevice();
+ if (isMobile) {
+ return (
+ }
+ renderHeader={() => }
+ >
+ {children}
+
+ );
+ }
+
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+};
+
+export default BuySellFormDisplayWrapper;
diff --git a/src/components/BuySellForm/BuySellFormFooter/BuySellFormFooter.tsx b/src/components/BuySellForm/BuySellFormFooter/BuySellFormFooter.tsx
new file mode 100644
index 00000000..50315c5d
--- /dev/null
+++ b/src/components/BuySellForm/BuySellFormFooter/BuySellFormFooter.tsx
@@ -0,0 +1,35 @@
+import { Button, useDevice } from '@deriv-com/ui';
+
+type TBuySellFormFooterProps = {
+ isDisabled: boolean;
+ onClickCancel: () => void;
+ onSubmit?: () => void;
+};
+const BuySellFormFooter = ({ isDisabled, onClickCancel, onSubmit }: TBuySellFormFooterProps) => {
+ const { isMobile } = useDevice();
+ return (
+
+
+ Cancel
+
+ onSubmit?.()}
+ size='lg'
+ textSize={isMobile ? 'md' : 'sm'}
+ type='submit'
+ >
+ Confirm
+
+
+ );
+};
+
+export default BuySellFormFooter;
diff --git a/src/components/BuySellForm/BuySellFormFooter/__tests__/BuySellFormFooter.spec.tsx b/src/components/BuySellForm/BuySellFormFooter/__tests__/BuySellFormFooter.spec.tsx
new file mode 100644
index 00000000..dceb7a81
--- /dev/null
+++ b/src/components/BuySellForm/BuySellFormFooter/__tests__/BuySellFormFooter.spec.tsx
@@ -0,0 +1,45 @@
+import { useDevice } from '@deriv-com/ui';
+import { render, screen } from '@testing-library/react';
+
+import BuySellFormFooter from '../BuySellFormFooter';
+
+const mockProps = {
+ isDisabled: false,
+ onClickCancel: jest.fn(),
+};
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({
+ isMobile: false,
+ }),
+}));
+
+const mockUseDevice = useDevice as jest.Mock;
+describe('BuySellFormFooter', () => {
+ it('should render the footer as expected', () => {
+ render( );
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
+ });
+ it('should handle onclick for cancel button', () => {
+ render( );
+ const cancelButton = screen.getByRole('button', { name: 'Cancel' });
+ cancelButton.click();
+ expect(mockProps.onClickCancel).toHaveBeenCalled();
+ });
+ it('should handle onclick for confirm button', () => {
+ const newProps = { mockProps, onSubmit: jest.fn() };
+ render( );
+ const confirmButton = screen.getByRole('button', { name: 'Confirm' });
+ confirmButton.click();
+ expect(newProps.onSubmit).toHaveBeenCalled();
+ });
+ it('should render as expected in responsive view as well', () => {
+ mockUseDevice.mockReturnValue({
+ isMobile: true,
+ });
+ render( );
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/BuySellForm/BuySellFormFooter/index.ts b/src/components/BuySellForm/BuySellFormFooter/index.ts
new file mode 100644
index 00000000..ca6ac471
--- /dev/null
+++ b/src/components/BuySellForm/BuySellFormFooter/index.ts
@@ -0,0 +1 @@
+export { default as BuySellFormFooter } from './BuySellFormFooter';
diff --git a/src/components/BuySellForm/BuySellFormHeader/BuySellFormHeader.tsx b/src/components/BuySellForm/BuySellFormHeader/BuySellFormHeader.tsx
new file mode 100644
index 00000000..91ff42f0
--- /dev/null
+++ b/src/components/BuySellForm/BuySellFormHeader/BuySellFormHeader.tsx
@@ -0,0 +1,18 @@
+import { Text, useDevice } from '@deriv-com/ui';
+
+type TBuySellFormHeaderProps = {
+ currency?: string;
+ isBuy: boolean;
+};
+
+const BuySellFormHeader = ({ currency = '', isBuy }: TBuySellFormHeaderProps) => {
+ const { isMobile } = useDevice();
+
+ return (
+
+ {`${isBuy ? 'Sell' : 'Buy'} ${currency}`}
+
+ );
+};
+
+export default BuySellFormHeader;
diff --git a/src/components/BuySellForm/BuySellFormHeader/__tests__/BuySellFormHeader.spec.tsx b/src/components/BuySellForm/BuySellFormHeader/__tests__/BuySellFormHeader.spec.tsx
new file mode 100644
index 00000000..7ca7e8ca
--- /dev/null
+++ b/src/components/BuySellForm/BuySellFormHeader/__tests__/BuySellFormHeader.spec.tsx
@@ -0,0 +1,20 @@
+import { render, screen } from '@testing-library/react';
+
+import BuySellFormHeader from '../BuySellFormHeader';
+
+const mockProps = {
+ currency: 'USD',
+ isBuy: true,
+};
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: false }),
+}));
+
+describe('BuySellFormHeader', () => {
+ it('should render the header as expected', () => {
+ render( );
+ expect(screen.getByText('Sell USD')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/BuySellForm/BuySellFormHeader/index.ts b/src/components/BuySellForm/BuySellFormHeader/index.ts
new file mode 100644
index 00000000..810affdd
--- /dev/null
+++ b/src/components/BuySellForm/BuySellFormHeader/index.ts
@@ -0,0 +1 @@
+export { default as BuySellFormHeader } from './BuySellFormHeader';
diff --git a/src/components/BuySellForm/BuySellPaymentSection/BuySellPaymentSection.tsx b/src/components/BuySellForm/BuySellPaymentSection/BuySellPaymentSection.tsx
new file mode 100644
index 00000000..6f89eb17
--- /dev/null
+++ b/src/components/BuySellForm/BuySellPaymentSection/BuySellPaymentSection.tsx
@@ -0,0 +1,81 @@
+import { TPaymentMethod } from 'types';
+
+import { Text, useDevice } from '@deriv-com/ui';
+
+import { LightDivider } from '@/components';
+import { sortPaymentMethodsWithAvailability } from '@/utils';
+
+import { PaymentMethodCard } from '../../PaymentMethodCard';
+
+type TBuySellPaymentSectionProps = {
+ availablePaymentMethods: (TPaymentMethod & { isAvailable?: boolean })[];
+ onSelectPaymentMethodCard?: (paymentMethodId: number) => void;
+ selectedPaymentMethodIds: number[];
+};
+
+const BuySellPaymentSection = ({
+ availablePaymentMethods,
+ onSelectPaymentMethodCard,
+ selectedPaymentMethodIds,
+}: TBuySellPaymentSectionProps) => {
+ const { isMobile } = useDevice();
+ const sortedList = sortPaymentMethodsWithAvailability(availablePaymentMethods);
+ //TODO: below section to be modified to handle payment method addition after handling of modal provider
+ // const [formState, dispatch] = useReducer(advertiserPaymentMethodsReducer, {});
+
+ // const handleAddPaymentMethod = (selectedPaymentMethod?: TSelectedPaymentMethod) => {
+ // dispatch({
+ // payload: {
+ // selectedPaymentMethod,
+ // },
+ // type: 'ADD',
+ // });
+ // };
+
+ // const handleResetFormState = useCallback(() => {
+ // dispatch({ type: 'RESET' });
+ // }, []);
+
+ // if (formState?.isVisible) {
+ // return (
+ //
+ // );
+ // }
+
+ return (
+ <>
+
+
+ Receive payment to
+
+
+ {sortedList && sortedList.length > 0
+ ? 'You may choose up to 3.'
+ : 'To place an order, add one of the advertiser’s preferred payment methods:'}
+
+
+ {sortedList?.map(paymentMethod => (
+
undefined}
+ onSelectPaymentMethodCard={onSelectPaymentMethodCard}
+ paymentMethod={paymentMethod}
+ selectedPaymentMethodIds={selectedPaymentMethodIds}
+ />
+ ))}
+
+
+
+ >
+ );
+};
+
+export default BuySellPaymentSection;
diff --git a/src/components/BuySellForm/BuySellPaymentSection/__tests__/BuySellPaymentSection.spec.tsx b/src/components/BuySellForm/BuySellPaymentSection/__tests__/BuySellPaymentSection.spec.tsx
new file mode 100644
index 00000000..0f45c650
--- /dev/null
+++ b/src/components/BuySellForm/BuySellPaymentSection/__tests__/BuySellPaymentSection.spec.tsx
@@ -0,0 +1,58 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { PaymentMethodCard } from '@/components/PaymentMethodCard';
+
+import BuySellPaymentSection from '../BuySellPaymentSection';
+
+const mockProps = {
+ availablePaymentMethods: [],
+ onSelectPaymentMethodCard: jest.fn(),
+ selectedPaymentMethodIds: ['123'],
+};
+
+const mockAvailablePaymentMethods = {
+ display_name: 'Other',
+ fields: {
+ account: {
+ display_name: 'Account ID / phone number / email',
+ required: 0,
+ type: 'text',
+ },
+ instructions: {
+ display_name: 'Instructions',
+ required: 0,
+ type: 'memo',
+ },
+ name: {
+ display_name: 'Payment method name',
+ required: 1,
+ type: 'text',
+ },
+ },
+ id: '67',
+ isAvailable: true,
+ type: 'other',
+};
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn(() => ({ isMobile: false })),
+}));
+
+const mockPaymentMethodCard = PaymentMethodCard as jest.MockedFunction;
+
+describe('BuySellPaymentSection', () => {
+ it('should render the component as expected', () => {
+ render( );
+ expect(screen.getByText('Receive payment to')).toBeInTheDocument();
+ });
+ it('should render the payment method cards when there are available payment methods', async () => {
+ render( );
+ expect(screen.getByText('Receive payment to')).toBeInTheDocument();
+ expect(screen.getByText('You may choose up to 3.')).toBeInTheDocument();
+ expect(screen.getByText('Other')).toBeInTheDocument();
+ const checkbox = screen.getByRole('checkbox');
+ await userEvent.click(checkbox);
+ expect(mockProps.onSelectPaymentMethodCard).toBeCalledWith(67);
+ });
+});
diff --git a/src/components/BuySellForm/BuySellPaymentSection/index.ts b/src/components/BuySellForm/BuySellPaymentSection/index.ts
new file mode 100644
index 00000000..c9140e46
--- /dev/null
+++ b/src/components/BuySellForm/BuySellPaymentSection/index.ts
@@ -0,0 +1 @@
+export { default as BuySellPaymentSection } from './BuySellPaymentSection';
diff --git a/src/components/BuySellForm/__tests__/BuySellForm.spec.tsx b/src/components/BuySellForm/__tests__/BuySellForm.spec.tsx
new file mode 100644
index 00000000..bbf1ba36
--- /dev/null
+++ b/src/components/BuySellForm/__tests__/BuySellForm.spec.tsx
@@ -0,0 +1,198 @@
+import Modal from 'react-modal';
+
+import { useDevice } from '@deriv-com/ui';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { floatingPointValidator } from '@/utils';
+
+import BuySellForm from '../BuySellForm';
+
+const mockMutateFn = jest.fn();
+jest.mock('@deriv/api-v2', () => ({
+ p2p: {
+ order: {
+ useCreate: jest.fn(() => ({
+ mutate: mockMutateFn,
+ })),
+ },
+ },
+}));
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn(() => ({ isMobile: false })),
+}));
+
+const mockUseDevice = useDevice as jest.Mock;
+const mockOnChange = jest.fn();
+const mockHandleSubmit = jest.fn();
+jest.mock('react-hook-form', () => ({
+ ...jest.requireActual('react-hook-form'),
+ Controller: ({ control, defaultValue, name, render }) =>
+ render({
+ field: { control, name, onBlur: jest.fn(), onChange: mockOnChange, value: defaultValue },
+ fieldState: { error: null },
+ }),
+ useForm: () => ({
+ control: 'mockedControl',
+ formState: { isValid: true },
+ getValues: jest.fn(() => ({
+ amount: 1,
+ })),
+ handleSubmit: mockHandleSubmit,
+ }),
+}));
+
+jest.mock('@/utils', () => ({
+ ...jest.requireActual('@/utils'),
+ floatingPointValidator: jest.fn(() => false),
+}));
+const mockFloatingPointValidator = floatingPointValidator as jest.Mock;
+
+const mockAdvertValues = {
+ account_currency: 'USD',
+ advertiser_details: {
+ name: 'name',
+ rating_average: 5,
+ rating_count: 5,
+ },
+ description: 'description',
+ id: 'id',
+ is_buy: true,
+ local_currency: 'USD',
+ max_order_amount_limit_display: '1000',
+ min_order_amount_limit: 1,
+ min_order_amount_limit_display: '1',
+ order_expiry_period: 30,
+ payment_method_names: ['alipay'],
+ rate: 1,
+ rate_type: 'fixed',
+ type: 'sell',
+};
+const mockProps = {
+ advert: mockAdvertValues,
+ advertiserBuyLimit: 1000,
+ advertiserPaymentMethods: [
+ {
+ display_name: 'alipay',
+ id: '1',
+ },
+ ],
+ advertiserSellLimit: 1000,
+ balanceAvailable: 10,
+ displayEffectiveRate: '1',
+ effectiveRate: 1,
+ isModalOpen: true,
+ onRequestClose: jest.fn(),
+ paymentMethods: [
+ {
+ display_name: 'alipay',
+ type: 'online',
+ },
+ ],
+};
+let element: HTMLElement;
+describe('BuySellForm', () => {
+ beforeAll(() => {
+ element = document.createElement('div');
+ element.setAttribute('id', 'v2_modal_root');
+ document.body.appendChild(element);
+ Modal.setAppElement('#v2_modal_root');
+ });
+ afterAll(() => {
+ document.body.removeChild(element);
+ });
+ it('should render the form as expected', () => {
+ render( );
+ expect(screen.getByText('Buy USD')).toBeInTheDocument();
+ });
+ it('should render the inline message when rate type is float', () => {
+ render( );
+ expect(
+ screen.getByText(
+ 'If the market rate changes from the rate shown here, we won’t be able to process your order.'
+ )
+ ).toBeInTheDocument();
+ });
+ it('should render the form as expected in mobile view', () => {
+ mockUseDevice.mockReturnValue({
+ isMobile: true,
+ });
+ render( );
+ expect(screen.getByText('Buy USD')).toBeInTheDocument();
+ });
+ it("should handle onsubmit when form is submitted and it's valid", async () => {
+ mockUseDevice.mockReturnValue({
+ isMobile: false,
+ });
+ render( );
+ const confirmButton = screen.getByRole('button', { name: 'Confirm' });
+ await userEvent.click(confirmButton);
+ expect(mockHandleSubmit).toHaveBeenCalled();
+ });
+ it('should disable the input field when balance is 0', () => {
+ render( );
+ const inputField = screen.getByPlaceholderText('Buy amount');
+ expect(inputField).toBeDisabled();
+ });
+ it('should check if the floating point validator is called on changing value in input field', async () => {
+ render( );
+ const inputField = screen.getByPlaceholderText('Buy amount');
+ await userEvent.type(inputField, '1');
+ expect(mockFloatingPointValidator).toHaveBeenCalled();
+ });
+ it('should render the advertiserSellLimit as max limit if buy limit < max order amount limit', () => {
+ render(
+
+ );
+ expect(screen.getByText('Limit: 1-5.00USD')).toBeInTheDocument();
+ });
+ it('should return the advertiserBuyLimit as max limit if sell limit < max order amount limit and sell order', () => {
+ render(
+
+ );
+ expect(screen.getByText('Limit: 1-5.00USD')).toBeInTheDocument();
+ });
+ it('should call onchange when input field value is changed', async () => {
+ mockFloatingPointValidator.mockReturnValue(true);
+ render( );
+ const inputField = screen.getByPlaceholderText('Buy amount');
+ await userEvent.type(inputField, '1');
+ expect(mockOnChange).toHaveBeenCalled();
+ });
+ it('should render the bank details text area when sell order and no payment methods are there', () => {
+ render( );
+ expect(screen.getByText('Your bank details')).toBeInTheDocument();
+ });
+ it('should render the contact details text area when sell order and payment methods are there', () => {
+ render(
+
+ );
+ expect(screen.getByText('Your contact details')).toBeInTheDocument();
+ });
+ it('should send the payment_method_ids when payment methods are selected and sell order', async () => {
+ render(
+
+ );
+ const checkbox = screen.getByRole('checkbox');
+ await userEvent.click(checkbox);
+ const confirmButton = screen.getByRole('button', { name: 'Confirm' });
+ await userEvent.click(confirmButton);
+ expect(mockMutateFn).toHaveBeenCalledWith(expect.objectContaining({ payment_method_ids: [1] }));
+ });
+});
diff --git a/src/components/BuySellForm/index.ts b/src/components/BuySellForm/index.ts
new file mode 100644
index 00000000..2d50f4ad
--- /dev/null
+++ b/src/components/BuySellForm/index.ts
@@ -0,0 +1 @@
+export { default as BuySellForm } from './BuySellForm';
diff --git a/src/components/Checklist/Checklist.scss b/src/components/Checklist/Checklist.scss
new file mode 100644
index 00000000..3891243b
--- /dev/null
+++ b/src/components/Checklist/Checklist.scss
@@ -0,0 +1,57 @@
+@mixin icon-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 3.2rem;
+ height: 3.2rem;
+}
+
+.p2p-checklist {
+ display: flex;
+ flex-direction: column;
+ margin-top: 2.4rem;
+
+ @include mobile {
+ width: 100%;
+ }
+
+ & span {
+ max-width: 80%;
+ }
+
+ &__item {
+ padding: 1.6rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ box-shadow: 0px -1px 0px 0px #f2f3f4 inset;
+ width: 41rem;
+
+ @include mobile {
+ width: 100%;
+ }
+
+ &-button {
+ @include icon-wrapper;
+
+ padding: 0;
+
+ &--disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ &-icon {
+ fill: #fff;
+ }
+ }
+
+ &-checkmark {
+ @include icon-wrapper;
+
+ &-icon {
+ fill: #4bb4b3;
+ }
+ }
+ }
+}
diff --git a/src/components/Checklist/Checklist.tsx b/src/components/Checklist/Checklist.tsx
new file mode 100644
index 00000000..640ce700
--- /dev/null
+++ b/src/components/Checklist/Checklist.tsx
@@ -0,0 +1,48 @@
+import { LabelPairedArrowRightLgBoldIcon, LabelPairedCheckMdBoldIcon } from '@deriv/quill-icons';
+import { Button, Text } from '@deriv-com/ui';
+
+import { useDevice } from '@/hooks/custom-hooks';
+
+import './Checklist.scss';
+
+type TChecklistItem = {
+ isDisabled?: boolean;
+ onClick?: () => void;
+ status: string;
+ testId?: string;
+ text: string;
+};
+
+const Checklist = ({ items }: { items: TChecklistItem[] }) => {
+ const { isMobile } = useDevice();
+ return (
+
+ {items.map(item => (
+
+
+ {item.text}
+
+ {item.status === 'done' ? (
+
+
+
+ ) : (
+
+ }
+ onClick={item.onClick}
+ />
+ )}
+
+ ))}
+
+ );
+};
+
+export default Checklist;
diff --git a/src/components/Checklist/index.ts b/src/components/Checklist/index.ts
new file mode 100644
index 00000000..0e0985df
--- /dev/null
+++ b/src/components/Checklist/index.ts
@@ -0,0 +1 @@
+export { default as Checklist } from './Checklist';
diff --git a/src/components/Clipboard/Clipboard.scss b/src/components/Clipboard/Clipboard.scss
new file mode 100644
index 00000000..8c5a5a4b
--- /dev/null
+++ b/src/components/Clipboard/Clipboard.scss
@@ -0,0 +1,11 @@
+.p2p-clipboard {
+ all: unset;
+ border-radius: 0rem 0.4rem 0.4rem 0rem;
+ border-left: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ height: 3.8rem;
+ width: 3.2rem;
+}
diff --git a/src/components/Clipboard/Clipboard.tsx b/src/components/Clipboard/Clipboard.tsx
new file mode 100644
index 00000000..23de84ed
--- /dev/null
+++ b/src/components/Clipboard/Clipboard.tsx
@@ -0,0 +1,47 @@
+//TODO: to be replaced with derivcom component
+import { useEffect, useRef, useState } from 'react';
+import { useCopyToClipboard } from 'usehooks-ts';
+
+import { Tooltip } from '@deriv-com/ui';
+
+import CheckmarkCircle from '../../public/ic-checkmark-circle.svg';
+import CopyIcon from '../../public/ic-clipboard.svg';
+
+import './Clipboard.scss';
+
+type TClipboardProps = {
+ textCopy: string;
+};
+
+const Clipboard = ({ textCopy }: TClipboardProps) => {
+ const timeoutClipboardRef = useRef | null>(null);
+ const [, copy] = useCopyToClipboard();
+ const [isCopied, setIsCopied] = useState(false);
+
+ const onClick = (event: { stopPropagation: () => void }) => {
+ setIsCopied(true);
+ copy(textCopy);
+ timeoutClipboardRef.current = setTimeout(() => {
+ setIsCopied(false);
+ }, 2000);
+ event.stopPropagation();
+ };
+
+ useEffect(() => {
+ return () => {
+ if (timeoutClipboardRef.current) {
+ clearTimeout(timeoutClipboardRef.current);
+ }
+ };
+ }, []);
+
+ return (
+
+
+ {isCopied ? : }
+
+
+ );
+};
+
+export default Clipboard;
diff --git a/src/components/Clipboard/index.ts b/src/components/Clipboard/index.ts
new file mode 100644
index 00000000..5878f8a2
--- /dev/null
+++ b/src/components/Clipboard/index.ts
@@ -0,0 +1 @@
+export { default as Clipboard } from './Clipboard';
diff --git a/src/components/CloseHeader/CloseHeader.scss b/src/components/CloseHeader/CloseHeader.scss
new file mode 100644
index 00000000..55b51b08
--- /dev/null
+++ b/src/components/CloseHeader/CloseHeader.scss
@@ -0,0 +1,26 @@
+.p2p-close-header {
+ width: 100%;
+ height: 4rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-size: 1.6rem;
+ font-weight: 800;
+ border-bottom: 2px solid #f2f3f4;
+ padding: 2.4rem;
+ margin-bottom: 2.4rem;
+
+ @include mobile {
+ padding: 0.8rem 2.4rem;
+ margin-bottom: 0;
+ }
+
+ &--icon {
+ position: absolute;
+ right: 2rem;
+ cursor: pointer;
+ border: none;
+ background-color: inherit;
+ font-weight: 800;
+ }
+}
diff --git a/src/components/CloseHeader/CloseHeader.tsx b/src/components/CloseHeader/CloseHeader.tsx
new file mode 100644
index 00000000..f6d28e5b
--- /dev/null
+++ b/src/components/CloseHeader/CloseHeader.tsx
@@ -0,0 +1,25 @@
+import { LabelPairedXmarkLgBoldIcon } from '@deriv/quill-icons';
+import { Text } from '@deriv-com/ui';
+
+import { useDevice } from '@/hooks/custom-hooks';
+
+import './CloseHeader.scss';
+
+const CloseHeader = () => {
+ const { isMobile } = useDevice();
+
+ return (
+
+
+ {isMobile ? 'Deriv P2P' : 'Cashier'}
+
+ window.history.back()}
+ />
+
+ );
+};
+
+export default CloseHeader;
diff --git a/src/components/CloseHeader/__tests__/CloseHeader.spec.tsx b/src/components/CloseHeader/__tests__/CloseHeader.spec.tsx
new file mode 100644
index 00000000..1218d6d4
--- /dev/null
+++ b/src/components/CloseHeader/__tests__/CloseHeader.spec.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import CloseHeader from '../CloseHeader';
+
+const mockUseDevice = {
+ isMobile: false,
+};
+
+jest.mock('@/hooks/useDevice', () => ({
+ __esModule: true,
+ default: jest.fn(() => mockUseDevice),
+}));
+let windowHistoryBackSpy: jest.SpyInstance;
+
+describe('CloseHeader', () => {
+ it('should navigate back when cross icon is clicked', async () => {
+ windowHistoryBackSpy = jest.spyOn(window.history, 'back');
+ render( );
+ const crossIcon = screen.getByTestId('dt_close_header_close_icon');
+ await userEvent.click(crossIcon);
+ expect(windowHistoryBackSpy).toBeCalled();
+ });
+ it('should render the correct header title on desktop', () => {
+ render( );
+ expect(screen.queryByText('Cashier')).toBeInTheDocument();
+ });
+ it('should render the correct header title on mobile', () => {
+ mockUseDevice.isMobile = true;
+ render( );
+ expect(screen.queryByText('Deriv P2P')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/CloseHeader/index.ts b/src/components/CloseHeader/index.ts
new file mode 100644
index 00000000..50383680
--- /dev/null
+++ b/src/components/CloseHeader/index.ts
@@ -0,0 +1 @@
+export { default as CloseHeader } from './CloseHeader';
diff --git a/src/components/Dropdown/Dropdown.scss b/src/components/Dropdown/Dropdown.scss
new file mode 100644
index 00000000..35ab3d0e
--- /dev/null
+++ b/src/components/Dropdown/Dropdown.scss
@@ -0,0 +1,144 @@
+.p2p-dropdown {
+ width: 100%;
+ position: relative;
+ cursor: pointer;
+
+ &--disabled {
+ pointer-events: none;
+
+ & label {
+ color: var(--system-light-5-active-background, #999);
+ }
+ }
+
+ &__button {
+ all: unset;
+ right: 1.6rem;
+ transform: rotate(0);
+ transform-origin: 50% 45%;
+ transition: transform 0.2s cubic-bezier(0.25, 0.1, 0.25, 1);
+
+ &--active {
+ transform: rotate(180deg);
+ }
+ }
+
+ &__content {
+ width: 100%;
+ background: var(--system-light-8-primary-background, #fff);
+ display: flex;
+ align-items: center;
+
+ .p2p-textfield__field {
+ cursor: pointer;
+ }
+ }
+
+ &__field {
+ position: absolute;
+ inset: 0;
+ min-width: 0; /* this is required to reset input's default width */
+ padding-left: 2rem;
+ display: flex;
+ flex-grow: 1;
+ font-family: inherit;
+ outline: 0;
+ font-size: 1.4rem;
+ background-color: transparent;
+ color: var(--system-light-2-general-text, #333);
+ transition: border-color 0.2s;
+ cursor: unset;
+ user-select: none;
+ &::selection {
+ background-color: transparent;
+ }
+
+ &::placeholder {
+ color: transparent;
+ }
+ }
+
+ &__field:placeholder-shown ~ &__label {
+ font-size: 1.4rem;
+ cursor: text;
+ top: 30%;
+ padding: 0;
+ }
+
+ &__field:placeholder-shown ~ &__label--with-icon {
+ left: 4.4rem;
+ }
+
+ label,
+ &__field:focus ~ &__label {
+ position: absolute;
+ top: -0.5rem;
+ display: block;
+ transition: 0.2s;
+ font-size: 1rem;
+ color: var(--system-light-3-less-prominent-text, #999);
+ background: var(--system-light-8-primary-background, #fff);
+ padding-inline: 0.4rem;
+ left: 1.6rem;
+ }
+
+ &__field:focus ~ &__label {
+ color: var(--brand-blue, #85acb0);
+ }
+
+ &__items {
+ position: absolute;
+ top: 4.8rem;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ z-index: 2;
+ border-radius: 0.4rem;
+ background: var(--system-light-8-primary-background, #fff);
+ box-shadow: 0 3.2rem 6.4rem 0 rgba(14, 14, 14, 0.14);
+ overflow-y: auto;
+
+ & > :first-child {
+ border-radius: 0.4rem 0.4rem 0 0;
+ }
+
+ & > :last-child {
+ border-radius: 0 0 0.4rem 0.4rem;
+ }
+
+ &--sm {
+ max-height: 22rem;
+ }
+
+ &--md {
+ max-height: 42rem;
+ }
+
+ &--lg {
+ max-height: 66rem;
+ }
+ }
+
+ &__icon {
+ position: absolute;
+ left: 1.6rem;
+ width: 1.6rem;
+ height: 1.6rem;
+ }
+
+ &__item {
+ padding: 1rem 1.6rem;
+ width: 100%;
+ z-index: 2;
+
+ &:hover:not(&--active) {
+ cursor: pointer;
+ background: var(--system-light-6-hover-background, #e6e9e9);
+ }
+
+ &--active {
+ background: var(--system-light-5-active-background, #d6dadb);
+ }
+ }
+}
diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx
new file mode 100644
index 00000000..6cb599ee
--- /dev/null
+++ b/src/components/Dropdown/Dropdown.tsx
@@ -0,0 +1,156 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import clsx from 'clsx';
+import { useCombobox } from 'downshift';
+
+import { LabelPairedChevronDownMdRegularIcon } from '@deriv/quill-icons';
+import { Text } from '@deriv-com/ui';
+
+import { TextField, TextFieldProps } from '@/components';
+import { reactNodeToString } from '@/utils';
+
+import './Dropdown.scss';
+
+type TGenericSizes = '2xl' | '2xs' | '3xl' | '3xs' | '4xl' | '5xl' | '6xl' | 'lg' | 'md' | 'sm' | 'xl' | 'xs';
+
+type TProps = {
+ disabled?: boolean;
+ errorMessage?: TextFieldProps['errorMessage'];
+ icon?: React.ReactNode;
+ isRequired?: boolean;
+ label?: TextFieldProps['label'];
+ list: {
+ text?: React.ReactNode;
+ value?: string;
+ }[];
+ listHeight?: Extract;
+ name: TextFieldProps['name'];
+ onChange?: (inputValue: string) => void;
+ onSelect: (value: string) => void;
+ value?: TextFieldProps['value'];
+ variant?: 'comboBox' | 'prompt';
+};
+
+const Dropdown: React.FC = ({
+ disabled = false,
+ errorMessage = '',
+ icon = false,
+ isRequired = false,
+ label = '',
+ list,
+ listHeight = 'md',
+ name,
+ onChange = () => {
+ // do nothing
+ },
+ onSelect,
+ value,
+ variant = 'prompt',
+}) => {
+ const [items, setItems] = useState(list);
+ const [hasSelected, setHasSelected] = useState(false);
+ const [shouldFilterList, setShouldFilterList] = useState(false);
+ const clearFilter = useCallback(() => {
+ setShouldFilterList(false);
+ setItems(list);
+ }, [list]);
+ const { closeMenu, getInputProps, getItemProps, getMenuProps, getToggleButtonProps, isOpen, openMenu } =
+ useCombobox({
+ defaultSelectedItem: items.find(item => item.value === value) ?? null,
+ items,
+ itemToString(item) {
+ return item ? reactNodeToString(item.text) : '';
+ },
+ onInputValueChange({ inputValue }) {
+ onChange?.(inputValue ?? '');
+ if (shouldFilterList) {
+ setItems(
+ list.filter(item =>
+ reactNodeToString(item.text)
+ .toLowerCase()
+ .includes(inputValue?.toLowerCase() ?? '')
+ )
+ );
+ }
+ },
+ onIsOpenChange({ isOpen }) {
+ if (!isOpen) {
+ clearFilter();
+ }
+ },
+ onSelectedItemChange({ selectedItem }) {
+ onSelect(selectedItem?.value ?? '');
+ closeMenu();
+ },
+ });
+
+ const handleInputClick = useCallback(() => {
+ variant === 'comboBox' && setShouldFilterList(true);
+
+ if (isOpen) {
+ closeMenu();
+ } else {
+ openMenu();
+ }
+ }, [closeMenu, isOpen, openMenu, variant]);
+
+ useEffect(() => {
+ setItems(list);
+ }, [list]);
+
+ return (
+
+
+ setHasSelected(true)}
+ onKeyUp={() => setShouldFilterList(true)}
+ placeholder={reactNodeToString(label)}
+ readOnly={variant !== 'comboBox'}
+ renderLeftIcon={icon ? () => icon : undefined}
+ renderRightIcon={() => (
+
+
+
+ )}
+ type='text'
+ value={value}
+ {...getInputProps()}
+ />
+
+
+ {isOpen &&
+ items.map((item, index) => (
+
+
+ {item.text}
+
+
+ ))}
+
+
+ );
+};
+
+export default Dropdown;
diff --git a/src/components/Dropdown/index.ts b/src/components/Dropdown/index.ts
new file mode 100644
index 00000000..9f1f7763
--- /dev/null
+++ b/src/components/Dropdown/index.ts
@@ -0,0 +1,3 @@
+// TODO: Delete this component once @deriv-com/ui has it published
+
+export { default as Dropdown } from './Dropdown';
diff --git a/src/components/FileDropzone/FadeInMessage/FadeInMessage.scss b/src/components/FileDropzone/FadeInMessage/FadeInMessage.scss
new file mode 100644
index 00000000..5f6f2800
--- /dev/null
+++ b/src/components/FileDropzone/FadeInMessage/FadeInMessage.scss
@@ -0,0 +1,30 @@
+@import '../FileDropzone.scss';
+
+.p2p-fade-in-message {
+ @include file-dropzone-message;
+
+ &--enter-done {
+ opacity: 1;
+ transform: translate3d(0, 0, 0);
+ }
+
+ &--enter {
+ opacity: 0;
+ transform: translate3d(0, -16px, 0);
+ }
+
+ &--enter-active {
+ opacity: 1;
+ transform: translate3d(0, 0, 0);
+ }
+
+ &--exit {
+ opacity: 1;
+ transform: translate3d(0, 0, 0);
+ }
+
+ &--exit-active {
+ opacity: 0;
+ transform: translate3d(0, -16px, 0);
+ }
+}
diff --git a/src/components/FileDropzone/FadeInMessage/FadeInMessage.tsx b/src/components/FileDropzone/FadeInMessage/FadeInMessage.tsx
new file mode 100644
index 00000000..eb6d40aa
--- /dev/null
+++ b/src/components/FileDropzone/FadeInMessage/FadeInMessage.tsx
@@ -0,0 +1,53 @@
+import { PropsWithChildren } from 'react';
+import { CSSTransition } from 'react-transition-group';
+
+import { Text, useDevice } from '@deriv-com/ui';
+
+import { TTextColors } from '@/utils';
+
+import './FadeInMessage.scss';
+
+type TFadeInMessage = {
+ color?: TTextColors;
+ isVisible: boolean;
+ key?: string;
+ noText?: boolean;
+ timeout: number;
+};
+
+const FadeInMessage = ({ children, color, isVisible, key, noText, timeout }: PropsWithChildren) => {
+ const { isMobile } = useDevice();
+
+ return (
+
+ {noText ? (
+ {children}
+ ) : (
+
+ {children}
+
+ )}
+
+ );
+};
+
+export default FadeInMessage;
diff --git a/src/components/FileDropzone/FadeInMessage/index.ts b/src/components/FileDropzone/FadeInMessage/index.ts
new file mode 100644
index 00000000..ed970169
--- /dev/null
+++ b/src/components/FileDropzone/FadeInMessage/index.ts
@@ -0,0 +1 @@
+export { default as FadeInMessage } from './FadeInMessage';
diff --git a/src/components/FileDropzone/FileDropzone.scss b/src/components/FileDropzone/FileDropzone.scss
new file mode 100644
index 00000000..d6c72300
--- /dev/null
+++ b/src/components/FileDropzone/FileDropzone.scss
@@ -0,0 +1,63 @@
+@mixin file-dropzone-message {
+ display: block;
+ max-width: 16.8rem;
+ opacity: 1;
+ pointer-events: none;
+ position: absolute;
+ transform: translate3d(0, 0, 0);
+ transition: transform 0.25s ease, opacity 0.15s linear;
+
+ @include mobile {
+ max-width: 26rem;
+ }
+}
+
+.p2p-file-dropzone {
+ border-radius: 4px;
+ border: 1px dashed #d6dadb;
+ cursor: pointer;
+ height: 14rem;
+ padding: 2rem;
+ position: relative;
+ width: 100%;
+
+ &__content {
+ align-items: center;
+ display: flex;
+ height: 100%;
+ justify-content: center;
+ width: 100%;
+ }
+
+ &__filename {
+ width: 100%;
+ }
+
+ &__message {
+ @include file-dropzone-message;
+ }
+
+ &--has-file {
+ border-style: solid;
+ border-color: #4bb4b3;
+ background-color: #f2f3f4;
+ }
+
+ &--has-error {
+ border-style: solid;
+ border-color: #ec3f3f;
+ }
+
+ &--is-noclick {
+ cursor: auto;
+ }
+
+ &:hover,
+ &:focus {
+ outline: 0;
+ }
+
+ &:hover {
+ background-color: rgba(0, 0, 0, 0.025);
+ }
+}
diff --git a/src/components/FileDropzone/FileDropzone.tsx b/src/components/FileDropzone/FileDropzone.tsx
new file mode 100644
index 00000000..3543480f
--- /dev/null
+++ b/src/components/FileDropzone/FileDropzone.tsx
@@ -0,0 +1,133 @@
+import { useCallback, useRef } from 'react';
+import Dropzone, { DropzoneRef } from 'react-dropzone';
+import classNames from 'classnames';
+
+import { Text } from '@deriv-com/ui';
+
+import { TFileDropzone, truncateFileName } from '@/utils';
+
+import { FadeInMessage } from './FadeInMessage';
+import { PreviewSingle } from './PreviewSingle';
+
+import './FileDropzone.scss';
+
+const DROPZONE_TIMEOUT = 150;
+
+// TODO: Remove this and other associated files once FileDropzone component from deriv-com/ui is completed
+const FileDropzone = ({ className, noClick = false, ...props }: TFileDropzone) => {
+ const {
+ accept,
+ errorMessage,
+ filenameLimit,
+ hoverMessage,
+ maxSize,
+ message,
+ multiple,
+ onDropAccepted,
+ onDropRejected,
+ validationErrorMessage,
+ value,
+ } = props;
+
+ const RenderErrorMessage = useCallback(
+ ({ open }: DropzoneRef) => {
+ if (noClick && typeof message === 'function') return <>{message(open)}>;
+
+ return <>{message}>;
+ },
+ [message, noClick]
+ );
+
+ const RenderValidationErrorMessage = useCallback(
+ ({ open }: DropzoneRef) => {
+ if (typeof validationErrorMessage === 'function') return <>{validationErrorMessage(open)}>;
+
+ return <>{validationErrorMessage}>;
+ },
+ [validationErrorMessage]
+ );
+
+ const dropzoneRef = useRef(null);
+
+ return (
+
+ {({ getInputProps, getRootProps, isDragAccept, isDragActive, isDragReject, open }) => (
+ 0,
+ 'p2p-file-dropzone--is-active': isDragActive,
+ 'p2p-file-dropzone--is-noclick': noClick,
+ })}
+ ref={dropzoneRef}
+ >
+
+
+
+
+
+
+ {hoverMessage}
+
+ {/* Handle cases for displaying multiple files and single filenames */}
+ {multiple && value.length > 0 && !validationErrorMessage
+ ? value.map(file => (
+
+ {filenameLimit ? truncateFileName(file, filenameLimit) : file.name}
+
+ ))
+ : value[0] &&
+ !isDragActive &&
+ !validationErrorMessage &&
}
+
+ {errorMessage}
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default FileDropzone;
diff --git a/src/components/FileDropzone/PreviewSingle/PreviewSingle.scss b/src/components/FileDropzone/PreviewSingle/PreviewSingle.scss
new file mode 100644
index 00000000..8ab5d7f6
--- /dev/null
+++ b/src/components/FileDropzone/PreviewSingle/PreviewSingle.scss
@@ -0,0 +1,11 @@
+@import '../FileDropzone.scss';
+
+.p2p-preview-single {
+ &__filename {
+ width: 100%;
+ }
+
+ &__message {
+ @include file-dropzone-message;
+ }
+}
diff --git a/src/components/FileDropzone/PreviewSingle/PreviewSingle.tsx b/src/components/FileDropzone/PreviewSingle/PreviewSingle.tsx
new file mode 100644
index 00000000..5f305c58
--- /dev/null
+++ b/src/components/FileDropzone/PreviewSingle/PreviewSingle.tsx
@@ -0,0 +1,33 @@
+import { RefObject } from 'react';
+
+import { Text } from '@deriv-com/ui';
+
+import { TFileDropzone, truncateFileName } from '@/utils';
+
+import './PreviewSingle.scss';
+
+type TPreviewSingle = TFileDropzone & {
+ dropzoneRef: RefObject;
+};
+
+const PreviewSingle = (props: TPreviewSingle) => {
+ const { dropzoneRef, filenameLimit, previewSingle, value } = props;
+
+ if (previewSingle) {
+ return {previewSingle}
;
+ }
+
+ return (
+
+
+ {filenameLimit ? truncateFileName(value[0], filenameLimit) : value[0].name}
+
+
+ );
+};
+
+export default PreviewSingle;
diff --git a/src/components/FileDropzone/PreviewSingle/index.ts b/src/components/FileDropzone/PreviewSingle/index.ts
new file mode 100644
index 00000000..e1e429ad
--- /dev/null
+++ b/src/components/FileDropzone/PreviewSingle/index.ts
@@ -0,0 +1 @@
+export { default as PreviewSingle } from './PreviewSingle';
diff --git a/src/components/FileDropzone/index.ts b/src/components/FileDropzone/index.ts
new file mode 100644
index 00000000..4a410dd7
--- /dev/null
+++ b/src/components/FileDropzone/index.ts
@@ -0,0 +1 @@
+export { default as FileDropzone } from './FileDropzone';
diff --git a/src/components/FileUploaderComponent/FileUploaderComponent.scss b/src/components/FileUploaderComponent/FileUploaderComponent.scss
new file mode 100644
index 00000000..b31af4cb
--- /dev/null
+++ b/src/components/FileUploaderComponent/FileUploaderComponent.scss
@@ -0,0 +1,12 @@
+.p2p-file-uploader-component {
+ position: relative;
+
+ &__close-icon {
+ position: absolute;
+ top: 0.8rem;
+ right: 0.8rem;
+ &:hover {
+ cursor: pointer;
+ }
+ }
+}
diff --git a/src/components/FileUploaderComponent/FileUploaderComponent.tsx b/src/components/FileUploaderComponent/FileUploaderComponent.tsx
new file mode 100644
index 00000000..5c66c298
--- /dev/null
+++ b/src/components/FileUploaderComponent/FileUploaderComponent.tsx
@@ -0,0 +1,73 @@
+import { memo, useCallback } from 'react';
+
+import { DerivLightIcCloudUploadIcon, StandaloneCircleXmarkBoldIcon } from '@deriv/quill-icons';
+import { Text } from '@deriv-com/ui';
+
+import { FileDropzone } from '../FileDropzone';
+
+import './FileUploaderComponent.scss';
+
+type TFileUploaderComponentProps = {
+ accept: string;
+ hoverMessage: string;
+ maxSize: number;
+ multiple?: boolean;
+ onClickClose: () => void;
+ onDropAccepted: (files: File[]) => void;
+ onDropRejected: (files: File[]) => void;
+ uploadedMessage: string;
+ validationErrorMessage: string | null;
+ value: (File & { file: Blob })[];
+};
+
+const FileUploaderComponent = ({
+ accept,
+ hoverMessage,
+ maxSize,
+ multiple = false,
+ onClickClose,
+ onDropAccepted,
+ onDropRejected,
+ uploadedMessage,
+ validationErrorMessage,
+ value,
+}: TFileUploaderComponentProps) => {
+ const getUploadMessage = useCallback(() => {
+ return (
+ <>
+
+
+ {uploadedMessage}
+
+ >
+ );
+ }, [uploadedMessage]);
+
+ return (
+
+
+ {(value.length > 0 || !!validationErrorMessage) && (
+
+ )}
+
+ );
+};
+
+export default memo(FileUploaderComponent);
diff --git a/src/components/FileUploaderComponent/index.ts b/src/components/FileUploaderComponent/index.ts
new file mode 100644
index 00000000..33cd55d4
--- /dev/null
+++ b/src/components/FileUploaderComponent/index.ts
@@ -0,0 +1 @@
+export { default as FileUploaderComponent } from './FileUploaderComponent';
diff --git a/src/components/FloatingRate/FloatingRate.scss b/src/components/FloatingRate/FloatingRate.scss
new file mode 100644
index 00000000..da9ecfc5
--- /dev/null
+++ b/src/components/FloatingRate/FloatingRate.scss
@@ -0,0 +1,63 @@
+.p2p-floating-rate {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+
+ @include mobile {
+ margin-top: -2.8rem;
+ margin-bottom: 1.6rem;
+ }
+
+ &__field {
+ display: flex;
+ align-items: center;
+
+ @include mobile {
+ flex-direction: column;
+ }
+
+ &--prefix {
+ @include mobile {
+ margin-bottom: 0.8rem;
+ }
+ margin-inline-end: 2rem;
+ }
+ }
+
+ &__mkt-rate {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: flex-start;
+ padding: 0 0.6rem;
+ background-color: #377cfc14;
+ border-radius: 0px 4px 4px 0px;
+ align-self: stretch;
+ gap: 0.2rem;
+ flex: none;
+
+ @include mobile {
+ padding-top: 0.4rem;
+ padding-bottom: 0.4rem;
+ flex-direction: row;
+ align-items: center;
+ gap: 1rem;
+ border-radius: 0px 0px 4px 4px;
+ }
+ }
+
+ &__hint {
+ padding-inline-start: 4rem;
+ margin-top: 0.3rem;
+ @include mobile {
+ padding-inline-start: 1rem;
+ }
+ }
+
+ &__error-message {
+ padding-inline-start: 4rem;
+ @include mobile {
+ padding-inline-start: 1rem;
+ }
+ }
+}
diff --git a/src/components/FloatingRate/FloatingRate.tsx b/src/components/FloatingRate/FloatingRate.tsx
new file mode 100644
index 00000000..375213a4
--- /dev/null
+++ b/src/components/FloatingRate/FloatingRate.tsx
@@ -0,0 +1,120 @@
+import { ChangeEvent, FocusEvent, useEffect } from 'react';
+
+import { useExchangeRateSubscription } from '@deriv/api-v2';
+import { Text, useDevice } from '@deriv-com/ui';
+import { FormatUtils } from '@deriv-com/utils';
+
+import { api } from '@/hooks';
+import { mobileOSDetect, percentOf, removeTrailingZeros, roundOffDecimal, setDecimalPlaces } from '@/utils';
+
+import InputField from '../InputField';
+
+import './FloatingRate.scss';
+
+type TFloatingRate = {
+ changeHandler?: (event: ChangeEvent) => void;
+ errorMessages: string;
+ fiatCurrency: string;
+ localCurrency: string;
+ name?: string;
+ onChange: (event: ChangeEvent) => void;
+ value?: string;
+};
+
+const FloatingRate = ({
+ changeHandler,
+ errorMessages,
+ fiatCurrency,
+ localCurrency,
+ name,
+ onChange,
+ value,
+}: TFloatingRate) => {
+ const { data: exchangeRateValue, subscribe } = useExchangeRateSubscription();
+ const { isMobile } = useDevice();
+
+ const { data: p2pSettings } = api.settings.useGetSettings();
+ const overrideExchangeRate = p2pSettings?.override_exchange_rate;
+ const marketRate = overrideExchangeRate
+ ? Number(overrideExchangeRate)
+ : exchangeRateValue?.rates?.[localCurrency] ?? 1;
+ const os = mobileOSDetect();
+ const marketFeed = value ? percentOf(marketRate, Number(value)) : marketRate;
+ const decimalPlace = setDecimalPlaces(marketFeed, 6);
+ const textSize = isMobile ? 'sm' : 'xs';
+
+ useEffect(() => {
+ if (localCurrency) {
+ subscribe({
+ base_currency: 'USD',
+ target_currency: localCurrency,
+ });
+ }
+ }, [localCurrency, subscribe]);
+
+ // Input mask for formatting value on blur of floating rate field
+ const onBlurHandler = (event: FocusEvent) => {
+ let floatRate = event.target.value;
+ if (!isNaN(parseFloat(floatRate)) && floatRate.trim().length) {
+ floatRate = parseFloat(floatRate).toFixed(2);
+ if (/^\d+/.test(floatRate) && parseFloat(floatRate) > 0) {
+ // Assign + symbol for positive rate
+ event.target.value = `+${floatRate}`;
+ } else {
+ event.target.value = floatRate;
+ }
+ }
+ onChange(event);
+ };
+
+ return (
+
+
+
+ at
+
+
+
+
+ of the market rate
+
+
+ 1 {fiatCurrency} ={' '}
+ {removeTrailingZeros(
+ FormatUtils.formatMoney(marketRate, {
+ currency: localCurrency,
+ decimalPlaces: decimalPlace,
+ })
+ )}
+
+
+
+ {errorMessages ? (
+
+ {errorMessages}
+
+ ) : (
+
+ Your rate is ={' '}
+ {removeTrailingZeros(
+ FormatUtils.formatMoney(Number(roundOffDecimal(marketFeed, decimalPlace)), {
+ currency: localCurrency,
+ decimalPlaces: decimalPlace,
+ })
+ )}{' '}
+ {localCurrency}
+
+ )}
+
+ );
+};
+
+export default FloatingRate;
diff --git a/src/components/FloatingRate/__tests__/FloatingRate.spec.tsx b/src/components/FloatingRate/__tests__/FloatingRate.spec.tsx
new file mode 100644
index 00000000..ab428ee0
--- /dev/null
+++ b/src/components/FloatingRate/__tests__/FloatingRate.spec.tsx
@@ -0,0 +1,70 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import FloatingRate from '../FloatingRate';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({
+ isMobile: false,
+ }),
+}));
+
+jest.mock('@deriv/api-v2', () => ({
+ p2p: {
+ settings: {
+ useGetSettings: () => ({
+ data: {
+ override_exchange_rate: 1,
+ },
+ }),
+ },
+ },
+ useExchangeRateSubscription: () => ({
+ data: {
+ rates: {
+ USD: 1,
+ },
+ },
+ subscribe: jest.fn(),
+ }),
+}));
+
+const mockProps = {
+ changeHandler: jest.fn(),
+ errorMessages: '',
+ fiatCurrency: 'USD',
+ localCurrency: 'IDR',
+ onChange: jest.fn(),
+ value: 1.1,
+};
+
+describe('FloatingRate', () => {
+ it('should render the component as expected', () => {
+ render( );
+ expect(screen.getByText(/of the market rate/i)).toBeInTheDocument();
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ });
+ it('should handle onChange', async () => {
+ render( );
+ const input = screen.getByDisplayValue('1.1');
+ expect(input).toBeInTheDocument();
+ await userEvent.type(input, '1');
+ expect(mockProps.changeHandler).toHaveBeenCalledTimes(1);
+ });
+ it('should show error message', () => {
+ render( );
+ expect(screen.getByText('Error')).toBeInTheDocument();
+ });
+ it('should display rate message when no errors are there', () => {
+ render( );
+ expect(screen.getByText(/Your rate is =/)).toBeInTheDocument();
+ });
+ it('should handle blur event', async () => {
+ render( );
+ const input = screen.getByDisplayValue('1.1');
+ await userEvent.click(input);
+ await userEvent.tab();
+ expect(input).toHaveValue('+1.10');
+ });
+});
diff --git a/src/components/FloatingRate/index.ts b/src/components/FloatingRate/index.ts
new file mode 100644
index 00000000..6d5b03fe
--- /dev/null
+++ b/src/components/FloatingRate/index.ts
@@ -0,0 +1 @@
+export { default as FloatingRate } from './FloatingRate';
diff --git a/src/components/FlyoutMenu/FlyoutMenu.scss b/src/components/FlyoutMenu/FlyoutMenu.scss
new file mode 100644
index 00000000..32763098
--- /dev/null
+++ b/src/components/FlyoutMenu/FlyoutMenu.scss
@@ -0,0 +1,35 @@
+.p2p-flyout-menu {
+ &__list {
+ position: absolute;
+ top: 4.8rem;
+ right: 0;
+ min-width: 12.8rem;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ z-index: 2;
+ border-radius: 0.4rem;
+ background: #fff;
+ box-shadow: 0 3.2rem 6.4rem 0 rgba(14, 14, 14, 0.14);
+ overflow-y: auto;
+ & > li {
+ width: 100%;
+ & > * {
+ padding: 1rem 1.6rem;
+ width: 100%;
+ display: inline-block;
+ & > * {
+ color: #0e0e0e;
+ font-weight: 400;
+ }
+ &:hover {
+ cursor: pointer;
+ background-color: #e6e9e9;
+ & > * {
+ text-decoration: none;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/FlyoutMenu/FlyoutMenu.tsx b/src/components/FlyoutMenu/FlyoutMenu.tsx
new file mode 100644
index 00000000..2418a2ae
--- /dev/null
+++ b/src/components/FlyoutMenu/FlyoutMenu.tsx
@@ -0,0 +1,33 @@
+import { HTMLAttributes, ReactNode, useRef, useState } from 'react';
+import { useOnClickOutside } from 'usehooks-ts';
+
+import FlyoutMenuList from './FlyoutMenuList';
+import FlyoutMenuToggle from './FlyoutMenuToggle';
+
+import './FlyoutMenu.scss';
+
+type TFlyoutMenuProps = HTMLAttributes & {
+ listItems?: ReactNode[];
+ renderIcon?: () => React.ReactNode;
+};
+
+const FlyoutMenu = ({ listItems, renderIcon, ...props }: TFlyoutMenuProps) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const flyoutMenuRef = useRef(null);
+ useOnClickOutside(flyoutMenuRef, () => {
+ setIsOpen(false);
+ });
+ return (
+
+ {
+ setIsOpen(!isOpen);
+ }}
+ renderIcon={renderIcon}
+ />
+
+
+ );
+};
+
+export default FlyoutMenu;
diff --git a/src/components/FlyoutMenu/FlyoutMenuList.tsx b/src/components/FlyoutMenu/FlyoutMenuList.tsx
new file mode 100644
index 00000000..3c79770a
--- /dev/null
+++ b/src/components/FlyoutMenu/FlyoutMenuList.tsx
@@ -0,0 +1,18 @@
+import { isValidElement, ReactNode } from 'react';
+
+type TFlyoutListProps = {
+ isOpen?: boolean;
+ listItems?: ReactNode[];
+};
+
+const FlyoutList = ({ isOpen = false, listItems }: TFlyoutListProps) => {
+ return isOpen ? (
+
+ {listItems?.map(listItem => {
+ return {listItem} ;
+ })}
+
+ ) : null;
+};
+
+export default FlyoutList;
diff --git a/src/components/FlyoutMenu/FlyoutMenuToggle.tsx b/src/components/FlyoutMenu/FlyoutMenuToggle.tsx
new file mode 100644
index 00000000..558145fb
--- /dev/null
+++ b/src/components/FlyoutMenu/FlyoutMenuToggle.tsx
@@ -0,0 +1,15 @@
+import React, { HTMLAttributes, ReactNode } from 'react';
+
+type TFlyoutToggleProps = HTMLAttributes & {
+ renderIcon?: () => ReactNode;
+};
+
+const FlyoutToggle = ({ renderIcon, ...props }: TFlyoutToggleProps) => {
+ return (
+
+ {renderIcon?.()}
+
+ );
+};
+
+export default FlyoutToggle;
diff --git a/src/components/FlyoutMenu/__tests__/FlyoutList.spec.tsx b/src/components/FlyoutMenu/__tests__/FlyoutList.spec.tsx
new file mode 100644
index 00000000..d40af0c1
--- /dev/null
+++ b/src/components/FlyoutMenu/__tests__/FlyoutList.spec.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from '@testing-library/react';
+
+import FlyoutMenuList from '../FlyoutMenuList';
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ isValidElement: jest.fn().mockReturnValue((item: Record) => !!item.key),
+}));
+
+describe('FlyoutMenuList', () => {
+ it('should render objects as items ', () => {
+ render(Item]]} />);
+ expect(screen.getByText('Item')).toBeInTheDocument();
+ });
+ it('should not render anything if isopen is not provided', () => {
+ render(Item]]} />);
+ expect(screen.queryByText('Item')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/FlyoutMenu/__tests__/FlyoutMenu.spec.tsx b/src/components/FlyoutMenu/__tests__/FlyoutMenu.spec.tsx
new file mode 100644
index 00000000..a7c88b88
--- /dev/null
+++ b/src/components/FlyoutMenu/__tests__/FlyoutMenu.spec.tsx
@@ -0,0 +1,31 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import FlyoutMenu from '../FlyoutMenu';
+
+const flyoutItems = ['item1', 'item2', 'item3'];
+
+describe('FlyoutMenu', () => {
+ it('should render the flyout menu correctly', () => {
+ render( 'MockIcCashierVerticalEllipsis'} />);
+ expect(screen.getByText('MockIcCashierVerticalEllipsis')).toBeInTheDocument();
+ });
+ it('should display the menu items when the icon is clicked', async () => {
+ render( );
+ await userEvent.click(screen.getByTestId('dt_flyout_toggle'));
+ flyoutItems.forEach(item => {
+ expect(screen.getByText(item)).toBeInTheDocument();
+ });
+ });
+ it('should hide the flyout menu list when the parent is clicked', async () => {
+ render(
+
+
+
+ );
+ await userEvent.click(screen.getByTestId('dt_flyout_toggle'));
+ expect(screen.queryByText(flyoutItems[0])).toBeInTheDocument();
+ await userEvent.click(screen.getByTestId('dt_flyout_parent'));
+ expect(screen.queryByText(flyoutItems[0])).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/FlyoutMenu/index.ts b/src/components/FlyoutMenu/index.ts
new file mode 100644
index 00000000..76059bfd
--- /dev/null
+++ b/src/components/FlyoutMenu/index.ts
@@ -0,0 +1 @@
+export { default as FlyoutMenu } from './FlyoutMenu';
diff --git a/src/components/FormProgress/FormProgress.scss b/src/components/FormProgress/FormProgress.scss
new file mode 100644
index 00000000..57acd822
--- /dev/null
+++ b/src/components/FormProgress/FormProgress.scss
@@ -0,0 +1,74 @@
+.p2p-form-progress {
+ width: 100%;
+ position: relative;
+
+ &__header {
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ margin-bottom: 3.2rem;
+ }
+ &__step {
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ height: 6rem;
+ justify-content: space-around;
+ width: 16rem;
+ z-index: 2;
+
+ & .p2p-identifier {
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.4rem;
+ height: 2.4rem;
+ background-color: #999999;
+ border: 1px solid #f2f3f4;
+ z-index: 1;
+
+ &--active {
+ background-color: #ff444f;
+ }
+ }
+ }
+ &__steps {
+ align-items: center;
+ display: flex;
+ justify-content: center;
+ margin-top: 2rem;
+ position: relative;
+
+ &--before {
+ bottom: 0;
+ content: '';
+ left: 0;
+ margin: 0 auto; /* this centers the line to the full width specified */
+ position: absolute; /* positioning must be absolute here, and relative positioning must be applied to the parent */
+ right: 0;
+ top: 1.7rem;
+ border-top: 2px solid #999999;
+ }
+ &--after {
+ border-top: 2px solid #ff444f;
+ transition: width 0.3s ease;
+ position: absolute;
+ @include desktop {
+ bottom: 0;
+ content: '';
+ left: 0;
+ margin: 0 auto; /* this centers the line to the full width specified */
+ top: 1.7rem;
+ }
+ @include mobile {
+ top: 0;
+ }
+ }
+ }
+
+ &--initial {
+ border-top: 2px solid #d6d6d6;
+ }
+}
diff --git a/src/components/FormProgress/FormProgress.tsx b/src/components/FormProgress/FormProgress.tsx
new file mode 100644
index 00000000..352e05c0
--- /dev/null
+++ b/src/components/FormProgress/FormProgress.tsx
@@ -0,0 +1,93 @@
+//TODO: Below component will be removed once deriv-com/ui form-progress is ready
+import React, { memo, useEffect, useRef } from 'react';
+import clsx from 'clsx';
+import { TStep } from 'types';
+import { Text, useDevice } from '@deriv-com/ui';
+import './FormProgress.scss';
+
+type TFormProgress = {
+ currentStep: number;
+ steps: TStep[];
+ subSectionIndex?: number;
+};
+
+const FormProgress = ({ currentStep, steps = [], subSectionIndex = 0 }: TFormProgress) => {
+ const { isDesktop } = useDevice();
+ useEffect(() => {
+ animateCompleteBar();
+ });
+
+ const elCompletedBar = useRef(null);
+
+ const animateCompleteBar = () => {
+ const subStepsCount = steps[currentStep]?.subStepCount || null;
+ const elFirstIdentifier = (document.querySelector('.p2p-identifier') as HTMLSpanElement) || {
+ clientWidth: 1,
+ offsetLeft: 0,
+ };
+ const each = 100 / steps.length;
+ const subDivisions = subStepsCount ? Math.ceil(each / subStepsCount) : 0;
+ if (elCompletedBar.current) {
+ elCompletedBar.current.style.width = `${currentStep * each + subSectionIndex * subDivisions}%`;
+ elCompletedBar.current.style.transform = `translateX(${
+ elFirstIdentifier.offsetLeft + elFirstIdentifier.clientWidth / 2
+ }px)`;
+ }
+ };
+
+ let active = false;
+
+ return (
+ <>
+ {isDesktop ? (
+
+
+
+
+ {steps.map((item, idx) => (
+
+ {(active = idx === currentStep)}
+
+
+ {idx + 1}
+
+
+ {item.header.title}
+
+
+
+ ))}
+
+
+
+
+ ) : (
+
+ )}
+ >
+ );
+};
+
+export default memo(FormProgress);
diff --git a/src/components/FormProgress/index.ts b/src/components/FormProgress/index.ts
new file mode 100644
index 00000000..210a3aba
--- /dev/null
+++ b/src/components/FormProgress/index.ts
@@ -0,0 +1 @@
+export { default as FormProgress } from './FormProgress';
diff --git a/src/components/FullPageMobileWrapper/FullPageMobileWrapper.scss b/src/components/FullPageMobileWrapper/FullPageMobileWrapper.scss
new file mode 100644
index 00000000..b13fef09
--- /dev/null
+++ b/src/components/FullPageMobileWrapper/FullPageMobileWrapper.scss
@@ -0,0 +1,39 @@
+.p2p-mobile-wrapper {
+ @include mobile {
+ height: calc(100vh - 4rem);
+ width: 100vw;
+ border-radius: 0;
+ display: grid;
+ grid-template-rows: 6rem auto;
+ background-color: #fff;
+
+ &--fixed-footer {
+ grid-template-rows: 6rem auto 8rem;
+ }
+
+ &--no-header {
+ grid-template-rows: auto;
+ }
+
+ &--no-header-fixed-footer {
+ grid-template-rows: auto 7rem;
+ }
+
+ &--no-footer {
+ grid-template-rows: 6rem auto;
+ }
+ }
+
+ &__header {
+ align-items: center;
+ padding: 2rem;
+ border-bottom: 2px solid #f2f3f4;
+ display: flex;
+ gap: 2rem;
+ }
+
+ &__footer {
+ padding: 2rem;
+ border-top: 2px solid #f2f3f4;
+ }
+}
diff --git a/src/components/FullPageMobileWrapper/FullPageMobileWrapper.tsx b/src/components/FullPageMobileWrapper/FullPageMobileWrapper.tsx
new file mode 100644
index 00000000..0133c7fa
--- /dev/null
+++ b/src/components/FullPageMobileWrapper/FullPageMobileWrapper.tsx
@@ -0,0 +1,51 @@
+import React, { PropsWithChildren } from 'react';
+import clsx from 'clsx';
+import { LabelPairedArrowLeftLgBoldIcon } from '@deriv/quill-icons';
+import './FullPageMobileWrapper.scss';
+
+type TFullPageMobileWrapperProps = {
+ className?: string;
+ onBack?: () => void;
+ renderFooter?: () => React.ReactNode;
+ renderHeader?: () => React.ReactNode;
+ shouldFixedFooter?: boolean;
+ shouldShowBackIcon?: boolean;
+};
+
+const FullPageMobileWrapper = ({
+ children,
+ className = '',
+ onBack = () => undefined,
+ renderFooter,
+ renderHeader,
+ shouldFixedFooter = true,
+ shouldShowBackIcon = true,
+}: PropsWithChildren) => {
+ return (
+
+ {renderHeader && (
+
+ {shouldShowBackIcon && (
+
+ )}
+ {renderHeader()}
+
+ )}
+
{children}
+ {renderFooter &&
{renderFooter()}
}
+
+ );
+};
+
+export default FullPageMobileWrapper;
diff --git a/src/components/FullPageMobileWrapper/__tests__/FullPageMobileWrapper.spec.tsx b/src/components/FullPageMobileWrapper/__tests__/FullPageMobileWrapper.spec.tsx
new file mode 100644
index 00000000..38ac09e2
--- /dev/null
+++ b/src/components/FullPageMobileWrapper/__tests__/FullPageMobileWrapper.spec.tsx
@@ -0,0 +1,62 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import FullPageMobileWrapper from '../FullPageMobileWrapper';
+
+const Header = () => Header ;
+const Footer = () => Footer ;
+const Body = () => Body ;
+
+describe('FullPageMobileWrapper', () => {
+ it('should render body, header and/or footer', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Header')).toBeVisible();
+ expect(screen.getByText('Body')).toBeVisible();
+ expect(screen.getByText('Footer')).toBeVisible();
+ });
+ it('should render header only with back functionality', async () => {
+ const spyOnBack = jest.fn();
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Header')).toBeVisible();
+ expect(screen.getByText('Body')).toBeVisible();
+ expect(screen.queryByText('Footer')).not.toBeInTheDocument();
+
+ const backBtn = screen.getByTestId('dt_mobile_wrapper_button');
+ await userEvent.click(backBtn);
+ expect(spyOnBack).toBeCalled();
+ });
+ it('should render footer only without back functionality', () => {
+ const spyOnBack = jest.fn();
+
+ render(
+
+
+
+ );
+
+ expect(screen.queryByText('Header')).not.toBeInTheDocument();
+ expect(screen.getByText('Body')).toBeVisible();
+ expect(screen.queryByText('Footer')).toBeVisible();
+ expect(screen.queryByTestId('dt_mobile_wrapper_button')).not.toBeInTheDocument();
+ });
+ it('should hide the left arrow icon', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.queryByTestId('dt_mobile_wrapper_button')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/FullPageMobileWrapper/index.ts b/src/components/FullPageMobileWrapper/index.ts
new file mode 100644
index 00000000..a7e1d43f
--- /dev/null
+++ b/src/components/FullPageMobileWrapper/index.ts
@@ -0,0 +1 @@
+export { default as FullPageMobileWrapper } from './FullPageMobileWrapper';
diff --git a/src/components/Input/Input.scss b/src/components/Input/Input.scss
new file mode 100644
index 00000000..e210602b
--- /dev/null
+++ b/src/components/Input/Input.scss
@@ -0,0 +1,65 @@
+.p2p-input {
+ display: flex;
+ justify-content: center;
+ margin: 0.5rem 0 3.6rem;
+ width: 100%;
+
+ &__leading-icon {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ margin: 0 1.6rem;
+ }
+
+ @include mobile {
+ flex-direction: column;
+ margin-bottom: 1rem;
+ }
+
+ &__error {
+ padding-top: 0.2rem;
+ position: absolute;
+ margin-left: 1rem;
+ bottom: 13.3rem;
+ left: 4.5rem;
+
+ @include mobile {
+ bottom: 0;
+ box-decoration-break: clone;
+ padding-top: 0.4rem;
+ margin: 0 0 0.6rem 1.6rem;
+ margin-bottom: 0.6rem;
+ position: relative;
+ left: 0;
+ }
+ }
+
+ &__field {
+ display: flex;
+ width: 92%;
+ height: 4rem;
+ padding: 1rem 1.6rem;
+ justify-content: center;
+ align-items: center;
+ border-radius: 4px;
+ border: 1px solid #d6dadb;
+
+ @include mobile {
+ width: 100%;
+ }
+
+ &:focus-visible {
+ outline: none;
+ border-color: #85acb0;
+ }
+
+ &::placeholder {
+ color: #999;
+ }
+
+ &--error {
+ // stylelint-disable-next-line declaration-no-important
+ border-color: #ec3f3f !important;
+ }
+ }
+}
diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx
new file mode 100644
index 00000000..4783c72d
--- /dev/null
+++ b/src/components/Input/Input.tsx
@@ -0,0 +1,51 @@
+import React, { forwardRef, ReactElement } from 'react';
+import clsx from 'clsx';
+
+import { Text } from '@deriv-com/ui';
+
+import { useDevice } from '@/hooks/custom-hooks';
+
+import './Input.scss';
+
+type TInputProps = {
+ errorMessage?: string;
+ hasError?: boolean;
+ leadingIcon?: ReactElement;
+ name: string;
+ onBlur?: () => void;
+ onChange?: () => void;
+ placeholder?: string;
+ value?: string;
+};
+
+const Input = forwardRef(
+ ({ errorMessage, hasError, leadingIcon, name, onBlur, onChange, placeholder, value, ...props }, ref) => {
+ const { isMobile } = useDevice();
+
+ return (
+
+ {leadingIcon &&
{leadingIcon}
}
+
+ {hasError && (
+
+ {errorMessage}
+
+ )}
+
+ );
+ }
+);
+
+Input.displayName = 'Input';
+
+export default Input;
diff --git a/src/components/Input/index.ts b/src/components/Input/index.ts
new file mode 100644
index 00000000..b4d38647
--- /dev/null
+++ b/src/components/Input/index.ts
@@ -0,0 +1 @@
+export { default as Input } from './Input';
diff --git a/src/components/InputField/InputField.scss b/src/components/InputField/InputField.scss
new file mode 100644
index 00000000..2efe4688
--- /dev/null
+++ b/src/components/InputField/InputField.scss
@@ -0,0 +1,46 @@
+.p2p-input-field {
+ width: 100%;
+ position: relative;
+ &__prefix {
+ position: absolute;
+ right: 33%;
+ top: 1rem;
+
+ @include mobile {
+ right: 45%;
+ }
+ }
+ .deriv-input {
+ position: relative;
+ border-radius: 4px 0px 0px 4px;
+ @include mobile {
+ border-radius: 4px 4px 0px 0px;
+ }
+
+ &__field {
+ padding-right: 3rem;
+ @include desktop {
+ padding-right: 40%;
+ }
+ }
+
+ &__left-content {
+ position: absolute;
+ left: 0;
+ & .deriv-text {
+ display: flex;
+ }
+ }
+
+ &__right-content {
+ position: absolute;
+ right: 0;
+ & .deriv-text {
+ display: flex;
+ }
+ }
+ &__helper-message {
+ display: none;
+ }
+ }
+}
diff --git a/src/components/InputField/InputField.tsx b/src/components/InputField/InputField.tsx
new file mode 100644
index 00000000..a27e7208
--- /dev/null
+++ b/src/components/InputField/InputField.tsx
@@ -0,0 +1,206 @@
+import React, { ChangeEvent, FocusEventHandler, KeyboardEvent, MouseEvent, TouchEvent, useRef, useState } from 'react';
+import { LabelPairedMinusSmBoldIcon, LabelPairedPlusSmBoldIcon } from '@deriv/quill-icons';
+import { Button, Input, Text, useDevice } from '@deriv-com/ui';
+import './InputField.scss';
+
+export type TChangeEvent = ChangeEvent;
+
+type TInputField = {
+ decimalPointChange?: number;
+ isError?: boolean;
+ name?: string;
+ onBlur?: FocusEventHandler;
+ onChange?: (e: TChangeEvent) => void;
+ type?: string;
+ value: number | string;
+};
+
+const InputField = ({ decimalPointChange, isError, name = '', onBlur, onChange, type = '', value }: TInputField) => {
+ const { isMobile } = useDevice();
+ const [localValue, setLocalValue] = useState();
+ const intervalRef = useRef>();
+ const timeoutRef = useRef>();
+ const isLongPressRef = useRef(false);
+
+ const handleButtonPress = (
+ onChange: (e: MouseEvent | TouchEvent, step: number) => void
+ ) => {
+ const handleTimeout = (ev: MouseEvent | TouchEvent) => {
+ timeoutRef.current = setTimeout(() => {
+ isLongPressRef.current = true;
+ let step = 1;
+ onChange(ev, step);
+ intervalRef.current = setInterval(() => {
+ onChange(ev, ++step);
+ }, 50);
+ }, 300);
+ };
+
+ return (ev: MouseEvent | TouchEvent) => {
+ handleTimeout(ev);
+ };
+ };
+
+ const handleButtonRelease = () => {
+ clearInterval(intervalRef.current);
+ clearTimeout(timeoutRef.current);
+
+ if (onLongPressEnd && isLongPressRef.current) onLongPressEnd();
+
+ isLongPressRef.current = false;
+ };
+
+ const getPressEvents = (
+ onChange: (e: MouseEvent | TouchEvent, step: number) => void
+ ) => {
+ return {
+ onContextMenu: (e: MouseEvent | TouchEvent) => e.preventDefault(),
+ onMouseDown: handleButtonPress(onChange),
+ onMouseUp: handleButtonRelease,
+ onTouchEnd: handleButtonRelease,
+ onTouchStart: handleButtonPress(onChange),
+ };
+ };
+
+ const changeValue = (e: ChangeEvent, callback?: (evt: ChangeEvent) => void) => {
+ if (e.target.value === value && type !== 'checkbox') {
+ return;
+ }
+
+ if (type === 'number' || type === 'tel') {
+ const isEmpty = !e.target.value || e.target.value === '' || e.target.value === ' ';
+ const signedRegex = '^([+-.0-9])';
+ e.target.value = e.target.value.replace(',', '.');
+
+ const isNumber = new RegExp(`${signedRegex}(\\d*)?${'(\\.\\d+)?'}$`).test(e.target.value);
+
+ const isNotCompletedNumber = new RegExp(`${signedRegex}(\\.|\\d+\\.)?$`).test(e.target.value);
+
+ if (isNumber || isEmpty) {
+ (e.target.value as number | string) = e.target.value;
+ } else if (!isNotCompletedNumber) {
+ (e.target.value as number | string) = value;
+ return;
+ }
+ }
+
+ onChange?.(e);
+ if (callback) {
+ callback(e);
+ }
+ };
+
+ const getDecimals = (val: number | string) => {
+ const arrayValue = typeof val === 'string' ? val.split('.') : val.toString().split('.');
+ return arrayValue && arrayValue.length > 1 ? arrayValue[1].length : 0;
+ };
+
+ const incrementValue = () => {
+ const currentValue = localValue || value.toString();
+
+ const decimalPlaces = currentValue ? getDecimals(currentValue) : 0;
+
+ const newValue =
+ parseFloat(currentValue || '0') +
+ parseFloat((1 * 10 ** (0 - (decimalPointChange ?? decimalPlaces))).toString());
+ const incrementValue = parseFloat(newValue.toString()).toFixed(decimalPointChange ?? decimalPlaces);
+
+ updateValue(incrementValue);
+ };
+
+ const calculateDecrementedValue = () => {
+ const currentValue = localValue || value?.toString();
+
+ const decimalPlaces = currentValue ? getDecimals(currentValue) : 0;
+ const newValue =
+ parseFloat(currentValue || '0') -
+ parseFloat((1 * 10 ** (0 - (decimalPointChange ?? decimalPlaces))).toString());
+ const decrementValue = parseFloat(newValue.toString()).toFixed(decimalPointChange ?? decimalPlaces);
+
+ return decrementValue;
+ };
+
+ const decrementValue = () => {
+ const decrementValue = calculateDecrementedValue();
+
+ updateValue(decrementValue);
+ };
+
+ const updateValue = (newValue: string) => {
+ let formattedValue = newValue;
+
+ if (/^\d+/.test(formattedValue) && +formattedValue > 0) {
+ formattedValue = `+${formattedValue}`;
+ }
+ onChange?.({ target: { value: formattedValue, name } });
+ };
+
+ const onLongPressEnd = () => {
+ const newValue = localValue;
+ const formattedValue = newValue;
+ onChange?.({ target: { value: formattedValue || '', name } });
+
+ setLocalValue('');
+ };
+
+ const onKeyPressed = (e: KeyboardEvent) => {
+ if (e.keyCode === 38) incrementValue(); // up-arrow pressed
+ if (e.keyCode === 40) decrementValue(); // down-arrow pressed
+ };
+
+ const onChangeValue = (e: ChangeEvent) => {
+ if (navigator.userAgent.indexOf('Safari') !== -1 && type !== 'checkbox') {
+ const cursor = e.target.selectionStart;
+ changeValue(e, evt => {
+ evt.target.selectionEnd = cursor; // reset the cursor position in callback
+ });
+ } else {
+ changeValue(e);
+ }
+ };
+
+ return (
+
+
+ %
+
+
+
+
+ }
+ onBlur={onBlur}
+ onChange={onChangeValue}
+ onKeyDown={onKeyPressed}
+ rightPlaceholder={
+
+
+
+ }
+ value={value}
+ wrapperClassName='lg:w-auto w-full'
+ />
+
+ );
+};
+
+export default InputField;
diff --git a/src/components/InputField/__tests__/InputField.spec.tsx b/src/components/InputField/__tests__/InputField.spec.tsx
new file mode 100644
index 00000000..1bb60a2b
--- /dev/null
+++ b/src/components/InputField/__tests__/InputField.spec.tsx
@@ -0,0 +1,64 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import InputField from '../InputField';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({
+ isMobile: false,
+ }),
+}));
+
+const mockProps = {
+ isError: false,
+ name: 'test',
+ onBlur: jest.fn(),
+ onChange: jest.fn(),
+ type: 'number',
+ value: 0,
+};
+describe('InputField', () => {
+ it('should render the component as expected', () => {
+ render( );
+ expect(screen.getByDisplayValue('0')).toBeInTheDocument();
+ });
+ it('should handle onChange', async () => {
+ render( );
+ const input = screen.getByDisplayValue('0');
+ expect(input).toBeInTheDocument();
+ await userEvent.type(input, '1');
+ expect(mockProps.onChange).toHaveBeenCalledTimes(1);
+ });
+ it('should handle increment change on plus button click', async () => {
+ render( );
+ const plusButton = screen.getByTestId('dt_input_field_increment');
+ await userEvent.click(plusButton);
+ expect(mockProps.onChange).toHaveBeenCalled();
+ });
+ it('should handle decrement change on minus button click', async () => {
+ render( );
+ const minusButton = screen.getByTestId('dt_input_field_decrement');
+ await userEvent.click(minusButton);
+ expect(mockProps.onChange).toHaveBeenCalled();
+ });
+ it('should handle onBlur', async () => {
+ render( );
+ const input = screen.getByDisplayValue('0');
+ await userEvent.click(input);
+ await userEvent.tab();
+ expect(mockProps.onBlur).toHaveBeenCalled();
+ });
+ it('should handle keyboard button press for increment', async () => {
+ render( );
+ const input = screen.getByDisplayValue('0');
+ await userEvent.type(input, '{ArrowUp}');
+ expect(mockProps.onChange).toHaveBeenCalled();
+ });
+ it('should handle keyboard button press for decrement', async () => {
+ render( );
+ const input = screen.getByDisplayValue('0');
+ await userEvent.type(input, '{ArrowDown}');
+ expect(mockProps.onChange).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/InputField/index.ts b/src/components/InputField/index.ts
new file mode 100644
index 00000000..22109d5b
--- /dev/null
+++ b/src/components/InputField/index.ts
@@ -0,0 +1,3 @@
+import InputField from './InputField';
+
+export default InputField;
diff --git a/src/components/LightDivider/LightDivider.tsx b/src/components/LightDivider/LightDivider.tsx
new file mode 100644
index 00000000..ca710c67
--- /dev/null
+++ b/src/components/LightDivider/LightDivider.tsx
@@ -0,0 +1,13 @@
+import { Divider } from '@deriv-com/ui';
+
+type TLightDividerProps = {
+ className?: string;
+ height?: string;
+ margin?: string;
+};
+
+const LightDivider = ({ className, height, margin }: TLightDividerProps) => {
+ return ;
+};
+
+export default LightDivider;
diff --git a/src/components/LightDivider/index.ts b/src/components/LightDivider/index.ts
new file mode 100644
index 00000000..295da5ff
--- /dev/null
+++ b/src/components/LightDivider/index.ts
@@ -0,0 +1 @@
+export { default as LightDivider } from './LightDivider';
diff --git a/src/components/MobileTabs/MobileTabs.scss b/src/components/MobileTabs/MobileTabs.scss
new file mode 100644
index 00000000..c6ff47a9
--- /dev/null
+++ b/src/components/MobileTabs/MobileTabs.scss
@@ -0,0 +1,17 @@
+.p2p-mobile-tabs {
+ display: flex;
+ flex-direction: column;
+
+ &__tab {
+ padding: 3rem 1.6rem;
+ display: flex;
+ justify-content: space-between;
+ border-bottom: 0.1rem solid #f2f3f4;
+ flex-direction: row-reverse;
+ border-radius: unset;
+
+ &:last-child {
+ border-bottom: none;
+ }
+ }
+}
diff --git a/src/components/MobileTabs/MobileTabs.tsx b/src/components/MobileTabs/MobileTabs.tsx
new file mode 100644
index 00000000..3de9aeeb
--- /dev/null
+++ b/src/components/MobileTabs/MobileTabs.tsx
@@ -0,0 +1,30 @@
+import { LabelPairedChevronRightSmRegularIcon } from '@deriv/quill-icons';
+import { Button, Text } from '@deriv-com/ui';
+
+import './MobileTabs.scss';
+
+type TMobileTabsProps = {
+ onChangeTab: (clickedTab: T[number]) => void;
+ tabs: T;
+};
+
+function MobileTabs({ onChangeTab, tabs }: TMobileTabsProps) {
+ return (
+
+ {tabs.map((tab, i) => (
+ }
+ key={`${tab}-${i}`}
+ onClick={() => onChangeTab(tab)}
+ variant='contained'
+ >
+ {tab}
+
+ ))}
+
+ );
+}
+
+export default MobileTabs;
diff --git a/src/components/MobileTabs/index.ts b/src/components/MobileTabs/index.ts
new file mode 100644
index 00000000..110b10d9
--- /dev/null
+++ b/src/components/MobileTabs/index.ts
@@ -0,0 +1 @@
+export { default as MobileTabs } from './MobileTabs';
diff --git a/src/components/Modals/AdCancelCreateEditModal/AdCancelCreateEditModal.scss b/src/components/Modals/AdCancelCreateEditModal/AdCancelCreateEditModal.scss
new file mode 100644
index 00000000..d3d94812
--- /dev/null
+++ b/src/components/Modals/AdCancelCreateEditModal/AdCancelCreateEditModal.scss
@@ -0,0 +1,31 @@
+.p2p-ad-cancel-create-edit-modal {
+ border-radius: 8px;
+ width: 44rem;
+ height: fit-content;
+ @include mobile {
+ max-width: calc(100vw - 3.2rem);
+ }
+
+ &__header {
+ height: unset;
+ padding: 2.4rem;
+ @include mobile {
+ padding: 1.6rem;
+ }
+ }
+ &__body {
+ padding: 0.8rem 2.4rem;
+ @include mobile {
+ padding: 0.8rem 1.6rem;
+ }
+ }
+
+ &__footer {
+ gap: 0.8rem;
+ padding-bottom: 2.4rem;
+
+ @include mobile {
+ padding-bottom: 1.6rem;
+ }
+ }
+}
diff --git a/src/components/Modals/AdCancelCreateEditModal/AdCancelCreateEditModal.tsx b/src/components/Modals/AdCancelCreateEditModal/AdCancelCreateEditModal.tsx
new file mode 100644
index 00000000..5ff43f90
--- /dev/null
+++ b/src/components/Modals/AdCancelCreateEditModal/AdCancelCreateEditModal.tsx
@@ -0,0 +1,58 @@
+import { useHistory } from 'react-router-dom';
+
+import { Button, Modal, Text, useDevice } from '@deriv-com/ui';
+
+import { MY_ADS_URL } from '@/constants';
+import { useQueryString } from '@/hooks/custom-hooks';
+
+import './AdCancelCreateEditModal.scss';
+
+type TAdCancelCreateEditModalProps = {
+ isModalOpen: boolean;
+ onRequestClose: () => void;
+};
+
+const AdCancelCreateEditModal = ({ isModalOpen, onRequestClose }: TAdCancelCreateEditModalProps) => {
+ const { isMobile } = useDevice();
+ const history = useHistory();
+ const { queryString } = useQueryString();
+ const { advertId = '' } = queryString;
+ const isEdit = !!advertId;
+ const textSize = isMobile ? 'md' : 'sm';
+ return (
+
+
+ {isEdit ? 'Cancel your edits?' : 'Cancel ad creation?'}
+
+
+
+ {isEdit
+ ? `If you choose to cancel, the edited details will be lost.`
+ : `If you choose to cancel, the details you've entered will be lost.`}
+
+
+
+ history.push(MY_ADS_URL)}
+ size='lg'
+ textSize={textSize}
+ variant='outlined'
+ >
+ Cancel
+
+
+ Don’t cancel
+
+
+
+ );
+};
+
+export default AdCancelCreateEditModal;
diff --git a/src/components/Modals/AdCancelCreateEditModal/__tests__/AdCancelCreateEditModal.spec.tsx b/src/components/Modals/AdCancelCreateEditModal/__tests__/AdCancelCreateEditModal.spec.tsx
new file mode 100644
index 00000000..cf78d5a2
--- /dev/null
+++ b/src/components/Modals/AdCancelCreateEditModal/__tests__/AdCancelCreateEditModal.spec.tsx
@@ -0,0 +1,54 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { MY_ADS_URL } from '@/constants';
+import { useQueryString } from '@/hooks/custom-hooks';
+
+import AdCancelCreateEditModal from '../AdCancelCreateEditModal';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: false }),
+}));
+
+jest.mock('@/hooks', () => ({
+ ...jest.requireActual('@/hooks'),
+ useQueryString: jest.fn().mockReturnValue({ queryString: { advertId: '' } }),
+}));
+
+const mockUseQueryString = useQueryString as jest.Mock;
+
+const mockFn = jest.fn();
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useHistory: () => ({ push: mockFn }),
+}));
+
+const mockProps = {
+ isModalOpen: true,
+ onRequestClose: jest.fn(),
+};
+
+describe('AdCancelCreateEditModal', () => {
+ it('should render the component as expected', () => {
+ render( );
+ expect(screen.getByText('Cancel ad creation?')).toBeInTheDocument();
+ });
+ it('should render the component as expected when isEdit is true', () => {
+ mockUseQueryString.mockReturnValueOnce({ queryString: { advertId: '123' } });
+ render( );
+ expect(screen.getByText('Cancel your edits?')).toBeInTheDocument();
+ });
+ it('should redirect to my ads page on clicking cancel button', async () => {
+ render( );
+ const button = screen.getByRole('button', { name: 'Cancel' });
+ await userEvent.click(button);
+ expect(mockFn).toHaveBeenCalledWith(MY_ADS_URL);
+ });
+ it("should close the modal on clicking don't cancel button", async () => {
+ render( );
+ const button = screen.getByRole('button', { name: 'Don’t cancel' });
+ await userEvent.click(button);
+ expect(mockProps.onRequestClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/components/Modals/AdCancelCreateEditModal/index.ts b/src/components/Modals/AdCancelCreateEditModal/index.ts
new file mode 100644
index 00000000..2fa88352
--- /dev/null
+++ b/src/components/Modals/AdCancelCreateEditModal/index.ts
@@ -0,0 +1 @@
+export { default as AdCancelCreateEditModal } from './AdCancelCreateEditModal';
diff --git a/src/components/Modals/AdConditionsModal/AdConditionsModal.scss b/src/components/Modals/AdConditionsModal/AdConditionsModal.scss
new file mode 100644
index 00000000..091e8406
--- /dev/null
+++ b/src/components/Modals/AdConditionsModal/AdConditionsModal.scss
@@ -0,0 +1,8 @@
+.p2p-ad-conditions-modal {
+ border-radius: 8px;
+ width: 44rem;
+ height: fit-content;
+ @include mobile {
+ max-width: calc(100vw - 3.2rem);
+ }
+}
diff --git a/src/components/Modals/AdConditionsModal/AdConditionsModal.tsx b/src/components/Modals/AdConditionsModal/AdConditionsModal.tsx
new file mode 100644
index 00000000..7c6b97e0
--- /dev/null
+++ b/src/components/Modals/AdConditionsModal/AdConditionsModal.tsx
@@ -0,0 +1,39 @@
+import { Button, Modal, Text, useDevice } from '@deriv-com/ui';
+
+import { AD_CONDITION_CONTENT } from '@/constants';
+
+import './AdConditionsModal.scss';
+
+type TAdConditionsModalProps = {
+ isModalOpen: boolean;
+ onRequestClose: () => void;
+ type: string;
+};
+
+const AdConditionsModal = ({ isModalOpen, onRequestClose, type }: TAdConditionsModalProps) => {
+ const { isMobile } = useDevice();
+ return (
+
+
+ {AD_CONDITION_CONTENT[type].title}
+
+
+
+ {AD_CONDITION_CONTENT[type].description}
+
+
+
+
+ OK
+
+
+
+ );
+};
+
+export default AdConditionsModal;
diff --git a/src/components/Modals/AdConditionsModal/index.ts b/src/components/Modals/AdConditionsModal/index.ts
new file mode 100644
index 00000000..d3efee3e
--- /dev/null
+++ b/src/components/Modals/AdConditionsModal/index.ts
@@ -0,0 +1 @@
+export { default as AdConditionsModal } from './AdConditionsModal';
diff --git a/src/components/Modals/AdCreateEditErrorModal/AdCreateEditErrorModal.scss b/src/components/Modals/AdCreateEditErrorModal/AdCreateEditErrorModal.scss
new file mode 100644
index 00000000..fd14e103
--- /dev/null
+++ b/src/components/Modals/AdCreateEditErrorModal/AdCreateEditErrorModal.scss
@@ -0,0 +1,29 @@
+.p2p-ad-create-edit-error-modal {
+ height: fit-content;
+ width: 44rem;
+ border-radius: 8px;
+ @include mobile {
+ max-width: calc(100vw - 3.2rem);
+ }
+
+ &__header {
+ @include mobile {
+ padding: 1.6rem;
+ }
+ }
+
+ &__footer {
+ @include mobile {
+ padding-right: 1.6rem;
+ padding-left: 1.6rem;
+ }
+ }
+
+ &__body {
+ padding: 2.4rem;
+
+ @include mobile {
+ padding: 0.8rem 2.4rem;
+ }
+ }
+}
diff --git a/src/components/Modals/AdCreateEditErrorModal/AdCreateEditErrorModal.tsx b/src/components/Modals/AdCreateEditErrorModal/AdCreateEditErrorModal.tsx
new file mode 100644
index 00000000..b02230b9
--- /dev/null
+++ b/src/components/Modals/AdCreateEditErrorModal/AdCreateEditErrorModal.tsx
@@ -0,0 +1,66 @@
+import { Button, Modal, Text, useDevice } from '@deriv-com/ui';
+
+import { ERROR_CODES } from '@/constants';
+
+import './AdCreateEditErrorModal.scss';
+
+type TAdCreateEditErrorModalProps = {
+ errorCode?: ErrorCodes;
+ errorMessage?: string;
+ isModalOpen: boolean;
+ onRequestClose: () => void;
+};
+
+type ErrorCodes = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
+
+type ErrorContent = {
+ [key in ErrorCodes]?: {
+ description: string;
+ title: string;
+ };
+};
+
+const errorContent: ErrorContent = {
+ [ERROR_CODES.ADVERT_SAME_LIMITS]: {
+ description:
+ 'Please set a different minimum and/or maximum order limit. \n\nThe range of your ad should not overlap with any of your active ads.',
+ title: 'You already have an ad with this range',
+ },
+ [ERROR_CODES.DUPLICATE_ADVERT]: {
+ description:
+ 'You already have an ad with the same exchange rate for this currency pair and order type. \n\nPlease set a different rate for your ad.',
+ title: 'You already have an ad with this rate',
+ },
+};
+
+const AdCreateEditErrorModal = ({
+ errorCode,
+ errorMessage,
+ isModalOpen,
+ onRequestClose,
+}: TAdCreateEditErrorModalProps) => {
+ const { isMobile } = useDevice();
+ const textSize = isMobile ? 'md' : 'sm';
+ return (
+
+
+ {(errorCode && errorContent?.[errorCode]?.title) ?? 'Something’s not right'}
+
+
+ {(errorCode && errorContent?.[errorCode]?.description) ?? errorMessage}
+
+
+
+ {errorCode && errorContent?.[errorCode]?.title ? 'Update ad' : 'Ok'}
+
+
+
+ );
+};
+
+export default AdCreateEditErrorModal;
diff --git a/src/components/Modals/AdCreateEditErrorModal/__tests__/AdCreateEditErrorModal.spec.tsx b/src/components/Modals/AdCreateEditErrorModal/__tests__/AdCreateEditErrorModal.spec.tsx
new file mode 100644
index 00000000..03d85780
--- /dev/null
+++ b/src/components/Modals/AdCreateEditErrorModal/__tests__/AdCreateEditErrorModal.spec.tsx
@@ -0,0 +1,35 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import AdCreateEditErrorModal from '../AdCreateEditErrorModal';
+
+const mockProps = {
+ errorCode: 'AdvertSameLimits',
+ isModalOpen: true,
+ onRequestClose: jest.fn(),
+};
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: false }),
+}));
+describe('AdCreateEditErrorModal', () => {
+ it('should render the component as expected', () => {
+ render( );
+ expect(screen.getByText('You already have an ad with this range')).toBeInTheDocument();
+ });
+ it('should render the error message for duplicate adverts', () => {
+ render( );
+ expect(screen.getByText('You already have an ad with this rate')).toBeInTheDocument();
+ });
+ it('should render the general error message if no error code is provided', () => {
+ render( );
+ expect(screen.getByText('Something’s not right')).toBeInTheDocument();
+ });
+ it('should call onRequestClose when the button is clicked', async () => {
+ render( );
+ const button = screen.getByRole('button', { name: 'Update ad' });
+ await userEvent.click(button);
+ expect(mockProps.onRequestClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/components/Modals/AdCreateEditErrorModal/index.ts b/src/components/Modals/AdCreateEditErrorModal/index.ts
new file mode 100644
index 00000000..6e4ccd88
--- /dev/null
+++ b/src/components/Modals/AdCreateEditErrorModal/index.ts
@@ -0,0 +1 @@
+export { default as AdCreateEditErrorModal } from './AdCreateEditErrorModal';
diff --git a/src/components/Modals/AdCreateEditSuccessModal/AdCreateEditSuccessModal.scss b/src/components/Modals/AdCreateEditSuccessModal/AdCreateEditSuccessModal.scss
new file mode 100644
index 00000000..ff8ec932
--- /dev/null
+++ b/src/components/Modals/AdCreateEditSuccessModal/AdCreateEditSuccessModal.scss
@@ -0,0 +1,20 @@
+.p2p-ad-create-edit-success-modal {
+ height: fit-content;
+ width: 44rem;
+ border-radius: 8px;
+
+ @include mobile {
+ max-width: calc(100vw - 3.2rem);
+ }
+
+ &__body {
+ padding: 2.4rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+
+ @include mobile {
+ padding: 0 2.4rem;
+ }
+ }
+}
diff --git a/src/components/Modals/AdCreateEditSuccessModal/AdCreateEditSuccessModal.tsx b/src/components/Modals/AdCreateEditSuccessModal/AdCreateEditSuccessModal.tsx
new file mode 100644
index 00000000..f3cb9a7c
--- /dev/null
+++ b/src/components/Modals/AdCreateEditSuccessModal/AdCreateEditSuccessModal.tsx
@@ -0,0 +1,61 @@
+import React, { useCallback, useState } from 'react';
+import { useHistory } from 'react-router-dom';
+import { MY_ADS_URL } from '@/constants';
+import { Button, Checkbox, Modal, Text, useDevice } from '@deriv-com/ui';
+import './AdCreateEditSuccessModal.scss';
+
+type TAdCreateEditSuccessModalProps = {
+ advertsArchivePeriod?: number;
+ isModalOpen: boolean;
+ onRequestClose: () => void;
+};
+
+const AdCreateEditSuccessModal = ({
+ advertsArchivePeriod,
+ isModalOpen,
+ onRequestClose,
+}: TAdCreateEditSuccessModalProps) => {
+ const { isMobile } = useDevice();
+ const history = useHistory();
+ const [isChecked, setIsChecked] = useState(false);
+ const textSize = isMobile ? 'md' : 'sm';
+ const onToggleCheckbox = useCallback(() => {
+ setIsChecked(prevState => !prevState);
+ }, []);
+
+ const onClickOk = () => {
+ localStorage.setItem('should_not_show_auto_archive_message_again', JSON.stringify(isChecked));
+ history.push(MY_ADS_URL);
+ onRequestClose();
+ };
+ return (
+
+
+ You’ve created an ad
+
+
+
+ {`If the ad doesn't receive an order for ${advertsArchivePeriod} days, it will be deactivated.`}
+
+
+
+
+
+ Ok
+
+
+
+ );
+};
+
+export default AdCreateEditSuccessModal;
diff --git a/src/components/Modals/AdCreateEditSuccessModal/__tests__/AdCreateEditSuccessModal.spec.tsx b/src/components/Modals/AdCreateEditSuccessModal/__tests__/AdCreateEditSuccessModal.spec.tsx
new file mode 100644
index 00000000..a2a00aec
--- /dev/null
+++ b/src/components/Modals/AdCreateEditSuccessModal/__tests__/AdCreateEditSuccessModal.spec.tsx
@@ -0,0 +1,47 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import AdCreateEditSuccessModal from '../AdCreateEditSuccessModal';
+
+const mockProps = {
+ advertsArchivePeriod: 7,
+ isModalOpen: true,
+ onRequestClose: jest.fn(),
+};
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: false }),
+}));
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useHistory: () => ({
+ push: jest.fn(),
+ }),
+}));
+
+describe('AdCreateEditSuccessModal', () => {
+ it('should render with passed props', () => {
+ render( );
+ expect(screen.getByText(/You’ve created an ad/i)).toBeInTheDocument();
+ expect(
+ screen.getByText(/If the ad doesn't receive an order for 7 days, it will be deactivated./i)
+ ).toBeInTheDocument();
+ });
+ it('should handle checkbox change', async () => {
+ render( );
+ const checkbox = screen.getByRole('checkbox');
+ expect(checkbox).toBeInTheDocument();
+ expect(checkbox).not.toBeChecked();
+ await userEvent.click(checkbox);
+ expect(checkbox).toBeChecked();
+ });
+ it('should handle ok button click', async () => {
+ render( );
+ const okButton = screen.getByRole('button', { name: 'Ok' });
+ expect(okButton).toBeInTheDocument();
+ await userEvent.click(okButton);
+ expect(mockProps.onRequestClose).toBeCalledTimes(1);
+ });
+});
diff --git a/src/components/Modals/AdCreateEditSuccessModal/index.ts b/src/components/Modals/AdCreateEditSuccessModal/index.ts
new file mode 100644
index 00000000..327637ef
--- /dev/null
+++ b/src/components/Modals/AdCreateEditSuccessModal/index.ts
@@ -0,0 +1 @@
+export { default as AdCreateEditSuccessModal } from './AdCreateEditSuccessModal';
diff --git a/src/components/Modals/AdErrorTooltipModal/AdErrorTooltipModal.scss b/src/components/Modals/AdErrorTooltipModal/AdErrorTooltipModal.scss
new file mode 100644
index 00000000..38321dc4
--- /dev/null
+++ b/src/components/Modals/AdErrorTooltipModal/AdErrorTooltipModal.scss
@@ -0,0 +1,22 @@
+.p2p-ad-error-tooltip-modal {
+ width: 44rem;
+ padding: 2.4rem;
+ height: fit-content;
+ border-radius: 8px;
+
+ @include mobile {
+ padding: 1.6rem;
+ max-width: calc(100vw - 3.2rem);
+ }
+
+ &__content {
+ margin-bottom: 2.4rem;
+ overflow-x: hidden;
+ overflow-y: auto;
+ max-height: 23rem;
+
+ @include mobile {
+ margin-bottom: 1.6rem;
+ }
+ }
+}
diff --git a/src/components/Modals/AdErrorTooltipModal/AdErrorTooltipModal.tsx b/src/components/Modals/AdErrorTooltipModal/AdErrorTooltipModal.tsx
new file mode 100644
index 00000000..8ef4af65
--- /dev/null
+++ b/src/components/Modals/AdErrorTooltipModal/AdErrorTooltipModal.tsx
@@ -0,0 +1,112 @@
+import React, { ReactNode } from 'react';
+import { ADVERT_TYPE, ERROR_CODES } from '@/constants';
+import { AdRateError } from '@/pages/my-ads/components';
+import { Button, Modal, Text, useDevice } from '@deriv-com/ui';
+import './AdErrorTooltipModal.scss';
+
+type TAdErrorTooltipModal = {
+ accountCurrency: string;
+ advertType: string;
+ balanceAvailable: number;
+ dailyBuyLimit: string;
+ dailySellLimit: string;
+ isModalOpen: boolean;
+ onRequestClose: () => void;
+ remainingAmount: number;
+ visibilityStatus: string[];
+};
+
+const getAdErrorMessage = (
+ errorCode: string,
+ accountCurrency: string,
+ remainingAmount: number,
+ balanceAvailable: number,
+ advertType: string,
+ dailyBuyLimit: string,
+ dailySellLimit: string
+): string => {
+ const errorMessages: { [key: string]: ReactNode | string } = {
+ [ERROR_CODES.ADVERT_INACTIVE]: ,
+ [ERROR_CODES.ADVERT_MAX_LIMIT]: `This ad is not listed on Buy/Sell because its minimum order is higher than the maximum amount per order ${accountCurrency}.`,
+ [ERROR_CODES.ADVERT_MIN_LIMIT]:
+ 'This ad is not listed on Buy/Sell because its maximum order is lower than the minimum amount you can specify for orders in your ads.',
+ [ERROR_CODES.ADVERT_REMAINING]: `This ad is not listed on Buy/Sell because its minimum order is higher than the ad’s remaining amount (${remainingAmount} ${accountCurrency}).`,
+ [ERROR_CODES.ADVERTISER_ADS_PAUSED]: 'This ad is not listed on Buy/Sell because you have paused all your ads.',
+ [ERROR_CODES.AD_EXCEEDS_BALANCE]: `This ad is not listed on Buy/Sell because its minimum order is higher than your Deriv P2P available balance (${balanceAvailable} ${accountCurrency}).`,
+ [ERROR_CODES.AD_EXCEEDS_DAILY_LIMIT]: `This ad is not listed on Buy/Sell because its minimum order is higher than your remaining daily limit (${
+ advertType.toLowerCase() === ADVERT_TYPE.BUY.toLowerCase() ? dailyBuyLimit : dailySellLimit
+ } ${accountCurrency}).`,
+ [ERROR_CODES.ADVERTISER_TEMP_BAN]: `You’re not allowed to use Deriv P2P to advertise. Please contact us via live chat for more information.`,
+ };
+
+ return (errorMessages[errorCode] as string) ?? 'Your ad is not listed';
+};
+
+const AdErrorTooltipModal = ({
+ accountCurrency,
+ advertType,
+ balanceAvailable,
+ dailyBuyLimit,
+ dailySellLimit,
+ isModalOpen,
+ onRequestClose,
+ remainingAmount,
+ visibilityStatus = [],
+}: TAdErrorTooltipModal) => {
+ const { isMobile } = useDevice();
+ const textSize = isMobile ? 'md' : 'sm';
+ const getMultipleErrorMessages = (errorStatuses: string[]) =>
+ errorStatuses.map((status, index) => (
+
+ {index + 1}.{' '}
+ {getAdErrorMessage(
+ status,
+ accountCurrency,
+ remainingAmount,
+ balanceAvailable,
+ advertType,
+ dailyBuyLimit,
+ dailySellLimit
+ )}
+
+ ));
+
+ return (
+
+
+
+
+ {visibilityStatus.length === 1 ? (
+ getAdErrorMessage(
+ visibilityStatus[0],
+ accountCurrency,
+ remainingAmount,
+ balanceAvailable,
+ advertType,
+ dailyBuyLimit,
+ dailySellLimit
+ )
+ ) : (
+ <>
+ Your ad isn’t listed on Buy/Sell due to the following reason(s):
+ {getMultipleErrorMessages(visibilityStatus)}
+ >
+ )}
+
+
+
+
+
+ OK
+
+
+
+ );
+};
+
+export default AdErrorTooltipModal;
diff --git a/src/components/Modals/AdErrorTooltipModal/__tests__/AdErrorTooltipModal.spec.tsx b/src/components/Modals/AdErrorTooltipModal/__tests__/AdErrorTooltipModal.spec.tsx
new file mode 100644
index 00000000..97b46229
--- /dev/null
+++ b/src/components/Modals/AdErrorTooltipModal/__tests__/AdErrorTooltipModal.spec.tsx
@@ -0,0 +1,144 @@
+import { render, screen } from '@testing-library/react';
+
+import AdErrorTooltipModal from '../AdErrorTooltipModal';
+
+const mockProps = {
+ accountCurrency: 'USD',
+ advertType: 'buy',
+ balanceAvailable: 100,
+ dailyBuyLimit: '150',
+ dailySellLimit: '230',
+ isModalOpen: true,
+ onRequestClose: jest.fn(),
+ remainingAmount: 100,
+ visibilityStatus: [],
+};
+
+jest.mock('@deriv/api-v2', () => ({
+ useAuthorize: () => ({
+ data: {
+ local_currencies: ['USD'],
+ },
+ }),
+}));
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: false }),
+}));
+
+jest.mock('@/pages/my-ads/components', () => ({
+ ...jest.requireActual('@/pages/my-ads/components'),
+ AdRateError: () => AdRateError
,
+}));
+
+describe('AdErrorTooltipModal', () => {
+ it('should render the component as expected', () => {
+ render( );
+ expect(
+ screen.getByText('Your ad isn’t listed on Buy/Sell due to the following reason(s):')
+ ).toBeInTheDocument();
+ });
+ it('should display the corresponding reason when visibilityStatus is advert_max_limit', () => {
+ const newProps = {
+ ...mockProps,
+ visibilityStatus: ['advert_max_limit'],
+ };
+ render( );
+ expect(
+ screen.getByText(
+ 'This ad is not listed on Buy/Sell because its minimum order is higher than the maximum amount per order USD.'
+ )
+ ).toBeInTheDocument();
+ });
+ it('should display the corresponding reason when visibilityStatus is advert_min_limit', () => {
+ const newProps = {
+ ...mockProps,
+ visibilityStatus: ['advert_min_limit'],
+ };
+ render( );
+ expect(
+ screen.getByText(
+ 'This ad is not listed on Buy/Sell because its maximum order is lower than the minimum amount you can specify for orders in your ads.'
+ )
+ ).toBeInTheDocument();
+ });
+ it('should display the corresponding reason when visibilityStatus is advert_remaining', () => {
+ const newProps = {
+ ...mockProps,
+ visibilityStatus: ['advert_remaining'],
+ };
+ render( );
+ expect(
+ screen.getByText(
+ 'This ad is not listed on Buy/Sell because its minimum order is higher than the ad’s remaining amount (100 USD).'
+ )
+ ).toBeInTheDocument();
+ });
+ it('should display the corresponding reason when visibilityStatus is advertiser_ads_paused', () => {
+ const newProps = {
+ ...mockProps,
+ visibilityStatus: ['advertiser_ads_paused'],
+ };
+ render( );
+ expect(
+ screen.getByText('This ad is not listed on Buy/Sell because you have paused all your ads.')
+ ).toBeInTheDocument();
+ });
+ it('should display the corresponding reason when visibilityStatus is advertiser_balance', () => {
+ const newProps = {
+ ...mockProps,
+ visibilityStatus: ['advertiser_balance'],
+ };
+ render( );
+ expect(
+ screen.getByText(
+ 'This ad is not listed on Buy/Sell because its minimum order is higher than your Deriv P2P available balance (100 USD).'
+ )
+ ).toBeInTheDocument();
+ });
+ it('should display the corresponding reason when visibilityStatus is advertiser_daily_limit', () => {
+ const newProps = {
+ ...mockProps,
+ visibilityStatus: ['advertiser_daily_limit'],
+ };
+ render( );
+ expect(
+ screen.getByText(
+ 'This ad is not listed on Buy/Sell because its minimum order is higher than your remaining daily limit (150 USD).'
+ )
+ ).toBeInTheDocument();
+ });
+ it('should display the corresponding reason when visibilityStatus is advertiser_temp_ban', () => {
+ const newProps = {
+ ...mockProps,
+ visibilityStatus: ['advertiser_temp_ban'],
+ };
+ render( );
+ expect(
+ screen.getByText(
+ 'You’re not allowed to use Deriv P2P to advertise. Please contact us via live chat for more information.'
+ )
+ ).toBeInTheDocument();
+ });
+ it('should display the corresponding reason when visibilityStatus is advert_inactive', () => {
+ const newProps = {
+ ...mockProps,
+ visibilityStatus: ['advert_inactive'],
+ };
+ render( );
+ expect(screen.getByText('AdRateError')).toBeInTheDocument();
+ });
+ it('should display the corresponding reasons when there are multiple reasons', () => {
+ const newProps = {
+ ...mockProps,
+ visibilityStatus: ['advertiser_temp_ban', 'advertiser_daily_limit'],
+ };
+ render( );
+ expect(
+ screen.getByText(
+ '1. You’re not allowed to use Deriv P2P to advertise. Please contact us via live chat for more information.'
+ )
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/Modals/AdErrorTooltipModal/index.ts b/src/components/Modals/AdErrorTooltipModal/index.ts
new file mode 100644
index 00000000..b9e1586b
--- /dev/null
+++ b/src/components/Modals/AdErrorTooltipModal/index.ts
@@ -0,0 +1 @@
+export { default as AdErrorTooltipModal } from './AdErrorTooltipModal';
diff --git a/src/components/Modals/AdRateSwitchModal/AdRateSwitchModal.scss b/src/components/Modals/AdRateSwitchModal/AdRateSwitchModal.scss
new file mode 100644
index 00000000..a162a139
--- /dev/null
+++ b/src/components/Modals/AdRateSwitchModal/AdRateSwitchModal.scss
@@ -0,0 +1,21 @@
+.p2p-ad-rate-switch-modal {
+ width: 44rem;
+ height: fit-content;
+ border-radius: 8px;
+
+ @include mobile {
+ max-width: calc(100% - 3.2rem);
+ }
+
+ &__body {
+ padding: 2.4rem;
+
+ @include mobile {
+ padding: 1.6rem;
+ }
+ }
+
+ &__footer {
+ gap: 1.6rem;
+ }
+}
diff --git a/src/components/Modals/AdRateSwitchModal/AdRateSwitchModal.tsx b/src/components/Modals/AdRateSwitchModal/AdRateSwitchModal.tsx
new file mode 100644
index 00000000..7b0c39c7
--- /dev/null
+++ b/src/components/Modals/AdRateSwitchModal/AdRateSwitchModal.tsx
@@ -0,0 +1,53 @@
+import { Button, Modal, Text, useDevice } from '@deriv-com/ui';
+
+import { RATE_TYPE } from '@/constants';
+
+import './AdRateSwitchModal.scss';
+
+type TAdRateSwitchModalProps = {
+ isModalOpen: boolean;
+ onClickSet: () => void;
+ onRequestClose: () => void;
+ rateType?: string;
+ reachedEndDate?: boolean;
+};
+const AdRateSwitchModal = ({
+ isModalOpen,
+ onClickSet,
+ onRequestClose,
+ rateType,
+ reachedEndDate,
+}: TAdRateSwitchModalProps) => {
+ const { isMobile } = useDevice();
+ const textSize = isMobile ? 'md' : 'sm';
+ const isFloat = rateType === RATE_TYPE.FLOAT;
+ return (
+
+
+ {isFloat ? 'Set a floating rate for your ad.' : 'Set a fixed rate for your ad.'}
+
+
+
+ {reachedEndDate ? 'Cancel' : `I'll do this later`}
+
+
+ {isFloat ? 'Set floating rate' : 'Set fixed rate'}
+
+
+
+ );
+};
+
+export default AdRateSwitchModal;
diff --git a/src/components/Modals/AdRateSwitchModal/__tests__/AdRateSwitchModal.spec.tsx b/src/components/Modals/AdRateSwitchModal/__tests__/AdRateSwitchModal.spec.tsx
new file mode 100644
index 00000000..a9cdb1fc
--- /dev/null
+++ b/src/components/Modals/AdRateSwitchModal/__tests__/AdRateSwitchModal.spec.tsx
@@ -0,0 +1,44 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import AdRateSwitchModal from '../AdRateSwitchModal';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({ isMobile: false }),
+}));
+
+const mockProps = {
+ isModalOpen: true,
+ onClickSet: jest.fn(),
+ onRequestClose: jest.fn(),
+ rateType: 'float',
+ reachedEndDate: false,
+};
+
+describe('AdRateSwitchModal', () => {
+ it('should render the modal as expected', () => {
+ render( );
+ expect(screen.getByText('Set a floating rate for your ad.')).toBeInTheDocument();
+ });
+ it('should handle the onClickSet', async () => {
+ render( );
+ const button = screen.getByRole('button', { name: /Set floating rate/i });
+ await userEvent.click(button);
+ expect(mockProps.onClickSet).toBeCalledTimes(1);
+ });
+ it('should handle the onRequestClose', async () => {
+ render( );
+ const button = screen.getByRole('button', { name: /I'll do this later/i });
+ await userEvent.click(button);
+ expect(mockProps.onRequestClose).toBeCalledTimes(1);
+ });
+ it('should render the corresponding text for fixed rate', () => {
+ render( );
+ expect(screen.getByText('Set a fixed rate for your ad.')).toBeInTheDocument();
+ });
+ it('should render the cancel button if reachedEndDate is true', () => {
+ render( );
+ expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/Modals/AdRateSwitchModal/index.ts b/src/components/Modals/AdRateSwitchModal/index.ts
new file mode 100644
index 00000000..d3012d78
--- /dev/null
+++ b/src/components/Modals/AdRateSwitchModal/index.ts
@@ -0,0 +1 @@
+export { default as AdRateSwitchModal } from './AdRateSwitchModal';
diff --git a/src/components/Modals/AvailableP2PBalanceModal/AvailableP2PBalanceModal.scss b/src/components/Modals/AvailableP2PBalanceModal/AvailableP2PBalanceModal.scss
new file mode 100644
index 00000000..95c87f57
--- /dev/null
+++ b/src/components/Modals/AvailableP2PBalanceModal/AvailableP2PBalanceModal.scss
@@ -0,0 +1,32 @@
+.p2p-available-p2p-balance-modal {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ right: auto;
+ bottom: auto;
+ transform: translate(-50%, -50%);
+ width: 44rem;
+ padding: 2.4rem;
+ border-radius: 8px;
+ background: #fff;
+ box-shadow: 0px 32px 64px 0px rgba(14, 14, 14, 0.14);
+
+ &__text {
+ margin: 2.4rem 0;
+
+ @include mobile {
+ margin: 1.6rem 0;
+ }
+ }
+
+ @include mobile {
+ padding: 1.6rem;
+ width: 32.8rem;
+ }
+
+ &__footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.8rem;
+ }
+}
diff --git a/src/components/Modals/AvailableP2PBalanceModal/AvailableP2PBalanceModal.tsx b/src/components/Modals/AvailableP2PBalanceModal/AvailableP2PBalanceModal.tsx
new file mode 100644
index 00000000..335668ab
--- /dev/null
+++ b/src/components/Modals/AvailableP2PBalanceModal/AvailableP2PBalanceModal.tsx
@@ -0,0 +1,45 @@
+import React, { useEffect } from 'react';
+import Modal from 'react-modal';
+import { Button, Text } from '@deriv-com/ui';
+import { customStyles } from '../helpers';
+import './AvailableP2PBalanceModal.scss';
+
+type TAvailableP2PBalanceModalProps = {
+ isModalOpen: boolean;
+ onRequestClose: () => void;
+};
+
+const AvailableP2PBalanceModal = ({ isModalOpen, onRequestClose }: TAvailableP2PBalanceModalProps) => {
+ useEffect(() => {
+ Modal.setAppElement('#v2_modal_root');
+ }, []);
+
+ return (
+
+
+ Available Deriv P2P Balance
+
+
+ Your Deriv P2P balance only includes deposits that can’t be reversed.
+
+
+ Deposits via cards and the following payment methods aren’t included: Maestro, Diners Club, ZingPay,
+ Skrill, Neteller, Ozow, and UPI QR.
+
+
+
+ Ok
+
+
+
+ );
+};
+
+export default AvailableP2PBalanceModal;
diff --git a/src/components/Modals/AvailableP2PBalanceModal/__tests__/AvailableP2PBalanceModal.spec.tsx b/src/components/Modals/AvailableP2PBalanceModal/__tests__/AvailableP2PBalanceModal.spec.tsx
new file mode 100644
index 00000000..3050425c
--- /dev/null
+++ b/src/components/Modals/AvailableP2PBalanceModal/__tests__/AvailableP2PBalanceModal.spec.tsx
@@ -0,0 +1,30 @@
+import React, { useState } from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import AvailableP2PBalanceModal from '../AvailableP2PBalanceModal';
+
+const mockOnRequestClose = jest.fn();
+
+const MockApp = () => {
+ return (
+ <>
+
+
+ >
+ );
+};
+
+describe('AvailableP2PBalanceModal', () => {
+ it('should render with correct message', () => {
+ render( );
+ expect(screen.getByTestId('dt_available_p2p_balance_modal')).toBeInTheDocument();
+ });
+ it('should perform callback to onRequestClose when Ok button is clicked', () => {
+ render( );
+ const okBtn = screen.getByRole('button', {
+ name: 'Ok',
+ });
+ userEvent.click(okBtn);
+ expect(mockOnRequestClose).toBeCalled();
+ });
+});
diff --git a/src/components/Modals/AvailableP2PBalanceModal/index.ts b/src/components/Modals/AvailableP2PBalanceModal/index.ts
new file mode 100644
index 00000000..e108507f
--- /dev/null
+++ b/src/components/Modals/AvailableP2PBalanceModal/index.ts
@@ -0,0 +1 @@
+export { default as AvailableP2PBalanceModal } from './AvailableP2PBalanceModal';
diff --git a/src/components/Modals/BlockUnblockUserModal/BlockUnblockUserModal.scss b/src/components/Modals/BlockUnblockUserModal/BlockUnblockUserModal.scss
new file mode 100644
index 00000000..85e89247
--- /dev/null
+++ b/src/components/Modals/BlockUnblockUserModal/BlockUnblockUserModal.scss
@@ -0,0 +1,33 @@
+.p2p-block-unblock-user-modal {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ right: auto;
+ bottom: auto;
+ margin-right: -50%;
+ transform: translate(-50%, -50%);
+ width: 44rem;
+ padding: 2.4rem;
+ border-radius: 8px;
+ background: #fff;
+ box-shadow: 0px 32px 64px 0px rgba(14, 14, 14, 0.14);
+
+ &__text {
+ margin: 2.4rem 0;
+
+ @include mobile {
+ margin: 1.6rem 0;
+ }
+ }
+
+ @include mobile {
+ padding: 1.6rem;
+ width: 32.8rem;
+ }
+
+ &__footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.8rem;
+ }
+}
diff --git a/src/components/Modals/BlockUnblockUserModal/BlockUnblockUserModal.tsx b/src/components/Modals/BlockUnblockUserModal/BlockUnblockUserModal.tsx
new file mode 100644
index 00000000..d95d1374
--- /dev/null
+++ b/src/components/Modals/BlockUnblockUserModal/BlockUnblockUserModal.tsx
@@ -0,0 +1,84 @@
+import { useEffect } from 'react';
+import Modal from 'react-modal';
+
+import { Button, Text } from '@deriv-com/ui';
+
+import { api } from '@/hooks';
+
+import { customStyles } from '../helpers';
+
+import './BlockUnblockUserModal.scss';
+
+type TBlockUnblockUserModalProps = {
+ advertiserName: string;
+ id: string;
+ isBlocked: boolean;
+ isModalOpen: boolean;
+ onRequestClose: () => void;
+};
+
+const BlockUnblockUserModal = ({
+ advertiserName,
+ id,
+ isBlocked,
+ isModalOpen,
+ onRequestClose,
+}: TBlockUnblockUserModalProps) => {
+ const { mutate: blockAdvertiser } = api.counterparty.useBlock();
+ const { mutate: unblockAdvertiser } = api.counterparty.useUnblock();
+
+ useEffect(() => {
+ Modal.setAppElement('#v2_modal_root');
+ }, []);
+
+ const getModalTitle = () => (isBlocked ? `Unblock ${advertiserName}?` : `Block ${advertiserName}?`);
+
+ const getModalContent = () =>
+ isBlocked
+ ? `You will be able to see ${advertiserName}'s ads. They'll be able to place orders on your ads, too.`
+ : `You won't see ${advertiserName}'s ads anymore and they won't be able to place orders on your ads.`;
+
+ const onClickBlockUnblock = () => {
+ if (isBlocked) {
+ unblockAdvertiser([parseInt(id)]);
+ } else {
+ blockAdvertiser([parseInt(id)]);
+ }
+
+ onRequestClose();
+ };
+
+ return (
+
+
+ {getModalTitle()}
+
+
+ {getModalContent()}
+
+
+
+ Cancel
+
+
+ {isBlocked ? 'Unblock' : 'Block'}
+
+
+
+ );
+};
+
+export default BlockUnblockUserModal;
diff --git a/src/components/Modals/BlockUnblockUserModal/__tests__/BlockUnblockUserModal.spec.tsx b/src/components/Modals/BlockUnblockUserModal/__tests__/BlockUnblockUserModal.spec.tsx
new file mode 100644
index 00000000..fa85d4bd
--- /dev/null
+++ b/src/components/Modals/BlockUnblockUserModal/__tests__/BlockUnblockUserModal.spec.tsx
@@ -0,0 +1,110 @@
+import { APIProvider, AuthProvider } from '@deriv/api-v2';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import BlockUnblockUserModal from '../BlockUnblockUserModal';
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+
+
+ {children}
+
+
+);
+
+const mockOnRequestClose = jest.fn();
+const mockUseBlockMutate = jest.fn();
+const mockUseUnblockMutate = jest.fn();
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ p2p: {
+ counterparty: {
+ useBlock: jest.fn(() => ({
+ mutate: mockUseBlockMutate,
+ })),
+ useUnblock: jest.fn(() => ({
+ mutate: mockUseUnblockMutate,
+ })),
+ },
+ },
+}));
+
+describe('BlockUnblockUserModal', () => {
+ it('should render the modal with correct title and behaviour for blocking user', async () => {
+ render(
+ ,
+ {
+ wrapper,
+ }
+ );
+
+ expect(
+ screen.queryByText(
+ `You won't see Jane Doe's ads anymore and they won't be able to place orders on your ads.`
+ )
+ ).toBeVisible();
+
+ const blockBtn = screen.getByRole('button', {
+ name: 'Block',
+ });
+ await userEvent.click(blockBtn);
+
+ expect(mockUseBlockMutate).toBeCalledWith([1]);
+ });
+ it('should render the modal with correct title and behaviour for unblocking user', async () => {
+ render(
+ ,
+ {
+ wrapper,
+ }
+ );
+
+ expect(
+ screen.queryByText(
+ `You will be able to see Hu Tao's ads. They'll be able to place orders on your ads, too.`
+ )
+ ).toBeVisible();
+
+ const unblockBtn = screen.getByRole('button', {
+ name: 'Unblock',
+ });
+ await userEvent.click(unblockBtn);
+
+ expect(mockUseUnblockMutate).toBeCalledWith([2]);
+ });
+ it('should hide the modal when user clicks cancel', async () => {
+ render(
+ ,
+ {
+ wrapper,
+ }
+ );
+
+ const cancelBtn = screen.getByRole('button', {
+ name: 'Cancel',
+ });
+ await userEvent.click(cancelBtn);
+
+ expect(mockOnRequestClose).toBeCalled();
+ });
+});
diff --git a/src/components/Modals/BlockUnblockUserModal/index.ts b/src/components/Modals/BlockUnblockUserModal/index.ts
new file mode 100644
index 00000000..c994b2d0
--- /dev/null
+++ b/src/components/Modals/BlockUnblockUserModal/index.ts
@@ -0,0 +1 @@
+export { default as BlockUnblockUserModal } from './BlockUnblockUserModal';
diff --git a/src/components/Modals/DailyLimitModal/DailyLimitModal.scss b/src/components/Modals/DailyLimitModal/DailyLimitModal.scss
new file mode 100644
index 00000000..ee66d69a
--- /dev/null
+++ b/src/components/Modals/DailyLimitModal/DailyLimitModal.scss
@@ -0,0 +1,33 @@
+.p2p-daily-limit-modal {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ right: auto;
+ bottom: auto;
+ margin-right: -50%;
+ transform: translate(-50%, -50%);
+ width: 44rem;
+ padding: 2.4rem;
+ border-radius: 8px;
+ background: #fff;
+ box-shadow: 0px 32px 64px 0px rgba(14, 14, 14, 0.14);
+
+ @include mobile {
+ padding: 1.6rem;
+ width: 32.8rem;
+ }
+
+ &__text {
+ margin: 2.4rem 0;
+
+ @include mobile {
+ margin: 1.6rem 0;
+ }
+ }
+
+ &__footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+ }
+}
diff --git a/src/components/Modals/DailyLimitModal/DailyLimitModal.tsx b/src/components/Modals/DailyLimitModal/DailyLimitModal.tsx
new file mode 100644
index 00000000..d9a5fa72
--- /dev/null
+++ b/src/components/Modals/DailyLimitModal/DailyLimitModal.tsx
@@ -0,0 +1,98 @@
+import { useEffect } from 'react';
+import Modal from 'react-modal';
+
+import { Button, Loader, Text } from '@deriv-com/ui';
+
+import { api } from '@/hooks';
+import { useDevice } from '@/hooks/custom-hooks';
+
+import { customStyles } from '../helpers';
+
+import './DailyLimitModal.scss';
+
+type TDailyLimitModalProps = {
+ currency: string;
+ isModalOpen: boolean;
+ onRequestClose: () => void;
+};
+
+const DailyLimitModal = ({ currency, isModalOpen, onRequestClose }: TDailyLimitModalProps) => {
+ const { data, error, isLoading, isSuccess, mutate } = api.advertiser.useUpdate();
+ const { daily_buy_limit, daily_sell_limit } = data ?? {};
+ const { isMobile } = useDevice();
+ useEffect(() => {
+ Modal.setAppElement('#v2_modal_root');
+ }, []);
+
+ const getModalContent = () => {
+ //TODO: modal header title to be moved out if needed according to implementation, can be moved to a separate getheader, getcontent, getfooter functions
+ if (isLoading) {
+ return ;
+ } else if (isSuccess) {
+ return (
+ <>
+
+ Success!
+
+
+ {`Your daily limits have been increased to ${daily_buy_limit} ${currency} (buy) and ${daily_sell_limit} ${currency} (sell).`}
+
+
+
+ Ok
+
+
+ >
+ );
+ } else if (error) {
+ return (
+ <>
+
+ An internal error occured
+
+
+ {`Sorry, we're unable to increase your limits right now. Please try again in a few minutes.`}
+
+
+
+ Ok
+
+
+ >
+ );
+ }
+ return (
+ <>
+
+ Are you sure?
+
+
+ You won’t be able to change your buy and sell limits again after this. Do you want to continue?
+
+
+
+ No
+
+ mutate({ upgrade_limits: 1 })} size='lg' textSize='sm'>
+ Yes, continue
+
+
+ >
+ );
+ };
+
+ return (
+ // TODO: below modal will be rewritten to use @deriv/ui modal
+
+ {getModalContent()}
+
+ );
+};
+
+export default DailyLimitModal;
diff --git a/src/components/Modals/DailyLimitModal/__tests__/DailyLimitModal.spec.tsx b/src/components/Modals/DailyLimitModal/__tests__/DailyLimitModal.spec.tsx
new file mode 100644
index 00000000..78ba31f6
--- /dev/null
+++ b/src/components/Modals/DailyLimitModal/__tests__/DailyLimitModal.spec.tsx
@@ -0,0 +1,106 @@
+import { APIProvider, AuthProvider } from '@deriv/api-v2';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import DailyLimitModal from '../DailyLimitModal';
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+
+
+ {children}
+
+
+);
+
+const mockUseAdvertiserUpdateMutate = jest.fn();
+const mockOnRequestClose = jest.fn();
+let mockUseAdvertiserUpdate = {
+ data: {
+ daily_buy_limit: 100,
+ daily_sell_limit: 200,
+ },
+ error: undefined,
+ isLoading: true,
+ isSuccess: false,
+ mutate: mockUseAdvertiserUpdateMutate,
+};
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ p2p: {
+ advertiser: {
+ useUpdate: jest.fn(() => mockUseAdvertiserUpdate),
+ },
+ },
+}));
+
+describe('DailyLimitModal', () => {
+ it('should render loader when data is not ready', () => {
+ render( , { wrapper });
+
+ expect(screen.getByTestId('dt_derivs-loader')).toBeVisible();
+ });
+ it('should render the correct title and behaviour', async () => {
+ mockUseAdvertiserUpdate = {
+ ...mockUseAdvertiserUpdate,
+ isLoading: false,
+ isSuccess: false,
+ };
+ render( , { wrapper });
+
+ expect(
+ screen.getByText(
+ `You won’t be able to change your buy and sell limits again after this. Do you want to continue?`
+ )
+ ).toBeVisible();
+
+ const continueBtn = screen.getByRole('button', {
+ name: 'Yes, continue',
+ });
+ await userEvent.click(continueBtn);
+ expect(mockUseAdvertiserUpdateMutate).toBeCalledWith({
+ upgrade_limits: 1,
+ });
+ });
+ it('should render the successful limits increase', async () => {
+ mockUseAdvertiserUpdate = {
+ ...mockUseAdvertiserUpdate,
+ isLoading: false,
+ isSuccess: true,
+ };
+ render( , { wrapper });
+
+ expect(
+ screen.getByText(`Your daily limits have been increased to 100 USD (buy) and 200 USD (sell).`)
+ ).toBeVisible();
+
+ const okBtn = screen.getByRole('button', {
+ name: 'Ok',
+ });
+ await userEvent.click(okBtn);
+ expect(mockOnRequestClose).toBeCalled();
+ });
+ it('should render the error information when limits are unable to be upgraded', async () => {
+ mockUseAdvertiserUpdate = {
+ ...mockUseAdvertiserUpdate,
+ // @ts-expect-error Mock assertion of error
+ error: new Error(),
+ isLoading: false,
+ isSuccess: false,
+ };
+ render( , { wrapper });
+
+ expect(
+ screen.getByText(
+ `Sorry, we're unable to increase your limits right now. Please try again in a few minutes.`
+ )
+ ).toBeVisible();
+
+ const okBtn = screen.getByRole('button', {
+ name: 'Ok',
+ });
+ await userEvent.click(okBtn);
+ expect(mockOnRequestClose).toBeCalled();
+ });
+});
diff --git a/src/components/Modals/DailyLimitModal/index.ts b/src/components/Modals/DailyLimitModal/index.ts
new file mode 100644
index 00000000..57d886f9
--- /dev/null
+++ b/src/components/Modals/DailyLimitModal/index.ts
@@ -0,0 +1 @@
+export { default as DailyLimitModal } from './DailyLimitModal';
diff --git a/src/components/Modals/EmailLinkVerifiedModal/EmailLinkVerifiedModal.scss b/src/components/Modals/EmailLinkVerifiedModal/EmailLinkVerifiedModal.scss
new file mode 100644
index 00000000..80c47efe
--- /dev/null
+++ b/src/components/Modals/EmailLinkVerifiedModal/EmailLinkVerifiedModal.scss
@@ -0,0 +1,9 @@
+.p2p-email-link-verified-modal {
+ height: auto;
+ width: 44rem;
+ border-radius: 8px;
+
+ @include mobile {
+ max-width: calc(100vw - 3.2rem);
+ }
+}
diff --git a/src/components/Modals/EmailLinkVerifiedModal/EmailLinkVerifiedModal.tsx b/src/components/Modals/EmailLinkVerifiedModal/EmailLinkVerifiedModal.tsx
new file mode 100644
index 00000000..2a18a483
--- /dev/null
+++ b/src/components/Modals/EmailLinkVerifiedModal/EmailLinkVerifiedModal.tsx
@@ -0,0 +1,40 @@
+import { DerivLightIcEmailVerificationLinkValidIcon } from '@deriv/quill-icons';
+import { Button, Modal, Text } from '@deriv-com/ui';
+
+import './EmailLinkVerifiedModal.scss';
+
+type TEmailLinkVerifiedModal = {
+ isModalOpen: boolean;
+ onRequestClose: () => void;
+};
+
+// TODO: replace value, currency and username with actual values when implementing function
+const EmailLinkVerifiedModal = ({ isModalOpen, onRequestClose }: TEmailLinkVerifiedModal) => {
+ return (
+
+
+
+
+
+ One last step before we close this order
+
+
+ If you’ve received 100 USD from Test in your bank account or e-wallet, hit the button below to
+ complete the order.
+
+
+
+ {
+ // add function here when implementing this modal
+ }}
+ size='md'
+ >
+ Confirm
+
+
+
+ );
+};
+
+export default EmailLinkVerifiedModal;
diff --git a/src/components/Modals/EmailLinkVerifiedModal/__tests__/EmailLinkVerifiedModal.spec.tsx b/src/components/Modals/EmailLinkVerifiedModal/__tests__/EmailLinkVerifiedModal.spec.tsx
new file mode 100644
index 00000000..c599572b
--- /dev/null
+++ b/src/components/Modals/EmailLinkVerifiedModal/__tests__/EmailLinkVerifiedModal.spec.tsx
@@ -0,0 +1,17 @@
+import { render, screen } from '@testing-library/react';
+
+import EmailLinkVerifiedModal from '../EmailLinkVerifiedModal';
+
+describe(' ', () => {
+ it('it should render the EmailLinkVerifiedModal', () => {
+ render( );
+
+ expect(screen.getByText('One last step before we close this order')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'If you’ve received 100 USD from Test in your bank account or e-wallet, hit the button below to complete the order.'
+ )
+ ).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/Modals/EmailLinkVerifiedModal/index.ts b/src/components/Modals/EmailLinkVerifiedModal/index.ts
new file mode 100644
index 00000000..70e97d59
--- /dev/null
+++ b/src/components/Modals/EmailLinkVerifiedModal/index.ts
@@ -0,0 +1 @@
+export { default as EmailLinkVerifiedModal } from './EmailLinkVerifiedModal';
diff --git a/src/components/Modals/EmailVerificationModal/EmailVerificationModal.scss b/src/components/Modals/EmailVerificationModal/EmailVerificationModal.scss
new file mode 100644
index 00000000..6f106823
--- /dev/null
+++ b/src/components/Modals/EmailVerificationModal/EmailVerificationModal.scss
@@ -0,0 +1,22 @@
+.p2p-email-verification-modal {
+ height: auto;
+ width: 44rem;
+ border-radius: 8px;
+
+ @include mobile {
+ max-width: calc(100vw - 3.2rem);
+ }
+
+ // TODO: Remove this when Button allows to pass prop to prevent hover styles
+ &__button {
+ &:hover {
+ // stylelint-disable-next-line declaration-no-important
+ background-color: transparent !important;
+
+ & > span {
+ // stylelint-disable-next-line declaration-no-important
+ color: #ff444f !important;
+ }
+ }
+ }
+}
diff --git a/src/components/Modals/EmailVerificationModal/EmailVerificationModal.tsx b/src/components/Modals/EmailVerificationModal/EmailVerificationModal.tsx
new file mode 100644
index 00000000..44e81389
--- /dev/null
+++ b/src/components/Modals/EmailVerificationModal/EmailVerificationModal.tsx
@@ -0,0 +1,87 @@
+import React, { useState } from 'react';
+import {
+ DerivLightIcEmailSentIcon,
+ DerivLightIcFirewallEmailPasskeyIcon,
+ DerivLightIcSpamEmailPasskeyIcon,
+ DerivLightIcTypoEmailPasskeyIcon,
+ DerivLightIcWrongEmailPasskeyIcon,
+} from '@deriv/quill-icons';
+import { Button, Modal, Text, useDevice } from '@deriv-com/ui';
+import './EmailVerificationModal.scss';
+
+const reasons = [
+ {
+ icon: DerivLightIcSpamEmailPasskeyIcon,
+ text: 'The email is in your spam folder (sometimes things get lost there).',
+ },
+ {
+ icon: DerivLightIcWrongEmailPasskeyIcon,
+ text: 'You accidentally gave us another email address (usually a work or a personal one instead of the one you meant).',
+ },
+ {
+ icon: DerivLightIcTypoEmailPasskeyIcon,
+ text: 'The email address you entered had a mistake or typo (happens to the best of us).',
+ },
+ {
+ icon: DerivLightIcFirewallEmailPasskeyIcon,
+ text: 'We can’t deliver the email to this address (usually because of firewalls or filtering).',
+ },
+];
+
+type TEmailVerificationModalProps = {
+ isModalOpen: boolean;
+ onRequestClose: () => void;
+};
+
+const EmailVerificationModal = ({ isModalOpen, onRequestClose }: TEmailVerificationModalProps) => {
+ const [shouldShowReasons, setShouldShowReasons] = useState(false);
+ const { isMobile } = useDevice();
+ const emailIconSize = isMobile ? 100 : 128;
+ const reasonIconSize = isMobile ? 32 : 36;
+
+ return (
+
+
+
+
+
+ Has the buyer paid you?
+
+
+ Releasing funds before receiving payment may result in losses. Check your email and follow the
+ instructions within 10 minutes to release the funds.
+
+ setShouldShowReasons(true)}
+ variant='ghost'
+ >
+ I didn’t receive the email
+
+ {shouldShowReasons && (
+
+ {reasons.map(reason => (
+
+
+ {reason.text}
+
+ ))}
+
+ {/* TODO: Replace 59s with epoch value (verification_next_request) from BE response
+ * and disable the button if the epoch value is not reached yet
+ */}
+ Resend email 59s
+
+
+ )}
+
+
+ );
+};
+
+export default EmailVerificationModal;
diff --git a/src/components/Modals/EmailVerificationModal/__tests__/EmailVerificationModal.spec.tsx b/src/components/Modals/EmailVerificationModal/__tests__/EmailVerificationModal.spec.tsx
new file mode 100644
index 00000000..6bd1d25f
--- /dev/null
+++ b/src/components/Modals/EmailVerificationModal/__tests__/EmailVerificationModal.spec.tsx
@@ -0,0 +1,70 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import EmailVerificationModal from '../EmailVerificationModal';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: false }),
+}));
+
+const mockProps = {
+ isModalOpen: true,
+ onRequestClose: jest.fn(),
+};
+
+describe(' ', () => {
+ it('should render the EmailVerificationModal', () => {
+ render( );
+
+ expect(screen.getByText('Has the buyer paid you?')).toBeInTheDocument();
+ expect(
+ screen.queryByText(
+ /Releasing funds before receiving payment may result in losses. Check your email and follow the instructions/
+ )
+ ).toBeInTheDocument();
+ expect(screen.queryByText('within 10 minutes', { selector: 'strong' })).toBeInTheDocument();
+ expect(screen.queryByText(/to release the funds./)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'I didn’t receive the email' })).toBeInTheDocument();
+ });
+
+ it('should show reasons if I didn’t receive the email button is clicked', async () => {
+ render( );
+
+ const didntReceiveEmailButton = screen.getByRole('button', { name: 'I didn’t receive the email' });
+
+ expect(
+ screen.queryByText('The email is in your spam folder (sometimes things get lost there).')
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(
+ 'You accidentally gave us another email address (usually a work or a personal one instead of the one you meant).'
+ )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText('The email address you entered had a mistake or typo (happens to the best of us).')
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(
+ 'We can’t deliver the email to this address (usually because of firewalls or filtering).'
+ )
+ ).not.toBeInTheDocument();
+
+ await userEvent.click(didntReceiveEmailButton);
+
+ expect(
+ screen.getByText('The email is in your spam folder (sometimes things get lost there).')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'You accidentally gave us another email address (usually a work or a personal one instead of the one you meant).'
+ )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText('The email address you entered had a mistake or typo (happens to the best of us).')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText('We can’t deliver the email to this address (usually because of firewalls or filtering).')
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/Modals/EmailVerificationModal/index.ts b/src/components/Modals/EmailVerificationModal/index.ts
new file mode 100644
index 00000000..1507cfe1
--- /dev/null
+++ b/src/components/Modals/EmailVerificationModal/index.ts
@@ -0,0 +1 @@
+export { default as EmailVerificationModal } from './EmailVerificationModal';
diff --git a/src/components/Modals/ErrorModal/ErrorModal.scss b/src/components/Modals/ErrorModal/ErrorModal.scss
new file mode 100644
index 00000000..7b614c12
--- /dev/null
+++ b/src/components/Modals/ErrorModal/ErrorModal.scss
@@ -0,0 +1,16 @@
+.p2p-error-modal {
+ width: 44rem;
+ height: unset;
+
+ @include mobile {
+ max-width: calc(100% - 3.2rem);
+ }
+
+ &__body {
+ padding: 2.4rem;
+
+ @include mobile {
+ padding: 1.6rem;
+ }
+ }
+}
diff --git a/src/components/Modals/ErrorModal/ErrorModal.tsx b/src/components/Modals/ErrorModal/ErrorModal.tsx
new file mode 100644
index 00000000..a96cbb79
--- /dev/null
+++ b/src/components/Modals/ErrorModal/ErrorModal.tsx
@@ -0,0 +1,31 @@
+import { Button, Modal, Text, useDevice } from '@deriv-com/ui';
+
+import './ErrorModal.scss';
+
+type TErrorModalProps = {
+ isModalOpen: boolean;
+ message?: string;
+ onRequestClose: () => void;
+};
+
+const ErrorModal = ({ isModalOpen, message, onRequestClose }: TErrorModalProps) => {
+ const { isMobile } = useDevice();
+ const textSize = isMobile ? 'lg' : 'md';
+ return (
+
+
+ {`Something's not right`}
+
+
+ {message ?? `Something's not right`}
+
+
+
+ Ok
+
+
+
+ );
+};
+
+export default ErrorModal;
diff --git a/src/components/Modals/ErrorModal/__tests__/ErrorModal.spec.tsx b/src/components/Modals/ErrorModal/__tests__/ErrorModal.spec.tsx
new file mode 100644
index 00000000..ca13df27
--- /dev/null
+++ b/src/components/Modals/ErrorModal/__tests__/ErrorModal.spec.tsx
@@ -0,0 +1,27 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import ErrorModal from '../ErrorModal';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: false }),
+}));
+
+const mockProps = {
+ isModalOpen: true,
+ message: 'error message',
+ onRequestClose: jest.fn(),
+};
+
+describe('ErrorModal', () => {
+ it('should render the component as expected', () => {
+ render( );
+ expect(screen.getByText("Something's not right")).toBeInTheDocument();
+ });
+ it('should call onRequestClose when the close button is clicked', async () => {
+ render( );
+ await userEvent.click(screen.getByRole('button', { name: 'Ok' }));
+ expect(mockProps.onRequestClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/components/Modals/ErrorModal/index.ts b/src/components/Modals/ErrorModal/index.ts
new file mode 100644
index 00000000..cc4da0ac
--- /dev/null
+++ b/src/components/Modals/ErrorModal/index.ts
@@ -0,0 +1 @@
+export { default as ErrorModal } from './ErrorModal';
diff --git a/src/components/Modals/FilterModal/FilterModal.scss b/src/components/Modals/FilterModal/FilterModal.scss
new file mode 100644
index 00000000..bc298d35
--- /dev/null
+++ b/src/components/Modals/FilterModal/FilterModal.scss
@@ -0,0 +1,12 @@
+.p2p-filter-modal {
+ height: 56rem;
+ width: 44rem;
+ border-radius: 8px;
+
+ @include mobile {
+ position: absolute;
+ top: -4rem;
+ z-index: 1;
+ height: calc(100vh - 8rem);
+ }
+}
diff --git a/src/components/Modals/FilterModal/FilterModal.tsx b/src/components/Modals/FilterModal/FilterModal.tsx
new file mode 100644
index 00000000..799cb0dd
--- /dev/null
+++ b/src/components/Modals/FilterModal/FilterModal.tsx
@@ -0,0 +1,151 @@
+import { useEffect, useState } from 'react';
+
+import { LabelPairedChevronRightLgRegularIcon } from '@deriv/quill-icons';
+import { Modal, Text, ToggleSwitch, useDevice } from '@deriv-com/ui';
+
+import { FullPageMobileWrapper, PageReturn } from '@/components';
+import { api } from '@/hooks';
+
+import { FilterModalContent } from './FilterModalContent';
+import { FilterModalFooter } from './FilterModalFooter';
+
+import './FilterModal.scss';
+
+type TFilterModalProps = {
+ isModalOpen: boolean;
+ isToggled: boolean;
+ onRequestClose: () => void;
+ onToggle: (value: boolean) => void;
+ selectedPaymentMethods: string[];
+ setSelectedPaymentMethods: (value: string[]) => void;
+};
+
+const FilterModal = ({
+ isModalOpen,
+ isToggled,
+ onRequestClose,
+ onToggle,
+ selectedPaymentMethods,
+ setSelectedPaymentMethods,
+}: TFilterModalProps) => {
+ const { data } = api.paymentMethods.useGet();
+ const [showPaymentMethods, setShowPaymentMethods] = useState(false);
+ const [isMatching, setIsMatching] = useState(isToggled);
+ const [paymentMethods, setPaymentMethods] = useState(selectedPaymentMethods);
+ const [paymentMethodNames, setPaymentMethodNames] = useState('All');
+ const { isMobile } = useDevice();
+
+ const filterOptions = [
+ {
+ component: ,
+ onClick: () => setShowPaymentMethods(true),
+ subtext: paymentMethodNames,
+ text: 'Payment methods',
+ },
+ {
+ component: setIsMatching(event.target.checked)} value={isMatching} />,
+ subtext: 'Ads that match your Deriv P2P balance and limit.',
+ text: 'Matching ads',
+ },
+ ];
+
+ const sortedSelectedPaymentMethods = [...selectedPaymentMethods].sort((a, b) => a.localeCompare(b));
+ const sortedPaymentMethods = [...paymentMethods].sort((a, b) => a.localeCompare(b));
+ const hasSamePaymentMethods = JSON.stringify(sortedSelectedPaymentMethods) === JSON.stringify(sortedPaymentMethods);
+ const hasSameMatching = isToggled === isMatching;
+ const hasSameFilters = hasSamePaymentMethods && hasSameMatching;
+ const headerText = showPaymentMethods ? 'Payment methods' : 'Filter';
+
+ const onApplyConfirm = () => {
+ if (showPaymentMethods) {
+ setShowPaymentMethods(false);
+ } else {
+ setSelectedPaymentMethods(paymentMethods);
+ onToggle(isMatching);
+ onRequestClose();
+ }
+ };
+
+ const onResetClear = () => {
+ setPaymentMethods([]);
+ if (!showPaymentMethods) {
+ setIsMatching(true);
+ }
+ };
+
+ useEffect(() => {
+ if (data && paymentMethods.length > 0) {
+ const selectedPaymentMethodsDisplayName = data
+ .filter(paymentMethod => paymentMethods.includes(paymentMethod.id))
+ .map(paymentMethod => paymentMethod.display_name);
+
+ setPaymentMethodNames(selectedPaymentMethodsDisplayName.join(', '));
+ } else if (paymentMethods.length === 0) {
+ setPaymentMethodNames('All');
+ }
+ }, [data, paymentMethods]);
+
+ if (isMobile && isModalOpen) {
+ return (
+ setShowPaymentMethods(false) : onRequestClose}
+ renderFooter={() => (
+
+ )}
+ renderHeader={() => (
+
+ {headerText}
+
+ )}
+ >
+
+
+ );
+ }
+
+ return (
+
+
+ setShowPaymentMethods(false)}
+ pageTitle={headerText}
+ shouldHideBackButton={!showPaymentMethods}
+ weight='bold'
+ />
+
+
+
+
+
+
+
+
+ );
+};
+
+export default FilterModal;
diff --git a/src/components/Modals/FilterModal/FilterModalContent/FilterModalContent.tsx b/src/components/Modals/FilterModal/FilterModalContent/FilterModalContent.tsx
new file mode 100644
index 00000000..f3edfbff
--- /dev/null
+++ b/src/components/Modals/FilterModal/FilterModalContent/FilterModalContent.tsx
@@ -0,0 +1,60 @@
+import clsx from 'clsx';
+
+import { Text } from '@deriv-com/ui';
+
+import { FilterModalPaymentMethods } from '../FilterModalPaymentMethods';
+
+type TFilterModalContentProps = {
+ filterOptions: {
+ component: JSX.Element;
+ onClick?: () => void;
+ subtext: string;
+ text: string;
+ }[];
+ paymentMethods: string[];
+ setPaymentMethods: (value: string[]) => void;
+ showPaymentMethods: boolean;
+};
+
+const FilterModalContent = ({
+ filterOptions,
+ paymentMethods,
+ setPaymentMethods,
+ showPaymentMethods,
+}: TFilterModalContentProps) => {
+ return (
+ <>
+ {showPaymentMethods ? (
+
+ ) : (
+ <>
+ {filterOptions.map(option => (
+
+
+ {option.text}
+
+ {option.subtext}
+
+
+ {option.component}
+
+ ))}
+ >
+ )}
+ >
+ );
+};
+
+export default FilterModalContent;
diff --git a/src/components/Modals/FilterModal/FilterModalContent/index.ts b/src/components/Modals/FilterModal/FilterModalContent/index.ts
new file mode 100644
index 00000000..e071719c
--- /dev/null
+++ b/src/components/Modals/FilterModal/FilterModalContent/index.ts
@@ -0,0 +1 @@
+export { default as FilterModalContent } from './FilterModalContent';
diff --git a/src/components/Modals/FilterModal/FilterModalFooter/FilterModalFooter.scss b/src/components/Modals/FilterModal/FilterModalFooter/FilterModalFooter.scss
new file mode 100644
index 00000000..a16ac896
--- /dev/null
+++ b/src/components/Modals/FilterModal/FilterModalFooter/FilterModalFooter.scss
@@ -0,0 +1,14 @@
+.p2p-filter-modal-footer {
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ display: flex;
+ justify-content: flex-end;
+ padding: 1.5rem;
+
+ @include mobile {
+ position: relative;
+ padding: 0;
+ border: none;
+ }
+}
diff --git a/src/components/Modals/FilterModal/FilterModalFooter/FilterModalFooter.tsx b/src/components/Modals/FilterModal/FilterModalFooter/FilterModalFooter.tsx
new file mode 100644
index 00000000..7c5fcf14
--- /dev/null
+++ b/src/components/Modals/FilterModal/FilterModalFooter/FilterModalFooter.tsx
@@ -0,0 +1,51 @@
+import { Button, useDevice } from '@deriv-com/ui';
+
+import './FilterModalFooter.scss';
+
+type TFilterModalFooterProps = {
+ hasSameFilters: boolean;
+ hasSamePaymentMethods: boolean;
+ onApplyConfirm: () => void;
+ onResetClear: () => void;
+ paymentMethods: string[];
+ showPaymentMethods: boolean;
+};
+
+const FilterModalFooter = ({
+ hasSameFilters,
+ hasSamePaymentMethods,
+ onApplyConfirm,
+ onResetClear,
+ paymentMethods,
+ showPaymentMethods,
+}: TFilterModalFooterProps) => {
+ const { isMobile } = useDevice();
+
+ return (
+
+
+ {showPaymentMethods ? 'Clear' : 'Reset'}
+
+
+ {showPaymentMethods ? 'Confirm' : 'Apply'}
+
+
+ );
+};
+
+export default FilterModalFooter;
diff --git a/src/components/Modals/FilterModal/FilterModalFooter/index.ts b/src/components/Modals/FilterModal/FilterModalFooter/index.ts
new file mode 100644
index 00000000..8da55c2e
--- /dev/null
+++ b/src/components/Modals/FilterModal/FilterModalFooter/index.ts
@@ -0,0 +1 @@
+export { default as FilterModalFooter } from './FilterModalFooter';
diff --git a/src/components/Modals/FilterModal/FilterModalPaymentMethods/FilterModalPaymentMethods.scss b/src/components/Modals/FilterModal/FilterModalPaymentMethods/FilterModalPaymentMethods.scss
new file mode 100644
index 00000000..beabd6f4
--- /dev/null
+++ b/src/components/Modals/FilterModal/FilterModalPaymentMethods/FilterModalPaymentMethods.scss
@@ -0,0 +1,19 @@
+.p2p-filter-modal-payment-methods {
+ .deriv-input {
+ border: none;
+ border-bottom: 1px solid #f2f3f4;
+ border-radius: 0;
+ // stylelint-disable-next-line declaration-no-important
+ padding: 1.5rem !important;
+
+ @include mobile {
+ // stylelint-disable-next-line declaration-no-important
+ padding: 2.5rem 1.5rem !important;
+ }
+
+ &__label {
+ // stylelint-disable-next-line declaration-no-important
+ left: 4rem !important;
+ }
+ }
+}
diff --git a/src/components/Modals/FilterModal/FilterModalPaymentMethods/FilterModalPaymentMethods.tsx b/src/components/Modals/FilterModal/FilterModalPaymentMethods/FilterModalPaymentMethods.tsx
new file mode 100644
index 00000000..aaa185d6
--- /dev/null
+++ b/src/components/Modals/FilterModal/FilterModalPaymentMethods/FilterModalPaymentMethods.tsx
@@ -0,0 +1,82 @@
+import { useEffect, useState } from 'react';
+import { THooks } from 'types';
+
+import { Checkbox, Text } from '@deriv-com/ui';
+
+import { Search } from '@/components/Search';
+import { api } from '@/hooks';
+
+import './FilterModalPaymentMethods.scss';
+
+type TFilterModalPaymentMethodsProps = {
+ selectedPaymentMethods: string[];
+ setSelectedPaymentMethods: (value: string[]) => void;
+};
+
+const FilterModalPaymentMethods = ({
+ selectedPaymentMethods,
+ setSelectedPaymentMethods,
+}: TFilterModalPaymentMethodsProps) => {
+ const { data = [] } = api.paymentMethods.useGet();
+ const [searchedPaymentMethod, setSearchedPaymentMethod] = useState('');
+ const [searchedPaymentMethods, setSearchedPaymentMethods] = useState(data);
+
+ const onSearch = (value: string) => {
+ if (!value) {
+ setSearchedPaymentMethods(data);
+ return;
+ }
+ setSearchedPaymentMethod(value);
+ if (value) {
+ setSearchedPaymentMethods(
+ data?.filter(paymentMethod => paymentMethod.display_name.toLowerCase().includes(value.toLowerCase()))
+ );
+ }
+ };
+
+ useEffect(() => {
+ if (data && JSON.stringify(data) !== JSON.stringify(searchedPaymentMethods)) setSearchedPaymentMethods(data);
+ }, [data]);
+
+ return (
+
+
onSearch(value)}
+ placeholder='Search payment method'
+ />
+ {searchedPaymentMethods?.length > 0 ? (
+
+ {searchedPaymentMethods?.map(paymentMethod => (
+ {
+ if (event.target.checked) {
+ setSelectedPaymentMethods([...selectedPaymentMethods, paymentMethod.id]);
+ } else {
+ setSelectedPaymentMethods(
+ selectedPaymentMethods.filter(id => id !== paymentMethod.id)
+ );
+ }
+ }}
+ wrapperClassName='p-[1.6rem] leading-[3rem]'
+ />
+ ))}
+
+ ) : (
+
+
+ No results for "{searchedPaymentMethod}".
+
+ Check your spelling or use a different term.
+
+ )}
+
+ );
+};
+
+export default FilterModalPaymentMethods;
diff --git a/src/components/Modals/FilterModal/FilterModalPaymentMethods/index.ts b/src/components/Modals/FilterModal/FilterModalPaymentMethods/index.ts
new file mode 100644
index 00000000..1a31aebb
--- /dev/null
+++ b/src/components/Modals/FilterModal/FilterModalPaymentMethods/index.ts
@@ -0,0 +1 @@
+export { default as FilterModalPaymentMethods } from './FilterModalPaymentMethods';
diff --git a/src/components/Modals/FilterModal/__tests__/FilterModal.spec.tsx b/src/components/Modals/FilterModal/__tests__/FilterModal.spec.tsx
new file mode 100644
index 00000000..99826460
--- /dev/null
+++ b/src/components/Modals/FilterModal/__tests__/FilterModal.spec.tsx
@@ -0,0 +1,321 @@
+import { useDevice } from '@deriv-com/ui';
+import { act, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import FilterModal from '../FilterModal';
+
+const mockProps = {
+ isModalOpen: true,
+ isToggled: true,
+ onRequestClose: jest.fn(),
+ onToggle: jest.fn(),
+ selectedPaymentMethods: [],
+ setSelectedPaymentMethods: jest.fn(),
+};
+
+let mockData = [
+ {
+ display_name: 'Alipay',
+ id: 'alipay',
+ },
+ {
+ display_name: 'Bank Transfer',
+ id: 'bank_transfer',
+ },
+];
+
+jest.mock('@deriv/api-v2', () => ({
+ p2p: {
+ paymentMethods: {
+ useGet: jest.fn(() => ({
+ data: mockData,
+ })),
+ },
+ },
+}));
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({
+ isMobile: false,
+ }),
+}));
+
+const mockUseDevice = useDevice as jest.Mock;
+
+describe(' ', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('should render the initial page of the FilterModal', () => {
+ render( );
+
+ const toggleSwitch = screen.getByRole('checkbox');
+ const resetButton = screen.getByRole('button', { name: 'Reset' });
+ const applyButton = screen.getByRole('button', { name: 'Apply' });
+
+ expect(screen.getByText('Filter')).toBeInTheDocument();
+ expect(screen.getByText('Payment methods')).toBeInTheDocument();
+ expect(screen.getByText('All')).toBeInTheDocument();
+ expect(screen.getByText('Matching ads')).toBeInTheDocument();
+ expect(screen.getByText('Ads that match your Deriv P2P balance and limit.')).toBeInTheDocument();
+ expect(toggleSwitch).toBeInTheDocument();
+ expect(toggleSwitch).toBeChecked();
+ expect(resetButton).toBeInTheDocument();
+ expect(applyButton).toBeInTheDocument();
+ expect(applyButton).toBeDisabled();
+ });
+
+ it('should enable the apply button when user toggles the ToggleSwitch', async () => {
+ render( );
+
+ const toggleSwitch = screen.getByRole('checkbox');
+ const applyButton = screen.getByRole('button', { name: 'Apply' });
+
+ await userEvent.click(toggleSwitch);
+
+ expect(toggleSwitch).not.toBeChecked();
+ expect(applyButton).toBeEnabled();
+ });
+
+ it('should call setSelectedPaymentMethods, onToggle, and onRequestClose when user clicks the Apply button', async () => {
+ render( );
+
+ const toggleSwitch = screen.getByRole('checkbox');
+ const applyButton = screen.getByRole('button', { name: 'Apply' });
+
+ await userEvent.click(toggleSwitch);
+ await userEvent.click(applyButton);
+
+ expect(mockProps.setSelectedPaymentMethods).toHaveBeenCalled();
+ expect(mockProps.onToggle).toHaveBeenCalled();
+ expect(mockProps.onRequestClose).toHaveBeenCalled();
+ });
+
+ it('should call setPaymentMethods when user clicks on Reset button', async () => {
+ render( );
+
+ const toggleSwitch = screen.getByRole('checkbox');
+ const resetButton = screen.getByRole('button', { name: 'Reset' });
+
+ await userEvent.click(toggleSwitch);
+ expect(toggleSwitch).not.toBeChecked();
+
+ await userEvent.click(resetButton);
+
+ expect(mockProps.setSelectedPaymentMethods).toHaveBeenCalled();
+ expect(toggleSwitch).toBeChecked();
+ });
+
+ it('should render the payment methods page of the FilterModal', async () => {
+ render( );
+
+ const paymentMethodsText = screen.getByText('Payment methods');
+ await userEvent.click(paymentMethodsText);
+
+ const clearButton = screen.getByRole('button', { name: 'Clear' });
+ const confirmButton = screen.getByRole('button', { name: 'Confirm' });
+ const alipayCheckbox = screen.getByRole('checkbox', { name: 'Alipay' });
+ const bankTransferCheckbox = screen.getByRole('checkbox', { name: 'Bank Transfer' });
+
+ expect(screen.queryByText('Filter')).not.toBeInTheDocument();
+ expect(screen.getByText('Payment methods')).toBeInTheDocument();
+ expect(screen.getByText('Search payment method')).toBeInTheDocument();
+ expect(alipayCheckbox).toBeInTheDocument();
+ expect(alipayCheckbox).not.toBeChecked();
+ expect(bankTransferCheckbox).toBeInTheDocument();
+ expect(bankTransferCheckbox).not.toBeChecked();
+ expect(clearButton).toBeInTheDocument();
+ expect(clearButton).toBeDisabled();
+ expect(confirmButton).toBeInTheDocument();
+ expect(confirmButton).toBeDisabled();
+ });
+
+ it('should show the search results when user types in the search input', async () => {
+ render( );
+
+ const paymentMethodsText = screen.getByText('Payment methods');
+ await userEvent.click(paymentMethodsText);
+
+ const searchInput = screen.getByRole('searchbox');
+
+ act(async () => {
+ await userEvent.type(searchInput, 'alipay');
+ });
+
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ expect(screen.getByText('Alipay')).toBeInTheDocument();
+ expect(screen.queryByText('Bank Transfer')).not.toBeInTheDocument();
+ });
+
+ it('should show No results for message if payment method is not in the list', async () => {
+ render( );
+
+ const paymentMethodsText = screen.getByText('Payment methods');
+ await userEvent.click(paymentMethodsText);
+
+ const searchInput = screen.getByRole('searchbox');
+
+ act(async () => {
+ await userEvent.type(searchInput, 'paypal');
+ });
+
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ expect(screen.getByText(/No results for "paypal"./s)).toBeInTheDocument();
+ expect(screen.getByText('Check your spelling or use a different term.')).toBeInTheDocument();
+
+ act(async () => {
+ await userEvent.clear(searchInput);
+ });
+
+ act(() => {
+ jest.runAllTimers();
+ });
+ });
+
+ it('should enable the clear and confirm buttons when user selects a payment method', async () => {
+ render( );
+
+ const paymentMethodsText = screen.getByText('Payment methods');
+ await userEvent.click(paymentMethodsText);
+
+ const alipayCheckbox = screen.getByRole('checkbox', { name: 'Alipay' });
+ const confirmButton = screen.getByRole('button', { name: 'Confirm' });
+ const clearButton = screen.getByRole('button', { name: 'Clear' });
+
+ await userEvent.click(alipayCheckbox);
+
+ expect(alipayCheckbox).toBeChecked();
+ expect(confirmButton).toBeEnabled();
+ expect(clearButton).toBeEnabled();
+ });
+
+ it('should clear the selected payment methods when user clicks on the clear button', async () => {
+ render( );
+
+ const paymentMethodsText = screen.getByText('Payment methods');
+ await userEvent.click(paymentMethodsText);
+
+ const alipayCheckbox = screen.getByRole('checkbox', { name: 'Alipay' });
+ const clearButton = screen.getByRole('button', { name: 'Clear' });
+
+ await userEvent.click(alipayCheckbox);
+ await userEvent.click(clearButton);
+
+ expect(alipayCheckbox).not.toBeChecked();
+ });
+
+ it('should go back to the initial page when user clicks on the back button', async () => {
+ render( );
+
+ const paymentMethodsText = screen.getByText('Payment methods');
+ await userEvent.click(paymentMethodsText);
+
+ const backButton = screen.getByTestId('dt_page_return_btn');
+
+ await userEvent.click(backButton);
+
+ expect(screen.getByText('Filter')).toBeInTheDocument();
+ });
+
+ it('should call go back to the initial page and display the selected payment methods when user clicks on the confirm button', async () => {
+ render( );
+
+ const paymentMethodsText = screen.getByText('Payment methods');
+ await userEvent.click(paymentMethodsText);
+
+ const alipayCheckbox = screen.getByRole('checkbox', { name: 'Alipay' });
+ const bankTransferCheckbox = screen.getByRole('checkbox', { name: 'Bank Transfer' });
+ const confirmButton = screen.getByRole('button', { name: 'Confirm' });
+
+ await userEvent.click(alipayCheckbox);
+ await userEvent.click(bankTransferCheckbox);
+ await userEvent.click(confirmButton);
+
+ expect(screen.getByText('Filter')).toBeInTheDocument();
+ expect(screen.getByText('Alipay, Bank Transfer')).toBeInTheDocument();
+ });
+
+ it('should call setSelectedPaymentMethods if a payment method is unselected', async () => {
+ render( );
+
+ const paymentMethodsText = screen.getByText('Payment methods');
+ await userEvent.click(paymentMethodsText);
+
+ const alipayCheckbox = screen.getByRole('checkbox', { name: 'Alipay' });
+
+ await userEvent.click(alipayCheckbox);
+ await userEvent.click(alipayCheckbox);
+
+ expect(mockProps.setSelectedPaymentMethods).toHaveBeenCalled();
+ });
+
+ it('should populate the payment methods list with the data from the API', async () => {
+ mockData = undefined;
+ const { rerender } = render( );
+
+ const paymentMethodsText = screen.getByText('Payment methods');
+ await userEvent.click(paymentMethodsText);
+
+ const alipayCheckbox = screen.queryByRole('checkbox', { name: 'Alipay' });
+ const bankTransferCheckbox = screen.queryByRole('checkbox', { name: 'Bank Transfer' });
+
+ expect(alipayCheckbox).not.toBeInTheDocument();
+ expect(bankTransferCheckbox).not.toBeInTheDocument();
+
+ mockData = [
+ {
+ display_name: 'Alipay',
+ id: 'alipay',
+ },
+ {
+ display_name: 'Bank Transfer',
+ id: 'bank_transfer',
+ },
+ ];
+
+ rerender( );
+
+ const alipayCheckboxRerendered = screen.getByRole('checkbox', { name: 'Alipay' });
+ const bankTransferCheckboxRerendered = screen.getByRole('checkbox', { name: 'Bank Transfer' });
+
+ expect(alipayCheckboxRerendered).toBeInTheDocument();
+ expect(bankTransferCheckboxRerendered).toBeInTheDocument();
+ });
+
+ it('should call onRequestClose if backButton is pressed on initial page in mobile', async () => {
+ mockUseDevice.mockReturnValue({
+ isMobile: true,
+ });
+
+ render( );
+
+ const backButton = screen.getByTestId('dt_mobile_wrapper_button');
+ await userEvent.click(backButton);
+
+ expect(mockProps.onRequestClose).toHaveBeenCalled();
+ });
+
+ it('should go back to initial page if backButton is pressed in payment methods page in mobile', async () => {
+ render( );
+
+ const paymentMethodsText = screen.getByText('Payment methods');
+ await userEvent.click(paymentMethodsText);
+
+ const backButton = screen.getByTestId('dt_mobile_wrapper_button');
+ await userEvent.click(backButton);
+
+ expect(screen.getByText('Filter')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/Modals/FilterModal/index.ts b/src/components/Modals/FilterModal/index.ts
new file mode 100644
index 00000000..53225b51
--- /dev/null
+++ b/src/components/Modals/FilterModal/index.ts
@@ -0,0 +1 @@
+export { default as FilterModal } from './FilterModal';
diff --git a/src/components/Modals/MyAdsDeleteModal/MyAdsDeleteModal.scss b/src/components/Modals/MyAdsDeleteModal/MyAdsDeleteModal.scss
new file mode 100644
index 00000000..b60a5a33
--- /dev/null
+++ b/src/components/Modals/MyAdsDeleteModal/MyAdsDeleteModal.scss
@@ -0,0 +1,28 @@
+.p2p-my-ads-delete-modal {
+ height: auto;
+ width: 44rem;
+ border-radius: 8px;
+
+ @include mobile {
+ width: 32.8rem;
+ }
+
+ &__header {
+ @include mobile {
+ padding: 0 1.6rem;
+ }
+ }
+
+ &__body {
+ padding: 1rem 2.4rem;
+ @include mobile {
+ padding: 0 1.6rem;
+ }
+ }
+
+ &__footer {
+ @include mobile {
+ padding: 1.6rem;
+ }
+ }
+}
diff --git a/src/components/Modals/MyAdsDeleteModal/MyAdsDeleteModal.tsx b/src/components/Modals/MyAdsDeleteModal/MyAdsDeleteModal.tsx
new file mode 100644
index 00000000..39fb9be8
--- /dev/null
+++ b/src/components/Modals/MyAdsDeleteModal/MyAdsDeleteModal.tsx
@@ -0,0 +1,94 @@
+import { memo } from 'react';
+
+import { Button, Modal, Text } from '@deriv-com/ui';
+
+import { api } from '@/hooks';
+import { useDevice } from '@/hooks/custom-hooks';
+
+import './MyAdsDeleteModal.scss';
+
+type TMyAdsDeleteModalProps = {
+ error?: string;
+ id: string;
+ isModalOpen: boolean;
+ onClickDelete: () => void;
+ onRequestClose: () => void;
+};
+
+const MyAdsDeleteModal = ({ error, id, isModalOpen, onClickDelete, onRequestClose }: TMyAdsDeleteModalProps) => {
+ const { data: advertInfo, isLoading: isLoadingInfo } = api.advert.useGet({ id });
+ const { isMobile } = useDevice();
+ const textSize = isMobile ? 'md' : 'sm';
+
+ const hasActiveOrders = advertInfo?.active_orders && advertInfo?.active_orders > 0;
+
+ const getModalText = () => {
+ if (hasActiveOrders && !error) {
+ return 'You have open orders for this ad. Complete all open orders before deleting this ad.';
+ } else if (error) {
+ return error;
+ }
+ return 'You will NOT be able to restore it.';
+ };
+
+ const getModalFooter = () => {
+ if (hasActiveOrders || error) {
+ return (
+
+ Ok
+
+ );
+ }
+
+ return (
+
+
+ Cancel
+
+
+ Delete
+
+
+ );
+ };
+ return (
+ <>
+ {!isLoadingInfo && (
+
+
+ Do you want to delete this ad?
+
+
+
+ {getModalText()}
+
+
+
+ {getModalFooter()}
+
+
+ )}
+ >
+ );
+};
+
+export default memo(MyAdsDeleteModal);
diff --git a/src/components/Modals/MyAdsDeleteModal/__tests__/MyAdsDeleteModal.spec.tsx b/src/components/Modals/MyAdsDeleteModal/__tests__/MyAdsDeleteModal.spec.tsx
new file mode 100644
index 00000000..a5d7048c
--- /dev/null
+++ b/src/components/Modals/MyAdsDeleteModal/__tests__/MyAdsDeleteModal.spec.tsx
@@ -0,0 +1,77 @@
+import Modal from 'react-modal';
+
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import MyAdsDeleteModal from '../MyAdsDeleteModal';
+
+const mockProps = {
+ error: '',
+ id: '123',
+ isModalOpen: true,
+ onClickDelete: jest.fn(),
+ onRequestClose: jest.fn(),
+};
+
+let element: HTMLElement;
+const mockUseGet = {
+ data: {
+ active_orders: 0,
+ },
+ isLoading: false,
+};
+
+jest.mock('@deriv/api-v2', () => ({
+ p2p: {
+ advert: {
+ useGet: jest.fn(() => mockUseGet),
+ },
+ },
+ useAuthorize: () => ({
+ data: {
+ local_currencies: ['USD'],
+ },
+ }),
+}));
+
+describe('MyAdsDeleteModal', () => {
+ beforeAll(() => {
+ element = document.createElement('div');
+ element.setAttribute('id', 'v2_modal_root');
+ document.body.appendChild(element);
+ Modal.setAppElement('#v2_modal_root');
+ });
+ afterAll(() => {
+ document.body.removeChild(element);
+ });
+ it('should render the component as expected', () => {
+ render( );
+ expect(screen.getByText('Do you want to delete this ad?')).toBeInTheDocument();
+ expect(screen.getByText('You will NOT be able to restore it.')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+ });
+ it('should not allow deletion if there are active orders', () => {
+ mockUseGet.data.active_orders = 1;
+ render( );
+ expect(
+ screen.getByText('You have open orders for this ad. Complete all open orders before deleting this ad.')
+ ).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Ok' })).toBeInTheDocument();
+ });
+ it('should display the error message if there is an error', () => {
+ const newProps = {
+ ...mockProps,
+ error: 'An error occurred',
+ };
+ render( );
+ expect(screen.getByText('An error occurred')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Ok' })).toBeInTheDocument();
+ });
+ it('should handle onclick Delete', async () => {
+ mockUseGet.data.active_orders = 0;
+ render( );
+ const deleteButton = screen.getByRole('button', { name: 'Delete' });
+ await userEvent.click(deleteButton);
+ expect(mockProps.onClickDelete).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/Modals/MyAdsDeleteModal/index.ts b/src/components/Modals/MyAdsDeleteModal/index.ts
new file mode 100644
index 00000000..2bb8e57a
--- /dev/null
+++ b/src/components/Modals/MyAdsDeleteModal/index.ts
@@ -0,0 +1 @@
+export { default as MyAdsDeleteModal } from './MyAdsDeleteModal';
diff --git a/src/components/Modals/NicknameModal/NicknameModal.scss b/src/components/Modals/NicknameModal/NicknameModal.scss
new file mode 100644
index 00000000..a59afcb6
--- /dev/null
+++ b/src/components/Modals/NicknameModal/NicknameModal.scss
@@ -0,0 +1,34 @@
+.p2p-nickname-modal {
+ height: auto;
+ width: 44rem;
+ padding: 2.4rem;
+ border-radius: 8px;
+
+ @include mobile {
+ padding: 1.6rem;
+ width: 32.8rem;
+ }
+
+ &__body {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ height: 100%;
+
+ &-title {
+ padding-top: 1.6rem;
+ }
+ }
+
+ .deriv-input {
+ width: 100%;
+
+ &__container {
+ width: 36rem;
+
+ @include mobile {
+ width: 100%;
+ }
+ }
+ }
+}
diff --git a/src/components/Modals/NicknameModal/NicknameModal.tsx b/src/components/Modals/NicknameModal/NicknameModal.tsx
new file mode 100644
index 00000000..f6f751f2
--- /dev/null
+++ b/src/components/Modals/NicknameModal/NicknameModal.tsx
@@ -0,0 +1,114 @@
+import { Dispatch, SetStateAction, useEffect } from 'react';
+import { Controller, useForm } from 'react-hook-form';
+import { useHistory } from 'react-router-dom';
+import { debounce } from 'lodash';
+
+import { DerivLightIcCashierUserIcon } from '@deriv/quill-icons';
+import { Button, Input, Modal, Text, useDevice } from '@deriv-com/ui';
+
+import { BUY_SELL_URL } from '@/constants';
+import { api } from '@/hooks';
+import { useAdvertiserInfoState } from '@/providers/AdvertiserInfoStateProvider';
+
+import './NicknameModal.scss';
+
+type TNicknameModalProps = {
+ isModalOpen: boolean | undefined;
+ setIsModalOpen: Dispatch>;
+};
+
+const NicknameModal = ({ isModalOpen, setIsModalOpen }: TNicknameModalProps) => {
+ const {
+ control,
+ formState: { isDirty, isValid },
+ getValues,
+ handleSubmit,
+ } = useForm({
+ defaultValues: {
+ nickname: '',
+ },
+ mode: 'onChange',
+ });
+
+ const history = useHistory();
+ const { error: createError, isError, isSuccess, mutate, reset } = api.advertiser.useCreate();
+ const { setHasCreatedAdvertiser } = useAdvertiserInfoState();
+ const { isMobile } = useDevice();
+ const textSize = isMobile ? 'md' : 'sm';
+ const debouncedReset = debounce(reset, 3000);
+
+ const onSubmit = () => {
+ mutate({ name: getValues('nickname') });
+ };
+
+ useEffect(() => {
+ if (isSuccess) {
+ setIsModalOpen(false);
+ setHasCreatedAdvertiser(true);
+ } else if (isError) {
+ debouncedReset();
+ }
+ }, [isError, isSuccess, setHasCreatedAdvertiser]);
+
+ return (
+
+
+
+
+
+ What’s your nickname?
+
+
+ Others will see this on your profile, ads and charts.
+
+ (
+
+ )}
+ rules={{
+ pattern: {
+ message: 'Can only contain letters, numbers, and special characters .-_@.',
+ value: /^[a-zA-Z0-9.@_-]*$/,
+ },
+ required: 'Nickname is required',
+ }}
+ />
+
+ Your nickname cannot be changed later.
+
+
+
+ {
+ history.push(BUY_SELL_URL);
+ setIsModalOpen(false);
+ }}
+ size='lg'
+ textSize={textSize}
+ type='button'
+ variant='outlined'
+ >
+ Cancel
+
+
+ Confirm
+
+
+
+
+ );
+};
+
+export default NicknameModal;
diff --git a/src/components/Modals/NicknameModal/__tests__/NicknameModal.spec.tsx b/src/components/Modals/NicknameModal/__tests__/NicknameModal.spec.tsx
new file mode 100644
index 00000000..0e53faaf
--- /dev/null
+++ b/src/components/Modals/NicknameModal/__tests__/NicknameModal.spec.tsx
@@ -0,0 +1,124 @@
+import { APIProvider, AuthProvider } from '@deriv/api-v2';
+import { act, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { api } from '@/hooks';
+import { useAdvertiserInfoState } from '@/providers/AdvertiserInfoStateProvider';
+
+import NicknameModal from '../NicknameModal';
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+
+
+ {children}
+
+
+);
+
+const mockedMutate = jest.fn();
+const mockedReset = jest.fn();
+const mockedUseAdvertiserCreate = api.advertiser.useCreate as jest.MockedFunction;
+const mockPush = jest.fn();
+const mockUseAdvertiserInfoState = useAdvertiserInfoState as jest.MockedFunction;
+
+jest.mock('lodash', () => ({
+ ...jest.requireActual('lodash'),
+ debounce: jest.fn(f => f),
+}));
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useHistory: jest.fn(() => ({
+ push: mockPush,
+ })),
+}));
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({
+ isMobile: false,
+ }),
+}));
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ p2p: {
+ advertiser: {
+ useCreate: jest.fn(() => ({
+ error: undefined,
+ isError: false,
+ isSuccess: true,
+ mutate: mockedMutate,
+ reset: mockedReset,
+ })),
+ },
+ },
+}));
+
+jest.mock('@/providers/AdvertiserInfoStateProvider', () => ({
+ useAdvertiserInfoState: jest.fn().mockReturnValue({
+ setHasCreatedAdvertiser: jest.fn(),
+ }),
+}));
+
+describe('NicknameModal', () => {
+ it('should render title and description correctly', () => {
+ render( , { wrapper });
+ expect(screen.getByText('What’s your nickname?')).toBeVisible();
+ expect(screen.getByText('Others will see this on your profile, ads and charts.')).toBeVisible();
+ });
+ it('should allow users to type and submit nickname', async () => {
+ render( , { wrapper });
+
+ const nicknameInput = screen.getByTestId('dt_nickname_modal_input');
+
+ await userEvent.type(nicknameInput, 'Nahida');
+
+ await waitFor(async () => {
+ const confirmBtn = screen.getByRole('button', {
+ name: 'Confirm',
+ });
+ await userEvent.click(confirmBtn);
+ });
+
+ expect(mockedMutate).toHaveBeenCalledWith({
+ name: 'Nahida',
+ });
+ expect(mockUseAdvertiserInfoState().setHasCreatedAdvertiser).toBeCalledWith(true);
+ });
+ it('should invoke reset when there is an error from creating advertiser', async () => {
+ (mockedUseAdvertiserCreate as jest.Mock).mockImplementationOnce(() => ({
+ error: undefined,
+ isError: true,
+ isSuccess: false,
+ mutate: mockedMutate,
+ reset: mockedReset,
+ }));
+
+ await act(() => {
+ render( , { wrapper });
+ });
+
+ expect(mockedReset).toBeCalled();
+ });
+ it('should close the modal when Cancel button is clicked', async () => {
+ (mockedUseAdvertiserCreate as jest.Mock).mockImplementationOnce(() => ({
+ error: undefined,
+ isError: false,
+ isSuccess: true,
+ mutate: mockedMutate,
+ reset: mockedReset,
+ }));
+ const mockIsModalOpen = jest.fn();
+ render( , { wrapper });
+
+ const cancelBtn = screen.getByRole('button', {
+ name: 'Cancel',
+ });
+ await userEvent.click(cancelBtn);
+
+ expect(mockPush).toBeCalledWith('/cashier/p2p-v2/buy-sell');
+ expect(mockIsModalOpen).toBeCalledWith(false);
+ });
+});
diff --git a/src/components/Modals/NicknameModal/index.ts b/src/components/Modals/NicknameModal/index.ts
new file mode 100644
index 00000000..7282abf5
--- /dev/null
+++ b/src/components/Modals/NicknameModal/index.ts
@@ -0,0 +1 @@
+export { default as NicknameModal } from './NicknameModal';
diff --git a/src/components/Modals/OrderDetailsComplainModal/OrderDetailsComplainModal.scss b/src/components/Modals/OrderDetailsComplainModal/OrderDetailsComplainModal.scss
new file mode 100644
index 00000000..54cbb1fc
--- /dev/null
+++ b/src/components/Modals/OrderDetailsComplainModal/OrderDetailsComplainModal.scss
@@ -0,0 +1,39 @@
+.p2p-order-details-complain-modal {
+ width: 44rem;
+ &__body {
+ padding: 1.6rem;
+ padding-top: 0;
+ }
+
+ &__explanation {
+ background-color: #f2f3f4;
+ margin-top: 1.6rem;
+ padding: 1.6rem;
+ border-radius: 4px;
+ }
+
+ &__complain-footer {
+ display: flex;
+ gap: 0.8rem;
+ }
+
+ @include mobile {
+ width: unset;
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 1;
+ height: calc(100vh - 8rem);
+
+ & .p2p-mobile-wrapper__body {
+ padding: 0 1.6rem;
+ }
+
+ &__complain-footer {
+ padding: 2rem;
+ display: flex;
+ justify-content: flex-end;
+ border-top: 2px solid #f2f3f4;
+ }
+ }
+}
diff --git a/src/components/Modals/OrderDetailsComplainModal/OrderDetailsComplainModal.tsx b/src/components/Modals/OrderDetailsComplainModal/OrderDetailsComplainModal.tsx
new file mode 100644
index 00000000..be933416
--- /dev/null
+++ b/src/components/Modals/OrderDetailsComplainModal/OrderDetailsComplainModal.tsx
@@ -0,0 +1,142 @@
+import { useEffect, useState } from 'react';
+
+import { Button, Modal, Text, useDevice } from '@deriv-com/ui';
+
+import { FullPageMobileWrapper } from '@/components/FullPageMobileWrapper';
+import { api } from '@/hooks';
+
+import { OrderDetailsComplainModalRadioGroup } from './OrderDetailsComplainModalRadioGroup';
+
+import './OrderDetailsComplainModal.scss';
+
+type TOrderDetailsComplainModal = {
+ id: string;
+ isBuyOrderForUser: boolean;
+ isModalOpen: boolean;
+ onRequestClose: () => void;
+};
+
+type TComplainFooterProps = {
+ disputeOrderRequest: () => void;
+ disputeReason: string;
+ onRequestClose: () => void;
+};
+
+const ComplainExplanation = () => {
+ const { isMobile } = useDevice();
+ const textSize = isMobile ? 'sm' : 'xs';
+ return (
+
+ If your complaint isn’t listed here, please contact our {' '}
+
+ Customer Support
+ {' '}
+ team.
+
+ );
+};
+
+const ComplainFooter = ({ disputeOrderRequest, disputeReason, onRequestClose }: TComplainFooterProps) => {
+ const { isMobile } = useDevice();
+ const buttonTextSize = isMobile ? 'md' : 'sm';
+ return (
+
+
+ Cancel
+
+
+ Submit
+
+
+ );
+};
+
+const OrderDetailsComplainModal = ({
+ id,
+ isBuyOrderForUser,
+ isModalOpen,
+ onRequestClose,
+}: TOrderDetailsComplainModal) => {
+ const { isMobile } = useDevice();
+ const [disputeReason, setDisputeReason] = useState('');
+ const { isSuccess, mutate } = api.orderDispute.useDispute();
+
+ useEffect(() => {
+ if (isSuccess) {
+ onRequestClose();
+ }
+ }, [isSuccess, onRequestClose]);
+
+ const disputeOrderRequest = () => {
+ mutate({
+ dispute_reason: disputeReason,
+ id,
+ });
+ };
+
+ const onCheckboxChange = (reason: string) => setDisputeReason(reason);
+
+ if (isMobile)
+ return (
+ (
+
+ )}
+ renderHeader={() => (
+
+ Complaint
+
+ )}
+ >
+
+
+
+ );
+ return (
+
+
+ What’s your complaint?
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default OrderDetailsComplainModal;
diff --git a/src/components/Modals/OrderDetailsComplainModal/OrderDetailsComplainModalRadioGroup/OrderDetailsComplainModalRadioGroup.scss b/src/components/Modals/OrderDetailsComplainModal/OrderDetailsComplainModalRadioGroup/OrderDetailsComplainModalRadioGroup.scss
new file mode 100644
index 00000000..c65faf5d
--- /dev/null
+++ b/src/components/Modals/OrderDetailsComplainModal/OrderDetailsComplainModalRadioGroup/OrderDetailsComplainModalRadioGroup.scss
@@ -0,0 +1,24 @@
+.p2p-order-details-complain-modal-radio-group {
+ &:not(.p2p-radio-group__item) {
+ display: inline;
+ }
+
+ .p2p-radio-group__item {
+ border-bottom: 1px solid #f2f3f4;
+ padding: 1.6rem 0;
+
+ @include desktop {
+ padding-left: 1.6rem;
+ padding-right: 1.6rem;
+ }
+
+ & .p2p-radio-group__circle {
+ align-self: unset;
+ border-color: #999999;
+
+ &--selected {
+ border-color: #ff444f;
+ }
+ }
+ }
+}
diff --git a/src/components/Modals/OrderDetailsComplainModal/OrderDetailsComplainModalRadioGroup/OrderDetailsComplainModalRadioGroup.tsx b/src/components/Modals/OrderDetailsComplainModal/OrderDetailsComplainModalRadioGroup/OrderDetailsComplainModalRadioGroup.tsx
new file mode 100644
index 00000000..46efbfc4
--- /dev/null
+++ b/src/components/Modals/OrderDetailsComplainModal/OrderDetailsComplainModalRadioGroup/OrderDetailsComplainModalRadioGroup.tsx
@@ -0,0 +1,65 @@
+import { useDevice } from '@deriv-com/ui';
+
+import { RadioGroup } from '@/components';
+
+import './OrderDetailsComplainModalRadioGroup.scss';
+
+type TOrderDetailsComplainModalRadioGroupProps = {
+ disputeReason: string;
+ isBuyOrderForUser: boolean;
+ onCheckboxChange: (reason: string) => void;
+};
+
+const getRadioItems = (isBuyOrderForUser: boolean) => {
+ const radioItems = [
+ {
+ label: isBuyOrderForUser
+ ? 'I’ve made full payment, but the seller hasn’t released the funds.'
+ : 'I’ve not received any payment.',
+ value: isBuyOrderForUser ? 'seller_not_released' : 'buyer_not_paid',
+ },
+ {
+ label: isBuyOrderForUser
+ ? 'I wasn’t able to make full payment.'
+ : 'I’ve received less than the agreed amount.',
+ value: 'buyer_underpaid',
+ },
+ {
+ label: isBuyOrderForUser
+ ? 'I’ve paid more than the agreed amount.'
+ : 'I’ve received more than the agreed amount.',
+ value: 'buyer_overpaid',
+ },
+ {
+ hidden: isBuyOrderForUser,
+ label: 'I’ve received payment from 3rd party.',
+ value: 'buyer_third_party_payment_method',
+ },
+ ];
+
+ return radioItems;
+};
+const OrderDetailsComplainModalRadioGroup = ({
+ disputeReason,
+ isBuyOrderForUser,
+ onCheckboxChange,
+}: TOrderDetailsComplainModalRadioGroupProps) => {
+ const { isMobile } = useDevice();
+
+ return (
+ onCheckboxChange(event.target.value)}
+ required
+ selected={disputeReason}
+ textSize={isMobile ? 'md' : 'sm'}
+ >
+ {getRadioItems(isBuyOrderForUser).map(item => (
+
+ ))}
+
+ );
+};
+
+export default OrderDetailsComplainModalRadioGroup;
diff --git a/src/components/Modals/OrderDetailsComplainModal/OrderDetailsComplainModalRadioGroup/index.ts b/src/components/Modals/OrderDetailsComplainModal/OrderDetailsComplainModalRadioGroup/index.ts
new file mode 100644
index 00000000..9ce21c58
--- /dev/null
+++ b/src/components/Modals/OrderDetailsComplainModal/OrderDetailsComplainModalRadioGroup/index.ts
@@ -0,0 +1 @@
+export { default as OrderDetailsComplainModalRadioGroup } from './OrderDetailsComplainModalRadioGroup';
diff --git a/src/components/Modals/OrderDetailsComplainModal/__tests__/OrderDetailsComplainModal.spec.tsx b/src/components/Modals/OrderDetailsComplainModal/__tests__/OrderDetailsComplainModal.spec.tsx
new file mode 100644
index 00000000..56f03f82
--- /dev/null
+++ b/src/components/Modals/OrderDetailsComplainModal/__tests__/OrderDetailsComplainModal.spec.tsx
@@ -0,0 +1,79 @@
+import { useDevice } from '@deriv-com/ui';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import OrderDetailsComplainModal from '../OrderDetailsComplainModal';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({ isMobile: false }),
+}));
+
+const mockUseDevice = useDevice as jest.Mock;
+const mockUseDispute = {
+ isSuccess: true,
+ mutate: jest.fn(),
+};
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ p2p: {
+ orderDispute: {
+ useDispute: () => mockUseDispute,
+ },
+ },
+}));
+
+const mockProps = {
+ id: '123',
+ isBuyOrderForUser: true,
+ isModalOpen: true,
+ onRequestClose: jest.fn(),
+};
+
+describe('OrderDetailsComplainModal', () => {
+ it('should render the modal as expected', () => {
+ render( );
+ expect(screen.getByText('What’s your complaint?')).toBeInTheDocument();
+ });
+ it('should close the modal on clicking cancel button', async () => {
+ render( );
+ const cancelButton = screen.getByRole('button', { name: 'Cancel' });
+ await userEvent.click(cancelButton);
+ expect(mockProps.onRequestClose).toHaveBeenCalled();
+ });
+ it('should disable the submit button when no reason is selected', () => {
+ render( );
+ const submitButton = screen.getByRole('button', { name: 'Submit' });
+ expect(submitButton).toBeDisabled();
+ });
+ it('should enable the submit button when a reason is selected', async () => {
+ render( );
+ const submitButton = screen.getByRole('button', { name: 'Submit' });
+ const reason = screen.getByRole('radio', { name: 'I wasn’t able to make full payment.' });
+ await userEvent.click(reason);
+ expect(submitButton).toBeEnabled();
+ });
+ it('should call mutate function on clicking submit button', async () => {
+ render( );
+ const submitButton = screen.getByRole('button', { name: 'Submit' });
+ const reason = screen.getByRole('radio', { name: 'I wasn’t able to make full payment.' });
+ await userEvent.click(reason);
+ await userEvent.click(submitButton);
+ expect(mockUseDispute.mutate).toHaveBeenCalledWith({ dispute_reason: 'buyer_underpaid', id: '123' });
+ expect(mockProps.onRequestClose).toHaveBeenCalled();
+ });
+ it('should render the full page mobile wrapper when in mobile view', () => {
+ mockUseDevice.mockReturnValue({ isMobile: true });
+ render( );
+ expect(screen.getByTestId('dt_full_page_mobile_wrapper')).toBeInTheDocument();
+ });
+ it('should render the corresponding labels for sell order', () => {
+ mockProps.isBuyOrderForUser = false;
+ render( );
+ expect(screen.getByRole('radio', { name: 'I’ve not received any payment.' })).toBeInTheDocument();
+ expect(screen.getByRole('radio', { name: 'I’ve received less than the agreed amount.' })).toBeInTheDocument();
+ expect(screen.getByRole('radio', { name: 'I’ve received more than the agreed amount.' })).toBeInTheDocument();
+ expect(screen.getByRole('radio', { name: 'I’ve received payment from 3rd party.' })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/Modals/OrderDetailsComplainModal/index.ts b/src/components/Modals/OrderDetailsComplainModal/index.ts
new file mode 100644
index 00000000..9dd93e60
--- /dev/null
+++ b/src/components/Modals/OrderDetailsComplainModal/index.ts
@@ -0,0 +1 @@
+export { default as OrderDetailsComplainModal } from './OrderDetailsComplainModal';
diff --git a/src/components/Modals/OrderDetailsConfirmModal/OrderDetailsConfirmModal.scss b/src/components/Modals/OrderDetailsConfirmModal/OrderDetailsConfirmModal.scss
new file mode 100644
index 00000000..6309727e
--- /dev/null
+++ b/src/components/Modals/OrderDetailsConfirmModal/OrderDetailsConfirmModal.scss
@@ -0,0 +1,13 @@
+.p2p-order-details-confirm-modal {
+ height: auto;
+ width: 44rem;
+ border-radius: 8px;
+
+ @include mobile {
+ max-width: calc(100vw - 3.2rem);
+ }
+
+ .deriv-modal__close-icon {
+ margin-right: 0;
+ }
+}
diff --git a/src/components/Modals/OrderDetailsConfirmModal/OrderDetailsConfirmModal.tsx b/src/components/Modals/OrderDetailsConfirmModal/OrderDetailsConfirmModal.tsx
new file mode 100644
index 00000000..ddf132b3
--- /dev/null
+++ b/src/components/Modals/OrderDetailsConfirmModal/OrderDetailsConfirmModal.tsx
@@ -0,0 +1,97 @@
+import React, { useState } from 'react';
+import { FileUploaderComponent } from '@/components/FileUploaderComponent';
+import { getErrorMessage, maxPotFileSize, TFile } from '@/utils';
+import { Button, InlineMessage, Modal, Text, useDevice } from '@deriv-com/ui';
+import './OrderDetailsConfirmModal.scss';
+
+type TOrderDetailsConfirmModalProps = {
+ isModalOpen: boolean;
+ onRequestClose: () => void;
+};
+
+type TDocumentFile = {
+ errorMessage: string | null;
+ files: TFile[];
+};
+
+const OrderDetailsConfirmModal = ({ isModalOpen, onRequestClose }: TOrderDetailsConfirmModalProps) => {
+ const [documentFile, setDocumentFile] = useState({ errorMessage: null, files: [] });
+ const { isMobile } = useDevice();
+ const buttonTextSize = isMobile ? 'md' : 'sm';
+
+ const handleAcceptedFiles = (files: TFile[]) => {
+ if (files.length > 0) {
+ setDocumentFile({ errorMessage: null, files });
+ }
+ };
+
+ const removeFile = () => {
+ setDocumentFile({ errorMessage: null, files: [] });
+ };
+
+ const handleRejectedFiles = (files: TFile[]) => {
+ setDocumentFile({ errorMessage: getErrorMessage(files), files });
+ };
+
+ // TODO: uncomment this when implementing the OrderDetailsConfirmModal
+ // const displayPaymentAmount = removeTrailingZeros(
+ // formatMoney(local_currency, amount_display * Number(roundOffDecimal(rate, setDecimalPlaces(rate, 6))), true)
+ // );
+
+ return (
+
+
+
+ Payment confirmation
+
+
+
+
+ Please make sure that you’ve paid 9.99 IDR to client CR90000012, and upload the receipt as proof of
+ your payment
+
+
+ We accept JPG, PDF, or PNG (up to 5MB).
+
+
+
+ Sending forged documents will result in an immediate and permanent ban.
+
+
+
+
+
+
+
+ Go Back
+
+
+
+
+ Confirm
+
+
+
+
+ );
+};
+
+export default OrderDetailsConfirmModal;
diff --git a/src/components/Modals/OrderDetailsConfirmModal/__tests__/OrderDetailsConfirmModal.spec.tsx b/src/components/Modals/OrderDetailsConfirmModal/__tests__/OrderDetailsConfirmModal.spec.tsx
new file mode 100644
index 00000000..7be796c7
--- /dev/null
+++ b/src/components/Modals/OrderDetailsConfirmModal/__tests__/OrderDetailsConfirmModal.spec.tsx
@@ -0,0 +1,102 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import OrderDetailsConfirmModal from '../OrderDetailsConfirmModal';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({
+ isMobile: false,
+ }),
+}));
+
+const mockProps = {
+ isModalOpen: true,
+ onRequestClose: jest.fn(),
+};
+
+describe(' ', () => {
+ it('should render the modal’s default screen', () => {
+ render( );
+
+ expect(screen.getByText('Payment confirmation')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /Please make sure that you’ve paid 9.99 IDR to client CR90000012, and upload the receipt as proof of your payment/
+ )
+ ).toBeInTheDocument();
+ expect(screen.getByText('We accept JPG, PDF, or PNG (up to 5MB).')).toBeInTheDocument();
+ expect(
+ screen.getByText('Sending forged documents will result in an immediate and permanent ban.')
+ ).toBeInTheDocument();
+ expect(screen.getByText('Upload receipt here')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Go Back' })).toBeInTheDocument();
+ });
+
+ it('should handle accepted files', async () => {
+ render( );
+
+ const file = new File(['test'], 'test.png', { type: 'image/png' });
+ const fileInput = screen.getByTestId('dt_file_upload_input') as HTMLInputElement;
+
+ await userEvent.upload(fileInput, file);
+
+ await waitFor(() => {
+ if (fileInput.files) {
+ expect(fileInput.files[0]).toBe(file);
+ expect(fileInput.files).toHaveLength(1);
+ }
+ });
+
+ expect(screen.getByText('test.png')).toBeInTheDocument();
+ });
+
+ it('should show error message if file is not supported', async () => {
+ render( );
+
+ const file = new File(['test'], 'test.mp4', { type: 'video/mp4' });
+ const fileInput = screen.getByTestId('dt_file_upload_input');
+
+ await userEvent.upload(fileInput, file);
+
+ await waitFor(() => {
+ expect(screen.getByText('The file you uploaded is not supported. Upload another.')).toBeInTheDocument();
+ });
+ });
+
+ it('should show error message if file is over 5MB', async () => {
+ render( );
+
+ const blob = new Blob([new Array(6 * 1024 * 1024).join('a')], { type: 'image/png' });
+ const file = new File([blob], 'test.png');
+ const fileInput = screen.getByTestId('dt_file_upload_input');
+
+ await userEvent.upload(fileInput, file);
+
+ await waitFor(() => {
+ expect(screen.getByText('Cannot upload a file over 5MB')).toBeInTheDocument();
+ });
+ });
+
+ it('should remove file when close icon is clicked', async () => {
+ render( );
+
+ const file = new File(['test'], 'test.png', { type: 'image/png' });
+ const fileInput = screen.getByTestId('dt_file_upload_input');
+
+ await userEvent.upload(fileInput, file);
+
+ await waitFor(() => {
+ expect(screen.getByText('test.png')).toBeInTheDocument();
+ });
+
+ const closeIcon = screen.getByTestId('dt_remove_file_icon');
+
+ await userEvent.click(closeIcon);
+
+ await waitFor(() => {
+ expect(screen.queryByText('test.png')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/Modals/OrderDetailsConfirmModal/index.ts b/src/components/Modals/OrderDetailsConfirmModal/index.ts
new file mode 100644
index 00000000..af6e4cd3
--- /dev/null
+++ b/src/components/Modals/OrderDetailsConfirmModal/index.ts
@@ -0,0 +1 @@
+export { default as OrderDetailsConfirmModal } from './OrderDetailsConfirmModal';
diff --git a/src/components/Modals/OrderTimeTooltipModal/OrderTimeTooltipModal.tsx b/src/components/Modals/OrderTimeTooltipModal/OrderTimeTooltipModal.tsx
new file mode 100644
index 00000000..4ae6b39c
--- /dev/null
+++ b/src/components/Modals/OrderTimeTooltipModal/OrderTimeTooltipModal.tsx
@@ -0,0 +1,25 @@
+import React, { MouseEventHandler } from 'react';
+import { ORDER_TIME_INFO_MESSAGE } from '@/constants';
+import { Button, Modal, Text } from '@deriv-com/ui';
+
+type TOrderTimeTooltipModalProps = {
+ isModalOpen: boolean;
+ onRequestClose: MouseEventHandler;
+};
+
+const OrderTimeTooltipModal = ({ isModalOpen, onRequestClose }: TOrderTimeTooltipModalProps) => {
+ return (
+
+
+
+ {ORDER_TIME_INFO_MESSAGE}
+
+
+
+ Ok
+
+
+ );
+};
+
+export default OrderTimeTooltipModal;
diff --git a/src/components/Modals/OrderTimeTooltipModal/__tests__/OrderTimeTooltipModal.spec.tsx b/src/components/Modals/OrderTimeTooltipModal/__tests__/OrderTimeTooltipModal.spec.tsx
new file mode 100644
index 00000000..153f3ffc
--- /dev/null
+++ b/src/components/Modals/OrderTimeTooltipModal/__tests__/OrderTimeTooltipModal.spec.tsx
@@ -0,0 +1,22 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import OrderTimeTooltipModal from '../OrderTimeTooltipModal';
+
+const mockProps = {
+ isModalOpen: true,
+ onRequestClose: jest.fn(),
+};
+
+describe(' ', () => {
+ it('should render just the star rating initially', () => {
+ render( );
+ expect(screen.getByText('Orders will expire if they aren’t completed within this time.')).toBeInTheDocument();
+ });
+ it('should handle the onclick for ok button', async () => {
+ render( );
+ const okButton = screen.getByRole('button', { name: 'Ok' });
+ await userEvent.click(okButton);
+ expect(mockProps.onRequestClose).toBeCalledTimes(1);
+ });
+});
diff --git a/src/components/Modals/OrderTimeTooltipModal/index.ts b/src/components/Modals/OrderTimeTooltipModal/index.ts
new file mode 100644
index 00000000..5a5ae477
--- /dev/null
+++ b/src/components/Modals/OrderTimeTooltipModal/index.ts
@@ -0,0 +1 @@
+export { default as OrderTimeTooltipModal } from './OrderTimeTooltipModal';
diff --git a/src/components/Modals/PaymentMethods/PaymentMethodErrorModal/PaymentMethodErrorModal.scss b/src/components/Modals/PaymentMethods/PaymentMethodErrorModal/PaymentMethodErrorModal.scss
new file mode 100644
index 00000000..704505af
--- /dev/null
+++ b/src/components/Modals/PaymentMethods/PaymentMethodErrorModal/PaymentMethodErrorModal.scss
@@ -0,0 +1,32 @@
+.p2p-payment-method-error-modal {
+ height: auto;
+ border-radius: 8px;
+ width: 44rem;
+
+ @include mobile {
+ max-width: calc(100vw - 3.2rem);
+ }
+
+ &__header {
+ margin-top: 0.5rem;
+
+ @include mobile {
+ margin-top: 0;
+ padding: 1.6rem;
+ }
+ }
+
+ &__body {
+ padding: 1rem 2.4rem;
+
+ @include mobile {
+ padding: 0.5rem 1.6rem;
+ }
+ }
+
+ &__footer {
+ @include mobile {
+ padding: 1.6rem;
+ }
+ }
+}
diff --git a/src/components/Modals/PaymentMethods/PaymentMethodErrorModal/PaymentMethodErrorModal.tsx b/src/components/Modals/PaymentMethods/PaymentMethodErrorModal/PaymentMethodErrorModal.tsx
new file mode 100644
index 00000000..2194485e
--- /dev/null
+++ b/src/components/Modals/PaymentMethods/PaymentMethodErrorModal/PaymentMethodErrorModal.tsx
@@ -0,0 +1,39 @@
+import { Button, Modal, Text, useDevice } from '@deriv-com/ui';
+
+import './PaymentMethodErrorModal.scss';
+
+type TPaymentMethodErrorModalProps = {
+ errorMessage: string;
+ isModalOpen: boolean;
+ onConfirm: () => void;
+ title: string;
+};
+
+const PaymentMethodErrorModal = ({ errorMessage, isModalOpen, onConfirm, title }: TPaymentMethodErrorModalProps) => {
+ const { isMobile } = useDevice();
+
+ // TODO: Remember to translate these strings
+ return (
+
+
+ {title}
+
+
+ {errorMessage}
+
+
+
+ Ok
+
+
+
+ );
+};
+
+export default PaymentMethodErrorModal;
diff --git a/src/components/Modals/PaymentMethods/PaymentMethodErrorModal/__tests__/PaymentMethodErrorModal.spec.tsx b/src/components/Modals/PaymentMethods/PaymentMethodErrorModal/__tests__/PaymentMethodErrorModal.spec.tsx
new file mode 100644
index 00000000..7ece6e21
--- /dev/null
+++ b/src/components/Modals/PaymentMethods/PaymentMethodErrorModal/__tests__/PaymentMethodErrorModal.spec.tsx
@@ -0,0 +1,24 @@
+import React, { PropsWithChildren } from 'react';
+import { render, screen } from '@testing-library/react';
+import PaymentMethodErrorModal from '../PaymentMethodErrorModal';
+
+const wrapper = ({ children }: PropsWithChildren) => {children}
;
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn(() => ({ isMobile: false })),
+}));
+
+describe('PaymentMethodErrorModal', () => {
+ it('should render the modal correctly', () => {
+ const props = {
+ errorMessage: 'error message',
+ isModalOpen: true,
+ onConfirm: jest.fn(),
+ title: 'title',
+ };
+ render( , { wrapper });
+ expect(screen.getByText('title')).toBeInTheDocument();
+ expect(screen.getByText('error message')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/Modals/PaymentMethods/PaymentMethodErrorModal/index.ts b/src/components/Modals/PaymentMethods/PaymentMethodErrorModal/index.ts
new file mode 100644
index 00000000..2f8450ae
--- /dev/null
+++ b/src/components/Modals/PaymentMethods/PaymentMethodErrorModal/index.ts
@@ -0,0 +1 @@
+export { default as PaymentMethodErrorModal } from './PaymentMethodErrorModal';
diff --git a/src/components/Modals/PaymentMethods/PaymentMethodModal/PaymentMethodModal.scss b/src/components/Modals/PaymentMethods/PaymentMethodModal/PaymentMethodModal.scss
new file mode 100644
index 00000000..24028a3e
--- /dev/null
+++ b/src/components/Modals/PaymentMethods/PaymentMethodModal/PaymentMethodModal.scss
@@ -0,0 +1,37 @@
+.p2p-payment-method-modal {
+ height: auto;
+ border-radius: 8px;
+ width: 44rem;
+
+ @include mobile {
+ max-width: calc(100vw - 3.2rem);
+ }
+
+ &__header {
+ margin-top: 0.5rem;
+
+ @include mobile {
+ margin-top: 0;
+ padding: 1.6rem;
+ }
+ }
+
+ &__body {
+ padding: 1rem 2.4rem;
+
+ @include mobile {
+ padding: 0.5rem 1.6rem;
+ }
+ }
+
+ &__footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.8rem;
+ width: 100%;
+
+ @include mobile {
+ padding: 1.6rem;
+ }
+ }
+}
diff --git a/src/components/Modals/PaymentMethods/PaymentMethodModal/PaymentMethodModal.tsx b/src/components/Modals/PaymentMethods/PaymentMethodModal/PaymentMethodModal.tsx
new file mode 100644
index 00000000..cfec6f88
--- /dev/null
+++ b/src/components/Modals/PaymentMethods/PaymentMethodModal/PaymentMethodModal.tsx
@@ -0,0 +1,71 @@
+import { Button, Modal, Text, useDevice } from '@deriv-com/ui';
+
+import './PaymentMethodModal.scss';
+
+type TPaymentMethodModalProps = {
+ description?: string;
+ isModalOpen: boolean;
+ onConfirm: () => void;
+ onReject: () => void;
+ primaryButtonLabel: string;
+ secondaryButtonLabel: string;
+ title?: string;
+};
+
+const PaymentMethodModal = ({
+ description,
+ isModalOpen,
+ onConfirm,
+ onReject,
+ primaryButtonLabel,
+ secondaryButtonLabel,
+ title,
+}: TPaymentMethodModalProps) => {
+ const { isMobile } = useDevice();
+ const buttonTextSize = isMobile ? 'md' : 'sm';
+
+ // TODO: Remember to translate these strings
+ return (
+
+
+ {title}
+
+
+ {description}
+
+
+ {
+ e.currentTarget.setAttribute('disabled', 'disabled');
+ onConfirm();
+ }}
+ size='lg'
+ textSize={buttonTextSize}
+ variant='outlined'
+ >
+ {secondaryButtonLabel}
+
+ {
+ e.currentTarget.setAttribute('disabled', 'disabled');
+ onReject();
+ }}
+ size='lg'
+ textSize={buttonTextSize}
+ >
+ {primaryButtonLabel}
+
+
+
+ );
+};
+
+export default PaymentMethodModal;
diff --git a/src/components/Modals/PaymentMethods/PaymentMethodModal/__tests__/PaymentMethodModal.spec.tsx b/src/components/Modals/PaymentMethods/PaymentMethodModal/__tests__/PaymentMethodModal.spec.tsx
new file mode 100644
index 00000000..5f954b6e
--- /dev/null
+++ b/src/components/Modals/PaymentMethods/PaymentMethodModal/__tests__/PaymentMethodModal.spec.tsx
@@ -0,0 +1,66 @@
+import React, { PropsWithChildren } from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import PaymentMethodModal from '../PaymentMethodModal';
+
+const wrapper = ({ children }: PropsWithChildren) => {children}
;
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn(() => ({ isMobile: false })),
+}));
+
+describe('PaymentMethodModal', () => {
+ it('should render the component correctly', () => {
+ render(
+ ,
+ { wrapper }
+ );
+ expect(screen.getByText('Payment Method Modal')).toBeInTheDocument();
+ expect(screen.getByText('Payment Method Modal Description')).toBeInTheDocument();
+ });
+ it('should handle onclick when the yes, remove button is clicked', () => {
+ const onConfirm = jest.fn();
+ render(
+ ,
+ { wrapper }
+ );
+ const confirmButton = screen.getByText('Yes, remove');
+ userEvent.click(confirmButton);
+ expect(onConfirm).toHaveBeenCalled();
+ });
+ it('should handle onclick when the yes button is clicked', () => {
+ const onReject = jest.fn();
+ render(
+ ,
+ { wrapper }
+ );
+ const rejectButton = screen.getByText('Yes');
+ userEvent.click(rejectButton);
+ expect(onReject).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/Modals/PaymentMethods/PaymentMethodModal/index.ts b/src/components/Modals/PaymentMethods/PaymentMethodModal/index.ts
new file mode 100644
index 00000000..50c373db
--- /dev/null
+++ b/src/components/Modals/PaymentMethods/PaymentMethodModal/index.ts
@@ -0,0 +1 @@
+export { default as PaymentMethodModal } from './PaymentMethodModal';
diff --git a/src/components/Modals/PaymentMethods/index.ts b/src/components/Modals/PaymentMethods/index.ts
new file mode 100644
index 00000000..78982f8d
--- /dev/null
+++ b/src/components/Modals/PaymentMethods/index.ts
@@ -0,0 +1,2 @@
+export * from './PaymentMethodErrorModal';
+export * from './PaymentMethodModal';
diff --git a/src/components/Modals/PreferredCountriesModal/NoSearchResults/NoSearchResults.tsx b/src/components/Modals/PreferredCountriesModal/NoSearchResults/NoSearchResults.tsx
new file mode 100644
index 00000000..b175f916
--- /dev/null
+++ b/src/components/Modals/PreferredCountriesModal/NoSearchResults/NoSearchResults.tsx
@@ -0,0 +1,20 @@
+import { Text, useDevice } from '@deriv-com/ui';
+
+type TNoSearchResultsProps = {
+ value: string;
+};
+const NoSearchResults = ({ value }: TNoSearchResultsProps) => {
+ const { isMobile } = useDevice();
+ return (
+
+
+ {`No results for “${value}”.`}
+
+
+ Check your spelling or use a different term.
+
+
+ );
+};
+
+export default NoSearchResults;
diff --git a/src/components/Modals/PreferredCountriesModal/NoSearchResults/__tests__/NoSearchResults.spec.tsx b/src/components/Modals/PreferredCountriesModal/NoSearchResults/__tests__/NoSearchResults.spec.tsx
new file mode 100644
index 00000000..ddf2ce87
--- /dev/null
+++ b/src/components/Modals/PreferredCountriesModal/NoSearchResults/__tests__/NoSearchResults.spec.tsx
@@ -0,0 +1,16 @@
+import { render, screen } from '@testing-library/react';
+
+import NoSearchResults from '../NoSearchResults';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: false }),
+}));
+
+describe('NoSearchResults', () => {
+ it('should render', () => {
+ render( );
+ expect(screen.getByText('No results for “test”.')).toBeInTheDocument();
+ expect(screen.getByText('Check your spelling or use a different term.')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/Modals/PreferredCountriesModal/NoSearchResults/index.ts b/src/components/Modals/PreferredCountriesModal/NoSearchResults/index.ts
new file mode 100644
index 00000000..f22a1c43
--- /dev/null
+++ b/src/components/Modals/PreferredCountriesModal/NoSearchResults/index.ts
@@ -0,0 +1 @@
+export { default as NoSearchResults } from './NoSearchResults';
diff --git a/src/components/Modals/PreferredCountriesModal/PreferredCountriesDropdown/PreferredCountriesDropdown.scss b/src/components/Modals/PreferredCountriesModal/PreferredCountriesDropdown/PreferredCountriesDropdown.scss
new file mode 100644
index 00000000..4f9a66d1
--- /dev/null
+++ b/src/components/Modals/PreferredCountriesModal/PreferredCountriesDropdown/PreferredCountriesDropdown.scss
@@ -0,0 +1,31 @@
+.p2p-preferred-countries-dropdown {
+ & .p2p-search {
+ & .deriv-input {
+ &__container {
+ & .deriv-input__left-content {
+ margin-left: -1rem;
+ }
+ & .deriv-input__field {
+ margin-left: 1.8rem;
+ }
+ }
+ }
+ }
+ &__content {
+ height: 37rem;
+ overflow-y: auto;
+
+ @include mobile {
+ height: unset;
+ max-height: calc(100vh - 28rem);
+ }
+
+ &--no-footer {
+ height: 44rem;
+ @include mobile {
+ height: 100vh;
+ max-height: unset;
+ }
+ }
+ }
+}
diff --git a/src/components/Modals/PreferredCountriesModal/PreferredCountriesDropdown/PreferredCountriesDropdown.tsx b/src/components/Modals/PreferredCountriesModal/PreferredCountriesDropdown/PreferredCountriesDropdown.tsx
new file mode 100644
index 00000000..72c95933
--- /dev/null
+++ b/src/components/Modals/PreferredCountriesModal/PreferredCountriesDropdown/PreferredCountriesDropdown.tsx
@@ -0,0 +1,106 @@
+import React, { useState } from 'react';
+import clsx from 'clsx';
+import { Search } from '@/components';
+import { Checkbox, Divider } from '@deriv-com/ui';
+import { NoSearchResults } from '../NoSearchResults';
+import './PreferredCountriesDropdown.scss';
+
+type TItem = { text: string; value: string };
+type TPreferredCountriesDropdownProps = {
+ list: TItem[];
+ selectedCountries: string[];
+ setSelectedCountries: (value: string[]) => void;
+ setShouldDisplayFooter: (value: boolean) => void;
+};
+
+const PreferredCountriesDropdown = ({
+ list = [],
+ selectedCountries,
+ setSelectedCountries,
+ setShouldDisplayFooter,
+}: TPreferredCountriesDropdownProps) => {
+ const [searchResults, setSearchResults] = useState([
+ ...list.filter(item => selectedCountries.includes(item.value)),
+ ...list.filter(item => !selectedCountries.includes(item.value)),
+ ]);
+ const [searchValue, setSearchValue] = useState('');
+
+ const onSearch = (value: string) => {
+ if (!value) {
+ setShouldDisplayFooter(true);
+ setSearchValue('');
+ setSearchResults([
+ ...list.filter(item => selectedCountries.includes(item.value)),
+ ...list.filter(item => !selectedCountries.includes(item.value)),
+ ]);
+ return;
+ }
+ setShouldDisplayFooter(false);
+ setSearchValue(value);
+ setSearchResults(list.filter(item => item.text.toLowerCase().includes(value.toLowerCase())));
+ };
+
+ return (
+
+
+
+
+
+
+ {searchResults?.length > 0 ? (
+
0,
+ })}
+ >
+ {searchResults?.length === list?.length && (
+
+ ) => {
+ if (event.target.checked) {
+ setSelectedCountries(list.map(item => item.value));
+ } else {
+ setSelectedCountries([]);
+ }
+ }}
+ />
+
+ )}
+
+
+ {searchResults?.map((item: TItem) => (
+
+ ) => {
+ if (event.target.checked) {
+ setSelectedCountries([...selectedCountries, item.value]);
+ } else {
+ setSelectedCountries(
+ selectedCountries.filter(value => value !== item.value)
+ );
+ }
+ }}
+ />
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default PreferredCountriesDropdown;
diff --git a/src/components/Modals/PreferredCountriesModal/PreferredCountriesDropdown/__tests__/PreferredCountriesDropdown.spec.tsx b/src/components/Modals/PreferredCountriesModal/PreferredCountriesDropdown/__tests__/PreferredCountriesDropdown.spec.tsx
new file mode 100644
index 00000000..09c30924
--- /dev/null
+++ b/src/components/Modals/PreferredCountriesModal/PreferredCountriesDropdown/__tests__/PreferredCountriesDropdown.spec.tsx
@@ -0,0 +1,96 @@
+import { act, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import PreferredCountriesDropdown from '../PreferredCountriesDropdown';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({
+ isMobile: false,
+ }),
+}));
+
+const mockProps = {
+ list: [
+ {
+ text: 'United Kingdom',
+ value: 'uk',
+ },
+ {
+ text: 'United States',
+ value: 'us',
+ },
+ ],
+ selectedCountries: ['uk'],
+ setSelectedCountries: jest.fn(),
+ setShouldDisplayFooter: jest.fn(),
+};
+
+describe('PreferredCountriesDropdown', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+ it('should render the component as expected', () => {
+ render( );
+ expect(screen.getByText('United Kingdom')).toBeInTheDocument();
+ expect(screen.getByText('United States')).toBeInTheDocument();
+ expect(screen.getByText('All countries')).toBeInTheDocument();
+ });
+ it('should handle selecting all countries checkbox for selecting all countries', async () => {
+ render( );
+ const allCountriesCheckbox = screen.getByRole('checkbox', { name: 'All countries' });
+ await userEvent.click(allCountriesCheckbox);
+ expect(mockProps.setSelectedCountries).toHaveBeenCalledWith(['uk', 'us']);
+ });
+ it('should handle unselecting all countries checkbox for unselecting all countries', async () => {
+ render( );
+ const allCountriesCheckbox = screen.getByRole('checkbox', { name: 'All countries' });
+ await userEvent.click(allCountriesCheckbox);
+ expect(mockProps.setSelectedCountries).toHaveBeenCalledWith([]);
+ });
+ it('should handle selecting of individual country', async () => {
+ render( );
+ const usCheckbox = screen.getByRole('checkbox', { name: 'United States' });
+ await userEvent.click(usCheckbox);
+ expect(mockProps.setSelectedCountries).toHaveBeenCalledWith(['uk', 'us']);
+ });
+ it('should handle unselecting of individual country', async () => {
+ render( );
+ const ukCheckbox = screen.getByRole('checkbox', { name: 'United Kingdom' });
+ await userEvent.click(ukCheckbox);
+ expect(mockProps.setSelectedCountries).toHaveBeenCalledWith(['us']);
+ });
+ it('should display no search results message when there are no search results', () => {
+ render( );
+ const searchInput = screen.getByPlaceholderText('Search countries');
+ act(async () => {
+ await userEvent.type(searchInput, 'India');
+ });
+ act(() => {
+ jest.runAllTimers();
+ });
+ expect(screen.getByText('No results for “India”.')).toBeInTheDocument();
+ });
+ it('should display full list on search clear', () => {
+ render( );
+ const searchInput = screen.getByPlaceholderText('Search countries');
+ act(async () => {
+ await userEvent.type(searchInput, 'India');
+ });
+ act(() => {
+ jest.runAllTimers();
+ });
+ act(async () => {
+ await userEvent.clear(searchInput);
+ });
+ act(() => {
+ jest.runAllTimers();
+ });
+ expect(screen.getByText('United Kingdom')).toBeInTheDocument();
+ expect(screen.getByText('United States')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/Modals/PreferredCountriesModal/PreferredCountriesDropdown/index.ts b/src/components/Modals/PreferredCountriesModal/PreferredCountriesDropdown/index.ts
new file mode 100644
index 00000000..af726fff
--- /dev/null
+++ b/src/components/Modals/PreferredCountriesModal/PreferredCountriesDropdown/index.ts
@@ -0,0 +1 @@
+export { default as PreferredCountriesDropdown } from './PreferredCountriesDropdown';
diff --git a/src/components/Modals/PreferredCountriesModal/PreferredCountriesFooter/PreferredCountriesFooter.tsx b/src/components/Modals/PreferredCountriesModal/PreferredCountriesFooter/PreferredCountriesFooter.tsx
new file mode 100644
index 00000000..bc1b7f70
--- /dev/null
+++ b/src/components/Modals/PreferredCountriesModal/PreferredCountriesFooter/PreferredCountriesFooter.tsx
@@ -0,0 +1,39 @@
+import { Button, useDevice } from '@deriv-com/ui';
+
+type TPreferredCountriesFooterProps = {
+ isDisabled: boolean;
+ onClickApply: () => void;
+ onClickClear: () => void;
+};
+
+const PreferredCountriesFooter = ({ isDisabled, onClickApply, onClickClear }: TPreferredCountriesFooterProps) => {
+ const { isMobile } = useDevice();
+ const textSize = isMobile ? 'md' : 'sm';
+ return (
+
+
+ Clear
+
+
+ Apply
+
+
+ );
+};
+
+export default PreferredCountriesFooter;
diff --git a/src/components/Modals/PreferredCountriesModal/PreferredCountriesFooter/__tests__/PreferredCountriesFooter.spec.tsx b/src/components/Modals/PreferredCountriesModal/PreferredCountriesFooter/__tests__/PreferredCountriesFooter.spec.tsx
new file mode 100644
index 00000000..9970a0a2
--- /dev/null
+++ b/src/components/Modals/PreferredCountriesModal/PreferredCountriesFooter/__tests__/PreferredCountriesFooter.spec.tsx
@@ -0,0 +1,37 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import PreferredCountriesFooter from '../PreferredCountriesFooter';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({
+ isMobile: false,
+ }),
+}));
+
+const mockProps = {
+ isDisabled: false,
+ onClickApply: jest.fn(),
+ onClickClear: jest.fn(),
+};
+
+describe('PreferredCountriesFooter', () => {
+ it('should render the component as expected', () => {
+ render( );
+ expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
+ });
+ it('should handle the onClick event for Clear button', async () => {
+ render( );
+ const clearButton = screen.getByRole('button', { name: 'Clear' });
+ await userEvent.click(clearButton);
+ expect(mockProps.onClickClear).toHaveBeenCalledTimes(1);
+ });
+ it('should handle the onClick event for apply button ', async () => {
+ render( );
+ const applyButton = screen.getByRole('button', { name: 'Apply' });
+ await userEvent.click(applyButton);
+ expect(mockProps.onClickApply).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/components/Modals/PreferredCountriesModal/PreferredCountriesFooter/index.ts b/src/components/Modals/PreferredCountriesModal/PreferredCountriesFooter/index.ts
new file mode 100644
index 00000000..c978cbc8
--- /dev/null
+++ b/src/components/Modals/PreferredCountriesModal/PreferredCountriesFooter/index.ts
@@ -0,0 +1 @@
+export { default as PreferredCountriesFooter } from './PreferredCountriesFooter';
diff --git a/src/components/Modals/PreferredCountriesModal/PreferredCountriesModal.scss b/src/components/Modals/PreferredCountriesModal/PreferredCountriesModal.scss
new file mode 100644
index 00000000..a356e331
--- /dev/null
+++ b/src/components/Modals/PreferredCountriesModal/PreferredCountriesModal.scss
@@ -0,0 +1,20 @@
+.p2p-preferred-countries-modal {
+ &__dialog {
+ width: 44rem;
+ border-radius: 4px;
+ height: 56rem;
+ }
+
+ &__full-page-modal {
+ position: absolute;
+ top: 4rem;
+ left: 0;
+ background: #fff;
+ z-index: 1;
+ height: calc(100vh - 8rem);
+
+ & .p2p-mobile-wrapper__header {
+ padding: 1.4rem 1.6rem;
+ }
+ }
+}
diff --git a/src/components/Modals/PreferredCountriesModal/PreferredCountriesModal.tsx b/src/components/Modals/PreferredCountriesModal/PreferredCountriesModal.tsx
new file mode 100644
index 00000000..f64f6e9a
--- /dev/null
+++ b/src/components/Modals/PreferredCountriesModal/PreferredCountriesModal.tsx
@@ -0,0 +1,87 @@
+import React, { useState } from 'react';
+import { FullPageMobileWrapper } from '@/components/FullPageMobileWrapper';
+import { Modal, Text, useDevice } from '@deriv-com/ui';
+import { PreferredCountriesDropdown } from './PreferredCountriesDropdown';
+import { PreferredCountriesFooter } from './PreferredCountriesFooter';
+import './PreferredCountriesModal.scss';
+
+type TPreferredCountriesModalProps = {
+ countryList: { text: string; value: string }[];
+ isModalOpen: boolean;
+ onClickApply: () => void;
+ onRequestClose: () => void;
+ selectedCountries: string[];
+ setSelectedCountries: (value: string[]) => void;
+};
+
+const PreferredCountriesModal = ({
+ countryList,
+ isModalOpen,
+ onClickApply,
+ onRequestClose,
+ selectedCountries,
+ setSelectedCountries,
+}: TPreferredCountriesModalProps) => {
+ const { isMobile } = useDevice();
+ const [shouldDisplayFooter, setShouldDisplayFooter] = useState(true);
+
+ if (isMobile) {
+ return (
+ (
+ setSelectedCountries([])}
+ />
+ )
+ : undefined
+ }
+ renderHeader={() => Preferred countries }
+ >
+
+
+ );
+ }
+ return (
+
+
+ Preferred countries
+
+
+
+
+ {shouldDisplayFooter && (
+
+ setSelectedCountries([])}
+ />
+
+ )}
+
+ );
+};
+
+export default PreferredCountriesModal;
diff --git a/src/components/Modals/PreferredCountriesModal/__tests__/PreferredCountriesModal.spec.tsx b/src/components/Modals/PreferredCountriesModal/__tests__/PreferredCountriesModal.spec.tsx
new file mode 100644
index 00000000..fb93df94
--- /dev/null
+++ b/src/components/Modals/PreferredCountriesModal/__tests__/PreferredCountriesModal.spec.tsx
@@ -0,0 +1,45 @@
+import { useDevice } from '@deriv-com/ui';
+import { render, screen } from '@testing-library/react';
+
+import PreferredCountriesModal from '../PreferredCountriesModal';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({ isMobile: false }),
+}));
+
+const mockUseDevice = useDevice as jest.Mock;
+const mockProps = {
+ countryList: [{ text: 'text', value: 'value' }],
+ isModalOpen: true,
+ onClickApply: jest.fn(),
+ onRequestClose: jest.fn(),
+ selectedCountries: ['value'],
+ setSelectedCountries: jest.fn(),
+};
+
+describe('PreferredCountriesModal', () => {
+ it('should render the component as expected', () => {
+ render( );
+ expect(screen.getByText('Preferred countries')).toBeInTheDocument();
+ });
+ it('should render the full page mobile wrapper when isMobile is true', () => {
+ mockUseDevice.mockReturnValue({ isMobile: true });
+ render( );
+ expect(screen.getByTestId('dt_full_page_mobile_wrapper')).toBeInTheDocument();
+ expect(screen.getByText('Preferred countries')).toBeInTheDocument();
+ });
+ it('should handle onClickClear in full page view', () => {
+ render( );
+ const clearButton = screen.getByRole('button', { name: 'Clear' });
+ clearButton.click();
+ expect(mockProps.setSelectedCountries).toHaveBeenCalledWith([]);
+ });
+ it('should handle onClickClear in modal view', () => {
+ mockUseDevice.mockReturnValue({ isMobile: false });
+ render( );
+ const clearButton = screen.getByRole('button', { name: 'Clear' });
+ clearButton.click();
+ expect(mockProps.setSelectedCountries).toHaveBeenCalledWith([]);
+ });
+});
diff --git a/src/components/Modals/PreferredCountriesModal/index.ts b/src/components/Modals/PreferredCountriesModal/index.ts
new file mode 100644
index 00000000..42347390
--- /dev/null
+++ b/src/components/Modals/PreferredCountriesModal/index.ts
@@ -0,0 +1 @@
+export { default as PreferredCountriesModal } from './PreferredCountriesModal';
diff --git a/src/components/Modals/RadioGroupFilterModal/RadioGroupFilterModal.scss b/src/components/Modals/RadioGroupFilterModal/RadioGroupFilterModal.scss
new file mode 100644
index 00000000..eebcc8e0
--- /dev/null
+++ b/src/components/Modals/RadioGroupFilterModal/RadioGroupFilterModal.scss
@@ -0,0 +1,46 @@
+.p2p-radio-group-filter-modal {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ right: auto;
+ bottom: auto;
+ margin-right: -50%;
+ transform: translate(-50%, -50%);
+ border-radius: 8px;
+ background: #fff;
+ width: 32.8rem;
+ box-shadow: 0px 32px 64px 0px rgba(14, 14, 14, 0.14);
+
+ &__text {
+ margin: 2.4rem 0;
+
+ @include mobile {
+ margin: 1.6rem 0;
+ }
+ }
+
+ .p2p-radio-group {
+ &.p2p-sort-radiogroup {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: flex-start;
+ margin: unset;
+ }
+ .p2p-radio-group__item {
+ &.p2p-sort-radiogroup {
+ display: flex;
+ flex-direction: row-reverse;
+ justify-content: space-between;
+ margin: unset;
+ padding: 1.2rem 2.4rem;
+ width: 100%;
+
+ &:first-child {
+ border-bottom: 1px solid var(--border-normal);
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/Modals/RadioGroupFilterModal/RadioGroupFilterModal.tsx b/src/components/Modals/RadioGroupFilterModal/RadioGroupFilterModal.tsx
new file mode 100644
index 00000000..3c64744d
--- /dev/null
+++ b/src/components/Modals/RadioGroupFilterModal/RadioGroupFilterModal.tsx
@@ -0,0 +1,46 @@
+import Modal from 'react-modal';
+
+import { RadioGroup } from '@/components';
+
+import { customStyles } from '../helpers';
+
+import './RadioGroupFilterModal.scss';
+
+type TRadioGroupFilterModalProps = {
+ isModalOpen: boolean;
+ list: readonly { text: string; value: string }[];
+ onRequestClose: () => void;
+ onToggle: (value: string) => void;
+ selected: string;
+};
+
+const RadioGroupFilterModal = ({
+ isModalOpen,
+ list,
+ onRequestClose,
+ onToggle,
+ selected,
+}: TRadioGroupFilterModalProps) => {
+ return (
+
+ onToggle(event.target.value)}
+ required
+ selected={selected}
+ >
+ {list.map(listItem => {
+ return ;
+ })}
+
+
+ );
+};
+
+export default RadioGroupFilterModal;
diff --git a/src/components/Modals/RadioGroupFilterModal/index.ts b/src/components/Modals/RadioGroupFilterModal/index.ts
new file mode 100644
index 00000000..2e2117a4
--- /dev/null
+++ b/src/components/Modals/RadioGroupFilterModal/index.ts
@@ -0,0 +1 @@
+export { default as RadioGroupFilterModal } from './RadioGroupFilterModal';
diff --git a/src/components/Modals/RatingModal/RatingModal.scss b/src/components/Modals/RatingModal/RatingModal.scss
new file mode 100644
index 00000000..f1d643e9
--- /dev/null
+++ b/src/components/Modals/RatingModal/RatingModal.scss
@@ -0,0 +1,38 @@
+.p2p-rating-modal {
+ height: auto;
+ width: 44rem;
+ border-radius: 8px;
+
+ @include mobile {
+ max-width: calc(100vw - 3.2rem);
+ }
+
+ &__button {
+ gap: 0.2rem;
+ padding-left: 0.4rem;
+
+ &--disabled {
+ // stylelint-disable-next-line declaration-no-important
+ border-color: #d6dadb !important;
+
+ & .deriv-text > span {
+ // stylelint-disable-next-line declaration-no-important
+ color: #999 !important;
+ }
+ }
+ }
+
+ &__stars {
+ @include mobile {
+ width: 75%;
+ display: flex;
+ justify-content: center;
+
+ & .p2p-star-rating {
+ & svg {
+ margin-right: 0.7rem;
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/Modals/RatingModal/RatingModal.tsx b/src/components/Modals/RatingModal/RatingModal.tsx
new file mode 100644
index 00000000..e54b17a6
--- /dev/null
+++ b/src/components/Modals/RatingModal/RatingModal.tsx
@@ -0,0 +1,123 @@
+import React, { useEffect, useState } from 'react';
+import clsx from 'clsx';
+import { StarRating } from '@/components';
+import { StandaloneThumbsDownRegularIcon, StandaloneThumbsUpRegularIcon } from '@deriv/quill-icons';
+import { Button, Modal, Text, useDevice } from '@deriv-com/ui';
+import './RatingModal.scss';
+
+export type TRatingModalProps = {
+ isBuyOrder: boolean;
+ isModalOpen: boolean;
+ isRecommendedPreviously: number | null;
+ onRequestClose: () => void;
+ ratingValue: number;
+};
+
+const RatingModal = ({
+ isBuyOrder,
+ isModalOpen,
+ isRecommendedPreviously,
+ onRequestClose,
+ ratingValue,
+}: TRatingModalProps) => {
+ const [rating, setRating] = useState(ratingValue);
+ const [isNoSelected, setIsNoSelected] = useState(false);
+ const [isYesSelected, setIsYesSelected] = useState(false);
+
+ const { isMobile } = useDevice();
+ const buttonTextSize = isMobile ? 'sm' : 'xs';
+
+ const handleSelectYes = () => {
+ if (isNoSelected) {
+ setIsNoSelected(false);
+ }
+ setIsYesSelected(prevState => !prevState);
+ };
+
+ const handleSelectNo = () => {
+ if (isYesSelected) {
+ setIsYesSelected(false);
+ }
+ setIsNoSelected(prevState => !prevState);
+ };
+
+ useEffect(() => {
+ if (isRecommendedPreviously !== null) {
+ if (isRecommendedPreviously) {
+ setIsYesSelected(true);
+ } else {
+ setIsNoSelected(true);
+ }
+ }
+ }, []);
+
+ return (
+
+
+
+ How would you rate this transaction?
+
+
+
+
+
+
+ {rating > 0 && (
+
+
Would you recommend this {`${isBuyOrder ? 'buyer' : 'seller'}`}?
+
+
+ }
+ onClick={handleSelectYes}
+ size='sm'
+ variant='outlined'
+ >
+ Yes
+
+
+ }
+ onClick={handleSelectNo}
+ size='sm'
+ variant='outlined'
+ >
+ No
+
+
+
+ )}
+
+
+
+ {rating ? 'Done' : 'Skip'}
+
+
+
+ );
+};
+
+export default RatingModal;
diff --git a/src/components/Modals/RatingModal/__tests__/RatingModal.spec.tsx b/src/components/Modals/RatingModal/__tests__/RatingModal.spec.tsx
new file mode 100644
index 00000000..206724ad
--- /dev/null
+++ b/src/components/Modals/RatingModal/__tests__/RatingModal.spec.tsx
@@ -0,0 +1,74 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import RatingModal, { TRatingModalProps } from '../RatingModal';
+
+let mockProps: TRatingModalProps = {
+ isBuyOrder: true,
+ isModalOpen: true,
+ isRecommendedPreviously: null,
+ onRequestClose: jest.fn(),
+ ratingValue: 0,
+};
+
+const disabledClassName = 'p2p-rating-modal__button--disabled';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: false }),
+}));
+
+describe(' ', () => {
+ it('should render just the star rating initially', () => {
+ render( );
+
+ expect(screen.getByText('How would you rate this transaction?')).toBeInTheDocument();
+ expect(screen.getByTestId('dt_rating_modal_stars')).toBeInTheDocument();
+ expect(screen.queryByText('Would you recommend this buyer?')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Yes' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'No' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Done' })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Skip' })).toBeInTheDocument();
+ });
+
+ it('should show the recommendation buttons if ratingValue is passed ', () => {
+ mockProps = { ...mockProps, ratingValue: 4 };
+ render( );
+
+ expect(screen.getByText('Would you recommend this buyer?')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Yes' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Skip' })).not.toBeInTheDocument();
+ });
+
+ it('should enable the Yes button when clicked and disabled the No button if isRecommendedPreviously is 0', async () => {
+ mockProps = { ...mockProps, isRecommendedPreviously: 0 };
+
+ render( );
+
+ const noButton = screen.getByRole('button', { name: 'No' });
+ expect(noButton).not.toHaveClass(disabledClassName);
+
+ const yesButton = screen.getByRole('button', { name: 'Yes' });
+ await userEvent.click(yesButton);
+
+ expect(yesButton).not.toHaveClass(disabledClassName);
+ expect(noButton).toHaveClass(disabledClassName);
+ });
+
+ it('should enable the No button when clicked and disabled the Yes button if isRecommendedPreviously is 1', async () => {
+ mockProps = { ...mockProps, isRecommendedPreviously: 1 };
+
+ render( );
+
+ const yesButton = screen.getByRole('button', { name: 'Yes' });
+ expect(yesButton).not.toHaveClass(disabledClassName);
+
+ const noButton = screen.getByRole('button', { name: 'No' });
+ await userEvent.click(noButton);
+
+ expect(noButton).not.toHaveClass(disabledClassName);
+ expect(yesButton).toHaveClass(disabledClassName);
+ });
+});
diff --git a/src/components/Modals/RatingModal/index.ts b/src/components/Modals/RatingModal/index.ts
new file mode 100644
index 00000000..61de794c
--- /dev/null
+++ b/src/components/Modals/RatingModal/index.ts
@@ -0,0 +1 @@
+export { default as RatingModal } from './RatingModal';
diff --git a/src/components/Modals/ShareAdsModal/ShareAdsCard.scss b/src/components/Modals/ShareAdsModal/ShareAdsCard.scss
new file mode 100644
index 00000000..f289924e
--- /dev/null
+++ b/src/components/Modals/ShareAdsModal/ShareAdsCard.scss
@@ -0,0 +1,61 @@
+.p2p-share-ads-card {
+ background: linear-gradient(349deg, #f2f3f4 36%, #ff444f 25%);
+ margin-bottom: 1.5rem;
+ padding: 2rem;
+
+ @include mobile {
+ width: 90%;
+ }
+
+ &__icon {
+ height: 1.6rem;
+ width: max-content;
+ margin: 1rem 0 3rem;
+
+ @include mobile {
+ margin-top: 0;
+ }
+ }
+
+ &__numbers {
+ &-text {
+ margin-bottom: 1rem;
+
+ &:first-child {
+ margin-right: 1rem;
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ &__qr {
+ margin-top: 2rem;
+
+ @include mobile {
+ margin-top: 1rem;
+ }
+
+ &-container {
+ background-color: #fff;
+ padding: 1.5rem;
+ border: 0.1rem solid #d6dadb;
+ border-radius: 1rem;
+ width: fit-content;
+ }
+
+ &-text {
+ margin: 1.5rem 0 2rem;
+
+ @include mobile {
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ &__title {
+ margin-bottom: 2rem;
+ }
+}
diff --git a/src/components/Modals/ShareAdsModal/ShareAdsCard.tsx b/src/components/Modals/ShareAdsModal/ShareAdsCard.tsx
new file mode 100644
index 00000000..a9ffd9c6
--- /dev/null
+++ b/src/components/Modals/ShareAdsModal/ShareAdsCard.tsx
@@ -0,0 +1,92 @@
+/* eslint-disable camelcase */
+import React, { ForwardedRef, forwardRef } from 'react';
+import { QRCodeSVG } from 'qrcode.react';
+import { ADVERT_TYPE, BUY_SELL, p2pLogo, RATE_TYPE } from '@/constants';
+import { Text, useDevice } from '@deriv-com/ui';
+import './ShareAdsCard.scss';
+import { THooks } from 'types';
+
+type TShareMyAdsCardProps = {
+ advert?: Partial;
+ advertUrl: string;
+};
+
+const ShareMyAdsCard = forwardRef(
+ ({ advert = {}, advertUrl }: TShareMyAdsCardProps, ref: ForwardedRef) => {
+ const { isMobile } = useDevice();
+ const {
+ account_currency,
+ id,
+ local_currency,
+ max_order_amount_limit_display,
+ min_order_amount_limit_display,
+ rate_display,
+ rate_type,
+ type,
+ } = advert;
+
+ const advertType = type === BUY_SELL.BUY ? ADVERT_TYPE.BUY : ADVERT_TYPE.SELL;
+ const textSize = isMobile ? 'md' : 'sm';
+
+ return (
+
+
+
+ {advertType} {account_currency}
+
+
+
+
+ ID number
+
+
+ Limits
+
+
+ Rate
+
+
+
+
+ {id}
+
+
+ {min_order_amount_limit_display} - {max_order_amount_limit_display} {account_currency}
+
+
+ {rate_display}
+ {rate_type === RATE_TYPE.FIXED ? ` ${local_currency}` : '%'}
+
+
+
+
+
+
+
+
+
+ Scan this code to order via Deriv P2P
+
+
+
+ );
+ }
+);
+
+ShareMyAdsCard.displayName = 'ShareMyAdsCard';
+export default ShareMyAdsCard;
diff --git a/src/components/Modals/ShareAdsModal/ShareAdsModal.scss b/src/components/Modals/ShareAdsModal/ShareAdsModal.scss
new file mode 100644
index 00000000..e31f0876
--- /dev/null
+++ b/src/components/Modals/ShareAdsModal/ShareAdsModal.scss
@@ -0,0 +1,97 @@
+.p2p-share-ads-modal {
+ padding: 2.4rem;
+ display: flex;
+ flex-direction: column;
+ height: auto;
+ width: 72rem;
+ border-radius: 8px;
+
+ @include mobile {
+ padding: 1.6rem;
+ width: 32.8rem;
+ }
+
+ &__container {
+ display: flex;
+ margin-top: 2.4rem;
+
+ @include mobile {
+ align-items: center;
+ justify-content: center;
+ margin-top: 0;
+ }
+
+ &__card {
+ display: flex;
+ flex-direction: column;
+ width: 48%;
+
+ @include mobile {
+ align-items: center;
+ width: 100%;
+ margin-top: 1.6rem;
+ }
+
+ &__button {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ border-width: 1px;
+ width: 100%;
+
+ & .deriv-text {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ }
+ }
+ }
+ }
+
+ &__copy {
+ display: flex;
+ align-items: center;
+ border-radius: 0.3rem;
+ border: 0.1em solid var(--general-active);
+ margin-top: 2.5rem;
+ padding: 0.8rem;
+
+ &-clipboard {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--general-section-5);
+ border-radius: 0.3rem;
+ margin-left: 1rem;
+
+ &--icon {
+ display: flex;
+ margin: 1.5rem 1rem;
+ }
+
+ &--popover {
+ display: flex;
+ right: 0;
+ top: 0;
+ }
+ }
+
+ &-link {
+ display: -webkit-box;
+ white-space: pre-wrap;
+ word-break: break-all;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ overflow: hidden;
+ }
+ }
+
+ &__line {
+ margin: 0 0 2.5rem;
+ }
+
+ &__share {
+ padding-left: 2.4rem;
+ width: 52%;
+ }
+}
diff --git a/src/components/Modals/ShareAdsModal/ShareAdsModal.tsx b/src/components/Modals/ShareAdsModal/ShareAdsModal.tsx
new file mode 100644
index 00000000..a4904b6e
--- /dev/null
+++ b/src/components/Modals/ShareAdsModal/ShareAdsModal.tsx
@@ -0,0 +1,172 @@
+import { memo, MouseEvent, useEffect, useRef } from 'react';
+import html2canvas from 'html2canvas';
+
+import { Button, Divider, Modal, Text, useDevice } from '@deriv-com/ui';
+
+import { Clipboard } from '@/components';
+import { ADVERTISER_URL, BUY_SELL, RATE_TYPE } from '@/constants';
+import { api } from '@/hooks';
+import { useCopyToClipboard } from '@/hooks/custom-hooks';
+
+//TODO: replace below icons with the one from quill once available
+import CheckmarkCircle from '../../../public/ic-checkmark-circle.svg';
+import ShareIcon from '../../../public/ic-share.svg';
+import ShareLinkIcon from '../../../public/ic-share-link.svg';
+
+import ShareMyAdsCard from './ShareAdsCard';
+import ShareMyAdsSocials from './ShareAdsSocials';
+
+import './ShareAdsModal.scss';
+
+type TShareAdsModalProps = {
+ id: string;
+ isModalOpen: boolean;
+ onRequestClose: () => void;
+};
+
+const websiteUrl = () => `${location.protocol}//${location.hostname}`;
+
+const ShareAdsModal = ({ id, isModalOpen, onRequestClose }: TShareAdsModalProps) => {
+ const timeoutClipboardRef = useRef | null>(null);
+ const { isDesktop, isMobile } = useDevice();
+ const { data: advertInfo, isLoading: isLoadingInfo } = api.advert.useGet({ id });
+ const [isCopied, copyToClipboard, setIsCopied] = useCopyToClipboard();
+ const {
+ account_currency: accountCurrency,
+ advertiser_details: advertiserDetails,
+ local_currency: localCurrency,
+ rate_display: rateDisplay,
+ rate_type: rateType,
+ type,
+ } = advertInfo ?? {};
+ const { id: advertiserId } = advertiserDetails ?? {};
+
+ const divRef = useRef(null);
+ const advertUrl = `${websiteUrl()}${ADVERTISER_URL}/${advertiserId}?advert_id=${id}`;
+ const isBuyAd = type === BUY_SELL.BUY;
+ const firstCurrency = isBuyAd ? localCurrency : accountCurrency;
+ const secondCurrency = isBuyAd ? accountCurrency : localCurrency;
+ const adRateType = rateType === RATE_TYPE.FLOAT ? '%' : ` ${localCurrency}`;
+ const customMessage = `Hi! I'd like to exchange ${firstCurrency} for ${secondCurrency} at ${rateDisplay}${adRateType} on Deriv P2P.\n\nIf you're interested, check out my ad 👉\n\n${advertUrl} \n\nThanks!`;
+
+ const onCopy = (event: MouseEvent) => {
+ copyToClipboard(advertUrl);
+ setIsCopied(true);
+ timeoutClipboardRef.current = setTimeout(() => {
+ setIsCopied(false);
+ }, 2000);
+ event.stopPropagation();
+ };
+
+ const handleGenerateImage = async () => {
+ if (divRef.current) {
+ const p2pLogo = divRef.current.querySelector('.p2p-share-ads-card__qr-icon');
+ if (p2pLogo) {
+ const canvas = await html2canvas(divRef.current, { allowTaint: true, useCORS: true });
+ const screenshot = canvas.toDataURL('image/png', 1.0);
+ const fileName = `${type}_${id}.png`;
+ const link = document.createElement('a');
+ link.download = fileName;
+ link.href = screenshot;
+ link.click();
+ }
+ }
+ };
+
+ const handleShareLink = () => {
+ navigator.share({
+ text: customMessage,
+ });
+ };
+
+ useEffect(() => {
+ return () => {
+ if (timeoutClipboardRef.current) {
+ clearTimeout(timeoutClipboardRef.current);
+ }
+ };
+ }, []);
+
+ return (
+ <>
+ {!isLoadingInfo && (
+
+
+ Share this ad
+
+
+ {isDesktop && Promote your ad by sharing the QR code and link. }
+
+
+
+
+ Download this QR code
+
+ {isMobile && (
+
+
+
+ Share link
+
+
+ {isCopied ? : }
+ Copy link
+
+
+ )}
+
+ {isDesktop && (
+
+
Share via
+
+
+
Or copy this link
+
+
+ {advertUrl}
+
+ {/* TODO: clipboard to be replaced */}
+
+
+
+
+
+ )}
+
+
+
+ )}
+ >
+ );
+};
+
+export default memo(ShareAdsModal);
diff --git a/src/components/Modals/ShareAdsModal/ShareAdsSocials.scss b/src/components/Modals/ShareAdsModal/ShareAdsSocials.scss
new file mode 100644
index 00000000..a10148d2
--- /dev/null
+++ b/src/components/Modals/ShareAdsModal/ShareAdsSocials.scss
@@ -0,0 +1,31 @@
+.p2p-share-ads-socials {
+ display: flex;
+ justify-content: space-around;
+ margin: 2.5rem 0;
+
+ @include mobile {
+ margin: 2rem;
+ }
+
+ a {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-decoration: none;
+ }
+
+ &__circle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 4.8rem;
+ width: 4.8rem;
+ background-color: #f2f3f4;
+ border-radius: 50%;
+ margin-bottom: 0.5rem;
+
+ @include mobile {
+ width: fit-content;
+ }
+ }
+}
diff --git a/src/components/Modals/ShareAdsModal/ShareAdsSocials.tsx b/src/components/Modals/ShareAdsModal/ShareAdsSocials.tsx
new file mode 100644
index 00000000..a8413822
--- /dev/null
+++ b/src/components/Modals/ShareAdsModal/ShareAdsSocials.tsx
@@ -0,0 +1,72 @@
+import { FacebookShareButton, TelegramShareButton, TwitterShareButton, WhatsappShareButton } from 'react-share';
+
+import {
+ LabelPairedXTwitterLgIcon,
+ SocialFacebookBrandIcon,
+ SocialGoogleBrandIcon,
+ SocialTelegramBrandIcon,
+} from '@deriv/quill-icons';
+
+import WhatsappIcon from '../../../public/ic-whatsapp-filled.svg';
+
+import './ShareAdsSocials.scss';
+
+type TShareMyAdsSocialsProps = {
+ advertUrl: string;
+ customMessage: string;
+};
+
+//TODO: fix the icon classnames once the icons are available in quillicons
+const getShareButtons = (advertUrl: string) => [
+ {
+ icon: ,
+ messagePropName: 'title',
+ ShareButton: WhatsappShareButton,
+ text: 'WhatsApp',
+ },
+ {
+ icon: ,
+ messagePropName: 'quote',
+ ShareButton: FacebookShareButton,
+ text: 'Facebook',
+ },
+ {
+ icon: ,
+ messagePropName: 'title',
+ ShareButton: TelegramShareButton,
+ text: 'Telegram',
+ },
+ {
+ icon: ,
+ messagePropName: 'title',
+ ShareButton: TwitterShareButton,
+ text: 'X',
+ },
+ {
+ href: `https://mail.google.com/mail/?view=cm&fs=1&body=${encodeURIComponent(advertUrl)}`,
+ icon: ,
+ rel: 'noreferrer',
+ ShareButton: 'a',
+ target: '_blank',
+ text: 'Gmail',
+ },
+];
+const ShareMyAdsSocials = ({ advertUrl, customMessage }: TShareMyAdsSocialsProps) => (
+
+ {getShareButtons(advertUrl).map(({ ShareButton, href, icon, messagePropName, rel, target, text }) => (
+
+ {icon}
+
+ ))}
+
+);
+
+export default ShareMyAdsSocials;
diff --git a/src/components/Modals/ShareAdsModal/__tests__/ShareAdsModal.spec.tsx b/src/components/Modals/ShareAdsModal/__tests__/ShareAdsModal.spec.tsx
new file mode 100644
index 00000000..8dcd57df
--- /dev/null
+++ b/src/components/Modals/ShareAdsModal/__tests__/ShareAdsModal.spec.tsx
@@ -0,0 +1,114 @@
+import html2canvas from 'html2canvas';
+
+import { useDevice } from '@deriv-com/ui';
+import { act, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import ShareAdsModal from '../ShareAdsModal';
+
+const mockProps = {
+ id: 'id',
+ isModalOpen: true,
+ onRequestClose: jest.fn(),
+};
+
+let element: HTMLElement;
+
+const mockUseGet = {
+ data: {
+ account_currency: 'USD',
+ advertiser_details: {
+ id: 'id',
+ },
+ local_currency: 'USD',
+ rate_display: '1',
+ rate_type: 'fixed',
+ type: 'buy',
+ },
+ isLoading: false,
+};
+
+jest.mock('@deriv/api-v2', () => ({
+ p2p: {
+ advert: {
+ useGet: jest.fn(() => mockUseGet),
+ },
+ },
+}));
+
+jest.mock('qrcode.react', () => ({ QRCodeSVG: () => QR code
}));
+
+jest.mock('html2canvas', () => ({
+ __esModule: true,
+ default: jest.fn().mockResolvedValue({
+ toDataURL: jest.fn(),
+ }),
+}));
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({
+ isDesktop: true,
+ isMobile: false,
+ }),
+}));
+
+const mockCopyFn = jest.fn();
+jest.mock('@/hooks', () => ({
+ ...jest.requireActual('@/hooks'),
+ useCopyToClipboard: jest.fn(() => [true, mockCopyFn, jest.fn()]),
+}));
+
+const mockUseDevice = useDevice as jest.Mock;
+
+describe('ShareAdsModal', () => {
+ beforeAll(() => {
+ element = document.createElement('div');
+ element.setAttribute('id', 'v2_modal_root');
+ document.body.appendChild(element);
+ });
+ afterAll(() => {
+ document.body.removeChild(element);
+ });
+ it('should render the modal as expected', () => {
+ render( );
+ expect(screen.getByText('Share this ad')).toBeInTheDocument();
+ expect(screen.getByText('Promote your ad by sharing the QR code and link.')).toBeInTheDocument();
+ });
+ it('should handle onclick when clicking on Share link', async () => {
+ mockUseDevice.mockReturnValue({
+ isDesktop: false,
+ isMobile: true,
+ });
+ const mockShare = jest.fn().mockResolvedValue(true);
+ global.navigator.share = mockShare;
+ render( );
+ const shareLinkButton = screen.getByRole('button', { name: 'Share link' });
+ await userEvent.click(shareLinkButton);
+ expect(mockShare).toBeCalled();
+ });
+
+ it('should call onCopy function when clicking on copy icon', async () => {
+ jest.useFakeTimers();
+
+ render( );
+ const copyButton = screen.getByRole('button', { name: 'Copy link' });
+ await userEvent.click(copyButton);
+ await act(async () => {
+ jest.runAllTimers();
+ await Promise.resolve();
+ });
+
+ expect(mockCopyFn).toHaveBeenCalledWith(
+ `${window.location.href}cashier/p2p-v2/advertiser/${mockUseGet.data.advertiser_details.id}?advert_id=${mockProps.id}`
+ );
+ });
+ it('should call html2canvas function when clicking on Download this QR code button', async () => {
+ render( );
+
+ const downloadButton = screen.getByRole('button', { name: 'Download this QR code' });
+ await userEvent.click(downloadButton);
+
+ await waitFor(() => expect(html2canvas).toBeCalled());
+ });
+});
diff --git a/src/components/Modals/ShareAdsModal/index.ts b/src/components/Modals/ShareAdsModal/index.ts
new file mode 100644
index 00000000..cecfce9f
--- /dev/null
+++ b/src/components/Modals/ShareAdsModal/index.ts
@@ -0,0 +1 @@
+export { default as ShareAdsModal } from './ShareAdsModal';
diff --git a/src/components/Modals/helpers.ts b/src/components/Modals/helpers.ts
new file mode 100644
index 00000000..594866fb
--- /dev/null
+++ b/src/components/Modals/helpers.ts
@@ -0,0 +1,13 @@
+type TCustomStyles = {
+ overlay: ReactModal.Styles['overlay'];
+};
+
+export const customStyles: TCustomStyles = {
+ overlay: {
+ alignItems: 'center',
+ backgroundColor: 'rgba(0, 0, 0, 0.72)',
+ display: 'flex',
+ justifyContent: 'center',
+ zIndex: 9999,
+ },
+};
diff --git a/src/components/Modals/index.ts b/src/components/Modals/index.ts
new file mode 100644
index 00000000..3438e365
--- /dev/null
+++ b/src/components/Modals/index.ts
@@ -0,0 +1,22 @@
+export * from './AdCancelCreateEditModal';
+export * from './AdConditionsModal';
+export * from './AdCreateEditErrorModal';
+export * from './AdCreateEditSuccessModal';
+export * from './AdErrorTooltipModal';
+export * from './AdRateSwitchModal';
+export * from './AvailableP2PBalanceModal';
+export * from './BlockUnblockUserModal';
+export * from './DailyLimitModal';
+export * from './EmailVerificationModal';
+export * from './ErrorModal';
+export * from './FilterModal';
+export * from './MyAdsDeleteModal';
+export * from './NicknameModal';
+export * from './OrderDetailsComplainModal';
+export * from './OrderDetailsConfirmModal';
+export * from './OrderTimeTooltipModal';
+export * from './PaymentMethods';
+export * from './PreferredCountriesModal';
+export * from './RadioGroupFilterModal';
+export * from './RatingModal';
+export * from './ShareAdsModal';
diff --git a/src/components/OnlineStatus/OnlineStatus.scss b/src/components/OnlineStatus/OnlineStatus.scss
new file mode 100644
index 00000000..54b7f066
--- /dev/null
+++ b/src/components/OnlineStatus/OnlineStatus.scss
@@ -0,0 +1,18 @@
+.p2p-online-status {
+ &__icon {
+ position: absolute;
+ bottom: -0.05px;
+ right: -0.08px;
+ margin: 0;
+ border-radius: 50%;
+ transform: scale(1.1);
+
+ &--online {
+ background: #4bb4b3;
+ }
+
+ &--offline {
+ background: #999;
+ }
+ }
+}
diff --git a/src/components/OnlineStatus/OnlineStatusIcon.tsx b/src/components/OnlineStatus/OnlineStatusIcon.tsx
new file mode 100644
index 00000000..15a1871e
--- /dev/null
+++ b/src/components/OnlineStatus/OnlineStatusIcon.tsx
@@ -0,0 +1,26 @@
+import clsx from 'clsx';
+
+type TOnlineStatusIconProps = {
+ isOnline: boolean;
+ isRelative?: boolean;
+ size?: number | string;
+};
+
+const OnlineStatusIcon = ({ isOnline, isRelative = false, size = '1em' }: TOnlineStatusIconProps) => {
+ return (
+
+ );
+};
+
+export default OnlineStatusIcon;
diff --git a/src/components/OnlineStatus/OnlineStatusLabel.tsx b/src/components/OnlineStatus/OnlineStatusLabel.tsx
new file mode 100644
index 00000000..4dc78371
--- /dev/null
+++ b/src/components/OnlineStatus/OnlineStatusLabel.tsx
@@ -0,0 +1,18 @@
+import { Text } from '@deriv-com/ui';
+
+import { getLastOnlineLabel } from '@/utils';
+
+type TOnlineStatusLabelProps = {
+ isOnline?: boolean;
+ lastOnlineTime?: number;
+};
+
+const OnlineStatusLabel = ({ isOnline = false, lastOnlineTime }: TOnlineStatusLabelProps) => {
+ return (
+
+ {getLastOnlineLabel(isOnline, lastOnlineTime)}
+
+ );
+};
+
+export default OnlineStatusLabel;
diff --git a/src/components/OnlineStatus/__tests__/OnlineStatusIcon.spec.tsx b/src/components/OnlineStatus/__tests__/OnlineStatusIcon.spec.tsx
new file mode 100644
index 00000000..12491c26
--- /dev/null
+++ b/src/components/OnlineStatus/__tests__/OnlineStatusIcon.spec.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from '@testing-library/react';
+
+import OnlineStatusIcon from '../OnlineStatusIcon';
+
+describe(' ', () => {
+ it('should render the default state as offline', () => {
+ render( );
+
+ const icon = screen.getByTestId('dt_online_status_icon');
+ expect(icon).toHaveClass('p2p-online-status__icon--offline');
+ });
+
+ it('should render online state when user is online', () => {
+ render( );
+
+ const icon = screen.getByTestId('dt_online_status_icon');
+ expect(icon).toHaveClass('p2p-online-status__icon--online');
+ });
+});
diff --git a/src/components/OnlineStatus/__tests__/OnlineStatusLabel.spec.tsx b/src/components/OnlineStatus/__tests__/OnlineStatusLabel.spec.tsx
new file mode 100644
index 00000000..52a9919b
--- /dev/null
+++ b/src/components/OnlineStatus/__tests__/OnlineStatusLabel.spec.tsx
@@ -0,0 +1,25 @@
+import { render, screen } from '@testing-library/react';
+
+import { getLastOnlineLabel } from '@/utils';
+
+import OnlineStatusLabel from '../OnlineStatusLabel';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({
+ isMobile: false,
+ }),
+}));
+
+jest.mock('@/utils', () => ({
+ ...jest.requireActual('@/utils'),
+ getLastOnlineLabel: jest.fn().mockReturnValue('Seen 2 days ago'),
+}));
+
+describe(' ', () => {
+ it('should call the getLastOnlineLabel function with isOnline and lastOnlineTime', () => {
+ render( );
+ expect(getLastOnlineLabel).toHaveBeenCalledWith(false, 1685446791);
+ expect(screen.getByText('Seen 2 days ago')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/OnlineStatus/index.ts b/src/components/OnlineStatus/index.ts
new file mode 100644
index 00000000..2a99d4e0
--- /dev/null
+++ b/src/components/OnlineStatus/index.ts
@@ -0,0 +1,5 @@
+import OnlineStatusIcon from './OnlineStatusIcon';
+import OnlineStatusLabel from './OnlineStatusLabel';
+import './OnlineStatus.scss';
+
+export { OnlineStatusIcon, OnlineStatusLabel };
diff --git a/src/components/PageReturn/PageReturn.scss b/src/components/PageReturn/PageReturn.scss
new file mode 100644
index 00000000..1fc849ef
--- /dev/null
+++ b/src/components/PageReturn/PageReturn.scss
@@ -0,0 +1,26 @@
+.p2p-page-return {
+ display: flex;
+ justify-content: space-between;
+ margin: 2.4rem 0;
+ width: 100%;
+ background: #fff;
+
+ @include mobile {
+ margin: 0;
+ padding: 1.6rem;
+ }
+
+ &--border {
+ border-bottom: 3px solid #f2f3f4;
+ }
+
+ &__button {
+ line-height: 0.8rem;
+ cursor: pointer;
+ margin-right: 0.8rem;
+
+ @include mobile {
+ margin-right: 1.8rem;
+ }
+ }
+}
diff --git a/src/components/PageReturn/PageReturn.tsx b/src/components/PageReturn/PageReturn.tsx
new file mode 100644
index 00000000..c11c93da
--- /dev/null
+++ b/src/components/PageReturn/PageReturn.tsx
@@ -0,0 +1,48 @@
+import clsx from 'clsx';
+
+import { LabelPairedArrowLeftLgBoldIcon } from '@deriv/quill-icons';
+import { Text } from '@deriv-com/ui';
+
+import { TGenericSizes } from '@/utils';
+
+import './PageReturn.scss';
+
+type TPageReturnProps = {
+ className?: string;
+ hasBorder?: boolean;
+ onClick: () => void;
+ pageTitle: string;
+ rightPlaceHolder?: JSX.Element;
+ shouldHideBackButton?: boolean;
+ size?: TGenericSizes;
+ weight?: string;
+};
+
+const PageReturn = ({
+ className = '',
+ hasBorder = false,
+ onClick,
+ pageTitle,
+ rightPlaceHolder,
+ shouldHideBackButton = false,
+ size = 'md',
+ weight = 'normal',
+}: TPageReturnProps) => {
+ return (
+
+
+
+
+ {pageTitle}
+
+
+ {rightPlaceHolder}
+
+ );
+};
+
+export default PageReturn;
diff --git a/src/components/PageReturn/__tests__/PageReturn.spec.tsx b/src/components/PageReturn/__tests__/PageReturn.spec.tsx
new file mode 100644
index 00000000..dc4cc0cd
--- /dev/null
+++ b/src/components/PageReturn/__tests__/PageReturn.spec.tsx
@@ -0,0 +1,16 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import PageReturn from '../PageReturn';
+
+const mockOnClick = jest.fn();
+describe('PageReturn', () => {
+ it('should render the title and behaviour of return correctly', async () => {
+ render( );
+
+ expect(screen.getByText('Cashier P2P')).toBeVisible();
+ const returnBtn = screen.getByTestId('dt_page_return_btn');
+ await userEvent.click(returnBtn);
+ expect(mockOnClick).toBeCalled();
+ });
+});
diff --git a/src/components/PageReturn/index.ts b/src/components/PageReturn/index.ts
new file mode 100644
index 00000000..7234b0ec
--- /dev/null
+++ b/src/components/PageReturn/index.ts
@@ -0,0 +1 @@
+export { default as PageReturn } from './PageReturn';
diff --git a/src/components/PaymentMethodCard/PaymentMethodCard.scss b/src/components/PaymentMethodCard/PaymentMethodCard.scss
new file mode 100644
index 00000000..d913b0bf
--- /dev/null
+++ b/src/components/PaymentMethodCard/PaymentMethodCard.scss
@@ -0,0 +1,37 @@
+.p2p-payment-method-card {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ max-width: 67.2rem;
+ border: 1px solid #d6dadb;
+ border-radius: 8px;
+ margin: 1.6rem 1.6rem 1.6rem 0;
+ overflow-wrap: break-word;
+ min-height: 12.8rem;
+ padding: 1.6rem;
+ width: 20.8rem;
+ @include mobile {
+ margin-right: 0;
+ }
+
+ @include mobile {
+ min-height: 8.8rem;
+ padding: 0.9rem 1rem;
+ width: 15.4rem;
+ }
+ &--medium {
+ width: 14.4rem;
+ padding: 1rem;
+ @include mobile {
+ min-width: 13.6rem;
+ }
+ }
+
+ &--dashed {
+ border: 1px dashed #d6dadb;
+ }
+
+ &--selected {
+ border: 2px solid #85acb0;
+ }
+}
diff --git a/src/components/PaymentMethodCard/PaymentMethodCard.tsx b/src/components/PaymentMethodCard/PaymentMethodCard.tsx
new file mode 100644
index 00000000..28fb307b
--- /dev/null
+++ b/src/components/PaymentMethodCard/PaymentMethodCard.tsx
@@ -0,0 +1,82 @@
+import React, { HTMLAttributes } from 'react';
+import clsx from 'clsx';
+import { TPaymentMethod } from 'types';
+import { LabelPairedPlusLgBoldIcon } from '@deriv/quill-icons';
+import { Button, Text } from '@deriv-com/ui';
+import { PaymentMethodCardBody } from './PaymentMethodCardBody';
+import { PaymentMethodCardHeader } from './PaymentMethodCardHeader';
+import './PaymentMethodCard.scss';
+
+type TPaymentMethodCardProps = HTMLAttributes & {
+ isDisabled?: boolean;
+ isEditable?: boolean;
+ medium?: boolean;
+ onClickAdd?: (paymentMethod: TPaymentMethod) => void;
+ onDeletePaymentMethod?: () => void;
+ onEditPaymentMethod?: () => void;
+ onSelectPaymentMethodCard?: (paymentMethodId: number) => void;
+ paymentMethod: TPaymentMethod & { isAvailable?: boolean };
+ selectedPaymentMethodIds?: number[];
+ shouldShowPaymentMethodDisplayName?: boolean;
+};
+
+const PaymentMethodCard = ({
+ isDisabled = false,
+ isEditable = false,
+ medium = false,
+ onClickAdd,
+ onDeletePaymentMethod,
+ onEditPaymentMethod,
+ onSelectPaymentMethodCard,
+ paymentMethod,
+ selectedPaymentMethodIds = [],
+ shouldShowPaymentMethodDisplayName,
+}: TPaymentMethodCardProps) => {
+ const { display_name, isAvailable, type } = paymentMethod;
+
+ // TODO: Add logic to display the "add" icon here when working on the sell modal under the sell tab
+
+ const toAdd = !!(isAvailable ?? isAvailable === undefined);
+ const isSelected = !!paymentMethod.id && selectedPaymentMethodIds.includes(Number(paymentMethod.id));
+
+ return (
+
+ {!toAdd ? (
+
+ onClickAdd?.(paymentMethod)}
+ >
+
+
+ {display_name}
+
+ ) : (
+ <>
+
onSelectPaymentMethodCard?.(Number(paymentMethod.id))}
+ type={type}
+ />
+
+ >
+ )}
+
+ );
+};
+
+export default PaymentMethodCard;
diff --git a/src/components/PaymentMethodCard/PaymentMethodCardBody/PaymentMethodCardBody.scss b/src/components/PaymentMethodCard/PaymentMethodCardBody/PaymentMethodCardBody.scss
new file mode 100644
index 00000000..6572e5a0
--- /dev/null
+++ b/src/components/PaymentMethodCard/PaymentMethodCardBody/PaymentMethodCardBody.scss
@@ -0,0 +1,8 @@
+.p2p-payment-method-card {
+ &__body {
+ display: flex;
+ flex-direction: column;
+ margin-top: 0.8rem;
+ vertical-align: baseline;
+ }
+}
diff --git a/src/components/PaymentMethodCard/PaymentMethodCardBody/PaymentMethodCardBody.tsx b/src/components/PaymentMethodCard/PaymentMethodCardBody/PaymentMethodCardBody.tsx
new file mode 100644
index 00000000..a3a95397
--- /dev/null
+++ b/src/components/PaymentMethodCard/PaymentMethodCardBody/PaymentMethodCardBody.tsx
@@ -0,0 +1,28 @@
+import { THooks } from 'types';
+
+import { Text } from '@deriv-com/ui';
+
+import './PaymentMethodCardBody.scss';
+
+type TPaymentMethodCardBodyProps = {
+ paymentMethod: THooks.AdvertiserPaymentMethods.Get[number];
+ shouldShowPaymentMethodDisplayName?: boolean;
+};
+
+const PaymentMethodCardBody = ({
+ paymentMethod,
+ shouldShowPaymentMethodDisplayName = true,
+}: TPaymentMethodCardBodyProps) => {
+ const displayName = paymentMethod?.display_name;
+ const modifiedDisplayName = displayName?.replace(/\s|-/gm, '');
+ const isBankOrOther = modifiedDisplayName && ['BankTransfer', 'Other'].includes(modifiedDisplayName);
+ return (
+
+ {isBankOrOther && !shouldShowPaymentMethodDisplayName ? null : {displayName} }
+ {paymentMethod.fields?.bank_name?.value ?? paymentMethod.fields?.name?.value}
+ {paymentMethod.fields?.account?.value}
+
+ );
+};
+
+export default PaymentMethodCardBody;
diff --git a/src/components/PaymentMethodCard/PaymentMethodCardBody/index.ts b/src/components/PaymentMethodCard/PaymentMethodCardBody/index.ts
new file mode 100644
index 00000000..5fcfce34
--- /dev/null
+++ b/src/components/PaymentMethodCard/PaymentMethodCardBody/index.ts
@@ -0,0 +1 @@
+export { default as PaymentMethodCardBody } from './PaymentMethodCardBody';
diff --git a/src/components/PaymentMethodCard/PaymentMethodCardHeader/PaymentMethodCardHeader.scss b/src/components/PaymentMethodCard/PaymentMethodCardHeader/PaymentMethodCardHeader.scss
new file mode 100644
index 00000000..1816aa1b
--- /dev/null
+++ b/src/components/PaymentMethodCard/PaymentMethodCardHeader/PaymentMethodCardHeader.scss
@@ -0,0 +1,19 @@
+.p2p-payment-method-card {
+ &__icon {
+ border-radius: 2px;
+ }
+ &__header {
+ display: flex;
+ width: 100%;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.1rem;
+ }
+ .derivs-button__variant--ghost {
+ color: #0e0e0e;
+ }
+ .derivs-button__variant--ghost:hover:not(:disabled) {
+ cursor: pointer;
+ background-color: #e6e9e9;
+ }
+}
diff --git a/src/components/PaymentMethodCard/PaymentMethodCardHeader/PaymentMethodCardHeader.tsx b/src/components/PaymentMethodCard/PaymentMethodCardHeader/PaymentMethodCardHeader.tsx
new file mode 100644
index 00000000..de7ad609
--- /dev/null
+++ b/src/components/PaymentMethodCard/PaymentMethodCardHeader/PaymentMethodCardHeader.tsx
@@ -0,0 +1,80 @@
+import React, { ComponentType, SVGAttributes } from 'react';
+import { THooks } from 'types';
+import { FlyoutMenu } from '@/components';
+import { LabelPairedEllipsisVerticalXlRegularIcon } from '@deriv/quill-icons';
+import { Button, Checkbox } from '@deriv-com/ui';
+import IcCashierBankTransfer from '../../../public/ic-cashier-bank-transfer.svg';
+import IcCashierEwallet from '../../../public/ic-cashier-ewallet.svg';
+import IcCashierOther from '../../../public/ic-cashier-other.svg';
+import './PaymentMethodCardHeader.scss';
+
+type TPaymentMethodCardHeaderProps = {
+ isDisabled?: boolean;
+ isEditable?: boolean;
+ isSelectable?: boolean;
+ isSelected?: boolean;
+ medium?: boolean;
+ onDeletePaymentMethod?: () => void;
+ onEditPaymentMethod?: () => void;
+ onSelectPaymentMethod?: () => void;
+ small?: boolean;
+ type: THooks.AdvertiserPaymentMethods.Get[number]['type'];
+};
+
+const PaymentMethodCardHeader = ({
+ isDisabled = false,
+ isEditable = false,
+ isSelectable = false,
+ isSelected = false,
+ medium = false,
+ onDeletePaymentMethod,
+ onEditPaymentMethod,
+ onSelectPaymentMethod,
+ small = false,
+ type,
+}: TPaymentMethodCardHeaderProps) => {
+ let Icon: ComponentType> = IcCashierOther;
+ if (type === 'bank') {
+ Icon = IcCashierBankTransfer;
+ } else if (type === 'ewallet') {
+ Icon = IcCashierEwallet;
+ }
+ // TODO: Remember to translate these
+ const flyoutMenuItems = [
+ onEditPaymentMethod?.()} size='xs' textSize='xs' variant='ghost'>
+ Edit
+ ,
+
+ onDeletePaymentMethod?.()} size='xs' textSize='xs' variant='ghost'>
+ Delete
+ ,
+ ];
+ return (
+
+
+ {isEditable && (
+
}
+ />
+ )}
+ {isSelectable && (
+
+
+
+ )}
+
+ );
+};
+
+export default PaymentMethodCardHeader;
diff --git a/src/components/PaymentMethodCard/PaymentMethodCardHeader/index.ts b/src/components/PaymentMethodCard/PaymentMethodCardHeader/index.ts
new file mode 100644
index 00000000..3fa1908d
--- /dev/null
+++ b/src/components/PaymentMethodCard/PaymentMethodCardHeader/index.ts
@@ -0,0 +1 @@
+export { default as PaymentMethodCardHeader } from './PaymentMethodCardHeader';
diff --git a/src/components/PaymentMethodCard/__tests__/PaymentMethodCard.spec.tsx b/src/components/PaymentMethodCard/__tests__/PaymentMethodCard.spec.tsx
new file mode 100644
index 00000000..cc334194
--- /dev/null
+++ b/src/components/PaymentMethodCard/__tests__/PaymentMethodCard.spec.tsx
@@ -0,0 +1,58 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import PaymentMethodCard from '../PaymentMethodCard';
+
+const mockProps = {
+ isEditable: false,
+ medium: false,
+ onClickAdd: jest.fn(),
+ onDeletePaymentMethod: jest.fn(),
+ onEditPaymentMethod: jest.fn(),
+ onSelectPaymentMethodCard: jest.fn(),
+ paymentMethod: {
+ fields: {},
+ id: 'test',
+ is_enabled: 0,
+ method: '',
+ type: 'other',
+ used_by_adverts: null,
+ used_by_orders: null,
+ },
+ shouldShowPaymentMethodDisplayName: false,
+};
+
+describe('PaymentMethodCard', () => {
+ it('should render the component correctly', () => {
+ render( );
+ expect(screen.getByTestId('dt_payment_method_card_header')).toBeInTheDocument();
+ });
+
+ it('should handle the onClickAdd', async () => {
+ const newProps = {
+ ...mockProps,
+ paymentMethod: {
+ ...mockProps.paymentMethod,
+ isAvailable: false,
+ },
+ };
+
+ render( );
+ const button = screen.getByRole('button');
+ await userEvent.click(button);
+ expect(mockProps.onClickAdd).toHaveBeenCalled();
+ });
+ it('should handle the selection of checkbox', async () => {
+ const newProps = {
+ ...mockProps,
+ paymentMethod: {
+ ...mockProps.paymentMethod,
+ isAvailable: true,
+ },
+ };
+ render( );
+ const checkbox = screen.getByRole('checkbox');
+ await userEvent.click(checkbox);
+ expect(mockProps.onSelectPaymentMethodCard).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/PaymentMethodCard/__tests__/PaymentMethodCardBody.spec.tsx b/src/components/PaymentMethodCard/__tests__/PaymentMethodCardBody.spec.tsx
new file mode 100644
index 00000000..79875e63
--- /dev/null
+++ b/src/components/PaymentMethodCard/__tests__/PaymentMethodCardBody.spec.tsx
@@ -0,0 +1,28 @@
+import { render, screen } from '@testing-library/react';
+
+import { PaymentMethodCardBody } from '../PaymentMethodCardBody';
+
+describe('PaymentMethodCardBody', () => {
+ it('should render the component correctly', () => {
+ render(
+
+ );
+ expect(screen.getByText('Account Number')).toBeInTheDocument();
+ expect(screen.getByText('Bank Name')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/PaymentMethodCard/__tests__/PaymentMethodCardHeader.spec.tsx b/src/components/PaymentMethodCard/__tests__/PaymentMethodCardHeader.spec.tsx
new file mode 100644
index 00000000..5ab538f2
--- /dev/null
+++ b/src/components/PaymentMethodCard/__tests__/PaymentMethodCardHeader.spec.tsx
@@ -0,0 +1,62 @@
+import { render, screen } from '@testing-library/react';
+
+import { PaymentMethodCardHeader } from '../PaymentMethodCardHeader';
+
+jest.mock('../../../public/ic-cashier-ewallet.svg', () => 'span');
+
+describe('PaymentMethodCardHeader', () => {
+ it('should render the component correctly', () => {
+ render(
+ undefined}
+ onEditPaymentMethod={() => undefined}
+ type='bank'
+ />
+ );
+ expect(screen.getByTestId('dt_payment_method_card_header')).toBeInTheDocument();
+ });
+ it('should render the component correctly when medium is true', () => {
+ render(
+ undefined}
+ onEditPaymentMethod={() => undefined}
+ type='bank'
+ />
+ );
+ expect(screen.getByTestId('dt_payment_method_card_header')).toBeInTheDocument();
+ });
+ it('should render the component correctly when iseditable is true', () => {
+ render(
+ undefined}
+ onEditPaymentMethod={() => undefined}
+ type='bank'
+ />
+ );
+ expect(screen.getByTestId('dt_flyout_toggle')).toBeInTheDocument();
+ });
+ it('should render the component correctly when isselectable is true', () => {
+ render(
+ undefined}
+ onEditPaymentMethod={() => undefined}
+ onSelectPaymentMethod={() => undefined}
+ type='bank'
+ />
+ );
+ expect(screen.getByTestId('p2p_v2_payment_method_card_header_checkbox')).toBeInTheDocument();
+ });
+ it('should render the correct icon when type is ewallet', () => {
+ render(
+ undefined}
+ onEditPaymentMethod={() => undefined}
+ type='ewallet'
+ />
+ );
+ expect(screen.getByTestId('dt_payment_method_card_header_icon')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/PaymentMethodCard/index.ts b/src/components/PaymentMethodCard/index.ts
new file mode 100644
index 00000000..6c6df4f3
--- /dev/null
+++ b/src/components/PaymentMethodCard/index.ts
@@ -0,0 +1 @@
+export { default as PaymentMethodCard } from './PaymentMethodCard';
diff --git a/src/components/PaymentMethodField/PaymentMethodField.tsx b/src/components/PaymentMethodField/PaymentMethodField.tsx
new file mode 100644
index 00000000..d1ee4652
--- /dev/null
+++ b/src/components/PaymentMethodField/PaymentMethodField.tsx
@@ -0,0 +1,68 @@
+import { Controller, useForm } from 'react-hook-form';
+
+import { Input } from '@deriv-com/ui';
+
+import { VALID_SYMBOLS_PATTERN } from '@/constants';
+
+import { TextArea } from '..';
+
+type TPaymentMethodField = {
+ control: ReturnType['control'];
+ defaultValue: string;
+ displayName: string;
+ field: string;
+ required?: boolean;
+};
+
+/**
+ * @component This component is used to display a field in the PaymentMethodForm component
+ * @param {Object} props
+ * @param {Object} props.control - The control object from react-hook-form
+ * @param {string} props.defaultValue - The default value of the field
+ * @param {string} props.displayName - The display name of the field
+ * @param {string} props.field - The name of the field
+ * @param {boolean} props.required - Whether the field is required or not
+ * @returns {JSX.Element}
+ * @example
+ * **/
+const PaymentMethodField = ({ control, defaultValue, displayName, field, required }: TPaymentMethodField) => {
+ return (
+
+ {
+ return field === 'instructions' ? (
+
+ ) : (
+
+ );
+ }}
+ rules={{
+ pattern: {
+ message: `${displayName} can only include letters, numbers, spaces, and any of these symbols: -+.,'#@():;`, // TODO: Remember to translate this
+ value: VALID_SYMBOLS_PATTERN,
+ },
+ required: required ? 'This field is required.' : false,
+ }}
+ />
+
+ );
+};
+
+export default PaymentMethodField;
diff --git a/src/components/PaymentMethodField/__tests__/PaymentMethodField.spec.tsx b/src/components/PaymentMethodField/__tests__/PaymentMethodField.spec.tsx
new file mode 100644
index 00000000..3483adb5
--- /dev/null
+++ b/src/components/PaymentMethodField/__tests__/PaymentMethodField.spec.tsx
@@ -0,0 +1,31 @@
+import { useForm } from 'react-hook-form';
+
+import { render, screen } from '@testing-library/react';
+
+import PaymentMethodField from '../PaymentMethodField';
+
+jest.mock('react-hook-form', () => ({
+ ...jest.requireActual('react-hook-form'),
+ Controller: ({ control, defaultValue, name, render }) =>
+ render({
+ field: { control, name, onBlur: jest.fn(), onChange: jest.fn(), value: defaultValue },
+ fieldState: { error: null },
+ }),
+ useForm: () => ({
+ control: 'mockedControl',
+ }),
+}));
+
+const mockUseForm = useForm as jest.MockedFunction;
+
+describe('PaymentMethodField', () => {
+ const { control } = mockUseForm();
+ it('should render a textarea when the field prop is set to instructions', () => {
+ render( );
+ expect(screen.getByText('textarea')).toBeInTheDocument();
+ });
+ it('should render an input when the field prop is set to text', () => {
+ render( );
+ expect(screen.getByPlaceholderText('input')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/PaymentMethodField/index.ts b/src/components/PaymentMethodField/index.ts
new file mode 100644
index 00000000..281fd019
--- /dev/null
+++ b/src/components/PaymentMethodField/index.ts
@@ -0,0 +1 @@
+export { default as PaymentMethodField } from './PaymentMethodField';
diff --git a/src/components/PaymentMethodForm/PaymentMethodForm.scss b/src/components/PaymentMethodForm/PaymentMethodForm.scss
new file mode 100644
index 00000000..2c81315b
--- /dev/null
+++ b/src/components/PaymentMethodForm/PaymentMethodForm.scss
@@ -0,0 +1,98 @@
+.p2p-payment-method-form {
+ max-width: 100%;
+ min-width: 67.2rem;
+ padding-bottom: 2.4rem;
+
+ @include mobile {
+ width: 100%;
+ min-width: 36rem;
+ position: absolute;
+ top: 4rem;
+ }
+
+ &__button {
+ display: inline-block;
+ margin-left: 0.5rem;
+ padding: 0;
+
+ // TODO: remove this once hover styles can be removed from the button component
+ &:hover {
+ // stylelint-disable-next-line declaration-no-important
+ background-color: transparent !important;
+
+ & > span {
+ // stylelint-disable-next-line declaration-no-important
+ color: #ff444f !important;
+ }
+ }
+ }
+ .derivs-button__variant--ghost:hover:not(:disabled) {
+ background: transparent;
+ text-decoration: underline;
+ text-decoration-color: #ec3f3f;
+ }
+ .deriv-input--field:not(:focus) {
+ border: 1px solid #d6dadb;
+ }
+ .deriv-input__field:not(:placeholder-shown) ~ label {
+ color: #333333;
+ }
+ .deriv-input__container {
+ width: 100%;
+ }
+ .deriv-input {
+ padding: 0.5 1.2rem;
+ }
+ .deriv-input,
+ .deriv-input--field {
+ width: 100%;
+ font-size: 1.4rem;
+ }
+
+ &__form {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+
+ @include mobile {
+ overflow: auto;
+ height: calc(100vh - 14rem);
+ padding-bottom: 8rem;
+ }
+ }
+
+ .deriv-input--right-content {
+ display: flex;
+ align-items: center;
+ height: 100%;
+ top: 0;
+ }
+
+ .p2p-textfield__message-container {
+ padding: 0;
+ height: 0;
+ }
+
+ &__icon--close {
+ cursor: pointer;
+ color: #ffffff;
+ font-size: large;
+ }
+
+ &__field {
+ color: #333333;
+ &-wrapper {
+ width: 100%;
+ padding: 1rem 0;
+
+ @include mobile {
+ padding: 1.4rem 1.6rem;
+ }
+ }
+ }
+ &__text {
+ display: inline-block;
+ line-height: 1.5;
+ vertical-align: baseline;
+ }
+}
diff --git a/src/components/PaymentMethodForm/PaymentMethodForm.tsx b/src/components/PaymentMethodForm/PaymentMethodForm.tsx
new file mode 100644
index 00000000..29caa48c
--- /dev/null
+++ b/src/components/PaymentMethodForm/PaymentMethodForm.tsx
@@ -0,0 +1,143 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { TSelectedPaymentMethod } from 'types';
+
+import { useDevice } from '@deriv-com/ui';
+
+import { PageReturn, PaymentMethodField, PaymentMethodsFormFooter } from '@/components';
+import { api } from '@/hooks';
+import { TFormState } from '@/reducers/types';
+
+import { PaymentMethodFormAutocomplete } from './PaymentMethodFormAutocomplete';
+import { PaymentMethodFormModalRenderer } from './PaymentMethodFormModalRenderer';
+
+import './PaymentMethodForm.scss';
+
+type TPaymentMethodFormProps = {
+ formState: TFormState;
+ onAdd: (selectedPaymentMethod?: TSelectedPaymentMethod) => void;
+ onResetFormState: () => void;
+};
+
+/**
+ * @component This component is used to display the form to add or edit a payment method
+ * @param formState - The current state of the form
+ * @returns {JSX.Element}
+ * @example
+ * **/
+const PaymentMethodForm = ({ onAdd, onResetFormState, ...rest }: TPaymentMethodFormProps) => {
+ const {
+ control,
+ formState: { isDirty, isSubmitting, isValid },
+ handleSubmit,
+ reset,
+ } = useForm({ mode: 'all' });
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const { actionType, selectedPaymentMethod, title } = rest.formState;
+ const { data: availablePaymentMethods } = api.paymentMethods.useGet();
+ const { create, error: createError, isSuccess: isCreateSuccessful } = api.advertiserPaymentMethods.useCreate();
+ const { error: updateError, isSuccess: isUpdateSuccessful, update } = api.advertiserPaymentMethods.useUpdate();
+
+ const { isMobile } = useDevice();
+
+ useEffect(() => {
+ if (isCreateSuccessful) {
+ onResetFormState();
+ }
+ }, [isCreateSuccessful, createError, onResetFormState]);
+
+ useEffect(() => {
+ if (isUpdateSuccessful) {
+ onResetFormState();
+ }
+ }, [isUpdateSuccessful, onResetFormState, updateError]);
+
+ const availablePaymentMethodsList = useMemo(() => {
+ const listItems = availablePaymentMethods?.map(availablePaymentMethod => ({
+ text: availablePaymentMethod?.display_name,
+ value: availablePaymentMethod?.id,
+ }));
+ return listItems || [];
+ }, [availablePaymentMethods]);
+ const handleGoBack = () => {
+ if (isDirty) {
+ setIsModalOpen(true);
+ } else {
+ onResetFormState();
+ }
+ };
+
+ return (
+
+
+
{
+ const hasData = Object.keys(data).length > 0;
+ if (actionType === 'ADD' && hasData) {
+ create({ ...data, method: String(selectedPaymentMethod?.method) });
+ } else if (actionType === 'EDIT' && hasData) {
+ update(String(selectedPaymentMethod?.id), {
+ ...data,
+ method: String(selectedPaymentMethod?.method),
+ });
+ }
+ })}
+ >
+
+
+ {Object.keys(selectedPaymentMethod?.fields || {})?.map(field => {
+ const paymentMethodField = selectedPaymentMethod?.fields?.[field];
+ return (
+
+ );
+ })}
+
+ {(isMobile || !!selectedPaymentMethod) && (
+
+ )}
+
+
+
+ );
+};
+
+export default PaymentMethodForm;
diff --git a/src/components/PaymentMethodForm/PaymentMethodFormAutocomplete/PaymentMethodFormAutocomplete.tsx b/src/components/PaymentMethodForm/PaymentMethodFormAutocomplete/PaymentMethodFormAutocomplete.tsx
new file mode 100644
index 00000000..d711d8a7
--- /dev/null
+++ b/src/components/PaymentMethodForm/PaymentMethodFormAutocomplete/PaymentMethodFormAutocomplete.tsx
@@ -0,0 +1,103 @@
+import { THooks, TSelectedPaymentMethod } from 'types';
+
+import { Button, Input, Text } from '@deriv-com/ui';
+
+import { Dropdown } from '@/components';
+import { TFormState } from '@/reducers/types';
+
+import CloseCircle from '../../../public/ic-close-circle.svg';
+
+type TPaymentMethodFormAutocompleteProps = {
+ actionType: TFormState['actionType'];
+ availablePaymentMethods?: THooks.PaymentMethods.Get;
+ availablePaymentMethodsList: { text: string; value: string }[];
+ onAdd: (selectedPaymentMethod?: TSelectedPaymentMethod) => void;
+ reset: () => void;
+ selectedPaymentMethod: TFormState['selectedPaymentMethod'];
+};
+
+const PaymentMethodFormAutocomplete = ({
+ actionType,
+ availablePaymentMethods,
+ availablePaymentMethodsList,
+ onAdd,
+ reset,
+ selectedPaymentMethod,
+}: TPaymentMethodFormAutocompleteProps) => {
+ if (selectedPaymentMethod) {
+ // TODO: Remember to translate this
+ return (
+ {
+ onAdd();
+ reset();
+ }}
+ width={15.7}
+ />
+ )
+ }
+ />
+ );
+ }
+ return (
+ <>
+ {
+ const selectedPaymentMethod = availablePaymentMethods?.find(p => p.id === value);
+ if (selectedPaymentMethod) {
+ onAdd({
+ displayName: selectedPaymentMethod?.display_name,
+ fields: selectedPaymentMethod?.fields,
+ method: value,
+ });
+ }
+ }}
+ // TODO: Remember to translate this
+ value={selectedPaymentMethod?.display_name ?? ''}
+ variant='comboBox'
+ />
+
+ {/* TODO: Remember to translate these */}
+
+ Don’t see your payment method?
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ const paymentMethod = availablePaymentMethods?.find(p => p.id === 'other');
+ if (paymentMethod) {
+ onAdd({
+ displayName: paymentMethod?.display_name,
+ fields: paymentMethod?.fields,
+ method: 'other',
+ });
+ }
+ }}
+ size='sm'
+ textSize='xs'
+ variant='ghost'
+ >
+ Add new.
+
+
+ >
+ );
+};
+
+export default PaymentMethodFormAutocomplete;
diff --git a/src/components/PaymentMethodForm/PaymentMethodFormAutocomplete/index.ts b/src/components/PaymentMethodForm/PaymentMethodFormAutocomplete/index.ts
new file mode 100644
index 00000000..7ba73b1f
--- /dev/null
+++ b/src/components/PaymentMethodForm/PaymentMethodFormAutocomplete/index.ts
@@ -0,0 +1 @@
+export { default as PaymentMethodFormAutocomplete } from './PaymentMethodFormAutocomplete';
diff --git a/src/components/PaymentMethodForm/PaymentMethodFormModalRenderer/PaymentMethodFormModalRenderer.tsx b/src/components/PaymentMethodForm/PaymentMethodFormModalRenderer/PaymentMethodFormModalRenderer.tsx
new file mode 100644
index 00000000..22c7211d
--- /dev/null
+++ b/src/components/PaymentMethodForm/PaymentMethodFormModalRenderer/PaymentMethodFormModalRenderer.tsx
@@ -0,0 +1,76 @@
+import { TSocketError } from '@deriv/api-v2/types';
+
+import { PaymentMethodErrorModal, PaymentMethodModal } from '@/components/Modals';
+import { TFormState } from '@/reducers/types';
+
+type TPaymentMethodFormModalRendererProps = {
+ actionType: TFormState['actionType'];
+ createError: TSocketError<'p2p_advertiser_payment_methods'> | null;
+ isCreateSuccessful: boolean;
+ isModalOpen: boolean;
+ isUpdateSuccessful: boolean;
+ onResetFormState: () => void;
+ setIsModalOpen: (isModalOpen: boolean) => void;
+ updateError: TSocketError<'p2p_advertiser_payment_methods'> | null;
+};
+
+const PaymentMethodFormModalRenderer = ({
+ actionType,
+ createError,
+ isCreateSuccessful,
+ isModalOpen,
+ isUpdateSuccessful,
+ onResetFormState,
+ setIsModalOpen,
+ updateError,
+}: TPaymentMethodFormModalRendererProps) => {
+ if (actionType === 'ADD' && (!isCreateSuccessful || !createError) && isModalOpen) {
+ return (
+ {
+ setIsModalOpen(false);
+ }}
+ primaryButtonLabel='Go back'
+ secondaryButtonLabel='Cancel'
+ title='Cancel adding this payment method?'
+ />
+ );
+ }
+
+ if (actionType === 'EDIT' && (!isUpdateSuccessful || !updateError) && isModalOpen) {
+ return (
+ {
+ setIsModalOpen(false);
+ }}
+ primaryButtonLabel="Don't cancel"
+ secondaryButtonLabel='Cancel'
+ title='Cancel your edits?'
+ />
+ );
+ }
+
+ // TODO: Remember to translate these strings
+ if (createError || updateError) {
+ return (
+ {
+ onResetFormState();
+ }}
+ title='Something’s not right'
+ />
+ );
+ }
+
+ return null;
+};
+
+export default PaymentMethodFormModalRenderer;
diff --git a/src/components/PaymentMethodForm/PaymentMethodFormModalRenderer/index.ts b/src/components/PaymentMethodForm/PaymentMethodFormModalRenderer/index.ts
new file mode 100644
index 00000000..9a05d2b4
--- /dev/null
+++ b/src/components/PaymentMethodForm/PaymentMethodFormModalRenderer/index.ts
@@ -0,0 +1 @@
+export { default as PaymentMethodFormModalRenderer } from './PaymentMethodFormModalRenderer';
diff --git a/src/components/PaymentMethodForm/__test__/PaymentMethodForm.spec.tsx b/src/components/PaymentMethodForm/__test__/PaymentMethodForm.spec.tsx
new file mode 100644
index 00000000..bb6b5419
--- /dev/null
+++ b/src/components/PaymentMethodForm/__test__/PaymentMethodForm.spec.tsx
@@ -0,0 +1,486 @@
+import { PropsWithChildren } from 'react';
+
+import { APIProvider, AuthProvider } from '@deriv/api-v2';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { api } from '@/hooks';
+
+import PaymentMethodForm from '../PaymentMethodForm';
+
+const wrapper = ({ children }: PropsWithChildren) => (
+
+
+ {children}
+
+
+);
+
+const mockPaymentMethods = [
+ {
+ display_name: 'Bank Transfer',
+ fields: {
+ account: {
+ display_name: 'Account Number',
+ required: 1,
+ type: 'text',
+ value: '00112233445566778899',
+ },
+ bank_name: {
+ display_name: 'Bank Transfer',
+ required: 1,
+ type: 'text',
+ value: 'Bank Name',
+ },
+ },
+ id: 'bank_transfer',
+ is_enabled: 0,
+ method: 'bank_transfer',
+ type: 'bank',
+ used_by_adverts: null,
+ used_by_orders: null,
+ },
+ {
+ display_name: 'Other',
+ fields: {
+ account: {
+ display_name: 'Account Number',
+ required: 0,
+ type: 'text',
+ value: 'Account 1',
+ },
+ },
+ id: 'other',
+ is_enabled: 1,
+ method: 'other',
+ type: 'other',
+ used_by_adverts: null,
+ used_by_orders: null,
+ },
+] as const;
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: false }),
+}));
+
+jest.mock('@deriv/api-v2', () => {
+ return {
+ ...jest.requireActual('@deriv/api-v2'),
+ p2p: {
+ advertiserPaymentMethods: {
+ useCreate: jest.fn(() => ({
+ create: jest.fn(),
+ })),
+ useUpdate: jest.fn(() => ({
+ update: jest.fn(),
+ })),
+ },
+ paymentMethods: {
+ useGet: jest.fn(() => ({
+ data: mockPaymentMethods,
+ })),
+ },
+ },
+ };
+});
+
+const mockUseCreate = api.advertiserPaymentMethods.useCreate as jest.MockedFunction<
+ typeof api.advertiserPaymentMethods.useCreate
+>;
+
+const mockUseUpdate = api.advertiserPaymentMethods.useUpdate as jest.MockedFunction<
+ typeof api.advertiserPaymentMethods.useUpdate
+>;
+
+describe('PaymentMethodForm', () => {
+ it('should render the component correctly when a selected payment method is not provided', () => {
+ render(
+ ,
+ { wrapper }
+ );
+ expect(screen.getByText('Payment method')).toBeInTheDocument();
+ });
+ it('should render the component correctly when a selected payment method is provided', () => {
+ const otherPaymentMethod = mockPaymentMethods.find(method => method.type === 'other');
+ render(
+ ,
+ { wrapper }
+ );
+ expect(screen.getByDisplayValue('Account 1')).toBeInTheDocument();
+ });
+ it('should render the component correctly when a selected payment method is passed in with an undefined display name and an undefined value', () => {
+ // This test covers the scenario where the display name and value "could be" undefined due to the types returned from the api-types package
+ render(
+ ,
+ { wrapper }
+ );
+ expect(screen.getByText('Choose your payment method')).toBeInTheDocument();
+ });
+ it('should render the component when the available payment methods are undefined', () => {
+ // This test covers the scenario where the available payment methods "could be" undefined because of the types returned from the api-types package
+ (api.paymentMethods.useGet as jest.Mock).mockReturnValueOnce({
+ data: undefined,
+ });
+ render(
+ ,
+ { wrapper }
+ );
+ expect(screen.getByText('Don’t see your payment method?')).toBeInTheDocument();
+ });
+ it('should handle the onclick event when the back arrow is clicked and the form is not dirty', async () => {
+ const onResetFormState = jest.fn();
+ const bankPaymentMethod = mockPaymentMethods.find(method => method.type === 'bank');
+ render(
+ ,
+ { wrapper }
+ );
+ const inputField = screen.getByDisplayValue('00112233445566778899');
+ expect(inputField).toBeInTheDocument();
+ const backArrow = screen.getByTestId('dt_page_return_btn');
+ await userEvent.click(backArrow);
+ expect(onResetFormState).toHaveBeenCalled();
+ });
+ it('should render the close icon when a payment method is selected', () => {
+ const otherPaymentMethod = mockPaymentMethods.find(method => method.type === 'other');
+ render(
+ ,
+ { wrapper }
+ );
+ expect(screen.getByTestId('dt_payment_methods_form_close_icon')).toBeInTheDocument();
+ });
+ it('should handle the onclick event when the close icon is clicked', async () => {
+ const onAdd = jest.fn();
+ const otherPaymentMethod = mockPaymentMethods.find(method => method.type === 'other');
+ render(
+ ,
+ { wrapper }
+ );
+ const closeIcon = screen.getByTestId('dt_payment_methods_form_close_icon');
+ await userEvent.click(closeIcon);
+ expect(onAdd).toHaveBeenCalled();
+ });
+ it('should handle onselect when an item is selected in the dropdown', async () => {
+ const onAdd = jest.fn();
+ render(
+ ,
+ { wrapper }
+ );
+ const dropdown = screen.getByText('Payment method');
+ await userEvent.click(dropdown);
+ const dropdownItem = screen.getByText('Bank Transfer');
+ await userEvent.click(dropdownItem);
+ const otherPaymentMethod = mockPaymentMethods.find(method => method.type === 'bank');
+ expect(onAdd).toHaveBeenCalledWith({
+ displayName: otherPaymentMethod?.display_name,
+ fields: otherPaymentMethod?.fields,
+ method: otherPaymentMethod?.method,
+ });
+ });
+ it('should handle onclick when the add new button is clicked', async () => {
+ const onAdd = jest.fn();
+ render(
+ ,
+ { wrapper }
+ );
+ const addNewButton = screen.getByRole('button', { name: 'Add new.' });
+ await userEvent.click(addNewButton);
+ const otherPaymentMethod = mockPaymentMethods.find(method => method.type === 'other');
+ expect(onAdd).toHaveBeenCalledWith({
+ displayName: otherPaymentMethod?.display_name,
+ fields: otherPaymentMethod?.fields,
+ method: otherPaymentMethod?.method,
+ });
+ });
+ it('should reset the form when usecreate returns issuccess set to true', () => {
+ (mockUseCreate as jest.Mock).mockReturnValueOnce({
+ create: jest.fn(),
+ isSuccess: true,
+ });
+ const onResetFormState = jest.fn();
+ render(
+ ,
+ { wrapper }
+ );
+ expect(onResetFormState).toHaveBeenCalled();
+ });
+ it('should reset the form when useupdate returns issuccess set to true', () => {
+ (mockUseUpdate as jest.Mock).mockReturnValueOnce({
+ isSuccess: true,
+ update: jest.fn(),
+ });
+ const onResetFormState = jest.fn();
+ render(
+ ,
+ { wrapper }
+ );
+ expect(onResetFormState).toHaveBeenCalled();
+ });
+ it('should show the error modal when a payment method is not created successfully and close it when the ok button is clicked', async () => {
+ (mockUseCreate as jest.Mock).mockReturnValueOnce({
+ create: jest.fn(),
+ error: {
+ error: {
+ message: 'Error creating payment method',
+ },
+ },
+ isSuccess: false,
+ });
+ const onResetFormState = jest.fn();
+ render(
+ ,
+ { wrapper }
+ );
+ expect(screen.getByText('Error creating payment method')).toBeInTheDocument();
+ const okButton = screen.getByRole('button', { name: 'Ok' });
+ expect(okButton).toBeInTheDocument();
+ await userEvent.click(okButton);
+ expect(onResetFormState).toHaveBeenCalled();
+ });
+ it('should show the error modal when a payment method is not updated successfully and close it when the ok button is clicked', async () => {
+ (mockUseUpdate as jest.Mock).mockReturnValueOnce({
+ error: {
+ error: {
+ message: 'Error updating payment method',
+ },
+ },
+ isSuccess: false,
+ update: jest.fn(),
+ });
+ const onResetFormState = jest.fn();
+ const otherPaymentMethod = mockPaymentMethods.find(method => method.type === 'other');
+ render(
+ ,
+ { wrapper }
+ );
+ expect(screen.getByText('Error updating payment method')).toBeInTheDocument();
+ const okButton = screen.getByRole('button', { name: 'Ok' });
+ expect(okButton).toBeInTheDocument();
+ await userEvent.click(okButton);
+ expect(onResetFormState).toHaveBeenCalled();
+ });
+ it('should handle submit when the form is submitted and the actiontype is add', async () => {
+ const create = jest.fn();
+ (mockUseCreate as jest.Mock).mockImplementation(() => {
+ return {
+ create,
+ };
+ });
+ const otherPaymentMethod = mockPaymentMethods.find(method => method.type === 'other');
+ render(
+ ,
+ { wrapper }
+ );
+ const inputField = screen.getByDisplayValue('Account 1');
+ expect(inputField).toBeInTheDocument();
+ await waitFor(async () => {
+ await userEvent.click(inputField);
+ await userEvent.type(inputField, 'Account 2');
+ await userEvent.tab();
+ const submitButton = screen.getByRole('button', { name: 'Add' });
+ expect(submitButton).toBeInTheDocument();
+ expect(submitButton).toBeEnabled();
+ await userEvent.click(submitButton);
+ });
+ expect(create).toHaveBeenCalled();
+ });
+ it('should handle submit when the form is submitted and the actiontype is edit', async () => {
+ const update = jest.fn();
+ (mockUseUpdate as jest.Mock).mockImplementation(() => {
+ return {
+ update,
+ };
+ });
+ const otherPaymentMethod = mockPaymentMethods.find(method => method.type === 'other');
+ render(
+ ,
+ { wrapper }
+ );
+ const inputField = screen.getByDisplayValue('Account 1');
+ expect(inputField).toBeInTheDocument();
+ await waitFor(async () => {
+ await userEvent.click(inputField);
+ await userEvent.type(inputField, 'Account 2');
+ await userEvent.tab();
+ const submitButton = screen.getByText('Save changes');
+ expect(submitButton).toBeInTheDocument();
+ expect(submitButton).toBeEnabled();
+ await userEvent.click(submitButton);
+ });
+ expect(update).toHaveBeenCalled();
+ });
+ it('should handle onclick when the back arrow is clicked and the form is dirty, and close the opened modal when go back button is clicked', async () => {
+ const otherPaymentMethod = mockPaymentMethods.find(method => method.type === 'other');
+ render(
+ ,
+ { wrapper }
+ );
+ const inputField = screen.getByDisplayValue('Account 1');
+ expect(inputField).toBeInTheDocument();
+ await waitFor(async () => {
+ await userEvent.click(inputField);
+ await userEvent.type(inputField, 'Account 2');
+ await userEvent.tab();
+ });
+ const backArrow = screen.getByTestId('dt_page_return_btn');
+ expect(backArrow).toBeInTheDocument();
+ await userEvent.click(backArrow);
+ expect(screen.getByText('Cancel adding this payment method?')).toBeInTheDocument();
+ const dontCancelButton = screen.getByRole('button', { name: 'Go back' });
+ expect(dontCancelButton).toBeInTheDocument();
+ await userEvent.click(dontCancelButton);
+ expect(screen.queryByText('Cancel adding this payment method?')).not.toBeInTheDocument();
+ });
+ it("should handle onclick when the back arrow is clicked and the form is dirty, and close the opened modal when don't cancel button is clicked", async () => {
+ const otherPaymentMethod = mockPaymentMethods.find(method => method.type === 'other');
+ render(
+ ,
+ { wrapper }
+ );
+ const inputField = screen.getByDisplayValue('Account 1');
+ expect(inputField).toBeInTheDocument();
+ await waitFor(async () => {
+ await userEvent.click(inputField);
+ await userEvent.type(inputField, 'Account 2');
+ await userEvent.tab();
+ });
+ const backArrow = screen.getByTestId('dt_page_return_btn');
+ expect(backArrow).toBeInTheDocument();
+ await userEvent.click(backArrow);
+ expect(screen.getByText('Cancel your edits?')).toBeInTheDocument();
+ const dontCancelButton = screen.getByRole('button', { name: "Don't cancel" });
+ expect(dontCancelButton).toBeInTheDocument();
+ await userEvent.click(dontCancelButton);
+ expect(screen.queryByText('Cancel your edits?')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/PaymentMethodForm/index.ts b/src/components/PaymentMethodForm/index.ts
new file mode 100644
index 00000000..77704eb8
--- /dev/null
+++ b/src/components/PaymentMethodForm/index.ts
@@ -0,0 +1 @@
+export { default as PaymentMethodForm } from './PaymentMethodForm';
diff --git a/src/components/PaymentMethodLabel/PaymentMethodLabel.tsx b/src/components/PaymentMethodLabel/PaymentMethodLabel.tsx
new file mode 100644
index 00000000..65d936f5
--- /dev/null
+++ b/src/components/PaymentMethodLabel/PaymentMethodLabel.tsx
@@ -0,0 +1,17 @@
+import { Text } from '@deriv-com/ui';
+
+type TPaymentMethodLabelProps = {
+ color?: string;
+ paymentMethodName: string;
+ size?: string;
+};
+
+const PaymentMethodLabel = ({ color = 'general', paymentMethodName, size = 'sm' }: TPaymentMethodLabelProps) => {
+ return (
+
+ {paymentMethodName}
+
+ );
+};
+
+export default PaymentMethodLabel;
diff --git a/src/components/PaymentMethodLabel/index.ts b/src/components/PaymentMethodLabel/index.ts
new file mode 100644
index 00000000..8f7279ba
--- /dev/null
+++ b/src/components/PaymentMethodLabel/index.ts
@@ -0,0 +1 @@
+export { default as PaymentMethodLabel } from './PaymentMethodLabel';
diff --git a/src/components/PaymentMethodWithIcon/PaymentMethodWithIcon.tsx b/src/components/PaymentMethodWithIcon/PaymentMethodWithIcon.tsx
new file mode 100644
index 00000000..c077dbab
--- /dev/null
+++ b/src/components/PaymentMethodWithIcon/PaymentMethodWithIcon.tsx
@@ -0,0 +1,31 @@
+import React, { ComponentType, SVGAttributes } from 'react';
+import clsx from 'clsx';
+import { THooks } from 'types';
+import { TGenericSizes } from '@/utils';
+import { Text } from '@deriv-com/ui';
+import IcCashierBankTransfer from '../../public/ic-cashier-bank-transfer.svg';
+import IcCashierEwallet from '../../public/ic-cashier-ewallet.svg';
+import IcCashierOther from '../../public/ic-cashier-other.svg';
+
+type TPaymentMethodWithIconProps = {
+ className: string;
+ name: string;
+ textSize?: TGenericSizes;
+ type: THooks.AdvertiserPaymentMethods.Get[number]['type'];
+};
+const PaymentMethodWithIcon = ({ className, name, textSize = 'sm', type }: TPaymentMethodWithIconProps) => {
+ let Icon: ComponentType> = IcCashierOther;
+ if (type === 'bank') {
+ Icon = IcCashierBankTransfer;
+ } else if (type === 'ewallet') {
+ Icon = IcCashierEwallet;
+ }
+ return (
+
+
+ {name}
+
+ );
+};
+
+export default PaymentMethodWithIcon;
diff --git a/src/components/PaymentMethodWithIcon/index.ts b/src/components/PaymentMethodWithIcon/index.ts
new file mode 100644
index 00000000..95b345bd
--- /dev/null
+++ b/src/components/PaymentMethodWithIcon/index.ts
@@ -0,0 +1 @@
+export { default as PaymentMethodWithIcon } from './PaymentMethodWithIcon';
diff --git a/src/components/PaymentMethodsFormFooter/PaymentMethodsFormFooter.scss b/src/components/PaymentMethodsFormFooter/PaymentMethodsFormFooter.scss
new file mode 100644
index 00000000..3aa0b467
--- /dev/null
+++ b/src/components/PaymentMethodsFormFooter/PaymentMethodsFormFooter.scss
@@ -0,0 +1,16 @@
+.p2p-payment-methods-form-footer {
+ display: flex;
+ justify-content: end;
+ gap: 0.8rem;
+ background: #fff;
+ margin-top: 2rem;
+
+ @include mobile {
+ margin-top: 0;
+ width: 100%;
+ padding: 1.6rem 2.4rem;
+ border-top: 2px solid #f2f3f4;
+ position: absolute;
+ bottom: 2.2rem;
+ }
+}
diff --git a/src/components/PaymentMethodsFormFooter/PaymentMethodsFormFooter.tsx b/src/components/PaymentMethodsFormFooter/PaymentMethodsFormFooter.tsx
new file mode 100644
index 00000000..761e9e09
--- /dev/null
+++ b/src/components/PaymentMethodsFormFooter/PaymentMethodsFormFooter.tsx
@@ -0,0 +1,60 @@
+import { Button, useDevice } from '@deriv-com/ui';
+
+import { TFormState } from '@/reducers/types';
+
+import './PaymentMethodsFormFooter.scss';
+
+type TPaymentMethodsFormFooterProps = {
+ actionType: TFormState['actionType'];
+ handleGoBack: () => void;
+ isDirty: boolean;
+ isSubmitting: boolean;
+ isValid: boolean;
+};
+
+/**
+ * @component This component is used to display the footer of the PaymentMethodForm
+ * @param actionType - The type of action to be performed (ADD or EDIT)
+ * @param handleGoBack - The function to be called when the back / cancel button is clicked
+ * @param isDirty - The state of the form (whether it has been modified or not)
+ * @param isSubmitting - The state of the form (whether it is being submitted or not)
+ * @param isValid - The state of the form (whether it is valid or not)
+ * @returns {JSX.Element}
+ * @example
+ * **/
+const PaymentMethodsFormFooter = ({
+ actionType,
+ handleGoBack,
+ isDirty,
+ isSubmitting,
+ isValid,
+}: TPaymentMethodsFormFooterProps) => {
+ const { isMobile } = useDevice();
+ const textSize = isMobile ? 'lg' : 'sm';
+
+ return (
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+
+ handleGoBack();
+ }}
+ size='lg'
+ textSize={textSize}
+ variant='outlined'
+ >
+ Cancel
+
+ {/* TODO: Remember to translate these */}
+
+ {actionType === 'ADD' ? 'Add' : 'Save changes'}
+
+
+ );
+};
+
+export default PaymentMethodsFormFooter;
diff --git a/src/components/PaymentMethodsFormFooter/__tests__/PaymentMethodsFormFooter.spec.tsx b/src/components/PaymentMethodsFormFooter/__tests__/PaymentMethodsFormFooter.spec.tsx
new file mode 100644
index 00000000..6672cf37
--- /dev/null
+++ b/src/components/PaymentMethodsFormFooter/__tests__/PaymentMethodsFormFooter.spec.tsx
@@ -0,0 +1,87 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import PaymentMethodsFormFooter from '../PaymentMethodsFormFooter';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: false }),
+}));
+
+describe('PaymentMethodsFormFooter', () => {
+ it('should render the PaymentMethodsFormFooter component correctly', () => {
+ render(
+ undefined}
+ isDirty={false}
+ isSubmitting={false}
+ isValid={false}
+ />
+ );
+ expect(screen.getByRole('payment-methods-form-footer')).toBeInTheDocument();
+ });
+ it('should render the correct button text when action type is edit', () => {
+ render(
+ undefined}
+ isDirty={false}
+ isSubmitting={false}
+ isValid={false}
+ />
+ );
+ expect(screen.getByRole('button', { name: 'Save changes' })).toBeInTheDocument();
+ });
+ it('should render the correct button text when action type is add', () => {
+ render(
+ undefined}
+ isDirty={false}
+ isSubmitting={false}
+ isValid={false}
+ />
+ );
+ expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
+ });
+ it('should render a disabled button if the form is not dirty', () => {
+ render(
+ undefined}
+ isDirty={false}
+ isSubmitting={false}
+ isValid={false}
+ />
+ );
+ expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
+ });
+ it('should render an enabled button when the form is dirty', () => {
+ render(
+ undefined}
+ isDirty={true}
+ isSubmitting={false}
+ isValid={true}
+ />
+ );
+ expect(screen.getByRole('button', { name: 'Add' })).toBeEnabled();
+ });
+ it('should handle onclick for the cancel button', async () => {
+ const handleGoBack = jest.fn();
+ render(
+
+ );
+ const button = screen.getByRole('button', { name: 'Cancel' });
+ await userEvent.click(button);
+ expect(handleGoBack).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/PaymentMethodsFormFooter/index.ts b/src/components/PaymentMethodsFormFooter/index.ts
new file mode 100644
index 00000000..81cc72df
--- /dev/null
+++ b/src/components/PaymentMethodsFormFooter/index.ts
@@ -0,0 +1 @@
+export { default as PaymentMethodsFormFooter } from './PaymentMethodsFormFooter';
diff --git a/src/components/PopoverDropdown/PopoverDropdown.scss b/src/components/PopoverDropdown/PopoverDropdown.scss
new file mode 100644
index 00000000..c17c5300
--- /dev/null
+++ b/src/components/PopoverDropdown/PopoverDropdown.scss
@@ -0,0 +1,45 @@
+.p2p-popover-dropdown {
+ display: flex;
+ flex-direction: column;
+ position: relative;
+
+ &__icon {
+ margin-left: 1rem;
+ }
+
+ & .deriv-tooltip {
+ @include mobile {
+ display: none;
+ }
+ }
+ &__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;
+
+ &-item {
+ padding: 1rem 1.6rem;
+ height: inherit;
+ justify-content: start;
+ border-radius: unset;
+
+ // TODO: remove this once hover styles can be removed from the button component
+ &:hover {
+ // stylelint-disable-next-line declaration-no-important
+ background-color: #e6e9e9 !important;
+
+ & span {
+ // stylelint-disable-next-line declaration-no-important
+ color: #333 !important;
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/PopoverDropdown/PopoverDropdown.tsx b/src/components/PopoverDropdown/PopoverDropdown.tsx
new file mode 100644
index 00000000..2f884f4f
--- /dev/null
+++ b/src/components/PopoverDropdown/PopoverDropdown.tsx
@@ -0,0 +1,61 @@
+import React, { useRef, useState } from 'react';
+import { useOnClickOutside } from 'usehooks-ts';
+import { LabelPairedEllipsisVerticalMdRegularIcon } from '@deriv/quill-icons';
+import { Button, Text, Tooltip, useDevice } from '@deriv-com/ui';
+import './PopoverDropdown.scss';
+
+type TItem = {
+ label: string;
+ value: string;
+};
+
+type TPopoverDropdownProps = {
+ dropdownList: TItem[];
+ onClick: (value: string) => void;
+ tooltipMessage: string;
+};
+
+const PopoverDropdown = ({ dropdownList, onClick, tooltipMessage }: TPopoverDropdownProps) => {
+ const [visible, setVisible] = useState(false);
+ const ref = useRef(null);
+ useOnClickOutside(ref, () => setVisible(false));
+ const { isMobile } = useDevice();
+
+ return (
+
+
+ setVisible(prevState => !prevState)}
+ />
+
+ {visible && (
+
+ {dropdownList.map(item => (
+ {
+ onClick(item.value);
+ setVisible(false);
+ }}
+ variant='ghost'
+ >
+
+ {item.label}
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default PopoverDropdown;
diff --git a/src/components/PopoverDropdown/__tests__/PopoverDropdown.spec.tsx b/src/components/PopoverDropdown/__tests__/PopoverDropdown.spec.tsx
new file mode 100644
index 00000000..2f670b2e
--- /dev/null
+++ b/src/components/PopoverDropdown/__tests__/PopoverDropdown.spec.tsx
@@ -0,0 +1,44 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import PopoverDropdown from '../PopoverDropdown';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({
+ isMobile: false,
+ }),
+}));
+
+const mockProps = {
+ dropdownList: [
+ {
+ label: 'label 1',
+ value: 'value 1',
+ },
+ {
+ label: 'label 2',
+ value: 'value 2',
+ },
+ ],
+ onClick: jest.fn(),
+ tooltipMessage: 'test tooltip message',
+};
+
+describe('PopoverDropdown', () => {
+ it('should render', () => {
+ render( );
+ expect(screen.getByTestId('dt_popover_dropdown_icon')).toBeInTheDocument();
+ });
+ it('should render the dropdown list on clicking on the icon', async () => {
+ render( );
+ await userEvent.click(screen.getByTestId('dt_popover_dropdown_icon'));
+ expect(screen.getByText('label 1')).toBeInTheDocument();
+ });
+ it('should call onClick when item is clicked', async () => {
+ render( );
+ await userEvent.click(screen.getByTestId('dt_popover_dropdown_icon'));
+ await userEvent.click(screen.getByText('label 1'));
+ expect(mockProps.onClick).toHaveBeenCalledWith('value 1');
+ });
+});
diff --git a/src/components/PopoverDropdown/index.ts b/src/components/PopoverDropdown/index.ts
new file mode 100644
index 00000000..bfe8f9b5
--- /dev/null
+++ b/src/components/PopoverDropdown/index.ts
@@ -0,0 +1 @@
+export { default as PopoverDropdown } from './PopoverDropdown';
diff --git a/src/components/ProfileContent/ProfileBalance/ProfileBalance.scss b/src/components/ProfileContent/ProfileBalance/ProfileBalance.scss
new file mode 100644
index 00000000..d61d5ecc
--- /dev/null
+++ b/src/components/ProfileContent/ProfileBalance/ProfileBalance.scss
@@ -0,0 +1,88 @@
+.p2p-profile-balance {
+ display: grid;
+ grid-template-columns: 1.5fr 3fr;
+ grid-gap: 2.4rem;
+
+ @include mobile {
+ grid-template-columns: 1fr;
+ grid-auto-flow: row;
+ }
+
+ &__amount {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+
+ @include mobile {
+ gap: 0;
+ }
+
+ & div {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ }
+ }
+
+ &__label {
+ display: block;
+ }
+
+ &__items {
+ display: grid;
+ grid-auto-flow: column;
+
+ @include mobile {
+ grid-template-columns: 1fr 1fr;
+ }
+ }
+
+ &__daily-limits {
+ border-top: 1px solid #f2f3f4;
+ padding-top: 1.1rem;
+ display: flex;
+ gap: 1.6rem;
+ }
+
+ &__item {
+ display: flex;
+ flex-direction: column;
+ gap: 1.6rem;
+
+ @include mobile {
+ border-right: 1px solid #f2f3f7;
+ gap: 1rem;
+
+ &:last-child {
+ border-right: none;
+ padding: 0 1.6rem;
+ }
+ }
+
+ &-limits {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+
+ @include mobile {
+ grid-template-columns: 1fr;
+ grid-template-rows: 1fr 1fr;
+ gap: 0.8rem;
+ }
+
+ & div {
+ border-left: 1px solid #f2f3f7;
+ padding: 0 3rem;
+
+ &:first-child {
+ border-left: none;
+ padding-left: 0;
+ }
+
+ @include mobile {
+ border-left: none;
+ padding: 0;
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/ProfileContent/ProfileBalance/ProfileBalance.tsx b/src/components/ProfileContent/ProfileBalance/ProfileBalance.tsx
new file mode 100644
index 00000000..da3a7e63
--- /dev/null
+++ b/src/components/ProfileContent/ProfileBalance/ProfileBalance.tsx
@@ -0,0 +1,113 @@
+import React, { useMemo, useState } from 'react';
+import { TAdvertiserStats } from 'types';
+
+import { useActiveAccount } from '@deriv/api-v2';
+import { LabelPairedCircleInfoMdRegularIcon } from '@deriv/quill-icons';
+import { Text } from '@deriv-com/ui';
+
+import { AvailableP2PBalanceModal } from '@/components/Modals';
+import { useDevice } from '@/hooks/custom-hooks';
+import { numberToCurrencyText } from '@/utils';
+
+import { ProfileDailyLimit } from '../ProfileDailyLimit';
+
+import './ProfileBalance.scss';
+
+const ProfileBalance = ({ advertiserStats }: { advertiserStats: DeepPartial }) => {
+ const { data: activeAccount } = useActiveAccount();
+ const { isDesktop } = useDevice();
+ const [shouldShowAvailableBalanceModal, setShouldShowAvailableBalanceModal] = useState(false);
+
+ const currency = activeAccount?.currency || 'USD';
+ const dailyLimits = useMemo(
+ () => [
+ {
+ available: `${numberToCurrencyText(advertiserStats?.dailyAvailableBuyLimit || 0)} ${currency}`,
+ dailyLimit: `${advertiserStats?.daily_buy_limit || numberToCurrencyText(0)} ${currency}`,
+ type: 'Buy',
+ },
+ {
+ available: `${numberToCurrencyText(advertiserStats?.dailyAvailableSellLimit || 0)} ${currency}`,
+ dailyLimit: `${advertiserStats?.daily_sell_limit || numberToCurrencyText(0)} ${currency}`,
+ type: 'Sell',
+ },
+ ],
+ [
+ advertiserStats?.dailyAvailableBuyLimit,
+ advertiserStats?.dailyAvailableSellLimit,
+ advertiserStats?.daily_buy_limit,
+ advertiserStats?.daily_sell_limit,
+ currency,
+ ]
+ );
+
+ return (
+ <>
+ setShouldShowAvailableBalanceModal(false)}
+ />
+
+
+
+
+ Available Deriv P2P Balance
+
+ setShouldShowAvailableBalanceModal(true)}
+ />
+
+
+ {numberToCurrencyText(advertiserStats?.balance_available || 0)} USD
+
+
+
+
+ {dailyLimits.map(({ available, dailyLimit, type }) => (
+
+
{type}
+
+
+
+ Daily limit
+
+
+ {dailyLimit}
+
+
+
+
+ Available
+
+
+ {available}
+
+
+
+
+ ))}
+
+ {advertiserStats?.isEligibleForLimitUpgrade && (
+
+ )}
+
+
+ >
+ );
+};
+
+export default ProfileBalance;
diff --git a/src/components/ProfileContent/ProfileBalance/__tests__/ProfileBalance.spec.tsx b/src/components/ProfileContent/ProfileBalance/__tests__/ProfileBalance.spec.tsx
new file mode 100644
index 00000000..65ccbf94
--- /dev/null
+++ b/src/components/ProfileContent/ProfileBalance/__tests__/ProfileBalance.spec.tsx
@@ -0,0 +1,117 @@
+import { APIProvider, AuthProvider } from '@deriv/api-v2';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import ProfileBalance from '../ProfileBalance';
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+
+
+ {children}
+
+
+);
+
+let mockAdvertiserStatsProp = {
+ advertiserStats: {
+ balance_available: 50000,
+ daily_buy_limit: '500',
+ daily_sell_limit: '500',
+ dailyAvailableBuyLimit: 10,
+ dailyAvailableSellLimit: 10,
+ fullName: 'Jane Doe',
+ isEligibleForLimitUpgrade: false,
+ name: 'Jane',
+ should_show_name: false,
+ },
+};
+const mockUseActiveAccount = {
+ data: {
+ currency: 'USD',
+ },
+ isLoading: false,
+};
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ useActiveAccount: jest.fn(() => mockUseActiveAccount),
+}));
+
+describe('ProfileBalance', () => {
+ it('should render the correct balance', async () => {
+ render( , { wrapper });
+ const availableBalanceNode = screen.getByTestId('dt_available_balance_amount');
+ expect(within(availableBalanceNode).getByText('50,000.00 USD')).toBeInTheDocument();
+
+ const balanceInfoIcon = screen.getByTestId('dt_available_balance_icon');
+ await userEvent.click(balanceInfoIcon);
+ expect(screen.getByTestId('dt_available_p2p_balance_modal')).toBeInTheDocument();
+ const okButton = screen.getByRole('button', {
+ name: 'Ok',
+ });
+ await userEvent.click(okButton);
+ expect(screen.queryByTestId('dt_available_p2p_balance_modal')).not.toBeInTheDocument();
+ });
+
+ it('should render the correct limits', () => {
+ mockAdvertiserStatsProp = {
+ advertiserStats: {
+ ...mockAdvertiserStatsProp.advertiserStats,
+ daily_buy_limit: '500',
+ daily_sell_limit: '2000',
+ dailyAvailableBuyLimit: 100,
+ dailyAvailableSellLimit: 600,
+ },
+ };
+ render( , { wrapper });
+ const dailyBuyLimitNode = screen.getByTestId('dt_profile_balance_daily_buy_limit');
+ expect(within(dailyBuyLimitNode).getByText('500 USD')).toBeInTheDocument();
+ const availableBuyLimitNode = screen.getByTestId('dt_profile_balance_available_buy_limit');
+ expect(within(availableBuyLimitNode).getByText('100.00 USD')).toBeInTheDocument();
+
+ const dailySellLimitNode = screen.getByTestId('dt_profile_balance_daily_sell_limit');
+ expect(within(dailySellLimitNode).getByText('2000 USD')).toBeInTheDocument();
+ const dailyAvailableSellLimit = screen.getByTestId('dt_profile_balance_available_sell_limit');
+ expect(within(dailyAvailableSellLimit).getByText('600.00 USD')).toBeInTheDocument();
+ });
+ it('should render eligibility for daily limit upgrade', async () => {
+ mockAdvertiserStatsProp = {
+ advertiserStats: {
+ ...mockAdvertiserStatsProp.advertiserStats,
+ isEligibleForLimitUpgrade: true,
+ },
+ };
+ render( , { wrapper });
+ expect(screen.getByTestId('dt_profile_daily_limit')).toBeInTheDocument();
+
+ const openDailyLimitModalBtn = screen.getByRole('button', {
+ name: 'Increase my limits',
+ });
+ await userEvent.click(openDailyLimitModalBtn);
+ const hideDailyLimitBtn = screen.getByRole('button', {
+ name: 'No',
+ });
+ await userEvent.click(hideDailyLimitBtn);
+ expect(screen.queryByTestId('dt_daily_limit_modal')).not.toBeInTheDocument();
+ });
+ it('should render the correct default values', () => {
+ mockAdvertiserStatsProp = {
+ // @ts-expect-error To clear the mocked values and assert the default values
+ advertiserStats: {},
+ isLoading: false,
+ };
+ render( , { wrapper });
+ const availableBalanceNode = screen.getByTestId('dt_available_balance_amount');
+ expect(within(availableBalanceNode).getByText('0.00 USD')).toBeInTheDocument();
+ const dailyBuyLimitNode = screen.getByTestId('dt_profile_balance_daily_buy_limit');
+ expect(within(dailyBuyLimitNode).getByText('0.00 USD')).toBeInTheDocument();
+ const availableBuyLimitNode = screen.getByTestId('dt_profile_balance_available_buy_limit');
+ expect(within(availableBuyLimitNode).getByText('0.00 USD')).toBeInTheDocument();
+
+ const dailySellLimitNode = screen.getByTestId('dt_profile_balance_daily_sell_limit');
+ expect(within(dailySellLimitNode).getByText('0.00 USD')).toBeInTheDocument();
+ const dailyAvailableSellLimit = screen.getByTestId('dt_profile_balance_available_sell_limit');
+ expect(within(dailyAvailableSellLimit).getByText('0.00 USD')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/ProfileContent/ProfileBalance/index.ts b/src/components/ProfileContent/ProfileBalance/index.ts
new file mode 100644
index 00000000..203f4be1
--- /dev/null
+++ b/src/components/ProfileContent/ProfileBalance/index.ts
@@ -0,0 +1 @@
+export { default as ProfileBalance } from './ProfileBalance';
diff --git a/src/components/ProfileContent/ProfileContent.scss b/src/components/ProfileContent/ProfileContent.scss
new file mode 100644
index 00000000..e650d7ba
--- /dev/null
+++ b/src/components/ProfileContent/ProfileContent.scss
@@ -0,0 +1,15 @@
+.p2p-profile-content {
+ display: flex;
+ flex-direction: column;
+ padding: 2.4rem;
+ gap: 4.8rem;
+ border-radius: 0.8rem;
+ border: 0.1rem solid #e6e9e9;
+
+ @include mobile {
+ border: none;
+ border-radius: 0;
+ width: 100vw;
+ border-bottom: 0.1rem solid #e6e9e9;
+ }
+}
diff --git a/src/components/ProfileContent/ProfileContent.tsx b/src/components/ProfileContent/ProfileContent.tsx
new file mode 100644
index 00000000..95c94fef
--- /dev/null
+++ b/src/components/ProfileContent/ProfileContent.tsx
@@ -0,0 +1,32 @@
+import { useDevice } from '@deriv-com/ui';
+
+import { AdvertiserName, AdvertiserNameToggle } from '@/components';
+import { useAdvertiserStats } from '@/hooks/custom-hooks';
+import { getCurrentRoute } from '@/utils';
+
+import { ProfileBalance } from './ProfileBalance';
+import { ProfileStats } from './ProfileStats';
+
+import './ProfileContent.scss';
+
+type TProfileContentProps = {
+ id?: string;
+};
+
+const ProfileContent = ({ id }: TProfileContentProps) => {
+ const { isMobile } = useDevice();
+ const { data } = useAdvertiserStats(id);
+ const isMyProfile = getCurrentRoute() === 'my-profile';
+
+ return (
+ <>
+
+ {isMobile && isMyProfile && }
+ >
+ );
+};
+
+export default ProfileContent;
diff --git a/src/components/ProfileContent/ProfileDailyLimit/ProfileDailyLimit.scss b/src/components/ProfileContent/ProfileDailyLimit/ProfileDailyLimit.scss
new file mode 100644
index 00000000..2d75228c
--- /dev/null
+++ b/src/components/ProfileContent/ProfileDailyLimit/ProfileDailyLimit.scss
@@ -0,0 +1,10 @@
+.p2p-profile-daily-limit {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 1.4rem;
+
+ & span {
+ flex: 1;
+ }
+}
diff --git a/src/components/ProfileContent/ProfileDailyLimit/ProfileDailyLimit.tsx b/src/components/ProfileContent/ProfileDailyLimit/ProfileDailyLimit.tsx
new file mode 100644
index 00000000..24505f5d
--- /dev/null
+++ b/src/components/ProfileContent/ProfileDailyLimit/ProfileDailyLimit.tsx
@@ -0,0 +1,49 @@
+import React, { useState } from 'react';
+
+import { useActiveAccount } from '@deriv/api-v2';
+import { Button, Text } from '@deriv-com/ui';
+
+import { DailyLimitModal } from '@/components/Modals';
+import { useAdvertiserStats, useDevice } from '@/hooks/custom-hooks';
+
+import './ProfileDailyLimit.scss';
+
+const ProfileDailyLimit = () => {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const { isMobile } = useDevice();
+ const { data: advertiserStats } = useAdvertiserStats();
+ const { data: activeAccount } = useActiveAccount();
+
+ return (
+ <>
+
+
+ Want to increase your daily limits to{' '}
+
+ {advertiserStats?.daily_buy_limit} {activeAccount?.currency || 'USD'}{' '}
+ {' '}
+ (buy) and{' '}
+
+ {advertiserStats?.daily_sell_limit} {activeAccount?.currency || 'USD'}{' '}
+ {' '}
+ (sell)?
+
+ setIsModalOpen(true)}
+ size='sm'
+ textSize={isMobile ? 'sm' : 'xs'}
+ variant='ghost'
+ >
+ Increase my limits
+
+
+ setIsModalOpen(false)}
+ />
+ >
+ );
+};
+
+export default ProfileDailyLimit;
diff --git a/src/components/ProfileContent/ProfileDailyLimit/__tests__/ProfileDailyLimit.spec.tsx b/src/components/ProfileContent/ProfileDailyLimit/__tests__/ProfileDailyLimit.spec.tsx
new file mode 100644
index 00000000..a87e7a4a
--- /dev/null
+++ b/src/components/ProfileContent/ProfileDailyLimit/__tests__/ProfileDailyLimit.spec.tsx
@@ -0,0 +1,62 @@
+import { APIProvider, AuthProvider } from '@deriv/api-v2';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import ProfileDailyLimit from '../ProfileDailyLimit';
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+
+
+ {children}
+
+
+);
+
+const mockUseAdvertiserStats = {
+ data: {
+ daily_buy_limit: 100,
+ daily_sell_limit: 200,
+ },
+ isLoading: true,
+};
+
+jest.mock('@/hooks/useDevice', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ isMobile: false,
+ })),
+}));
+jest.mock('@/hooks/useAdvertiserStats', () => ({
+ __esModule: true,
+ default: jest.fn(() => mockUseAdvertiserStats),
+}));
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ useActiveAccount: jest.fn(() => ({
+ currency: 'USD',
+ })),
+}));
+
+describe('ProfileDailyLimit', () => {
+ it('should render the correct limits message', () => {
+ render( , { wrapper });
+ const tokens = ['Want to increase your daily limits to (buy) and (sell)?', '100 USD ', '200 USD '];
+
+ expect(
+ screen.getByText((content, element) => {
+ return element?.tagName.toLowerCase() === 'span' && tokens.includes(content.trim());
+ })
+ ).toBeInTheDocument();
+ });
+ it('should render limits modal when requested to increase limits', async () => {
+ render( , { wrapper });
+ const increaseLimitsBtn = screen.getByRole('button', {
+ name: 'Increase my limits',
+ });
+ expect(screen.queryByTestId('dt_daily_limit_modal')).not.toBeInTheDocument();
+ await userEvent.click(increaseLimitsBtn);
+ expect(screen.getByTestId('dt_daily_limit_modal')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/ProfileContent/ProfileDailyLimit/index.ts b/src/components/ProfileContent/ProfileDailyLimit/index.ts
new file mode 100644
index 00000000..b39fda0d
--- /dev/null
+++ b/src/components/ProfileContent/ProfileDailyLimit/index.ts
@@ -0,0 +1 @@
+export { default as ProfileDailyLimit } from './ProfileDailyLimit';
diff --git a/src/components/ProfileContent/ProfileStats/ProfileStats.scss b/src/components/ProfileContent/ProfileStats/ProfileStats.scss
new file mode 100644
index 00000000..888a2b19
--- /dev/null
+++ b/src/components/ProfileContent/ProfileStats/ProfileStats.scss
@@ -0,0 +1,32 @@
+.p2p-profile-stats {
+ display: grid;
+ grid-template-rows: 1fr 1fr;
+ row-gap: 5rem;
+
+ @include mobile {
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: none;
+ column-gap: 3rem;
+ row-gap: 2rem;
+ }
+
+ &__item {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ column-gap: 2rem;
+
+ @include mobile {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+ }
+
+ &-stat {
+ @include desktop {
+ &:not(:last-child) {
+ border-right: 0.1rem solid #ededed;
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/ProfileContent/ProfileStats/ProfileStats.tsx b/src/components/ProfileContent/ProfileStats/ProfileStats.tsx
new file mode 100644
index 00000000..cf36be76
--- /dev/null
+++ b/src/components/ProfileContent/ProfileStats/ProfileStats.tsx
@@ -0,0 +1,81 @@
+import React, { useMemo } from 'react';
+import clsx from 'clsx';
+import { TAdvertiserStats } from 'types';
+import { Text, useDevice } from '@deriv-com/ui';
+import './ProfileStats.scss';
+
+const ProfileStats = ({ advertiserStats }: { advertiserStats: Partial }) => {
+ const { isMobile } = useDevice();
+
+ const advertiserStatsList = useMemo(() => {
+ if (!advertiserStats) return [];
+
+ const {
+ averagePayTime,
+ averageReleaseTime,
+ buyCompletionRate,
+ buyOrdersCount,
+ sellCompletionRate,
+ sellOrdersCount,
+ tradePartners,
+ tradeVolume,
+ } = advertiserStats;
+
+ return [
+ {
+ text: 'Buy completion 30d',
+ value: buyCompletionRate && buyCompletionRate > 0 ? `${buyCompletionRate}% (${buyOrdersCount})` : '-',
+ },
+ {
+ text: 'Sell completion 30d',
+ value:
+ sellCompletionRate && sellCompletionRate > 0 ? `${sellCompletionRate}% (${sellOrdersCount})` : '-',
+ },
+ { text: 'Trade volume 30d', value: `${tradeVolume ? tradeVolume.toFixed(2) : '0.00'} USD` },
+ { text: 'Avg pay time 30d', value: averagePayTime !== -1 ? `${averagePayTime} min` : '-' },
+ { text: 'Avg release time 30d', value: averageReleaseTime !== -1 ? `${averageReleaseTime} min` : '-' },
+ { text: 'Trade partners', value: tradePartners },
+ ];
+ }, [advertiserStats]);
+
+ return (
+
+
+ {advertiserStatsList.slice(0, 3).map(stat => (
+
+
+ {stat.text}
+
+
+ {stat.value}
+
+
+ ))}
+
+
+ {advertiserStatsList.slice(-3).map(stat => (
+
+
+ {stat.text}
+
+
+ {stat.value}
+
+
+ ))}
+
+
+ );
+};
+
+export default ProfileStats;
diff --git a/src/components/ProfileContent/ProfileStats/__tests__/ProfileStats.spec.tsx b/src/components/ProfileContent/ProfileStats/__tests__/ProfileStats.spec.tsx
new file mode 100644
index 00000000..9bd30576
--- /dev/null
+++ b/src/components/ProfileContent/ProfileStats/__tests__/ProfileStats.spec.tsx
@@ -0,0 +1,72 @@
+import { render, screen } from '@testing-library/react';
+
+import ProfileStats from '../ProfileStats';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn(() => ({ isMobile: false })),
+}));
+
+let mockProps = {
+ advertiserStats: {
+ averagePayTime: -1,
+ averageReleaseTime: -1,
+ buyCompletionRate: 0,
+ buyOrdersCount: 0,
+ sellCompletionRate: 0,
+ sellOrdersCount: 0,
+ tradePartners: 0,
+ tradeVolume: 0,
+ },
+};
+
+describe(' ', () => {
+ it('should render no results if advertiserStats is empty', () => {
+ render( );
+
+ expect(screen.queryByText('Buy completion 30d')).not.toBeInTheDocument();
+ expect(screen.queryByText('Sell completion 30d')).not.toBeInTheDocument();
+ expect(screen.queryByText('Trade volume 30d')).not.toBeInTheDocument();
+ expect(screen.queryByText('0.00 USD')).not.toBeInTheDocument();
+ expect(screen.queryByText('Avg pay time 30d')).not.toBeInTheDocument();
+ expect(screen.queryByText('Avg release time 30d')).not.toBeInTheDocument();
+ expect(screen.queryByText('Trade partners')).not.toBeInTheDocument();
+ });
+
+ it('should render the ProfileStats component with empty results', () => {
+ render( );
+
+ expect(screen.getByText('Buy completion 30d')).toBeInTheDocument();
+ expect(screen.getByText('Sell completion 30d')).toBeInTheDocument();
+ expect(screen.getByText('Trade volume 30d')).toBeInTheDocument();
+ expect(screen.getByText('0.00 USD')).toBeInTheDocument();
+ expect(screen.getByText('Avg pay time 30d')).toBeInTheDocument();
+ expect(screen.getByText('Avg release time 30d')).toBeInTheDocument();
+ expect(screen.getByText('Trade partners')).toBeInTheDocument();
+ expect(screen.getByText('0')).toBeInTheDocument();
+ expect(screen.getAllByText('-')).toHaveLength(4);
+ });
+
+ it('should render the ProfileStats component with non-empty results', () => {
+ mockProps = {
+ advertiserStats: {
+ averagePayTime: 10,
+ averageReleaseTime: 20,
+ buyCompletionRate: 50,
+ buyOrdersCount: 10,
+ sellCompletionRate: 60,
+ sellOrdersCount: 20,
+ tradePartners: 30,
+ tradeVolume: 100,
+ },
+ };
+ render( );
+
+ expect(screen.getByText('50% (10)')).toBeInTheDocument();
+ expect(screen.getByText('60% (20)')).toBeInTheDocument();
+ expect(screen.getByText('100.00 USD')).toBeInTheDocument();
+ expect(screen.getByText('10 min')).toBeInTheDocument();
+ expect(screen.getByText('20 min')).toBeInTheDocument();
+ expect(screen.getByText('30')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/ProfileContent/ProfileStats/index.ts b/src/components/ProfileContent/ProfileStats/index.ts
new file mode 100644
index 00000000..65e0b77c
--- /dev/null
+++ b/src/components/ProfileContent/ProfileStats/index.ts
@@ -0,0 +1 @@
+export { default as ProfileStats } from './ProfileStats';
diff --git a/src/components/ProfileContent/__tests__/ProfileContent.spec.tsx b/src/components/ProfileContent/__tests__/ProfileContent.spec.tsx
new file mode 100644
index 00000000..174cb5e0
--- /dev/null
+++ b/src/components/ProfileContent/__tests__/ProfileContent.spec.tsx
@@ -0,0 +1,73 @@
+import { APIProvider, AuthProvider } from '@deriv/api-v2';
+import { useDevice } from '@deriv-com/ui';
+import { render, screen } from '@testing-library/react';
+
+import ProfileContent from '../ProfileContent';
+
+jest.mock('@/components', () => ({
+ ...jest.requireActual('@/components'),
+ AdvertiserName: () => AdvertiserName
,
+ AdvertiserNameToggle: () => AdvertiserNameToggle
,
+}));
+
+jest.mock('../ProfileBalance', () => ({
+ ProfileBalance: () => ProfileBalance
,
+}));
+
+jest.mock('../ProfileStats', () => ({
+ ProfileStats: () => ProfileStats
,
+}));
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn(() => ({ isMobile: false })),
+}));
+
+const mockUseDevice = useDevice as jest.Mock;
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+ {children}
+
+);
+
+describe('ProfileContent', () => {
+ it('should render the advertiser name and profile balance if location is my-profile', () => {
+ Object.defineProperty(window, 'location', {
+ value: {
+ href: 'https://app.deriv.com/cashier/p2p-v2/my-profile',
+ },
+ writable: true,
+ });
+ render( , { wrapper });
+ expect(screen.getByText('AdvertiserName')).toBeInTheDocument();
+ expect(screen.getByText('ProfileBalance')).toBeInTheDocument();
+ });
+
+ it('should render the advertiser name and profile stats if location is advertiser', () => {
+ Object.defineProperty(window, 'location', {
+ value: {
+ href: 'https://app.deriv.com/cashier/p2p-v2/advertiser',
+ },
+ writable: true,
+ });
+ render( , { wrapper });
+ expect(screen.getByText('AdvertiserName')).toBeInTheDocument();
+ expect(screen.getByText('ProfileStats')).toBeInTheDocument();
+ });
+
+ it('should render the AdvertiserNameToggle if isMobile is true and location is my-profile', () => {
+ Object.defineProperty(window, 'location', {
+ value: {
+ href: 'https://app.deriv.com/cashier/p2p-v2/my-profile',
+ },
+ writable: true,
+ });
+
+ mockUseDevice.mockReturnValue({
+ isMobile: true,
+ });
+ render( , { wrapper });
+ expect(screen.getByText('AdvertiserNameToggle')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/ProfileContent/index.ts b/src/components/ProfileContent/index.ts
new file mode 100644
index 00000000..175dad25
--- /dev/null
+++ b/src/components/ProfileContent/index.ts
@@ -0,0 +1 @@
+export { default as ProfileContent } from './ProfileContent';
diff --git a/src/components/RadioGroup/Radio.tsx b/src/components/RadioGroup/Radio.tsx
new file mode 100644
index 00000000..c56c66a8
--- /dev/null
+++ b/src/components/RadioGroup/Radio.tsx
@@ -0,0 +1,65 @@
+import React, { ChangeEvent, PropsWithChildren, useEffect, useState } from 'react';
+import clsx from 'clsx';
+import { Text } from '@deriv-com/ui';
+import './RadioGroup.scss';
+
+type TRadio = {
+ className?: string;
+ classNameLabel?: string;
+ defaultChecked: boolean;
+ id: string;
+ onChange: (e: ChangeEvent) => void;
+};
+
+const Radio = ({
+ children,
+ className,
+ classNameLabel,
+ defaultChecked,
+ id,
+ onChange, // This needs to be here so it's not included in `otherProps`
+ ...otherProps
+}: PropsWithChildren) => {
+ const [checked, setChecked] = useState(defaultChecked);
+
+ /*
+ * We use useEffect here to tell the Radio component to update itself
+ * when it's no longer selected
+ * This is because we're handling the state of what's selected in RadioGroup with the defaultChecked prop
+ */
+ useEffect(() => {
+ setChecked(defaultChecked);
+ }, [defaultChecked]);
+
+ const onInputChange = (e: ChangeEvent) => {
+ setChecked(e.target.checked);
+ onChange(e);
+ };
+
+ return (
+
+
+
+
+ {children}
+
+
+ );
+};
+
+export default Radio;
diff --git a/src/components/RadioGroup/RadioGroup.scss b/src/components/RadioGroup/RadioGroup.scss
new file mode 100644
index 00000000..020c7a24
--- /dev/null
+++ b/src/components/RadioGroup/RadioGroup.scss
@@ -0,0 +1,48 @@
+.p2p-radio-group {
+ display: flex;
+ margin-top: 1.6rem;
+ flex-direction: row;
+ align-items: center;
+ &__input {
+ display: none;
+ }
+ &__item {
+ display: flex;
+ @include typeface(--paragraph-left-normal-prominent);
+
+ cursor: pointer;
+ color: var(--text-general);
+ }
+ &__item:not(:last-child) {
+ margin-right: 1.6rem;
+ }
+ &__circle {
+ border: 2px solid var(--text-general);
+ border-radius: 50%;
+ box-shadow: 0 0 1px 0 var(--shadow-menu);
+ min-width: 1.6rem;
+ height: 1.6rem;
+ transition: all 0.3s ease-in-out;
+ margin-right: 0.8rem;
+ align-self: center;
+
+ &--disabled {
+ border-color: var(--border-disabled);
+ }
+ &--selected {
+ border-width: 4px;
+ border-color: var(--brand-red-coral);
+ }
+ &--error {
+ border-color: var(--text-less-prominent);
+ }
+ }
+ &__label {
+ &--disabled {
+ color: var(--text-disabled);
+ }
+ &--error {
+ color: var(--text-loss-danger);
+ }
+ }
+}
diff --git a/src/components/RadioGroup/RadioGroup.tsx b/src/components/RadioGroup/RadioGroup.tsx
new file mode 100644
index 00000000..3daf902f
--- /dev/null
+++ b/src/components/RadioGroup/RadioGroup.tsx
@@ -0,0 +1,109 @@
+import React, { ChangeEvent, ComponentProps, HTMLAttributes, PropsWithChildren, useEffect, useState } from 'react';
+import clsx from 'clsx';
+import { Text } from '@deriv-com/ui';
+import './RadioGroup.scss';
+
+type TItem = HTMLAttributes & {
+ disabled?: boolean;
+ hasError?: boolean;
+ hidden?: boolean;
+ id?: string;
+ label: string;
+ value: string;
+};
+type TItemWrapper = {
+ shouldWrapItems?: boolean;
+};
+type TRadioGroup = TItemWrapper & {
+ className?: string;
+ name: string;
+ onToggle: (e: ChangeEvent) => void;
+ required?: boolean;
+ selected: string;
+ textSize?: ComponentProps['size'];
+};
+
+const ItemWrapper = ({ children, shouldWrapItems }: PropsWithChildren) => {
+ if (shouldWrapItems) {
+ return {children}
;
+ }
+
+ return <>{children}>;
+};
+
+const RadioGroup = ({
+ children,
+ className,
+ name,
+ onToggle,
+ required,
+ selected,
+ shouldWrapItems,
+ textSize = 'lg',
+}: PropsWithChildren) => {
+ const [selectedOption, setSelectedOption] = useState(selected);
+
+ useEffect(() => {
+ setSelectedOption(selected);
+ }, [selected]);
+
+ const onChange = (e: ChangeEvent) => {
+ setSelectedOption(e.target.value);
+ onToggle(e);
+ };
+
+ return (
+
+ {Array.isArray(children) &&
+ children
+ .filter(item => !item.props.hidden)
+ .map(item => (
+
+
+
+
+
+ {item.props.label}
+
+
+
+ ))}
+
+ );
+};
+
+const Item = ({ children, hidden = false, ...props }: PropsWithChildren) => (
+
+ {children}
+
+);
+
+RadioGroup.Item = Item;
+
+export default RadioGroup;
diff --git a/src/components/RadioGroup/index.ts b/src/components/RadioGroup/index.ts
new file mode 100644
index 00000000..a93a1d6b
--- /dev/null
+++ b/src/components/RadioGroup/index.ts
@@ -0,0 +1 @@
+export { default as RadioGroup } from './RadioGroup';
diff --git a/src/components/Search/Search.scss b/src/components/Search/Search.scss
new file mode 100644
index 00000000..0cf7bdb2
--- /dev/null
+++ b/src/components/Search/Search.scss
@@ -0,0 +1,30 @@
+.p2p-search {
+ width: 100%;
+ background-color: #fff;
+
+ .deriv-input {
+ padding: 6px 8px;
+ font-size: 1.4rem;
+
+ &__container {
+ width: 100%;
+ }
+
+ &__field {
+ margin-left: 0.8rem;
+
+ &:not(:placeholder-shown) ~ label,
+ &:focus ~ label {
+ display: none;
+ }
+ }
+
+ &__helper-message {
+ display: none;
+ }
+
+ &__label {
+ left: 3rem;
+ }
+ }
+}
diff --git a/src/components/Search/Search.tsx b/src/components/Search/Search.tsx
new file mode 100644
index 00000000..f28404e8
--- /dev/null
+++ b/src/components/Search/Search.tsx
@@ -0,0 +1,44 @@
+import React, { useCallback } from 'react';
+import clsx from 'clsx';
+import { LabelPairedSearchMdRegularIcon } from '@deriv/quill-icons';
+import { Input } from '@deriv-com/ui';
+import './Search.scss';
+
+type TSearchProps = {
+ delayTimer?: number;
+ hideBorder?: boolean;
+ name: string;
+ onSearch: (value: string) => void;
+ placeholder: string;
+};
+
+//TODO: replace the component with deriv shared component
+const Search = ({ delayTimer = 500, hideBorder = false, name, onSearch, placeholder }: TSearchProps) => {
+ const debounce = (func: (value: string) => void, delay: number) => {
+ let timer: ReturnType;
+ return (value: string) => {
+ clearTimeout(timer);
+ timer = setTimeout(() => func(value), delay);
+ };
+ };
+
+ const debouncedOnSearch = useCallback(debounce(onSearch, delayTimer), [onSearch]);
+
+ return (
+ debouncedOnSearch((event.target as HTMLInputElement).value)}
+ role='search'
+ >
+ }
+ name={name}
+ type='search'
+ />
+
+ );
+};
+
+export default Search;
diff --git a/src/components/Search/index.ts b/src/components/Search/index.ts
new file mode 100644
index 00000000..1d53a98d
--- /dev/null
+++ b/src/components/Search/index.ts
@@ -0,0 +1 @@
+export { default as Search } from './Search';
diff --git a/src/components/StarRating/StarRating.scss b/src/components/StarRating/StarRating.scss
new file mode 100644
index 00000000..e7ae5a36
--- /dev/null
+++ b/src/components/StarRating/StarRating.scss
@@ -0,0 +1,6 @@
+.p2p-star-rating {
+ & svg {
+ transform: scale(0.7);
+ margin-right: -0.3rem;
+ }
+}
diff --git a/src/components/StarRating/StarRating.tsx b/src/components/StarRating/StarRating.tsx
new file mode 100644
index 00000000..e246d71a
--- /dev/null
+++ b/src/components/StarRating/StarRating.tsx
@@ -0,0 +1,47 @@
+import React, { memo } from 'react';
+import { Rating } from 'react-simple-star-rating';
+import { LabelPairedStarLgFillIcon, LabelPairedStarLgRegularIcon } from '@deriv/quill-icons';
+import './StarRating.scss';
+
+type TStarRatingProps = {
+ allowHalfIcon?: boolean;
+ allowHover?: boolean;
+ initialValue?: number;
+ isReadonly?: boolean;
+ onClick?: (rate: number) => void;
+ ratingValue: number;
+ starsScale?: number;
+};
+
+const StarRating = ({
+ allowHalfIcon = false,
+ allowHover = false,
+ initialValue = 0,
+ isReadonly = false,
+ onClick,
+ ratingValue,
+ starsScale = 1,
+}: TStarRatingProps) => {
+ // Converts initial value to be in the form of x.0 or x.5
+ // to show full and half stars only
+ const fractionalizedValue = Math.round(initialValue * 2) / 2;
+
+ return (
+ }
+ fullIcon={ }
+ iconsCount={5}
+ initialValue={ratingValue}
+ onClick={onClick}
+ ratingValue={fractionalizedValue}
+ readonly={isReadonly}
+ size={12}
+ style={{ transform: `scale(${starsScale})`, transformOrigin: 'left' }}
+ />
+ );
+};
+
+export default memo(StarRating);
diff --git a/src/components/StarRating/__tests__/StarRating.spec.tsx b/src/components/StarRating/__tests__/StarRating.spec.tsx
new file mode 100644
index 00000000..12406be3
--- /dev/null
+++ b/src/components/StarRating/__tests__/StarRating.spec.tsx
@@ -0,0 +1,11 @@
+import { render, screen } from '@testing-library/react';
+
+import StarRating from '../StarRating';
+
+describe('StarRating', () => {
+ it('should render the passed filled/empty star icons', () => {
+ render( );
+ expect(screen.queryAllByTestId('dt_star_rating_empty_star')).toHaveLength(5);
+ expect(screen.queryAllByTestId('dt_star_rating_full_star')).toHaveLength(5);
+ });
+});
diff --git a/src/components/StarRating/index.ts b/src/components/StarRating/index.ts
new file mode 100644
index 00000000..796aca89
--- /dev/null
+++ b/src/components/StarRating/index.ts
@@ -0,0 +1 @@
+export { default as StarRating } from './StarRating';
diff --git a/src/components/Table/Table.scss b/src/components/Table/Table.scss
new file mode 100644
index 00000000..9c56df64
--- /dev/null
+++ b/src/components/Table/Table.scss
@@ -0,0 +1,51 @@
+.p2p-table {
+ &__content {
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+
+ @include mobile {
+ height: 100%;
+ }
+
+ &-row {
+ &:not(:last-child) {
+ border-bottom: 1px solid var(--general-section-1);
+ }
+ }
+ }
+
+ scrollbar-width: thin; /* For Firefox */
+ &::-webkit-scrollbar {
+ scrollbar-width: thin;
+ width: 5px;
+ height: 5px;
+ background-color: transparent;
+ border-radius: 10px;
+ }
+
+ &::-webkit-scrollbar-track {
+ scrollbar-width: thin;
+ background-color: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ display: none;
+ }
+
+ &:hover {
+ &::-webkit-scrollbar-thumb {
+ scrollbar-width: thin;
+ display: unset;
+ border-radius: 10px;
+ background-color: var(--state-active);
+ }
+ }
+
+ &__header {
+ display: grid;
+ border-bottom: 2px solid var(--general-section-1);
+ padding: 1.6rem;
+ }
+}
diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx
new file mode 100644
index 00000000..0c6b6e27
--- /dev/null
+++ b/src/components/Table/Table.tsx
@@ -0,0 +1,91 @@
+import React, { memo, useLayoutEffect, useRef, useState } from 'react';
+import clsx from 'clsx';
+import { useDevice, useFetchMore } from '@/hooks/custom-hooks';
+import { Text } from '@deriv-com/ui';
+import { ColumnDef, getCoreRowModel, getGroupedRowModel, GroupingState, useReactTable } from '@tanstack/react-table';
+import './Table.scss';
+
+type TProps = {
+ columns?: ColumnDef[];
+ data: T[];
+ emptyDataMessage?: string;
+ groupBy?: GroupingState;
+ isFetching: boolean;
+ loadMoreFunction: () => void;
+ renderHeader?: (data: string) => JSX.Element;
+ rowRender: (data: T) => JSX.Element;
+ tableClassname: string;
+};
+
+const Table = ({
+ columns = [],
+ data,
+ emptyDataMessage,
+ isFetching,
+ loadMoreFunction,
+ renderHeader = () =>
,
+ rowRender,
+ tableClassname,
+}: TProps) => {
+ const { isDesktop } = useDevice();
+ const table = useReactTable({
+ columns,
+ data,
+ getCoreRowModel: getCoreRowModel(),
+ getGroupedRowModel: getGroupedRowModel(),
+ });
+
+ 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({
+ isFetching,
+ loadMore: loadMoreFunction,
+ ref: tableContainerRef,
+ });
+
+ return (
+
+ {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%' }}
+ >
+ {data && data.length > 0 ? (
+ table.getRowModel().rows.map(row => (
+
+ {rowRender(row.original)}
+
+ ))
+ ) : (
+
+ {emptyDataMessage}
+
+ )}
+
+
+ );
+};
+
+export default memo(Table);
diff --git a/src/components/Table/index.ts b/src/components/Table/index.ts
new file mode 100644
index 00000000..56abcd12
--- /dev/null
+++ b/src/components/Table/index.ts
@@ -0,0 +1 @@
+export { default as Table } from './Table';
diff --git a/src/components/TextArea/TextArea.scss b/src/components/TextArea/TextArea.scss
new file mode 100644
index 00000000..adf63c7f
--- /dev/null
+++ b/src/components/TextArea/TextArea.scss
@@ -0,0 +1,69 @@
+.p2p-textarea {
+ position: relative;
+ width: 100%;
+
+ & textarea {
+ resize: none;
+ width: 100%;
+ height: 9.6rem;
+ border-radius: 0.4rem;
+ border: 1px solid #d6dadb;
+ outline: none;
+ padding: 1rem 1.6rem;
+ font-size: 1.4rem;
+
+ &:focus {
+ border: 1px solid #85acb0;
+ }
+
+ &::placeholder {
+ color: #999999;
+ }
+ &:focus + label span {
+ color: #85acb0;
+ }
+ &:focus + label,
+ &[data-has-value='true'] + label {
+ transform: translate(0, -1.4rem) scale(0.75);
+ background-color: #ffffff;
+ }
+ &:not(:focus)[data-has-value='true'] + label {
+ & span {
+ color: #333;
+ }
+ }
+ &:has(~ label)::placeholder {
+ color: transparent;
+ }
+ }
+
+ & label {
+ display: block;
+ position: absolute;
+ left: 1.6rem;
+ top: 1rem;
+ transition: all 0.2s ease;
+ transform-origin: left top;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ &--error {
+ & textarea,
+ & textarea:focus {
+ border: 1px solid #ec3f3f;
+ & + label span {
+ color: #ec3f3f;
+ }
+ }
+ }
+
+ &__footer {
+ display: flex;
+ line-height: 1.5;
+
+ & span {
+ margin-left: auto;
+ }
+ }
+}
diff --git a/src/components/TextArea/TextArea.tsx b/src/components/TextArea/TextArea.tsx
new file mode 100644
index 00000000..bd2a3701
--- /dev/null
+++ b/src/components/TextArea/TextArea.tsx
@@ -0,0 +1,69 @@
+import React, { HtmlHTMLAttributes, useState } from 'react';
+import clsx from 'clsx';
+import { Text } from '@deriv-com/ui';
+import './TextArea.scss';
+
+type TTextAreaProps = HtmlHTMLAttributes & {
+ hint?: string;
+ isInvalid?: boolean;
+ label?: string;
+ maxLength?: number;
+ onChange: React.ChangeEventHandler;
+ placeholder?: string;
+ shouldShowCounter?: boolean;
+ testId?: string;
+ value?: string;
+};
+const TextArea = ({
+ hint,
+ isInvalid = false,
+ label,
+ maxLength,
+ onChange,
+ placeholder,
+ shouldShowCounter = false,
+ testId,
+ value,
+}: TTextAreaProps) => {
+ const [currentValue, setCurrentValue] = useState(value);
+
+ return (
+
+
{
+ setCurrentValue(event.target.value);
+ onChange?.(event);
+ }}
+ placeholder={placeholder}
+ value={currentValue}
+ />
+ {label && (
+
+
+ {label}
+
+
+ )}
+
+ {hint && (
+
+ {hint}
+
+ )}
+ {shouldShowCounter && (
+
+ {currentValue?.length || 0}/{maxLength}
+
+ )}
+
+
+ );
+};
+
+export default TextArea;
diff --git a/src/components/TextArea/index.ts b/src/components/TextArea/index.ts
new file mode 100644
index 00000000..a8b97019
--- /dev/null
+++ b/src/components/TextArea/index.ts
@@ -0,0 +1 @@
+export { default as TextArea } from './TextArea';
diff --git a/src/components/TextField/HelperMessage.tsx b/src/components/TextField/HelperMessage.tsx
new file mode 100644
index 00000000..017dcf6c
--- /dev/null
+++ b/src/components/TextField/HelperMessage.tsx
@@ -0,0 +1,45 @@
+import React, { ComponentProps, InputHTMLAttributes, memo } from 'react';
+import { Text } from '@deriv-com/ui';
+
+export type HelperMessageProps = {
+ inputValue?: InputHTMLAttributes['value'];
+ isError?: boolean;
+ maxLength?: InputHTMLAttributes['maxLength'];
+ message?: string;
+ messageVariant?: 'error' | 'general' | 'warning';
+};
+
+const HelperMessage: React.FC = memo(
+ ({ inputValue, isError, maxLength, message, messageVariant = 'general' }) => {
+ const HelperMessageColors: Record['color']> = {
+ error: 'error',
+ general: 'less-prominent',
+ warning: 'warning',
+ };
+
+ return (
+
+ {message && (
+
+
+ {message}
+
+
+ )}
+ {maxLength && (
+
+
+ {inputValue?.toString().length || 0} / {maxLength}
+
+
+ )}
+
+ );
+ }
+);
+
+HelperMessage.displayName = 'HelperMessage';
+export default HelperMessage;
diff --git a/src/components/TextField/TextField.scss b/src/components/TextField/TextField.scss
new file mode 100644
index 00000000..ed780c9f
--- /dev/null
+++ b/src/components/TextField/TextField.scss
@@ -0,0 +1,143 @@
+.p2p-textfield {
+ min-width: 12rem;
+ width: 100%;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+
+ &--error {
+ .p2p-textfield__box,
+ .p2p-textfield__box:hover {
+ border: 1px solid var(--status-light-danger, #ec3f3f);
+ }
+
+ .p2p-textfield__box:has(.p2p-textfield__field:focus) {
+ border: 1px solid var(--brand-blue, #ec3f3f);
+ }
+
+ .p2p-textfield__box:has(.p2p-textfield__field:valid) {
+ border: 1px solid var(--brand-blue, #ec3f3f);
+ }
+
+ .p2p-textfield__label {
+ color: #ec3f3f;
+ }
+
+ .p2p-textfield__field:focus ~ .p2p-textfield__label {
+ color: #ec3f3f;
+ }
+ }
+
+ &--disabled {
+ & .p2p-textfield__box,
+ .p2p-textfield__box:hover {
+ border: 1px solid var(--system-light-5-active-background, #eaeced);
+
+ & input {
+ color: var(--system-light-5-active-background, #999);
+ background: transparent;
+ }
+ }
+ & .p2p-textfield__box:has(.p2p-textfield__field:focus) {
+ border: 1px solid var(--system-light-5-active-background, #eaeced);
+ }
+ & .p2p-textfield__field {
+ background: inherit;
+ }
+ }
+
+ &__box {
+ height: 4rem;
+ width: 100%;
+ border-radius: 0.4rem;
+ padding: 1rem 1.6rem;
+ border: 1px solid var(--system-light-5-active-background, #d6dadb);
+ display: inline-flex;
+ align-items: center;
+ transition: border-color 0.2s;
+
+ &:hover {
+ border-color: var(--system-light-3-less-prominent-text, #999);
+ }
+ }
+
+ &__box:has(&__field:focus) {
+ border: 1px solid var(--brand-blue, #85acb0);
+ }
+
+ &__box:has(&__field:invalid) {
+ border: 1px solid var(--status-light-danger, #ec3f3f);
+ }
+
+ &__field {
+ min-width: 0;
+ font-family: inherit;
+ outline: 0;
+ font-size: 1.4rem;
+ color: var(--system-light-2-general-text, #333);
+ transition: border-color 0.2s;
+ flex: 1;
+ }
+
+ &__field::placeholder {
+ color: transparent;
+ }
+
+ &__field:placeholder-shown ~ &__label {
+ font-size: 1.4rem;
+ cursor: text;
+ top: 2rem;
+ padding: 0;
+ }
+
+ label,
+ &__field:focus ~ &__label {
+ position: absolute;
+ top: 0%;
+ transform: translateY(-50%);
+ display: block;
+ transition: 0.2s;
+ font-size: 1rem;
+ color: var(--system-light-3-less-prominent-text, #999);
+ background: var(--system-light-8-primary-background, #fff);
+ padding-inline: 0.4rem;
+ left: 1.6rem;
+ -webkit-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ }
+
+ &__field:focus ~ &__label {
+ color: var(--brand-blue, #85acb0);
+ }
+
+ &__field:invalid ~ &__label {
+ color: var(--status-light-danger, #ec3f3f);
+ }
+
+ &__icon {
+ &-left {
+ margin-right: 0.8rem;
+ }
+
+ &-right {
+ margin-left: 1.6rem;
+ }
+ }
+
+ &__message-container {
+ height: 2rem;
+ padding: 0rem 0rem 0rem 1.6rem;
+ width: 100%;
+
+ &--maxchar {
+ float: right;
+ }
+
+ &--msg {
+ float: left;
+ text-align: left;
+ }
+ }
+}
diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx
new file mode 100644
index 00000000..657332c2
--- /dev/null
+++ b/src/components/TextField/TextField.tsx
@@ -0,0 +1,103 @@
+import React, { ChangeEvent, ComponentProps, forwardRef, Ref, useState } from 'react';
+import classNames from 'classnames';
+import HelperMessage, { HelperMessageProps } from './HelperMessage';
+import './TextField.scss';
+
+export interface TextFieldProps extends ComponentProps<'input'>, HelperMessageProps {
+ defaultValue?: string;
+ disabled?: boolean;
+ errorMessage?: string[] | string;
+ isInvalid?: boolean;
+ label?: string;
+ renderLeftIcon?: () => React.ReactNode;
+ renderRightIcon?: () => React.ReactNode;
+ shouldShowWarningMessage?: boolean;
+ showMessage?: boolean;
+}
+
+const TextField = forwardRef(
+ (
+ {
+ defaultValue = '',
+ disabled,
+ errorMessage,
+ isInvalid,
+ label,
+ maxLength,
+ message,
+ messageVariant = 'general',
+ name = 'textField',
+ onChange,
+ renderLeftIcon,
+ renderRightIcon,
+ shouldShowWarningMessage = false,
+ showMessage = false,
+ ...rest
+ }: TextFieldProps,
+ ref: Ref
+ ) => {
+ const [value, setValue] = useState(defaultValue);
+
+ const handleChange = (e: ChangeEvent) => {
+ const newValue = e.target.value;
+ setValue(newValue);
+ onChange?.(e);
+ };
+
+ return (
+
+
+ {typeof renderLeftIcon === 'function' && (
+
{renderLeftIcon()}
+ )}
+
+ {label && (
+
+ {label}
+
+ )}
+ {typeof renderRightIcon === 'function' && (
+
{renderRightIcon()}
+ )}
+
+
+ {showMessage && !isInvalid && (
+
+ )}
+ {errorMessage && (isInvalid || (!isInvalid && shouldShowWarningMessage)) && (
+
+ )}
+
+
+ );
+ }
+);
+
+TextField.displayName = 'TextField';
+export default TextField;
diff --git a/src/components/TextField/index.ts b/src/components/TextField/index.ts
new file mode 100644
index 00000000..97d7c656
--- /dev/null
+++ b/src/components/TextField/index.ts
@@ -0,0 +1,2 @@
+// TODO: Delete this component once @deriv-com/ui has it published
+export { default as TextField } from './TextField';
diff --git a/src/components/UserAvatar/UserAvatar.scss b/src/components/UserAvatar/UserAvatar.scss
new file mode 100644
index 00000000..1dc8886d
--- /dev/null
+++ b/src/components/UserAvatar/UserAvatar.scss
@@ -0,0 +1,13 @@
+.p2p-user-avatar {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: fit-content;
+ height: fit-content;
+
+ &__short-nickname {
+ position: absolute;
+ }
+}
diff --git a/src/components/UserAvatar/UserAvatar.tsx b/src/components/UserAvatar/UserAvatar.tsx
new file mode 100644
index 00000000..5e31c23b
--- /dev/null
+++ b/src/components/UserAvatar/UserAvatar.tsx
@@ -0,0 +1,52 @@
+import React, { memo } from 'react';
+import clsx from 'clsx';
+import { getShortNickname, TGenericSizes } from '@/utils';
+import { Text } from '@deriv-com/ui';
+import { OnlineStatusIcon } from '../OnlineStatus';
+import './UserAvatar.scss';
+
+type TUserAvatarProps = {
+ className?: string;
+ isOnline?: boolean;
+ nickname: string;
+ showOnlineStatus?: boolean;
+ size?: number;
+ textSize?: TGenericSizes;
+};
+
+const UserAvatar = memo(
+ ({
+ className,
+ isOnline = false,
+ nickname,
+ showOnlineStatus = false,
+ size = 32,
+ textSize = 'md',
+ }: TUserAvatarProps) => {
+ return (
+
+
+ {getShortNickname(nickname)}
+
+ {showOnlineStatus && }
+
+
+
+ {showOnlineStatus && }
+
+
+
+
+ );
+ }
+);
+
+UserAvatar.displayName = 'UserAvatar';
+export default UserAvatar;
diff --git a/src/components/UserAvatar/__tests__/UserAvatar.spec.tsx b/src/components/UserAvatar/__tests__/UserAvatar.spec.tsx
new file mode 100644
index 00000000..00b4c4a6
--- /dev/null
+++ b/src/components/UserAvatar/__tests__/UserAvatar.spec.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@testing-library/react';
+
+import UserAvatar from '../UserAvatar';
+
+describe('UserAvatar', () => {
+ it('should render the component correctly', () => {
+ render( );
+ expect(screen.getByTestId('dt_user_avatar')).toBeInTheDocument();
+ });
+ it('should show the nickname correctly for first name', () => {
+ render( );
+ expect(screen.getByText('JA')).toBeInTheDocument();
+ });
+ it('should show the username nickname correctly for full name', () => {
+ render( );
+ expect(screen.getByText('JA')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/UserAvatar/index.ts b/src/components/UserAvatar/index.ts
new file mode 100644
index 00000000..3cb50e03
--- /dev/null
+++ b/src/components/UserAvatar/index.ts
@@ -0,0 +1 @@
+export { default as UserAvatar } from './UserAvatar';
diff --git a/src/components/Verification/Verification.scss b/src/components/Verification/Verification.scss
new file mode 100644
index 00000000..2aed90fd
--- /dev/null
+++ b/src/components/Verification/Verification.scss
@@ -0,0 +1,21 @@
+.p2p-verification {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-top: 4rem;
+ width: 67.2rem;
+
+ @include mobile {
+ margin-top: 2rem;
+ padding: 2.4rem;
+ width: 100%;
+ }
+
+ &__text {
+ margin-bottom: 1.6rem;
+ }
+
+ &__icon {
+ margin-bottom: 3rem;
+ }
+}
diff --git a/src/components/Verification/Verification.tsx b/src/components/Verification/Verification.tsx
new file mode 100644
index 00000000..c4969c32
--- /dev/null
+++ b/src/components/Verification/Verification.tsx
@@ -0,0 +1,98 @@
+import { useHistory } from 'react-router-dom';
+
+import { DerivLightIcCashierSendEmailIcon } from '@deriv/quill-icons';
+import { Loader, Text } from '@deriv-com/ui';
+
+import { Checklist } from '@/components';
+import { useDevice, usePoiPoaStatus } from '@/hooks/custom-hooks';
+
+import './Verification.scss';
+
+const getPoiAction = (status: string | undefined) => {
+ switch (status) {
+ case 'pending':
+ return 'Identity verification in progress.';
+ case 'rejected':
+ return 'Identity verification failed. Please try again.';
+ case 'verified':
+ return 'Identity verification complete.';
+ default:
+ return 'Upload documents to verify your identity.';
+ }
+};
+
+const getPoaAction = (status: string | undefined) => {
+ switch (status) {
+ case 'pending':
+ return 'Address verification in progress.';
+ case 'rejected':
+ return 'Address verification failed. Please try again.';
+ case 'verified':
+ return 'Address verification complete.';
+ default:
+ return 'Upload documents to verify your address.';
+ }
+};
+
+const Verification = () => {
+ const { isMobile } = useDevice();
+ const history = useHistory();
+ const { data, isLoading } = usePoiPoaStatus();
+ const { isP2PPoaRequired, isPoaPending, isPoaVerified, isPoiPending, isPoiVerified, poaStatus, poiStatus } =
+ data || {};
+
+ const redirectToVerification = (route: string) => {
+ const search = window.location.search;
+ let updatedUrl = `${route}?ext_platform_url=/cashier/p2p`;
+
+ if (search) {
+ const urlParams = new URLSearchParams(search);
+ const updatedUrlParams = new URLSearchParams(updatedUrl);
+ urlParams.forEach((value, key) => updatedUrlParams.append(key, value));
+ updatedUrl = `${updatedUrl}&${urlParams.toString()}`;
+ }
+ history.push(updatedUrl);
+ };
+
+ const checklistItems = [
+ {
+ isDisabled: isPoiPending,
+ onClick: () => {
+ if (!isPoiVerified) redirectToVerification('/account/proof-of-identity');
+ },
+ status: isPoiVerified ? 'done' : 'action',
+ testId: 'dt_verification_poi_arrow_button',
+ text: getPoiAction(poiStatus),
+ },
+ ...(isP2PPoaRequired
+ ? [
+ {
+ isDisabled: isPoaPending,
+ onClick: () => {
+ if (!isPoaVerified) redirectToVerification('/account/proof-of-address');
+ },
+ status: isPoaVerified ? 'done' : 'action',
+ testId: 'dt_verification_poa_arrow_button',
+ text: getPoaAction(poaStatus),
+ },
+ ]
+ : []),
+ ];
+
+ if (isLoading) return ;
+
+ return (
+
+
+
+ Verify your P2P account
+
+
+ Verify your identity and address to use Deriv P2P.
+
+
+
+ );
+};
+
+export default Verification;
diff --git a/src/components/Verification/__tests__/Verification.spec.tsx b/src/components/Verification/__tests__/Verification.spec.tsx
new file mode 100644
index 00000000..60109a57
--- /dev/null
+++ b/src/components/Verification/__tests__/Verification.spec.tsx
@@ -0,0 +1,173 @@
+import { APIProvider, AuthProvider } from '@deriv/api-v2';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import Verification from '../Verification';
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+ {children}
+
+);
+
+let mockUsePoiPoaStatusData = {
+ data: {
+ isP2PPoaRequired: 1,
+ isPoaPending: false,
+ isPoaVerified: false,
+ isPoiPending: false,
+ isPoiVerified: false,
+ poaStatus: 'none',
+ poiStatus: 'none',
+ },
+ isLoading: true,
+};
+
+const mockHistoryPush = jest.fn();
+
+jest.mock('../../../hooks', () => ({
+ ...jest.requireActual('../../../hooks'),
+ usePoiPoaStatus: jest.fn(() => mockUsePoiPoaStatusData),
+}));
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useHistory: () => ({
+ push: mockHistoryPush,
+ }),
+}));
+
+describe(' ', () => {
+ it('should show Loader if isLoading is true', () => {
+ render( , { wrapper });
+ expect(screen.getByTestId('dt_derivs-loader')).toBeInTheDocument();
+ });
+
+ it('should ask user to upload their documents if isLoading is false and poi/poa status is none', () => {
+ mockUsePoiPoaStatusData = {
+ ...mockUsePoiPoaStatusData,
+ isLoading: false,
+ };
+
+ render( , { wrapper });
+
+ expect(screen.getByText('Verify your P2P account')).toBeInTheDocument();
+ expect(screen.getByText('Verify your identity and address to use Deriv P2P.')).toBeInTheDocument();
+ expect(screen.getByText('Upload documents to verify your identity.')).toBeInTheDocument();
+ expect(screen.getByText('Upload documents to verify your address.')).toBeInTheDocument();
+ });
+
+ it('should redirect user to proof-of-identity route if user clicks on arrow button', async () => {
+ mockUsePoiPoaStatusData = {
+ ...mockUsePoiPoaStatusData,
+ isLoading: false,
+ };
+
+ render( , { wrapper });
+
+ const poiButton = screen.getByTestId('dt_verification_poi_arrow_button');
+ expect(poiButton).toBeInTheDocument();
+
+ await userEvent.click(poiButton);
+
+ expect(mockHistoryPush).toHaveBeenCalledWith('/account/proof-of-identity?ext_platform_url=/cashier/p2p');
+ });
+
+ it('should redirect user to proof-of-address route if user clicks on arrow button', async () => {
+ mockUsePoiPoaStatusData = {
+ ...mockUsePoiPoaStatusData,
+ isLoading: false,
+ };
+
+ render( , { wrapper });
+
+ const poaButton = screen.getByTestId('dt_verification_poa_arrow_button');
+ expect(poaButton).toBeInTheDocument();
+
+ await userEvent.click(poaButton);
+
+ expect(mockHistoryPush).toHaveBeenCalledWith('/account/proof-of-address?ext_platform_url=/cashier/p2p');
+ });
+
+ it('should update url with search params if user clicks on arrow button and url has search params', async () => {
+ mockUsePoiPoaStatusData = {
+ ...mockUsePoiPoaStatusData,
+ isLoading: false,
+ };
+
+ Object.defineProperty(window, 'location', {
+ value: {
+ href: 'https://test.com',
+ search: '?test=1',
+ },
+ writable: true,
+ });
+
+ render( , { wrapper });
+
+ const poiButton = screen.getByTestId('dt_verification_poi_arrow_button');
+ expect(poiButton).toBeInTheDocument();
+
+ await userEvent.click(poiButton);
+
+ expect(mockHistoryPush).toHaveBeenCalledWith('/account/proof-of-identity?ext_platform_url=/cashier/p2p&test=1');
+ });
+
+ it('should show the pending message if poi/poa status is pending and poi/poa buttons are disabled', () => {
+ mockUsePoiPoaStatusData = {
+ ...mockUsePoiPoaStatusData,
+ data: {
+ ...mockUsePoiPoaStatusData.data,
+ isPoaPending: true,
+ isPoiPending: true,
+ poaStatus: 'pending',
+ poiStatus: 'pending',
+ },
+ isLoading: false,
+ };
+
+ render( , { wrapper });
+
+ const poaButton = screen.getAllByRole('button')[0];
+ const poiButton = screen.getAllByRole('button')[1];
+
+ expect(screen.getByText('Identity verification in progress.')).toBeInTheDocument();
+ expect(screen.getByText('Address verification in progress.')).toBeInTheDocument();
+ expect(poaButton).toBeDisabled();
+ expect(poiButton).toBeDisabled();
+ });
+
+ it('should show rejected message if poi/poa status is rejected', () => {
+ mockUsePoiPoaStatusData = {
+ ...mockUsePoiPoaStatusData,
+ data: {
+ ...mockUsePoiPoaStatusData.data,
+ poaStatus: 'rejected',
+ poiStatus: 'rejected',
+ },
+ isLoading: false,
+ };
+
+ render( , { wrapper });
+
+ expect(screen.getByText('Identity verification failed. Please try again.')).toBeInTheDocument();
+ expect(screen.getByText('Address verification failed. Please try again.')).toBeInTheDocument();
+ });
+
+ it('should show verified message if poi/poa status is verified', () => {
+ mockUsePoiPoaStatusData = {
+ ...mockUsePoiPoaStatusData,
+ data: {
+ ...mockUsePoiPoaStatusData.data,
+ poaStatus: 'verified',
+ poiStatus: 'verified',
+ },
+ isLoading: false,
+ };
+
+ render( , { wrapper });
+
+ expect(screen.getByText('Identity verification complete.')).toBeInTheDocument();
+ expect(screen.getByText('Address verification complete.')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/Verification/index.ts b/src/components/Verification/index.ts
new file mode 100644
index 00000000..345054f9
--- /dev/null
+++ b/src/components/Verification/index.ts
@@ -0,0 +1 @@
+export { default as Verification } from './Verification';
diff --git a/src/components/Wizard/Wizard.scss b/src/components/Wizard/Wizard.scss
new file mode 100644
index 00000000..c4d078b6
--- /dev/null
+++ b/src/components/Wizard/Wizard.scss
@@ -0,0 +1,5 @@
+.p2p-wizard {
+ width: inherit;
+ height: inherit;
+ max-width: 67.2rem;
+}
diff --git a/src/components/Wizard/Wizard.tsx b/src/components/Wizard/Wizard.tsx
new file mode 100644
index 00000000..40b132a3
--- /dev/null
+++ b/src/components/Wizard/Wizard.tsx
@@ -0,0 +1,105 @@
+//TODO: Below component to be removed once wizard fom deriv-com/ui is ready
+import React, {
+ Children,
+ cloneElement,
+ isValidElement,
+ MutableRefObject,
+ PropsWithChildren,
+ ReactNode,
+ useCallback,
+ useEffect,
+ useState,
+} from 'react';
+import clsx from 'clsx';
+import Step from './WizardStep';
+import './Wizard.scss';
+
+type TWizard = {
+ className?: string;
+ initialStep: number;
+ nav: ReactNode;
+ onStepChange?: (prop: { [key: string]: number }) => void;
+ selectedStepRef?: () => MutableRefObject;
+};
+
+const Wizard = ({
+ children = [],
+ className,
+ initialStep = 0,
+ nav = null,
+ onStepChange,
+ selectedStepRef,
+}: PropsWithChildren) => {
+ const [activeStep, setActiveStep] = useState(0);
+
+ const getSteps = useCallback(() => Children.toArray(children), [children]);
+
+ useEffect(() => {
+ const localChildren = getSteps();
+
+ if (initialStep > 0 && localChildren[initialStep]) {
+ setActiveStep(initialStep);
+ }
+ }, [initialStep, getSteps]);
+
+ const onChangeStep = (stats: { [key: string]: number }) => {
+ // User callback
+ onStepChange?.(stats);
+ };
+
+ const isInvalidStep = (next: number) => next < 0 || next >= getTotalSteps();
+
+ const onSetActiveStep = (next: number) => {
+ if (activeStep === next || isInvalidStep(next)) return;
+ setActiveStep(next);
+ onChangeStep({
+ activeStep: next + 1,
+ });
+ };
+
+ const getCurrentStep = () => activeStep + 1;
+
+ const getTotalSteps = () => getSteps().length;
+
+ const goToStep = (step: number) => onSetActiveStep(step - 1);
+
+ const goToFirstStep = () => goToStep(1);
+
+ const goToLastStep = () => goToStep(getTotalSteps());
+
+ const goToNextStep = () => onSetActiveStep(activeStep + 1);
+
+ const goToPreviousStep = () => onSetActiveStep(activeStep - 1);
+
+ const properties = {
+ getCurrentStep,
+ getTotalSteps,
+ goToFirstStep,
+ goToLastStep,
+ goToNextStep,
+ goToPreviousStep,
+ goToStep,
+ selectedStepRef,
+ };
+
+ const childrenWithProps = Children.map(getSteps(), (child, i) => {
+ if (!child) return null;
+
+ if (i === activeStep)
+ return {isValidElement(child) ? cloneElement(child, properties) : child} ;
+
+ return null;
+ });
+
+ return (
+
+ {nav && isValidElement(nav) && cloneElement(nav, properties)}
+ {childrenWithProps}
+
+ );
+};
+
+Wizard.displayName = 'Wizard';
+Wizard.Step = Step;
+
+export default Wizard;
diff --git a/src/components/Wizard/WizardStep.tsx b/src/components/Wizard/WizardStep.tsx
new file mode 100644
index 00000000..054bc3cd
--- /dev/null
+++ b/src/components/Wizard/WizardStep.tsx
@@ -0,0 +1,8 @@
+import React, { PropsWithChildren } from 'react';
+import clsx from 'clsx';
+
+const Step = ({ children = [], className }: PropsWithChildren<{ className?: string }>) => (
+ {children}
+);
+
+export default Step;
diff --git a/src/components/Wizard/index.ts b/src/components/Wizard/index.ts
new file mode 100644
index 00000000..f07d6622
--- /dev/null
+++ b/src/components/Wizard/index.ts
@@ -0,0 +1 @@
+export { default as Wizard } from './Wizard';
diff --git a/src/components/index.ts b/src/components/index.ts
new file mode 100644
index 00000000..26dd7955
--- /dev/null
+++ b/src/components/index.ts
@@ -0,0 +1,35 @@
+export * from './AdvertiserName';
+export * from './AdvertsTableRow';
+export * from './Badge';
+export * from './BuySellForm';
+export * from './Checklist';
+export * from './Clipboard';
+export * from './CloseHeader';
+export * from './Dropdown';
+export * from './FileDropzone';
+export * from './FloatingRate';
+export * from './FlyoutMenu';
+export * from './FormProgress';
+export * from './FullPageMobileWrapper';
+export * from './Input';
+export * from './LightDivider';
+export * from './MobileTabs';
+export * from './OnlineStatus';
+export * from './PageReturn';
+export * from './PaymentMethodCard';
+export * from './PaymentMethodField';
+export * from './PaymentMethodForm';
+export * from './PaymentMethodLabel';
+export * from './PaymentMethodsFormFooter';
+export * from './PaymentMethodWithIcon';
+export * from './PopoverDropdown';
+export * from './ProfileContent';
+export * from './RadioGroup';
+export * from './Search';
+export * from './StarRating';
+export * from './Table';
+export * from './TextArea';
+export * from './TextField';
+export * from './UserAvatar';
+export * from './Verification';
+export * from './Wizard';
diff --git a/src/constants/ad-constants.ts b/src/constants/ad-constants.ts
new file mode 100644
index 00000000..3038dd7e
--- /dev/null
+++ b/src/constants/ad-constants.ts
@@ -0,0 +1,65 @@
+export const COUNTERPARTIES_DROPDOWN_LIST = Object.freeze([
+ { text: 'All', value: 'all' },
+ { text: 'Blocked', value: 'blocked' },
+]);
+
+export const RATE_TYPE = Object.freeze({
+ FIXED: 'fixed',
+ FLOAT: 'float',
+});
+
+export const AD_ACTION = {
+ ACTIVATE: 'activate',
+ COPY: 'copy',
+ CREATE: 'create',
+ DEACTIVATE: 'deactivate',
+ DELETE: 'delete',
+ EDIT: 'edit',
+ SHARE: 'share',
+} as const;
+
+export const ADVERT_TYPE = Object.freeze({
+ BUY: 'Buy',
+ SELL: 'Sell',
+});
+
+export const SORT_BY_LIST = Object.freeze([
+ { text: 'Exchange rate', value: 'rate' },
+ { text: 'User rating', value: 'rating' },
+]);
+
+export const AD_CONDITION_TYPES = {
+ COMPLETION_RATE: 'completionRates',
+ JOINING_DATE: 'joiningDate',
+ PREFERRED_COUNTRIES: 'preferredCountries',
+} as const;
+
+export const AD_CONDITION_CONTENT: Record<
+ string,
+ { description: string; options?: { label: string; value: number }[]; title: string }
+> = {
+ completionRates: {
+ description:
+ 'We’ll only show your ad to people with a completion rate higher than your selection. \n\nThe completion rate is the percentage of successful orders.',
+ options: [
+ { label: '50%', value: 50 },
+ { label: '70%', value: 70 },
+ { label: '90%', value: 90 },
+ ],
+ title: 'Completion rate of more than',
+ },
+ joiningDate: {
+ description:
+ 'We’ll only show your ad to people who’ve been using Deriv P2P for longer than the time you choose.',
+ options: [
+ { label: '15 days', value: 15 },
+ { label: '30 days', value: 30 },
+ { label: '60 days', value: 60 },
+ ],
+ title: 'Joined more than',
+ },
+ preferredCountries: {
+ description: 'We’ll only show your ad to people in the countries you choose.',
+ title: 'Preferred countries',
+ },
+} as const;
diff --git a/src/constants/api-error-codes.ts b/src/constants/api-error-codes.ts
new file mode 100644
index 00000000..7cbf7e52
--- /dev/null
+++ b/src/constants/api-error-codes.ts
@@ -0,0 +1,13 @@
+export const ERROR_CODES = {
+ AD_EXCEEDS_BALANCE: 'advertiser_balance',
+ AD_EXCEEDS_DAILY_LIMIT: 'advertiser_daily_limit',
+ ADVERT_INACTIVE: 'advert_inactive',
+ ADVERT_MAX_LIMIT: 'advert_max_limit',
+ ADVERT_MIN_LIMIT: 'advert_min_limit',
+ ADVERT_REMAINING: 'advert_remaining',
+ ADVERT_SAME_LIMITS: 'AdvertSameLimits',
+ ADVERTISER_ADS_PAUSED: 'advertiser_ads_paused',
+ ADVERTISER_NOT_FOUND: 'AdvertiserNotFound',
+ ADVERTISER_TEMP_BAN: 'advertiser_temp_ban',
+ DUPLICATE_ADVERT: 'DuplicateAdvert',
+} as const;
diff --git a/src/constants/buy-sell.ts b/src/constants/buy-sell.ts
new file mode 100644
index 00000000..c86d6ff7
--- /dev/null
+++ b/src/constants/buy-sell.ts
@@ -0,0 +1,4 @@
+export const BUY_SELL = Object.freeze({
+ BUY: 'buy',
+ SELL: 'sell',
+});
diff --git a/src/constants/chat-constants.ts b/src/constants/chat-constants.ts
new file mode 100644
index 00000000..982558eb
--- /dev/null
+++ b/src/constants/chat-constants.ts
@@ -0,0 +1,16 @@
+export const CHAT_MESSAGE_TYPE = {
+ ADMIN: 'admin',
+ FILE: 'file',
+ USER: 'user',
+} as const;
+
+export const CHAT_MESSAGE_STATUS = {
+ ERRORED: 1,
+ PENDING: 0,
+} as const;
+
+export const CHAT_FILE_TYPE = {
+ FILE: 'file',
+ IMAGE: 'image',
+ PDF: 'pdf',
+} as const;
diff --git a/src/constants/index.ts b/src/constants/index.ts
new file mode 100644
index 00000000..1c1a6aec
--- /dev/null
+++ b/src/constants/index.ts
@@ -0,0 +1,9 @@
+export * from './ad-constants';
+export * from './api-error-codes';
+export * from './buy-sell';
+export * from './chat-constants';
+export * from './orders';
+export * from './p2p-logo';
+export * from './payment-methods';
+export * from './url';
+export * from './validation';
diff --git a/src/constants/orders.ts b/src/constants/orders.ts
new file mode 100644
index 00000000..8d696e14
--- /dev/null
+++ b/src/constants/orders.ts
@@ -0,0 +1,35 @@
+export const ORDERS_STATUS = {
+ ACTIVE_ORDERS: 'Active orders',
+ BUYER_CONFIRMED: 'buyer-confirmed',
+ CANCELLED: 'cancelled',
+ COMPLETED: 'completed',
+ DISPUTE_COMPLETED: 'dispute-completed',
+ DISPUTE_REFUNDED: 'dispute-refunded',
+ DISPUTED: 'disputed',
+ PAST_ORDERS: 'Past orders',
+ PENDING: 'pending',
+ REFUNDED: 'refunded',
+ TIMED_OUT: 'timed-out',
+} as const;
+
+//TODO: Below constant to be removed once list is fetched from API
+export const ORDER_COMPLETION_TIME_LIST = [
+ {
+ text: '1 hour',
+ value: '3600',
+ },
+ {
+ text: '45 minutes',
+ value: '2700',
+ },
+ {
+ text: '30 minutes',
+ value: '1800',
+ },
+ {
+ text: '15 minutes',
+ value: '900',
+ },
+] as const;
+
+export const ORDER_TIME_INFO_MESSAGE = 'Orders will expire if they aren’t completed within this time.';
diff --git a/src/constants/p2p-logo.ts b/src/constants/p2p-logo.ts
new file mode 100644
index 00000000..61d2fcba
--- /dev/null
+++ b/src/constants/p2p-logo.ts
@@ -0,0 +1,6 @@
+export const p2pLogo = Object.freeze({
+ deriv_p2p:
+ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4NSIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iI2ZmZiIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJtOS4yMTguNDgyLS43ODcgNC40NjdINS42OTZDMy4xNDUgNC45NS43MTMgNy4wMTYuMjYyIDkuNTY3bC0uMTkgMS4wODZjLS40NDkgMi41NTEgMS4yNTMgNC42MTggMy44MDQgNC42MThoMi4yODFjMS44NiAwIDMuNjMtMS41MDUgMy45NTgtMy4zNjRMMTIuMjE1IDAgOS4yMTguNDgyWm0tMS45NCAxMS4wMDNjLS4xLjU3NS0uNjE4IDEuMDQyLTEuMTkyIDEuMDQySDQuN2MtMS4xNDYgMC0xLjkxNC0uOTMtMS43MTItMi4wOGwuMTItLjY3NkMzLjMxIDguNjI0IDQuNDA0IDcuNjkgNS41NSA3LjY5aDIuMzk2bC0uNjY5IDMuNzk1WiIgY2xpcC1ydWxlPSJldmVub2RkIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTMwLjI3OCAxNS4yNzJoMi44MzhsMS43OTQtMTAuMThoLTIuODM4bC0xLjc5NCAxMC4xOFpNMzAuMzcyIDYuNDJ2LS4wMDNsLjIxNC0xLjIwN2E5LjYwMSA5LjYwMSAwIDAgMC02LjEwNS40MDhsLTEuNzAzIDkuNjU3aDIuODRMMjcgNy40MzJjLjQyNi0uMDg1IDEuODE0LS4yMTggMy4xNTguMmwuMjE0LTEuMjEyWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTE5LjA3NSA0Ljk0OGgtMi4yMDhjLTIuMTUzIDAtNC4yMDUgMS43NDQtNC41ODQgMy44OTdsLS40NDcgMi41M2MtLjM3OSAyLjE1MyAxLjA1NyAzLjg5OCAzLjIxIDMuODk4aDQuN2wuNDgyLTIuNzM2SDE1LjgxYy0uNzE3IDAtMS4xOTctLjU4LTEuMDctMS4zbC4wMTYtLjA4Nmg3LjEybC40MDctMi4zMDZjLjM4LTIuMTUzLTEuMDU2LTMuODk3LTMuMjEtMy44OTdabS4zNiAzLjY4LS4wMTYuMTQ2aC00LjIzNWwuMDIzLS4xM2MuMTI4LS43MTcuNzgyLTEuMzQ4IDEuNTAxLTEuMzQ4aDEuNjU0Yy43MSAwIDEuMTkuNjIgMS4wNzMgMS4zMzFaIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMzkuODQzIDExLjgwN2MxLjI5LTIuMzE5IDIuNzEtNS4zNiAzLjMwMi02LjcxNGgyLjg0M2MtLjk2OCAyLjU3Mi0zLjE4NSA2Ljk0OC01LjMzMyAxMC4xOGgtMi44NGMtLjk4Ny0zLjA3Ni0xLjYyMy03LjM2LTEuNzQ1LTEwLjE4aDIuODRjLjA1Mi45Mi40NjQgNC4zMy45MzQgNi43MTRaTTU0LjA1My45MTZjMi4xNyAwIDMuODM3LjM4NSA0Ljk5OSAxLjE1NSAxLjE2Mi43NTYgMS43NDMgMi4wMDIgMS43NDMgMy43MzggMCAxLjc1LS41ODggMy4wMTctMS43NjQgMy44MDEtMS4xNzYuNzctMi44NTYgMS4xNTYtNS4wNCAxLjE1NmgtMS4wM3Y0Ljg3MmgtMy4yNzZWMS4yOTRjLjcxNC0uMTQgMS40Ny0uMjM4IDIuMjY4LS4yOTRhMzAuNDU1IDMwLjQ1NSAwIDAgMSAyLjEtLjA4NFptLjIxIDIuNzkzYy0uMjM4IDAtLjQ3Ni4wMDctLjcxNC4wMi0uMjI0LjAxNS0uNDIuMDI5LS41ODguMDQzdjQuMmgxLjAzYzEuMTMzIDAgMS45ODctLjE1NCAyLjU2Mi0uNDYyLjU3NC0uMzA4Ljg2LS44ODIuODYtMS43MjIgMC0uNDA2LS4wNzYtLjc0Mi0uMjMtMS4wMDhhMS41MTggMS41MTggMCAwIDAtLjYzLS42M2MtLjI2Ny0uMTY4LS41OTUtLjI4LS45ODctLjMzNmE3LjQzMyA3LjQzMyAwIDAgMC0xLjMwMy0uMTA1Wk03MC44ODggNC45MDZjMCAuNTMyLS4xMDUgMS4wNDMtLjMxNSAxLjUzMy0uMjEuNDktLjQ4My45NjYtLjgyIDEuNDI4LS4zMzUuNDQ4LS43MTMuODgyLTEuMTMzIDEuMzAyLS40Mi40Mi0uODM0LjgyLTEuMjQgMS4xOTgtLjIxLjE5Ni0uNDQuNDItLjY5My42NzItLjIzOC4yMzgtLjQ2OS40ODMtLjY5My43MzUtLjIyNC4yNTItLjQyNy40OS0uNjA5LjcxNC0uMTY4LjIxLS4yNzMuMzg1LS4zMTUuNTI1aDYuMjU5djIuNjI1aC05LjcwM2EzLjM4NSAzLjM4NSAwIDAgMS0uMDQyLS41ODh2LS41MDRjMC0uNjcyLjEwNS0xLjI4OC4zMTUtMS44NDguMjI0LS41Ni41MS0xLjA3OC44Ni0xLjU1NC4zNS0uNDkuNzQzLS45NDUgMS4xNzctMS4zNjUuNDQ4LS40Mi44OS0uODQgMS4zMjMtMS4yNi4zMzYtLjMyMy42NTEtLjYyNC45NDUtLjkwNC4yOTQtLjI5NC41NTMtLjU3NC43NzctLjg0LjIyNC0uMjguNC0uNTUzLjUyNS0uODE5LjEyNi0uMjguMTktLjU2LjE5LS44NCAwLS42MTYtLjE3Ni0xLjA1LS41MjYtMS4zMDItLjM1LS4yNTItLjc4NC0uMzc4LTEuMzAyLS4zNzgtLjM3OCAwLS43MzUuMDYzLTEuMDcuMTg5YTQuODM3IDQuODM3IDAgMCAwLS45MDQuNDIgNC42MTUgNC42MTUgMCAwIDAtLjY5My40ODNjLS4xOTYuMTU0LS4zNDMuMjgtLjQ0MS4zNzhsLTEuNTU0LTIuMTg0YTcuNzg1IDcuNzg1IDAgMCAxIDIuMTQyLTEuNDA3IDYuMjg0IDYuMjg0IDAgMCAxIDIuNjQ2LS41NjdjLjg1NCAwIDEuNTkuMDk4IDIuMjA1LjI5NC42MTcuMTk2IDEuMTIuNDc2IDEuNTEzLjg0YTMgMyAwIDAgMSAuODgyIDEuMzAyYy4xOTYuNTA0LjI5NCAxLjA3OC4yOTQgMS43MjJaTTc3LjQwNi45MTZjMi4xNyAwIDMuODM2LjM4NSA0Ljk5OCAxLjE1NSAxLjE2My43NTYgMS43NDQgMi4wMDIgMS43NDQgMy43MzggMCAxLjc1LS41ODggMy4wMTctMS43NjQgMy44MDEtMS4xNzcuNzctMi44NTcgMS4xNTYtNS4wNDEgMS4xNTZoLTEuMDN2NC44NzJoLTMuMjc2VjEuMjk0Yy43MTUtLjE0IDEuNDctLjIzOCAyLjI2OS0uMjk0YTMwLjQ1NyAzMC40NTcgMCAwIDEgMi4xLS4wODRabS4yMSAyLjc5M2MtLjIzOCAwLS40NzYuMDA3LS43MTQuMDItLjIyNC4wMTUtLjQyLjAyOS0uNTg4LjA0M3Y0LjJoMS4wMjljMS4xMzQgMCAxLjk4OC0uMTU0IDIuNTYyLS40NjIuNTc0LS4zMDguODYxLS44ODIuODYxLTEuNzIyIDAtLjQwNi0uMDc3LS43NDItLjIzLTEuMDA4YTEuNTE4IDEuNTE4IDAgMCAwLS42My0uNjNjLS4yNjctLjE2OC0uNTk2LS4yOC0uOTg4LS4zMzZhNy40MzIgNy40MzIgMCAwIDAtMS4zMDItLjEwNVoiLz48L3N2Zz4=',
+ dp2p_logo:
+ 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjUiIGhlaWdodD0iMjUiIHZpZXdCb3g9IjAgMCAyMiAyMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgaWQ9ImRlcml2IHAycCI+CjxnIGlkPSJsb2dvIj4KPHBhdGggZD0iTTAuNTAxOTUzIDQuNDM3NTdDMC41MDE5NTMgMi4yNjI5MSAyLjI2NDg2IDAuNSA0LjQzOTUzIDAuNUgxNy41NjQ4QzE5LjczOTQgMC41IDIxLjUwMjMgMi4yNjI5MSAyMS41MDIzIDQuNDM3NTdWMTcuNTYyOEMyMS41MDIzIDE5LjczNzUgMTkuNzM5NCAyMS41MDA0IDE3LjU2NDggMjEuNTAwNEg0LjQzOTUzQzIuMjY0ODYgMjEuNTAwNCAwLjUwMTk1MyAxOS43Mzc1IDAuNTAxOTUzIDE3LjU2MjhWNC40Mzc1N1oiIGZpbGw9IiNGRjQ0NEYiLz4KPHBhdGggZD0iTTQuNDM5NTMgMC41QzIuMjY0ODYgMC41IDAuNTAxOTUzIDIuMjYyOTEgMC41MDE5NTMgNC40Mzc1N1Y0LjU1MTk1TDIwLjEzMzcgMS40NTMzMUMxOS40NDQxIDAuODU5MTk0IDE4LjU0NjQgMC41IDE3LjU2NDggMC41SDQuNDM5NTNaIiBmaWxsPSIjRTEyRTNBIi8+CjxwYXRoIGQ9Ik0yMC41Mzg5IDEuODU2OThMMTYuOTM3NiAyMS41MDA0TDE3LjU2NDggMjEuNTAwNEMxOS43Mzk0IDIxLjUwMDQgMjEuNTAyMyAxOS43Mzc1IDIxLjUwMjMgMTcuNTYyOFY0LjQzNzU3QzIxLjUwMjMgMy40NTA0IDIxLjEzOTEgMi41NDgwNyAyMC41Mzg5IDEuODU2OThaIiBmaWxsPSIjRTEyRTNBIi8+CjxwYXRoIGQ9Ik01Ljc3NzEyIDguMDQ0MDZDNi42NTg3IDguMDQ0MDYgNy4zMzU1MiA4LjIwMDQ3IDcuODA3NTkgOC41MTMyOEM4LjI3OTY3IDguODIwNDEgOC41MTU3IDkuMzI2NjEgOC41MTU3IDEwLjAzMTlDOC41MTU3IDEwLjc0MjggOC4yNzY4MiAxMS4yNTc2IDcuNzk5MDYgMTEuNTc2MUM3LjMyMTMgMTEuODg4OSA2LjYzODc5IDEyLjA0NTMgNS43NTE1MyAxMi4wNDUzSDUuMzMzNDlWMTQuMDI0Nkg0LjAwMjU5VjguMTk3NjJDNC4yOTI2NSA4LjE0MDc1IDQuNTk5NzkgOC4xMDA5MyA0LjkyMzk4IDguMDc4MThDNS4yNDgxNyA4LjA1NTQzIDUuNTMyNTUgOC4wNDQwNiA1Ljc3NzEyIDguMDQ0MDZaTTUuODYyNDMgOS4xNzg3M0M1Ljc2NTc0IDkuMTc4NzMgNS42NjkwNiA5LjE4MTU4IDUuNTcyMzcgOS4xODcyNkM1LjQ4MTM2IDkuMTkyOTUgNS40MDE3NCA5LjE5ODY0IDUuMzMzNDkgOS4yMDQzM1YxMC45MTA2SDUuNzUxNTNDNi4yMTIyMiAxMC45MTA2IDYuNTU5MTcgMTAuODQ4IDYuNzkyMzYgMTAuNzIyOUM3LjAyNTU1IDEwLjU5NzggNy4xNDIxNCAxMC4zNjQ2IDcuMTQyMTQgMTAuMDIzM0M3LjE0MjE0IDkuODU4NCA3LjExMDg2IDkuNzIxOSA3LjA0ODMgOS42MTM4NEM2Ljk5MTQyIDkuNTA1NzcgNi45MDYxMSA5LjQyMDQ2IDYuNzkyMzYgOS4zNTc4OUM2LjY4NDI5IDkuMjg5NjQgNi41NTA2MyA5LjI0NDE0IDYuMzkxMzggOS4yMjEzOUM2LjIzMjEzIDkuMTkyOTUgNi4wNTU4MSA5LjE3ODczIDUuODYyNDMgOS4xNzg3M1oiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0xMi42MTU1IDkuNjY1MDJDMTIuNjE1NSA5Ljg4MTE1IDEyLjU3MjkgMTAuMDg4NyAxMi40ODc1IDEwLjI4NzhDMTIuNDAyMiAxMC40ODY5IDEyLjI5MTMgMTAuNjgwMyAxMi4xNTQ4IDEwLjg2OEMxMi4wMTgzIDExLjA1IDExLjg2NDcgMTEuMjI2MyAxMS42OTQxIDExLjM5NjlDMTEuNTIzNSAxMS41Njc1IDExLjM1NTcgMTEuNzI5NiAxMS4xOTA4IDExLjg4MzJDMTEuMTA1NCAxMS45NjI4IDExLjAxMTYgMTIuMDUzOCAxMC45MDkyIDEyLjE1NjJDMTAuODEyNSAxMi4yNTI5IDEwLjcxODcgMTIuMzUyNCAxMC42Mjc3IDEyLjQ1NDhDMTAuNTM2NyAxMi41NTcyIDEwLjQ1NDIgMTIuNjUzOSAxMC4zODAzIDEyLjc0NDlDMTAuMzEyIDEyLjgzMDIgMTAuMjY5NCAxMi45MDEzIDEwLjI1MjMgMTIuOTU4MUgxMi43OTQ3VjE0LjAyNDZIOC44NTMxNkM4Ljg0MTc4IDEzLjk2MiA4LjgzNjEgMTMuODgyNCA4LjgzNjEgMTMuNzg1N0M4LjgzNjEgMTMuNjg5IDguODM2MSAxMy42MjA4IDguODM2MSAxMy41ODA5QzguODM2MSAxMy4zMDc5IDguODc4NzUgMTMuMDU3NyA4Ljk2NDA3IDEyLjgzMDJDOS4wNTUwNyAxMi42MDI3IDkuMTcxNjYgMTIuMzkyMiA5LjMxMzg1IDEyLjE5ODlDOS40NTYwNCAxMS45OTk4IDkuNjE1MyAxMS44MTQ5IDkuNzkxNjEgMTEuNjQ0M0M5Ljk3MzYyIDExLjQ3MzcgMTAuMTUyOCAxMS4zMDMxIDEwLjMyOTEgMTEuMTMyNEMxMC40NjU2IDExLjAwMTYgMTAuNTkzNiAxMC44NzkzIDEwLjcxMyAxMC43NjU2QzEwLjgzMjQgMTAuNjQ2MSAxMC45Mzc3IDEwLjUzMjQgMTEuMDI4NyAxMC40MjQzQzExLjExOTcgMTAuMzEwNiAxMS4xOTA4IDEwLjE5OTcgMTEuMjQyIDEwLjA5MTZDMTEuMjkzMSA5Ljk3Nzg0IDExLjMxODcgOS44NjQwOSAxMS4zMTg3IDkuNzUwMzRDMTEuMzE4NyA5LjUwMDA4IDExLjI0NzYgOS4zMjM3NyAxMS4xMDU0IDkuMjIxMzlDMTAuOTYzMyA5LjExOTAxIDEwLjc4NjkgOS4wNjc4MyAxMC41NzY1IDkuMDY3ODNDMTAuNDIyOSA5LjA2NzgzIDEwLjI3NzkgOS4wOTM0MiAxMC4xNDE0IDkuMTQ0NjFDMTAuMDEwNiA5LjE5MDExIDkuODg4MyA5LjI0Njk4IDkuNzc0NTUgOS4zMTUyNEM5LjY2NjQ5IDkuMzc3OCA5LjU3MjY0IDkuNDQzMjEgOS40OTMwMSA5LjUxMTQ2QzkuNDEzMzkgOS41NzQwMiA5LjM1MzY3IDkuNjI1MjEgOS4zMTM4NSA5LjY2NTAyTDguNjgyNTMgOC43Nzc3NkM4LjkzMjc5IDguNTQ0NTcgOS4yMjI4NSA4LjM1NDAzIDkuNTUyNzMgOC4yMDYxNUM5Ljg4ODMgOC4wNTI1OSAxMC4yNDY2IDcuOTc1OCAxMC42Mjc3IDcuOTc1OEMxMC45NzQ2IDcuOTc1OCAxMS4yNzMyIDguMDE1NjIgMTEuNTIzNSA4LjA5NTI0QzExLjc3MzcgOC4xNzQ4NyAxMS45Nzg1IDguMjg4NjIgMTIuMTM3NyA4LjQzNjVDMTIuMzAyNyA4LjU3ODY5IDEyLjQyMjEgOC43NTUwMSAxMi40OTYxIDguOTY1NDVDMTIuNTc1NyA5LjE3MDIgMTIuNjE1NSA5LjQwMzM5IDEyLjYxNTUgOS42NjUwMloiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0xNS4yNjMzIDguMDQ0MDZDMTYuMTQ0OSA4LjA0NDA2IDE2LjgyMTcgOC4yMDA0NyAxNy4yOTM4IDguNTEzMjhDMTcuNzY1OSA4LjgyMDQxIDE4LjAwMTkgOS4zMjY2MSAxOC4wMDE5IDEwLjAzMTlDMTguMDAxOSAxMC43NDI4IDE3Ljc2MyAxMS4yNTc2IDE3LjI4NTMgMTEuNTc2MUMxNi44MDc1IDExLjg4ODkgMTYuMTI1IDEyLjA0NTMgMTUuMjM3NyAxMi4wNDUzSDE0LjgxOTdWMTQuMDI0NkgxMy40ODg4VjguMTk3NjJDMTMuNzc4OCA4LjE0MDc1IDE0LjA4NiA4LjEwMDkzIDE0LjQxMDIgOC4wNzgxOEMxNC43MzQ0IDguMDU1NDMgMTUuMDE4NyA4LjA0NDA2IDE1LjI2MzMgOC4wNDQwNlpNMTUuMzQ4NiA5LjE3ODczQzE1LjI1MTkgOS4xNzg3MyAxNS4xNTUyIDkuMTgxNTggMTUuMDU4NiA5LjE4NzI2QzE0Ljk2NzYgOS4xOTI5NSAxNC44ODc5IDkuMTk4NjQgMTQuODE5NyA5LjIwNDMzVjEwLjkxMDZIMTUuMjM3N0MxNS42OTg0IDEwLjkxMDYgMTYuMDQ1NCAxMC44NDggMTYuMjc4NSAxMC43MjI5QzE2LjUxMTcgMTAuNTk3OCAxNi42MjgzIDEwLjM2NDYgMTYuNjI4MyAxMC4wMjMzQzE2LjYyODMgOS44NTg0IDE2LjU5NzEgOS43MjE5IDE2LjUzNDUgOS42MTM4NEMxNi40Nzc2IDkuNTA1NzcgMTYuMzkyMyA5LjQyMDQ2IDE2LjI3ODUgOS4zNTc4OUMxNi4xNzA1IDkuMjg5NjQgMTYuMDM2OCA5LjI0NDE0IDE1Ljg3NzYgOS4yMjEzOUMxNS43MTgzIDkuMTkyOTUgMTUuNTQyIDkuMTc4NzMgMTUuMzQ4NiA5LjE3ODczWiIgZmlsbD0id2hpdGUiLz4KPC9nPgo8L2c+Cjwvc3ZnPgo=',
+});
diff --git a/src/constants/payment-methods.ts b/src/constants/payment-methods.ts
new file mode 100644
index 00000000..73e433b6
--- /dev/null
+++ b/src/constants/payment-methods.ts
@@ -0,0 +1,6 @@
+// TODO: Remember to localise these strings
+export const PAYMENT_METHOD_CATEGORIES = Object.freeze({
+ bank: 'Bank Transfers',
+ ewallet: 'E-wallets',
+ other: 'Others',
+});
diff --git a/src/constants/url.ts b/src/constants/url.ts
new file mode 100644
index 00000000..e436b6fc
--- /dev/null
+++ b/src/constants/url.ts
@@ -0,0 +1,6 @@
+export const BASE_URL = '/cashier/p2p-v2';
+export const BUY_SELL_URL = `${BASE_URL}/buy-sell`;
+export const ORDERS_URL = `${BASE_URL}/orders`;
+export const MY_ADS_URL = `${BASE_URL}/my-ads`;
+export const MY_PROFILE_URL = `${BASE_URL}/my-profile`;
+export const ADVERTISER_URL = `${BASE_URL}/advertiser`;
diff --git a/src/constants/validation.ts b/src/constants/validation.ts
new file mode 100644
index 00000000..c68f7fce
--- /dev/null
+++ b/src/constants/validation.ts
@@ -0,0 +1 @@
+export const VALID_SYMBOLS_PATTERN = /^[a-zA-Z0-9\s\-.@_+#(),:;']+$/;
diff --git a/src/hooks/__tests__/useAdvertiserStats.spec.tsx b/src/hooks/__tests__/useAdvertiserStats.spec.tsx
new file mode 100644
index 00000000..ec898b8f
--- /dev/null
+++ b/src/hooks/__tests__/useAdvertiserStats.spec.tsx
@@ -0,0 +1,226 @@
+import { APIProvider, AuthProvider, useAuthentication, useAuthorize, useSettings } from '@deriv/api-v2';
+import { renderHook } from '@testing-library/react-hooks';
+
+import useAdvertiserStats from '../custom-hooks/useAdvertiserStats';
+import { api } from '..';
+
+const mockUseSettings = useSettings as jest.MockedFunction;
+const mockUseAuthentication = useAuthentication as jest.MockedFunction;
+const mockUseAdvertiserInfo = api.advertiser.useGetInfo as jest.MockedFunction;
+const mockUseAuthorize = useAuthorize as jest.MockedFunction;
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ p2p: {
+ advertiser: {
+ useGetInfo: jest.fn().mockReturnValue({
+ data: {
+ currency: 'USD',
+ },
+ isLoading: false,
+ subscribe: jest.fn(),
+ unsubscribe: jest.fn(),
+ }),
+ },
+ },
+ useAuthentication: jest.fn().mockReturnValue({
+ data: {
+ currency: 'USD',
+ },
+ isLoading: false,
+ isSuccess: true,
+ }),
+ useAuthorize: jest.fn().mockReturnValue({
+ isSuccess: false,
+ }),
+ useSettings: jest.fn().mockReturnValue({
+ data: {
+ currency: 'USD',
+ },
+ isLoading: false,
+ isSuccess: true,
+ }),
+}));
+
+describe('useAdvertiserStats', () => {
+ test('should not return data when useSettings and useAuthentication is still fetching', () => {
+ (mockUseAuthentication as jest.Mock).mockReturnValueOnce({
+ ...mockUseAuthentication,
+ isSuccess: false,
+ });
+ (mockUseSettings as jest.Mock).mockReturnValueOnce({
+ ...mockUseSettings,
+ isSuccess: false,
+ });
+ (mockUseAdvertiserInfo as jest.Mock).mockReturnValueOnce({
+ ...mockUseAdvertiserInfo,
+ data: {},
+ });
+
+ const wrapper = ({ children }: { children: JSX.Element }) => (
+
+ {children}
+
+ );
+ const { result } = renderHook(() => useAdvertiserStats(), { wrapper });
+
+ expect(result.current.data).toBe(undefined);
+ });
+ test('should return the correct information', () => {
+ const wrapper = ({ children }: { children: JSX.Element }) => (
+
+ {children}
+
+ );
+ (mockUseAuthorize as jest.Mock).mockReturnValueOnce({
+ isSuccess: true,
+ });
+ (mockUseSettings as jest.Mock).mockReturnValueOnce({
+ data: {
+ first_name: 'Jane',
+ has_submitted_personal_details: false,
+ last_name: 'Doe',
+ },
+ });
+
+ jest.useFakeTimers('modern').setSystemTime(new Date('2024-02-20'));
+
+ (mockUseAdvertiserInfo as jest.Mock).mockReturnValueOnce({
+ ...mockUseAdvertiserInfo('2'),
+ data: {
+ buy_orders_count: 10,
+ created_time: 1698034883,
+ partner_count: 1,
+ sell_orders_count: 5,
+ },
+ });
+ const { result } = renderHook(() => useAdvertiserStats('2'), { wrapper });
+
+ expect(result?.current?.data?.fullName).toBe('Jane Doe');
+ expect(result?.current?.data?.tradePartners).toBe(1);
+ expect(result?.current?.data?.buyOrdersCount).toBe(10);
+ expect(result?.current?.data?.sellOrdersCount).toBe(5);
+ expect(result?.current?.data?.daysSinceJoined).toBe(120);
+ });
+ test('should return the correct total count and lifetime', () => {
+ const wrapper = ({ children }: { children: JSX.Element }) => (
+
+ {children}
+
+ );
+
+ (mockUseAdvertiserInfo as jest.Mock).mockReturnValueOnce({
+ ...mockUseAdvertiserInfo,
+ data: {
+ buy_orders_amount: '10',
+ buy_orders_count: 10,
+ partner_count: 1,
+ sell_orders_amount: '50',
+ sell_orders_count: 5,
+ total_orders_count: 30,
+ total_turnover: '100',
+ },
+ });
+ const { result } = renderHook(() => useAdvertiserStats(), { wrapper });
+
+ expect(result?.current?.data?.totalOrders).toBe(15);
+ expect(result?.current?.data?.totalOrdersLifetime).toBe(30);
+ expect(result?.current?.data?.tradeVolume).toBe(60);
+ expect(result?.current?.data?.tradeVolumeLifetime).toBe(100);
+ });
+ test('should return the correct rates and limits', () => {
+ const wrapper = ({ children }: { children: JSX.Element }) => (
+
+ {children}
+
+ );
+
+ (mockUseAdvertiserInfo as jest.Mock).mockReturnValueOnce({
+ data: {
+ buy_completion_rate: 1.4,
+ daily_buy: '10',
+ daily_buy_limit: '100',
+ daily_sell: '40',
+ daily_sell_limit: '50',
+ sell_completion_rate: 2.4,
+ upgradable_daily_limits: {
+ max_daily_buy: '1000',
+ max_daily_sell: '1000',
+ },
+ },
+ });
+ const { result } = renderHook(() => useAdvertiserStats(), { wrapper });
+
+ expect(result?.current?.data?.buyCompletionRate).toBe(1.4);
+ expect(result?.current?.data?.sellCompletionRate).toBe(2.4);
+ expect(result?.current?.data?.dailyAvailableBuyLimit).toBe(90);
+ expect(result?.current?.data?.dailyAvailableSellLimit).toBe(10);
+ expect(result?.current?.data?.isEligibleForLimitUpgrade).toBe(true);
+ });
+ test('should return the correct buy/release times', () => {
+ const wrapper = ({ children }: { children: JSX.Element }) => (
+
+ {children}
+
+ );
+
+ (mockUseAdvertiserInfo as jest.Mock).mockReturnValueOnce({
+ data: {
+ buy_time_avg: 150,
+ release_time_avg: 40,
+ },
+ });
+ const { result } = renderHook(() => useAdvertiserStats(), { wrapper });
+
+ expect(result?.current?.data?.averagePayTime).toBe(3);
+ expect(result?.current?.data?.averageReleaseTime).toBe(1);
+ });
+ test('should return the correct verification statuses', () => {
+ const wrapper = ({ children }: { children: JSX.Element }) => (
+
+ {children}
+
+ );
+ (mockUseAdvertiserInfo as jest.Mock).mockReturnValueOnce({
+ data: {
+ has_full_verification: false,
+ is_approved_boolean: false,
+ },
+ });
+ (mockUseAuthentication as jest.Mock).mockReturnValueOnce({
+ data: {
+ document: {
+ status: 'verified',
+ },
+ identity: {
+ status: 'pending',
+ },
+ },
+ });
+ const { result } = renderHook(() => useAdvertiserStats(), { wrapper });
+
+ expect(result?.current?.data?.isAddressVerified).toBe(true);
+ expect(result?.current?.data?.isIdentityVerified).toBe(false);
+
+ (mockUseAdvertiserInfo as jest.Mock).mockReturnValueOnce({
+ data: {
+ has_full_verification: true,
+ is_approved_boolean: true,
+ },
+ });
+ (mockUseAuthentication as jest.Mock).mockReturnValueOnce({
+ data: {
+ document: {
+ status: 'verified',
+ },
+ identity: {
+ status: 'pending',
+ },
+ },
+ });
+ const { result: verifiedResult } = renderHook(() => useAdvertiserStats(), { wrapper });
+
+ expect(verifiedResult?.current?.data?.isAddressVerified).toBe(true);
+ expect(verifiedResult?.current?.data?.isIdentityVerified).toBe(undefined);
+ });
+});
diff --git a/src/hooks/__tests__/useIsAdvertiser.spec.tsx b/src/hooks/__tests__/useIsAdvertiser.spec.tsx
new file mode 100644
index 00000000..47dee481
--- /dev/null
+++ b/src/hooks/__tests__/useIsAdvertiser.spec.tsx
@@ -0,0 +1,42 @@
+import { renderHook } from '@testing-library/react';
+
+import useIsAdvertiser from '../custom-hooks/useIsAdvertiser';
+import { api } from '..';
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ p2p: {
+ advertiser: {
+ useGetInfo: jest.fn().mockReturnValue({
+ data: {
+ currency: 'USD',
+ },
+ isLoading: false,
+ subscribe: jest.fn(),
+ unsubscribe: jest.fn(),
+ }),
+ },
+ },
+}));
+
+const mockUseGetInfo = api.advertiser.useGetInfo as jest.MockedFunction;
+
+describe('useIsAdvertiser', () => {
+ it('should return true if data is not empty and there is no error in the response', () => {
+ const { result } = renderHook(() => useIsAdvertiser());
+ expect(result.current).toBeTruthy();
+ });
+
+ it('should return false if error.code is AdvertiserNotFound, and data is empty', () => {
+ (mockUseGetInfo as jest.Mock).mockReturnValueOnce({
+ ...mockUseGetInfo,
+ data: {},
+ error: {
+ code: 'AdvertiserNotFound',
+ },
+ });
+
+ const { result } = renderHook(() => useIsAdvertiser());
+ expect(result.current).toBeFalsy();
+ });
+});
diff --git a/src/hooks/__tests__/useModalManager.spec.tsx b/src/hooks/__tests__/useModalManager.spec.tsx
new file mode 100644
index 00000000..44f8020e
--- /dev/null
+++ b/src/hooks/__tests__/useModalManager.spec.tsx
@@ -0,0 +1,332 @@
+import * as React from 'react';
+import { createMemoryHistory } from 'history';
+import { Router } from 'react-router-dom';
+import { useDevice } from '@deriv-com/ui';
+import { act } from '@testing-library/react';
+import { renderHook } from '@testing-library/react-hooks';
+import useModalManager from '../custom-hooks/useModalManager';
+import useQueryString from '../custom-hooks/useQueryString';
+
+const mockReplace = jest.fn();
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useHistory: () => ({
+ push: jest.fn(),
+ replace: mockReplace,
+ }),
+}));
+
+const mockedUseQueryString = useQueryString as jest.MockedFunction;
+jest.mock('@/hooks/useQueryString', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ deleteQueryString: jest.fn(),
+ queryString: {},
+ setQueryString: jest.fn(),
+ })),
+}));
+
+const mockedUseDevice = useDevice as jest.MockedFunction;
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockImplementation(() => ({
+ isMobile: false,
+ })),
+}));
+
+let windowLocationSpy: jest.SpyInstance;
+
+describe('useModalManager', () => {
+ beforeEach(() => {
+ windowLocationSpy = jest.spyOn(window, 'location', 'get');
+ });
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+ it('should render and show the correct modal states when showModal is called', async () => {
+ const history = createMemoryHistory();
+ const originalLocation = window.location;
+ const wrapper = ({ children }: { children: JSX.Element }) => {
+ return {children} ;
+ };
+
+ const { rerender, result } = renderHook(() => useModalManager(), { wrapper });
+
+ expect(result.current.isModalOpenFor('ModalA')).toBe(false);
+ expect(result.current.isModalOpenFor('ModalB')).toBe(false);
+
+ act(() => {
+ result.current.showModal('ModalA');
+ });
+ expect(result.current.isModalOpenFor('ModalA')).toBe(true);
+ expect(result.current.isModalOpenFor('ModalB')).toBe(false);
+
+ windowLocationSpy.mockImplementationOnce(() => ({
+ ...originalLocation,
+ href: 'http://localhost?modal=ModalA',
+ search: '?modal=ModalA',
+ }));
+ mockedUseQueryString.mockImplementationOnce(() => ({
+ deleteQueryString: jest.fn(),
+ queryString: {
+ modal: 'ModalA',
+ advertId: undefined,
+ formAction: undefined,
+ paymentMethodId: undefined,
+ tab: undefined,
+ },
+ setQueryString: jest.fn(),
+ }));
+ rerender();
+
+ act(() => {
+ result.current.showModal('ModalB');
+ });
+
+ expect(result.current.isModalOpenFor('ModalA')).toBe(false);
+ expect(result.current.isModalOpenFor('ModalB')).toBe(true);
+
+ windowLocationSpy.mockImplementationOnce(() => ({
+ ...originalLocation,
+ href: 'http://localhost?modal=ModalA,ModalB',
+ search: '?modal=ModalA,ModalB',
+ }));
+ mockedUseQueryString.mockImplementationOnce(() => ({
+ deleteQueryString: jest.fn(),
+ queryString: {
+ modal: 'ModalA,ModalB',
+ advertId: undefined,
+ formAction: undefined,
+ paymentMethodId: undefined,
+ tab: undefined,
+ },
+ setQueryString: jest.fn(),
+ }));
+ rerender();
+
+ act(() => {
+ result.current.showModal('ModalC');
+ });
+
+ expect(result.current.isModalOpenFor('ModalA')).toBe(false);
+ expect(result.current.isModalOpenFor('ModalB')).toBe(false);
+ expect(result.current.isModalOpenFor('ModalC')).toBe(true);
+ });
+ it('should hide the modals and show previous modal when current modal hidden', () => {
+ const history = createMemoryHistory();
+ const wrapper = ({ children }: { children: JSX.Element }) => {
+ return {children} ;
+ };
+
+ const { rerender, result } = renderHook(() => useModalManager(), { wrapper });
+
+ expect(result.current.isModalOpenFor('ModalA')).toBe(false);
+ expect(result.current.isModalOpenFor('ModalB')).toBe(false);
+
+ act(() => {
+ result.current.showModal('ModalA');
+ });
+ expect(result.current.isModalOpenFor('ModalA')).toBe(true);
+ expect(result.current.isModalOpenFor('ModalB')).toBe(false);
+
+ const originalLocation = window.location;
+ windowLocationSpy.mockImplementationOnce(() => ({
+ ...originalLocation,
+ href: 'http://localhost?modal=ModalA',
+ search: '?modal=ModalA',
+ }));
+ mockedUseQueryString.mockImplementationOnce(() => ({
+ queryString: {
+ modal: 'ModalA',
+ },
+ setQueryString: jest.fn(),
+ deleteQueryString: jest.fn(),
+ }));
+ rerender();
+
+ act(() => {
+ result.current.showModal('ModalB');
+ });
+
+ windowLocationSpy.mockImplementation(() => ({
+ ...originalLocation,
+ href: 'http://localhost?modal=ModalA,ModalB',
+ search: '?modal=ModalA,ModalB',
+ }));
+ mockedUseQueryString.mockImplementationOnce(() => ({
+ queryString: {
+ modal: 'ModalA,ModalB',
+ },
+ setQueryString: jest.fn(),
+ deleteQueryString: jest.fn(),
+ }));
+ rerender();
+
+ act(() => {
+ result.current.hideModal();
+ });
+ expect(result.current.isModalOpenFor('ModalA')).toBe(true);
+ expect(result.current.isModalOpenFor('ModalB')).toBe(false);
+
+ windowLocationSpy.mockImplementationOnce(() => ({
+ ...originalLocation,
+ href: 'http://localhost?modal=ModalA',
+ search: '?modal=ModalA',
+ }));
+ mockedUseQueryString.mockImplementationOnce(() => ({
+ queryString: {
+ modal: 'ModalA',
+ advertId: undefined,
+ formAction: undefined,
+ paymentMethodId: undefined,
+ tab: undefined,
+ },
+ setQueryString: jest.fn(),
+ deleteQueryString: jest.fn(),
+ }));
+ rerender();
+
+ act(() => {
+ result.current.hideModal();
+ });
+ expect(result.current.isModalOpenFor('ModalA')).toBe(false);
+ expect(result.current.isModalOpenFor('ModalB')).toBe(false);
+ });
+ it('should show the modals when URL is initialized with default modal states', () => {
+ const history = createMemoryHistory();
+ const wrapper = ({ children }: { children: JSX.Element }) => {
+ return {children} ;
+ };
+
+ const originalLocation = window.location;
+ windowLocationSpy.mockImplementationOnce(() => ({
+ ...originalLocation,
+ href: 'http://localhost?modal=ModalA,ModalB,ModalC',
+ search: '?modal=ModalA,ModalB,ModalC',
+ }));
+ mockedUseQueryString.mockImplementationOnce(() => ({
+ queryString: {
+ modal: 'ModalA,ModalB,ModalC',
+ advertId: undefined,
+ formAction: undefined,
+ paymentMethodId: undefined,
+ tab: undefined,
+ },
+ setQueryString: jest.fn(),
+ deleteQueryString: jest.fn(),
+ }));
+
+ const { result } = renderHook(() => useModalManager(), { wrapper });
+
+ expect(result.current.isModalOpenFor('ModalA')).toBe(false);
+ expect(result.current.isModalOpenFor('ModalB')).toBe(false);
+ expect(result.current.isModalOpenFor('ModalC')).toBe(true);
+ });
+ it('should should not show the modals on navigated back when shouldReinitializeModals is set to false', () => {
+ const history = createMemoryHistory();
+ const wrapper = ({ children }: { children: JSX.Element }) => {
+ return {children} ;
+ };
+
+ const originalLocation = window.location;
+ windowLocationSpy.mockImplementationOnce(() => ({
+ ...originalLocation,
+ href: 'http://localhost?modal=ModalA,ModalB,ModalC',
+ search: '?modal=ModalA,ModalB,ModalC',
+ }));
+ mockedUseQueryString.mockImplementationOnce(() => ({
+ queryString: {
+ modal: 'ModalA,ModalB,ModalC',
+ },
+ setQueryString: jest.fn(),
+ deleteQueryString: jest.fn(),
+ }));
+
+ const { result } = renderHook(
+ () =>
+ useModalManager({
+ shouldReinitializeModals: false,
+ }),
+ { wrapper }
+ );
+
+ expect(result.current.isModalOpenFor('ModalA')).toBe(false);
+ expect(result.current.isModalOpenFor('ModalB')).toBe(false);
+ expect(result.current.isModalOpenFor('ModalC')).toBe(false);
+ });
+ it('should should show the modals on navigated back when shouldReinitializeModals is set to true', () => {
+ const history = createMemoryHistory();
+ const wrapper = ({ children }: { children: JSX.Element }) => {
+ return {children} ;
+ };
+
+ const originalLocation = window.location;
+ windowLocationSpy.mockImplementationOnce(() => ({
+ ...originalLocation,
+ href: 'http://localhost?modal=ModalA,ModalB,ModalC',
+ search: '?modal=ModalA,ModalB,ModalC',
+ }));
+ mockedUseQueryString.mockImplementationOnce(() => ({
+ queryString: {
+ modal: 'ModalA,ModalB,ModalC',
+ advertId: undefined,
+ formAction: undefined,
+ paymentMethodId: undefined,
+ tab: undefined,
+ },
+ setQueryString: jest.fn(),
+ deleteQueryString: jest.fn(),
+ }));
+
+ const { result } = renderHook(
+ () =>
+ useModalManager({
+ shouldReinitializeModals: true,
+ }),
+ { wrapper }
+ );
+
+ expect(result.current.isModalOpenFor('ModalA')).toBe(false);
+ expect(result.current.isModalOpenFor('ModalB')).toBe(false);
+ expect(result.current.isModalOpenFor('ModalC')).toBe(true);
+ });
+ it('should should stack the modals in mobile', () => {
+ const history = createMemoryHistory();
+ const wrapper = ({ children }: { children: JSX.Element }) => {
+ return {children} ;
+ };
+
+ const originalLocation = window.location;
+ windowLocationSpy.mockImplementationOnce(() => ({
+ ...originalLocation,
+ href: 'http://localhost?modal=ModalA,ModalB,ModalC',
+ search: '?modal=ModalA,ModalB,ModalC',
+ }));
+ mockedUseDevice.mockImplementation(() => ({
+ isMobile: true,
+ }));
+ mockedUseQueryString.mockImplementationOnce(() => ({
+ queryString: {
+ modal: 'ModalA,ModalB,ModalC',
+ },
+ setQueryString: jest.fn(),
+ deleteQueryString: jest.fn(),
+ }));
+
+ const { result } = renderHook(() => useModalManager(), { wrapper });
+
+ expect(result.current.isModalOpenFor('ModalA')).toBe(true);
+ expect(result.current.isModalOpenFor('ModalB')).toBe(true);
+ expect(result.current.isModalOpenFor('ModalC')).toBe(true);
+
+ act(() => {
+ result.current.showModal('ModalD');
+ });
+
+ expect(result.current.isModalOpenFor('ModalA')).toBe(true);
+ expect(result.current.isModalOpenFor('ModalB')).toBe(true);
+ expect(result.current.isModalOpenFor('ModalC')).toBe(true);
+ expect(result.current.isModalOpenFor('ModalD')).toBe(true);
+ });
+});
diff --git a/src/hooks/__tests__/usePoiPoaStatus.spec.tsx b/src/hooks/__tests__/usePoiPoaStatus.spec.tsx
new file mode 100644
index 00000000..7430bbc5
--- /dev/null
+++ b/src/hooks/__tests__/usePoiPoaStatus.spec.tsx
@@ -0,0 +1,77 @@
+import { APIProvider, AuthProvider, useGetAccountStatus } from '@deriv/api-v2';
+import { renderHook } from '@testing-library/react-hooks';
+
+import usePoiPoaStatus from '../custom-hooks/usePoiPoaStatus';
+
+const mockUseGetAccountStatus = useGetAccountStatus as jest.MockedFunction;
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+ {children}
+
+);
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ useGetAccountStatus: jest.fn().mockReturnValue({
+ data: {
+ authentication: {
+ document: {
+ status: 'pending',
+ },
+ identity: {
+ status: 'pending',
+ },
+ },
+ p2p_poa_required: true,
+ },
+ }),
+}));
+
+describe('usePoiPoaStatus', () => {
+ it('should return the correct pending verification statuses', () => {
+ const { result } = renderHook(() => usePoiPoaStatus(), { wrapper });
+
+ expect(result.current.data).toStrictEqual({
+ isP2PPoaRequired: true,
+ isPoaPending: true,
+ isPoaVerified: false,
+ isPoiPending: true,
+ isPoiVerified: false,
+ poaStatus: 'pending',
+ poiStatus: 'pending',
+ });
+ });
+ it('should return the correct verified verification statuses', () => {
+ mockUseGetAccountStatus.mockReturnValueOnce({
+ data: {
+ authentication: {
+ document: {
+ status: 'verified',
+ },
+ identity: {
+ status: 'verified',
+ },
+ },
+ p2p_poa_required: false,
+ },
+ });
+ const { result } = renderHook(() => usePoiPoaStatus(), { wrapper });
+
+ expect(result.current.data).toStrictEqual({
+ isP2PPoaRequired: false,
+ isPoaPending: false,
+ isPoaVerified: true,
+ isPoiPending: false,
+ isPoiVerified: true,
+ poaStatus: 'verified',
+ poiStatus: 'verified',
+ });
+ });
+ it('should return undefined if data is not available', () => {
+ mockUseGetAccountStatus.mockReturnValueOnce({
+ data: undefined,
+ });
+ const { result } = renderHook(() => usePoiPoaStatus(), { wrapper });
+
+ expect(result.current.data).toBeUndefined();
+ });
+});
diff --git a/src/hooks/__tests__/useQueryString.spec.tsx b/src/hooks/__tests__/useQueryString.spec.tsx
new file mode 100644
index 00000000..b5dd8c90
--- /dev/null
+++ b/src/hooks/__tests__/useQueryString.spec.tsx
@@ -0,0 +1,52 @@
+import { createMemoryHistory } from 'history';
+import { useQueryParams } from 'use-query-params';
+
+import { renderHook } from '@testing-library/react-hooks';
+
+import useQueryString from '../custom-hooks/useQueryString';
+
+jest.mock('use-query-params', () => ({
+ ...jest.requireActual('use-query-params'),
+ useQueryParams: jest.fn().mockReturnValue([
+ {},
+ jest.fn(), // setQuery
+ ]),
+}));
+const mockUseQueryParams = useQueryParams as jest.MockedFunction;
+describe('useQueryString', () => {
+ it('returns correct query string', () => {
+ const history = createMemoryHistory({ initialEntries: ['?x=3'] });
+ history.push('/');
+ const { result } = renderHook(() => useQueryString());
+ expect(result.current.queryString).toEqual({});
+ });
+
+ it('should add and replace query strings', () => {
+ mockUseQueryParams.mockReturnValueOnce([
+ {
+ modal: 'NicknameModal',
+ tab: 'Stats',
+ },
+ jest.fn(),
+ ]);
+ const { result } = renderHook(() => useQueryString());
+ const { queryString, setQueryString } = result.current;
+
+ setQueryString({ tab: 'Stats' });
+ expect(queryString.tab).toEqual('Stats');
+
+ setQueryString({ modal: 'NicknameModal' });
+
+ expect(queryString.tab).toEqual('Stats');
+ expect(queryString.modal).toEqual('NicknameModal');
+ });
+
+ it('calls deleteQueryString with correct key', () => {
+ mockUseQueryParams.mockReturnValueOnce([{}, jest.fn()]);
+ const { result } = renderHook(() => useQueryString());
+ const { deleteQueryString } = result.current;
+
+ deleteQueryString('tab');
+ expect(result.current.queryString.tab).toBe(undefined);
+ });
+});
diff --git a/src/hooks/api/__tests__/useAdvertiserAdverts.spec.tsx b/src/hooks/api/__tests__/useAdvertiserAdverts.spec.tsx
new file mode 100644
index 00000000..f8554358
--- /dev/null
+++ b/src/hooks/api/__tests__/useAdvertiserAdverts.spec.tsx
@@ -0,0 +1,125 @@
+import { renderHook } from '@testing-library/react-hooks';
+
+import APIProvider from '../../../APIProvider';
+import AuthProvider from '../../../AuthProvider';
+import useInfiniteQuery from '../../../useInfiniteQuery';
+import useAdvertiserAdverts from '../advert/p2p-advertiser-adverts/useAdvertiserAdverts';
+
+jest.mock('../../../useInfiniteQuery', () => jest.fn());
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+ {children}
+
+);
+
+const mockUseInfiniteQuery = useInfiniteQuery as jest.MockedFunction>;
+
+describe('useAdvertiserAdverts', () => {
+ it('should return undefined when there is no data', () => {
+ // @ts-expect-error need to come up with a way to mock the return type of useInfiinteQuery
+ (useInfiniteQuery as jest.MockedFunction).mockReturnValue({ data: {} });
+ const { result } = renderHook(() => useAdvertiserAdverts(), { wrapper });
+ expect(result.current.data).toBeUndefined();
+ });
+
+ it('should return adverts list with the correct details', () => {
+ mockUseInfiniteQuery.mockReturnValue({
+ data: {
+ pages: [
+ {
+ p2p_advertiser_adverts: {
+ list: {
+ // @ts-expect-error need to come up with a way to mock the return type of useQuery
+ advertiser_details: {
+ completed_orders_count: 0,
+ id: '1',
+ is_online: 1,
+ is_recommended: null,
+ last_online_time: 111,
+ loginid: '111',
+ name: 'test',
+ rating_average: 0,
+ rating_count: 0,
+ recommended_average: 0,
+ recommended_count: 0,
+ total_completion_rate: 0,
+ },
+ amount: 50,
+ id: '101',
+ price: 13500,
+ type: 'buy',
+ rate_type: 'float',
+ },
+ },
+ },
+ ],
+ },
+ });
+
+ const { result } = renderHook(() => useAdvertiserAdverts(), { wrapper });
+ const advertiser_adverts = result.current.data;
+
+ expect(advertiser_adverts).toHaveLength(1);
+ expect(advertiser_adverts?.[0].advertiser_details?.completed_orders_count).toBe(0);
+ expect(advertiser_adverts?.[0].advertiser_details?.id).toBe('1');
+ expect(advertiser_adverts?.[0].amount).toBe(50);
+ expect(advertiser_adverts?.[0].id).toBe('101');
+ expect(advertiser_adverts?.[0].price).toBe(13500);
+ expect(advertiser_adverts?.[0].type).toBe('buy');
+ expect(advertiser_adverts?.[0].rate_type).toBe('float');
+ expect(advertiser_adverts?.[0].is_floating).toBe(true);
+ expect(advertiser_adverts?.[0].advertiser_details?.is_online).toBe(true);
+ expect(advertiser_adverts?.[0].advertiser_details?.last_online_time).toBe(111);
+ expect(advertiser_adverts?.[0].advertiser_details?.loginid).toBe('111');
+ expect(advertiser_adverts?.[0].advertiser_details?.name).toBe('test');
+ expect(advertiser_adverts?.[0].advertiser_details?.rating_average).toBe(0);
+ expect(advertiser_adverts?.[0].advertiser_details?.rating_count).toBe(0);
+ expect(advertiser_adverts?.[0].advertiser_details?.recommended_average).toBe(0);
+ expect(advertiser_adverts?.[0].advertiser_details?.recommended_count).toBe(0);
+ expect(advertiser_adverts?.[0].advertiser_details?.total_completion_rate).toBe(0);
+ expect(advertiser_adverts?.[0].advertiser_details?.is_recommended).toBe(false);
+ expect(advertiser_adverts?.[0].advertiser_details?.has_not_been_recommended).toBe(true);
+ });
+
+ it('should call fetchNextPage when loadMoreAdverts is called', () => {
+ mockUseInfiniteQuery.mockReturnValue({
+ data: {
+ pages: [
+ {
+ p2p_advertiser_adverts: {
+ list: {
+ // @ts-expect-error need to come up with a way to mock the return type of useQuery
+ advertiser_details: {
+ completed_orders_count: 0,
+ id: '1',
+ is_online: 1,
+ is_recommended: null,
+ last_online_time: 111,
+ loginid: '111',
+ name: 'test',
+ rating_average: 0,
+ rating_count: 0,
+ recommended_average: 0,
+ recommended_count: 0,
+ total_completion_rate: 0,
+ },
+ amount: 50,
+ id: '101',
+ price: 13500,
+ type: 'buy',
+ rate_type: 'float',
+ },
+ },
+ },
+ ],
+ },
+ fetchNextPage: jest.fn(),
+ });
+
+ const { result } = renderHook(() => useAdvertiserAdverts(), { wrapper });
+ result.current.loadMoreAdverts();
+
+ expect(mockUseInfiniteQuery('p2p_advertiser_adverts').fetchNextPage).toBeCalled();
+ });
+});
diff --git a/src/hooks/api/__tests__/useCountryList.spec.tsx b/src/hooks/api/__tests__/useCountryList.spec.tsx
new file mode 100644
index 00000000..edfab33c
--- /dev/null
+++ b/src/hooks/api/__tests__/useCountryList.spec.tsx
@@ -0,0 +1,81 @@
+import { renderHook } from '@testing-library/react-hooks';
+
+import APIProvider from '../../../APIProvider';
+import AuthProvider from '../../../AuthProvider';
+import useQuery from '../../../useQuery';
+import useCountryList from '../country/p2p-country-list/useCountryList';
+
+jest.mock('../../../useQuery', () => jest.fn());
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+ {children}
+
+);
+jest.mock('../../useAuthorize', () => jest.fn(() => ({ isSuccess: true })));
+
+const mockUseQuery = useQuery as jest.MockedFunction>;
+
+describe('useCountryList', () => {
+ it('should return undefined when there is no data', () => {
+ // @ts-expect-error need to come up with a way to mock the return type of useQuery
+ mockUseQuery.mockReturnValue({ data: {} });
+ const { result } = renderHook(() => useCountryList(), { wrapper });
+ expect(result.current.data).toBeUndefined();
+ });
+
+ it('should return country list with the correct details', () => {
+ const mockQueryData = {
+ p2p_country_list: {
+ ai: {
+ country_name: 'Anguilla',
+ cross_border_ads_enabled: 1,
+ fixed_rate_adverts: 'enabled',
+ float_rate_adverts: 'disabled',
+ float_rate_offset_limit: 10,
+ local_currency: 'XCD',
+ payment_methods: {
+ alipay: {
+ display_name: 'Alipay',
+ fields: {
+ account: {
+ display_name: 'Alipay ID',
+ required: 1,
+ type: 'text',
+ },
+ instructions: {
+ display_name: 'Instructions',
+ required: 0,
+ type: 'memo',
+ },
+ },
+ type: 'ewallet',
+ },
+ },
+ },
+ },
+ };
+ // @ts-expect-error need to come up with a way to mock the return type of useQuery
+ mockUseQuery.mockReturnValue({
+ data: mockQueryData,
+ });
+
+ const { result } = renderHook(() => useCountryList(), { wrapper });
+ const p2p_country_list = result.current.data;
+ expect(p2p_country_list).toEqual(mockQueryData.p2p_country_list);
+ });
+ it('should call the useQuery with parameters if passed', () => {
+ renderHook(() => useCountryList({ country: 'id' }), { wrapper });
+ expect(mockUseQuery).toHaveBeenCalledWith('p2p_country_list', {
+ payload: { country: 'id' },
+ options: { enabled: true, refetchOnWindowFocus: false },
+ });
+ });
+ it('should call the useQuery with default parameters if not passed', () => {
+ renderHook(() => useCountryList(), { wrapper });
+ expect(mockUseQuery).toHaveBeenCalledWith('p2p_country_list', {
+ payload: undefined,
+ options: { enabled: true, refetchOnWindowFocus: false },
+ });
+ });
+});
diff --git a/src/hooks/api/__tests__/useSettings.spec.tsx b/src/hooks/api/__tests__/useSettings.spec.tsx
new file mode 100644
index 00000000..012ed451
--- /dev/null
+++ b/src/hooks/api/__tests__/useSettings.spec.tsx
@@ -0,0 +1,109 @@
+import { renderHook } from '@testing-library/react-hooks';
+
+import APIProvider from '../../../APIProvider';
+import AuthProvider from '../../../AuthProvider';
+import useP2PSettings from '../settings/p2p-settings/useSettings';
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+ {children}
+
+);
+
+describe('useP2PSettings', () => {
+ it('should return an empty object if data is not available', () => {
+ const { result } = renderHook(() => useP2PSettings(), { wrapper });
+ expect(result.current.data).toEqual({});
+ });
+
+ it('should return the correct data if data is available', () => {
+ const mockData = {
+ adverts_active_limit: 3,
+ adverts_archive_period: 3,
+ block_trade: {
+ disabled: 1,
+ maximum_advert_amount: 20000,
+ },
+ cancellation_block_duration: 24,
+ cancellation_count_period: 24,
+ cancellation_grace_period: 0,
+ cancellation_limit: 300,
+ cross_border_ads_enabled: 1,
+ disabled: 0,
+ feature_level: 2,
+ fixed_rate_adverts: 'enabled',
+ float_rate_adverts: 'disabled',
+ float_rate_offset_limit: 10,
+ local_currencies: [
+ {
+ display_name: 'US Dollar',
+ has_adverts: 0,
+ symbol: 'USD',
+ },
+ ],
+ maximum_advert_amount: 3000,
+ maximum_order_amount: 1000,
+ order_daily_limit: 300,
+ order_payment_period: 15,
+ payment_methods_enabled: 1,
+ review_period: 24,
+ supported_currencies: ['usd'],
+ is_cross_border_ads_enabled: true,
+ is_disabled: false,
+ is_payment_methods_enabled: true,
+ rate_type: 'fixed',
+ float_rate_offset_limit_string: '10.00',
+ reached_target_date: false,
+ currency_list: [
+ {
+ display_name: 'US Dollar',
+ has_adverts: 0,
+ is_default: 1,
+ text: 'USD',
+ value: 'USD',
+ },
+ ],
+ };
+
+ window.localStorage.setItem('p2p_v2_p2p_settings', JSON.stringify(mockData));
+
+ const { result } = renderHook(() => useP2PSettings(), { wrapper });
+ const p2p_settings = result.current.data;
+
+ expect(p2p_settings?.adverts_active_limit).toBe(3);
+ expect(p2p_settings?.adverts_archive_period).toBe(3);
+ expect(p2p_settings?.block_trade?.disabled).toBe(1);
+ expect(p2p_settings?.block_trade?.maximum_advert_amount).toBe(20000);
+ expect(p2p_settings?.cancellation_block_duration).toBe(24);
+ expect(p2p_settings?.cancellation_count_period).toBe(24);
+ expect(p2p_settings?.cancellation_grace_period).toBe(0);
+ expect(p2p_settings?.cancellation_limit).toBe(300);
+ expect(p2p_settings?.cross_border_ads_enabled).toBe(1);
+ expect(p2p_settings?.disabled).toBe(0);
+ expect(p2p_settings?.feature_level).toBe(2);
+ expect(p2p_settings?.fixed_rate_adverts).toBe('enabled');
+ expect(p2p_settings?.float_rate_adverts).toBe('disabled');
+ expect(p2p_settings?.float_rate_offset_limit).toBe(10);
+ expect(p2p_settings?.local_currencies?.[0]?.display_name).toBe('US Dollar');
+ expect(p2p_settings?.local_currencies?.[0]?.has_adverts).toBe(0);
+ expect(p2p_settings?.local_currencies?.[0]?.symbol).toBe('USD');
+ expect(p2p_settings?.maximum_advert_amount).toBe(3000);
+ expect(p2p_settings?.maximum_order_amount).toBe(1000);
+ expect(p2p_settings?.order_daily_limit).toBe(300);
+ expect(p2p_settings?.order_payment_period).toBe(15);
+ expect(p2p_settings?.payment_methods_enabled).toBe(1);
+ expect(p2p_settings?.review_period).toBe(24);
+ expect(p2p_settings?.supported_currencies).toEqual(['usd']);
+ expect(p2p_settings?.is_cross_border_ads_enabled).toBe(true);
+ expect(p2p_settings?.is_disabled).toBe(false);
+ expect(p2p_settings?.is_payment_methods_enabled).toBe(true);
+ expect(p2p_settings?.rate_type).toBe('fixed');
+ expect(p2p_settings?.float_rate_offset_limit_string).toBe('10.00');
+ expect(p2p_settings?.reached_target_date).toBe(false);
+ expect(p2p_settings?.currency_list?.[0]?.display_name).toBe('US Dollar');
+ expect(p2p_settings?.currency_list?.[0]?.has_adverts).toBe(0);
+ expect(p2p_settings?.currency_list?.[0]?.is_default).toBe(1);
+ expect(p2p_settings?.currency_list?.[0]?.text).toBe('USD');
+ expect(p2p_settings?.currency_list?.[0]?.value).toBe('USD');
+ });
+});
diff --git a/src/hooks/api/account/useDerivAccountsList.ts b/src/hooks/api/account/useDerivAccountsList.ts
new file mode 100644
index 00000000..a7fdafd2
--- /dev/null
+++ b/src/hooks/api/account/useDerivAccountsList.ts
@@ -0,0 +1,84 @@
+import { useMemo } from 'react';
+
+import useQuery from '../useQuery';
+import { displayMoney } from '../utils';
+
+import useAuthorize from './useAuthorize';
+import useBalance from './useBalance';
+import useCurrencyConfig from './useCurrencyConfig';
+
+/** A custom hook that returns the list of accounts for the current user. */
+const useDerivAccountsList = () => {
+ const { data: authorize_data, isSuccess } = useAuthorize();
+ const { data: account_list_data, ...rest } = useQuery('account_list', {
+ options: {
+ enabled: isSuccess,
+ refetchOnWindowFocus: false,
+ },
+ });
+ const { data: balance_data } = useBalance();
+ const { getConfig } = useCurrencyConfig();
+
+ // Add additional information to the authorize response.
+ const modified_accounts = useMemo(() => {
+ return account_list_data?.account_list?.map(account => {
+ return {
+ ...account,
+ /** Creation time of the account. */
+ created_at: account.created_at ? new Date(account.created_at) : undefined,
+ /** Account's currency config information */
+ currency_config: account.currency ? getConfig(account.currency) : undefined,
+ /** Date till client has excluded him/herself from the website, only present if client is self excluded. */
+ excluded_until: account.excluded_until ? new Date(account.excluded_until) : undefined,
+ /** Indicating whether the wallet is the currently active account. */
+ is_active: account.loginid === authorize_data?.loginid,
+ /** Indicating whether any linked account is active */
+ is_linked_account_active: account.linked_to?.some(
+ account => account.loginid === authorize_data?.loginid
+ ),
+ /** indicating whether the account is marked as disabled or not. */
+ is_disabled: Boolean(account.is_disabled),
+ /** indicating whether the account is a trading account. */
+ is_trading: account.account_category === 'trading',
+ /** indicating whether the account is a virtual-money account. */
+ is_virtual: Boolean(account.is_virtual),
+ /** indicating whether the account is a wallet account. */
+ is_wallet: account.account_category === 'wallet',
+ /** The account ID of specified account. */
+ loginid: `${account.loginid}`,
+ /** The platform of the account */
+ platform: 'deriv' as const,
+ /** To indicate whether the account is MF or not */
+ is_mf: account.loginid?.startsWith('MF'),
+ } as const;
+ });
+ }, [account_list_data?.account_list, authorize_data?.loginid, getConfig]);
+
+ // Add balance to each account
+ const modified_accounts_with_balance = useMemo(
+ () =>
+ modified_accounts?.map(account => {
+ const balance = balance_data?.accounts?.[account.loginid]?.balance || 0;
+
+ return {
+ ...account,
+ /** The balance of the account. */
+ balance,
+ /** The balance of the account in currency format. */
+ display_balance: displayMoney(balance, account.currency_config?.display_code || 'USD', {
+ fractional_digits: account.currency_config?.fractional_digits,
+ preferred_language: authorize_data?.preferred_language,
+ }),
+ };
+ }),
+ [balance_data?.accounts, modified_accounts, authorize_data?.preferred_language]
+ );
+
+ return {
+ /** The list of accounts for the current user. */
+ data: modified_accounts_with_balance,
+ ...rest,
+ };
+};
+
+export default useDerivAccountsList;
diff --git a/src/hooks/api/advert/index.ts b/src/hooks/api/advert/index.ts
new file mode 100644
index 00000000..42e68033
--- /dev/null
+++ b/src/hooks/api/advert/index.ts
@@ -0,0 +1,2 @@
+export * as advert from './p2p-advert';
+export * as advertiserAdverts from './p2p-advertiser-adverts';
diff --git a/src/hooks/api/advert/p2p-advert/index.ts b/src/hooks/api/advert/p2p-advert/index.ts
new file mode 100644
index 00000000..dac8037f
--- /dev/null
+++ b/src/hooks/api/advert/p2p-advert/index.ts
@@ -0,0 +1,5 @@
+export { default as useGet } from './useAdvertInfo';
+export { default as useGetList } from './useAdvertList';
+export { default as useCreate } from './useAdvertCreate';
+export { default as useUpdate } from './useAdvertUpdate';
+export { default as useDelete } from './useAdvertDelete';
diff --git a/src/hooks/api/advert/p2p-advert/useAdvertCreate.ts b/src/hooks/api/advert/p2p-advert/useAdvertCreate.ts
new file mode 100644
index 00000000..5ad07136
--- /dev/null
+++ b/src/hooks/api/advert/p2p-advert/useAdvertCreate.ts
@@ -0,0 +1,61 @@
+import { useCallback, useMemo } from 'react';
+import useMutation from '../../../../../useMutation';
+import useInvalidateQuery from '../../../../../useInvalidateQuery';
+
+type TPayload = Parameters>['mutate']>[0]['payload'];
+
+/** A custom hook that creates a P2P advert. This can only be used by an approved P2P advertiser.
+ *
+ * To create an advert, specify the following payload arguments in the `mutate` call (some arguments are optional):
+ * @example
+ * mutate({
+ description: 'Please transfer to account number 1234',
+ type: 'buy',
+ amount: 100,
+ max_order_amount: 50,
+ min_order_amount: 20,
+ payment_method: 'bank_transfer',
+ rate: 4.25,
+ });
+ *
+*/
+const useAdvertCreate = () => {
+ const invalidate = useInvalidateQuery();
+ const {
+ data,
+ mutate: _mutate,
+ ...rest
+ } = useMutation('p2p_advert_create', {
+ onSuccess: () => {
+ invalidate('p2p_advert_list');
+ },
+ });
+
+ const mutate = useCallback((payload: TPayload) => _mutate({ payload }), [_mutate]);
+
+ const modified_data = useMemo(() => {
+ if (!data?.p2p_advert_create) return undefined;
+
+ return {
+ ...data?.p2p_advert_create,
+ /** Indicates if this is block trade advert or not. */
+ block_trade: Boolean(data?.p2p_advert_create?.block_trade),
+ /** The advert creation time in epoch. */
+ created_time: data?.p2p_advert_create?.created_time
+ ? new Date(data?.p2p_advert_create?.created_time)
+ : undefined,
+ /** The activation status of the advert. */
+ is_active: Boolean(data?.p2p_advert_create?.is_active),
+ /** Indicates that this advert will appear on the main advert list. */
+ is_visible: Boolean(data?.p2p_advert_create?.is_visible),
+ };
+ }, [data?.p2p_advert_create]);
+
+ return {
+ data: modified_data,
+ mutate,
+ ...rest,
+ };
+};
+
+export default useAdvertCreate;
diff --git a/src/hooks/api/advert/p2p-advert/useAdvertDelete.ts b/src/hooks/api/advert/p2p-advert/useAdvertDelete.ts
new file mode 100644
index 00000000..ddf59d57
--- /dev/null
+++ b/src/hooks/api/advert/p2p-advert/useAdvertDelete.ts
@@ -0,0 +1,66 @@
+import { useCallback, useMemo } from 'react';
+import useMutation from '../../../../../useMutation';
+import useInvalidateQuery from '../../../../../useInvalidateQuery';
+
+type TPayload = Parameters>['mutate']>[0]['payload'];
+
+/** A custom hook that deletes a P2P advert. This can only be used by an approved P2P advertiser.
+ *
+ * To delete an advert, specify the advert ID to delete, for instance:
+ * @example
+ * mutate({
+ "id": 1234
+ });
+ *
+ * Once this is mutated, the advert with ID of 1234 will be deleted.
+*/
+const useAdvertDelete = () => {
+ const invalidate = useInvalidateQuery();
+ const {
+ data,
+ mutate: _mutate,
+ ...rest
+ } = useMutation('p2p_advert_update', {
+ onSuccess: () => {
+ invalidate('p2p_advert_list');
+ invalidate('p2p_advertiser_adverts');
+ },
+ });
+
+ const mutate = useCallback(
+ (payload: Omit) =>
+ _mutate({
+ payload: {
+ ...payload,
+ delete: 1,
+ },
+ }),
+ [_mutate]
+ );
+
+ const modified_data = useMemo(() => {
+ const p2p_advert_update = data?.p2p_advert_update;
+
+ if (!p2p_advert_update) return undefined;
+
+ return {
+ ...p2p_advert_update,
+ /** Indicates if this is block trade advert or not. */
+ is_block_trade: Boolean(p2p_advert_update.block_trade),
+ /** The activation status of the advert. */
+ is_active: Boolean(p2p_advert_update.is_active),
+ /** Indicates that this advert will appear on the main advert list. */
+ is_visible: Boolean(p2p_advert_update.is_visible),
+ /** Indicates that the advert has been deleted. */
+ is_deleted: Boolean(p2p_advert_update.deleted),
+ };
+ }, [data?.p2p_advert_update]);
+
+ return {
+ data: modified_data,
+ mutate,
+ ...rest,
+ };
+};
+
+export default useAdvertDelete;
diff --git a/src/hooks/api/advert/p2p-advert/useAdvertInfo.ts b/src/hooks/api/advert/p2p-advert/useAdvertInfo.ts
new file mode 100644
index 00000000..71d80f2a
--- /dev/null
+++ b/src/hooks/api/advert/p2p-advert/useAdvertInfo.ts
@@ -0,0 +1,45 @@
+import { useMemo } from 'react';
+import useQuery from '../../../../../useQuery';
+
+/**
+ * This custom hook returns the advert information about the given advert ID.
+ */
+const useAdvertInfo = (
+ payload: NonNullable>[1]>['payload'],
+ options?: NonNullable>[1]>['options']
+) => {
+ const { data, ...rest } = useQuery('p2p_advert_info', {
+ payload,
+ options,
+ });
+
+ const modified_data = useMemo(() => {
+ const p2p_advert_info = data?.p2p_advert_info;
+
+ if (!p2p_advert_info) return undefined;
+
+ return {
+ ...p2p_advert_info,
+ /** Determines whether the advert is a buy advert or not. */
+ is_buy: p2p_advert_info.type === 'buy',
+ /** Determines whether the advert is a sell advert or not. */
+ is_sell: p2p_advert_info.type === 'sell',
+ is_block_trade: Boolean(p2p_advert_info.block_trade),
+ is_deleted: Boolean(p2p_advert_info.deleted),
+ is_active: Boolean(p2p_advert_info.is_active),
+ is_visible: Boolean(p2p_advert_info.is_visible),
+ /**
+ * @deprecated This property was deprecated on back-end
+ * @see https://api.deriv.com/api-explorer#p2p_advert_info
+ * **/
+ payment_method: p2p_advert_info.payment_method,
+ };
+ }, [data?.p2p_advert_info]);
+
+ return {
+ data: modified_data,
+ ...rest,
+ };
+};
+
+export default useAdvertInfo;
diff --git a/src/hooks/api/advert/p2p-advert/useAdvertList.ts b/src/hooks/api/advert/p2p-advert/useAdvertList.ts
new file mode 100644
index 00000000..083944f0
--- /dev/null
+++ b/src/hooks/api/advert/p2p-advert/useAdvertList.ts
@@ -0,0 +1,67 @@
+import useInfiniteQuery from '../../../../../useInfiniteQuery';
+import useAuthorize from '../../../../useAuthorize';
+
+/**
+ * This custom hook returns available adverts for use with 'p2p_order_create' by calling 'p2p_advert_list' endpoint
+ */
+const useAdvertList = (
+ payload?: NonNullable>[1]>['payload'],
+ config?: NonNullable>[1]>['options']
+) => {
+ const { isSuccess } = useAuthorize();
+ const { data, fetchNextPage, ...rest } = useInfiniteQuery('p2p_advert_list', {
+ payload: { ...payload, offset: payload?.offset, limit: payload?.limit },
+ options: {
+ getNextPageParam: (lastPage, pages) => {
+ if (lastPage?.p2p_advert_list?.list.length === 0 || !lastPage?.p2p_advert_list?.list) return;
+
+ return pages.length;
+ },
+ enabled: isSuccess && (config?.enabled === undefined || config.enabled),
+ },
+ });
+
+ // Flatten the data array.
+ const flatten_data = React.useMemo(() => {
+ if (!data?.pages?.length) return;
+
+ return data?.pages?.flatMap(page => page?.p2p_advert_list?.list);
+ }, [data?.pages]);
+
+ // Add additional information to the 'p2p_advert_list' data
+ const modified_data = React.useMemo(() => {
+ if (!flatten_data?.length) return undefined;
+
+ return flatten_data.map(advert => ({
+ ...advert,
+ /** Determine if the rate is floating or fixed */
+ is_floating: advert?.rate_type === 'float',
+ /** The activation status of the advert. */
+ is_active: Boolean(advert?.is_active),
+ /** Indicates that this advert will appear on the main advert list. */
+ is_visible: Boolean(advert?.is_visible),
+ advertiser_details: {
+ ...advert?.advertiser_details,
+ /** Indicates that the advertiser is blocked by the current user. */
+ is_blocked: Boolean(advert?.advertiser_details.is_blocked),
+ /** Indicates that the advertiser is a favourite. */
+ is_favourite: Boolean(advert?.advertiser_details.is_favourite),
+ /** Indicates if the advertiser is currently online. */
+ is_online: Boolean(advert?.advertiser_details?.is_online),
+ /** Indicates that the advertiser was recommended in the most recent review by the current user. */
+ is_recommended: Boolean(advert?.advertiser_details?.is_recommended),
+ /** Indicates that the advertiser has not been recommended yet. */
+ has_not_been_recommended: advert?.advertiser_details.is_recommended === null,
+ },
+ }));
+ }, [flatten_data]);
+
+ return {
+ /** The 'p2p_advert_list' response. */
+ data: modified_data,
+ loadMoreAdverts: fetchNextPage,
+ ...rest,
+ };
+};
+
+export default useAdvertList;
diff --git a/src/hooks/api/advert/p2p-advert/useAdvertUpdate.ts b/src/hooks/api/advert/p2p-advert/useAdvertUpdate.ts
new file mode 100644
index 00000000..cf64af4d
--- /dev/null
+++ b/src/hooks/api/advert/p2p-advert/useAdvertUpdate.ts
@@ -0,0 +1,56 @@
+import { useCallback, useMemo } from 'react';
+import useMutation from '../../../../../useMutation';
+import useInvalidateQuery from '../../../../../useInvalidateQuery';
+
+type TPayload = Parameters>['mutate']>[0]['payload'];
+
+/** A custom hook that updates a P2P advert. This can only be used by an approved P2P advertiser.
+ *
+ * To update an advert, specify the payload arguments that should be updated, for instance:
+ * @example
+ * mutate({
+ "id": 1234, // required
+ "is_active": 0 // optional
+ });
+ *
+*/
+const useAdvertUpdate = () => {
+ const invalidate = useInvalidateQuery();
+ const {
+ data,
+ mutate: _mutate,
+ ...rest
+ } = useMutation('p2p_advert_update', {
+ onSuccess: () => {
+ invalidate('p2p_advert_list');
+ invalidate('p2p_advertiser_adverts');
+ },
+ });
+
+ const mutate = useCallback((payload: TPayload) => _mutate({ payload }), [_mutate]);
+
+ const modified_data = useMemo(() => {
+ const p2p_advert_update = data?.p2p_advert_update;
+ if (!p2p_advert_update) return undefined;
+
+ return {
+ ...p2p_advert_update,
+ /** Indicates if this is block trade advert or not. */
+ is_block_trade: Boolean(p2p_advert_update.block_trade),
+ /** The activation status of the advert. */
+ is_active: Boolean(p2p_advert_update.is_active),
+ /** Indicates that this advert will appear on the main advert list. */
+ is_visible: Boolean(p2p_advert_update.is_visible),
+ /** Indicates that the advert has been deleted. */
+ is_deleted: Boolean(p2p_advert_update.deleted),
+ };
+ }, [data?.p2p_advert_update]);
+
+ return {
+ data: modified_data,
+ mutate,
+ ...rest,
+ };
+};
+
+export default useAdvertUpdate;
diff --git a/src/hooks/api/advert/p2p-advertiser-adverts/index.ts b/src/hooks/api/advert/p2p-advertiser-adverts/index.ts
new file mode 100644
index 00000000..1f302b72
--- /dev/null
+++ b/src/hooks/api/advert/p2p-advertiser-adverts/index.ts
@@ -0,0 +1 @@
+export { default as useGet } from './useAdvertiserAdverts';
diff --git a/src/hooks/api/advert/p2p-advertiser-adverts/useAdvertiserAdverts.ts b/src/hooks/api/advert/p2p-advertiser-adverts/useAdvertiserAdverts.ts
new file mode 100644
index 00000000..a32b32f0
--- /dev/null
+++ b/src/hooks/api/advert/p2p-advertiser-adverts/useAdvertiserAdverts.ts
@@ -0,0 +1,70 @@
+import { useMemo } from 'react';
+import useInfiniteQuery from '../../../../../useInfiniteQuery';
+import useAuthorize from '../../../../useAuthorize';
+
+/** This custom hook returns a list of adverts under the current active client. */
+const useAdvertiserAdverts = (
+ payload?: NonNullable>[1]>['payload'],
+ config?: NonNullable>[1]>['options']
+) => {
+ const { isSuccess } = useAuthorize();
+ const { data, fetchNextPage, ...rest } = useInfiniteQuery('p2p_advertiser_adverts', {
+ payload: { ...payload, offset: payload?.offset, limit: payload?.limit },
+ options: {
+ ...config,
+ getNextPageParam: (lastPage, pages) => {
+ if (!lastPage?.p2p_advertiser_adverts?.list?.length) return;
+
+ return pages.length;
+ },
+ enabled: isSuccess && (config?.enabled === undefined || config.enabled),
+ },
+ });
+
+ const flatten_data = useMemo(() => {
+ if (!data?.pages?.length) return;
+
+ return data?.pages?.flatMap(page => page?.p2p_advertiser_adverts?.list);
+ }, [data?.pages]);
+
+ const modified_data = useMemo(() => {
+ if (!flatten_data?.length) return undefined;
+
+ return flatten_data.map(advert => ({
+ ...advert,
+ /** Determine if the rate is floating or fixed */
+ is_floating: advert?.rate_type === 'float',
+ /** The activation status of the advert. */
+ is_active: Boolean(advert?.is_active),
+ /** Indicates that this advert will appear on the main advert list. */
+ is_visible: Boolean(advert?.is_visible),
+ advertiser_details: {
+ ...advert?.advertiser_details,
+ /** Indicates that the advertiser is blocked by the current user. */
+ is_blocked: Boolean(advert?.advertiser_details.is_blocked),
+ /** Indicates that the advertiser is a favourite. */
+ is_favourite: Boolean(advert?.advertiser_details.is_favourite),
+ /** Indicates if the advertiser is currently online. */
+ is_online: Boolean(advert?.advertiser_details?.is_online),
+ /** Indicates that the advertiser was recommended in the most recent review by the current user. */
+ is_recommended: Boolean(advert?.advertiser_details?.is_recommended),
+ /** Indicates that the advertiser has not been recommended yet. */
+ has_not_been_recommended: advert?.advertiser_details.is_recommended === null,
+ },
+ /** 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]);
+
+ return {
+ /** The 'p2p_advertiser_adverts' response. */
+ data: modified_data,
+ /** Function to fetch the next batch of adverts */
+ loadMoreAdverts: fetchNextPage,
+ ...rest,
+ };
+};
+
+export default useAdvertiserAdverts;
diff --git a/src/hooks/api/advertiser/index.ts b/src/hooks/api/advertiser/index.ts
new file mode 100644
index 00000000..d52021c3
--- /dev/null
+++ b/src/hooks/api/advertiser/index.ts
@@ -0,0 +1 @@
+export * as advertiser from './p2p-advertiser';
diff --git a/src/hooks/api/advertiser/p2p-advertiser/index.ts b/src/hooks/api/advertiser/p2p-advertiser/index.ts
new file mode 100644
index 00000000..b6a87afd
--- /dev/null
+++ b/src/hooks/api/advertiser/p2p-advertiser/index.ts
@@ -0,0 +1,4 @@
+export { default as useCreate } from './useAdvertiserCreate';
+export { default as useGetInfo } from './useAdvertiserInfo';
+export { default as useGetList } from './useAdvertiserList';
+export { default as useUpdate } from './useAdvertiserUpdate';
diff --git a/src/hooks/api/advertiser/p2p-advertiser/useAdvertiserCreate.ts b/src/hooks/api/advertiser/p2p-advertiser/useAdvertiserCreate.ts
new file mode 100644
index 00000000..50c77d45
--- /dev/null
+++ b/src/hooks/api/advertiser/p2p-advertiser/useAdvertiserCreate.ts
@@ -0,0 +1,71 @@
+import { useCallback, useMemo } from 'react';
+import useMutation from '../../../../../useMutation';
+import useInvalidateQuery from '../../../../../useInvalidateQuery';
+
+type TCreateAdvertisePayload = NonNullable<
+ Parameters>['mutate']>
+>[0]['payload'];
+
+/** A custom hook that creates a P2P advertiser. This can only be used when the user is authorized.
+ *
+ * To create an advertiser, specify the following payload arguments in the `mutate` call:
+ * @example
+ * mutate({
+ payload: {
+ name: 'John Doe',
+ }
+ });
+ *
+*/
+const useAdvertiserCreate = () => {
+ const invalidate = useInvalidateQuery();
+ const {
+ data,
+ mutate: _mutate,
+ ...rest
+ } = useMutation('p2p_advertiser_create', {
+ onSuccess: () => {
+ invalidate('p2p_advertiser_info');
+ },
+ });
+
+ const mutate = useCallback(
+ (payload: TCreateAdvertisePayload) => {
+ _mutate({ payload });
+ },
+ [_mutate]
+ );
+
+ const modified_data = useMemo(() => {
+ const advertiser = data?.p2p_advertiser_create;
+
+ if (!advertiser) return undefined;
+
+ const { basic_verification, full_verification, is_approved, is_listed, is_online, show_name, created_time } =
+ advertiser;
+
+ return {
+ ...data?.p2p_advertiser_create,
+ /** Indicating whether the advertiser's identify has been verified. */
+ has_basic_verification: Boolean(basic_verification),
+ /** Indicating whether the advertiser's address has been verified. */
+ has_full_verification: Boolean(full_verification),
+ /** The approval status of the advertiser. */
+ is_approved: Boolean(is_approved),
+ /** Indicates if the advertiser's active adverts are listed. When false, adverts won't be listed regardless if they are active or not. */
+ is_listed: Boolean(is_listed),
+ /** Indicates if the advertiser is currently online. */
+ is_online: Boolean(is_online),
+ /** When true, the advertiser's real name will be displayed on to other users on adverts and orders. */
+ should_show_name: Boolean(show_name),
+ };
+ }, [data]);
+
+ return {
+ data: modified_data,
+ mutate,
+ ...rest,
+ };
+};
+
+export default useAdvertiserCreate;
diff --git a/src/hooks/api/advertiser/p2p-advertiser/useAdvertiserInfo.ts b/src/hooks/api/advertiser/p2p-advertiser/useAdvertiserInfo.ts
new file mode 100644
index 00000000..f70e48ab
--- /dev/null
+++ b/src/hooks/api/advertiser/p2p-advertiser/useAdvertiserInfo.ts
@@ -0,0 +1,94 @@
+import { useCallback, useEffect } from 'react';
+import { useLocalStorage } from 'usehooks-ts';
+
+import { TSocketRequestPayload, TSocketResponseData } from '../../../../../types';
+import useSubscription from '../../../../../useSubscription';
+
+type TP2PAdvertiserInfo = TSocketResponseData<'p2p_advertiser_info'>['p2p_advertiser_info'] & {
+ has_basic_verification: boolean;
+ has_full_verification: boolean;
+ is_approved_boolean: boolean;
+ is_blocked_boolean: boolean;
+ is_favourite_boolean: boolean;
+ is_listed_boolean: boolean;
+ is_online_boolean: boolean;
+ should_show_name: boolean;
+};
+
+type TPayload = NonNullable> & { id?: string };
+
+/** This custom hook returns information about the given advertiser ID */
+const useAdvertiserInfo = (id?: string) => {
+ const { data, error, subscribe: subscribeAdvertiserInfo, ...rest } = useSubscription('p2p_advertiser_info');
+
+ /**
+ * Use different local storage key for each advertiser, one to keep the current user's info, the other to keep the advertiser's info
+ * This is to prevent the current user's info from being overwritten by the advertiser's info when the current user views the advertiser's profile.
+ *
+ * Key removal is handled in useAdvertiserStats hook's useEffect.
+ * */
+ const local_storage_key = id ? `p2p_v2_p2p_advertiser_info_${id}` : 'p2p_v2_p2p_advertiser_info';
+ const [p2p_advertiser_info, setP2PAdvertiserInfo] = useLocalStorage>(
+ local_storage_key,
+ {}
+ );
+
+ const subscribe = useCallback(
+ (payload?: TPayload) => {
+ subscribeAdvertiserInfo({ payload });
+ },
+ [subscribeAdvertiserInfo]
+ );
+
+ // Add additional information to the p2p_advertiser_info data
+ useEffect(() => {
+ if (data) {
+ const advertiser_info = data?.p2p_advertiser_info;
+
+ if (!advertiser_info) return;
+
+ const {
+ basic_verification,
+ full_verification,
+ is_approved,
+ is_blocked,
+ is_favourite,
+ is_listed,
+ is_online,
+ show_name,
+ } = advertiser_info;
+
+ setP2PAdvertiserInfo({
+ ...advertiser_info,
+ /** Indicating whether the advertiser's identify has been verified. */
+ has_basic_verification: Boolean(basic_verification),
+ /** Indicating whether the advertiser's address has been verified. */
+ has_full_verification: Boolean(full_verification),
+ /** The approval status of the advertiser. */
+ is_approved_boolean: Boolean(is_approved),
+ /** Indicates that the advertiser is blocked by the current user. */
+ is_blocked_boolean: Boolean(is_blocked),
+ /** Indicates that the advertiser is a favourite of the current user. */
+ is_favourite_boolean: Boolean(is_favourite),
+ /** Indicates if the advertiser's active adverts are listed. When false, adverts won't be listed regardless if they are active or not. */
+ is_listed_boolean: Boolean(is_listed),
+ /** Indicates if the advertiser is currently online. */
+ is_online_boolean: Boolean(is_online),
+ /** When true, the advertiser's real name will be displayed on to other users on adverts and orders. */
+ should_show_name: Boolean(show_name),
+ });
+ } else if (error) {
+ setP2PAdvertiserInfo({});
+ }
+ }, [data, error, setP2PAdvertiserInfo]);
+
+ return {
+ /** P2P advertiser information */
+ data: p2p_advertiser_info,
+ error,
+ subscribe,
+ ...rest,
+ };
+};
+
+export default useAdvertiserInfo;
diff --git a/src/hooks/api/advertiser/p2p-advertiser/useAdvertiserList.ts b/src/hooks/api/advertiser/p2p-advertiser/useAdvertiserList.ts
new file mode 100644
index 00000000..88981901
--- /dev/null
+++ b/src/hooks/api/advertiser/p2p-advertiser/useAdvertiserList.ts
@@ -0,0 +1,70 @@
+import useInfiniteQuery from '../../../../../useInfiniteQuery';
+import useAuthorize from '../../../../useAuthorize';
+
+/**
+ * This custom hook returns the available advertisers who have had or currently have trades with the current advertiser.
+ */
+const useAdvertiserList = (
+ payload?: NonNullable>[1]>['payload']
+) => {
+ const { isSuccess } = useAuthorize();
+ if (!payload?.is_blocked) {
+ delete payload?.is_blocked;
+ }
+ if (!payload?.advertiser_name) {
+ delete payload?.advertiser_name;
+ }
+ const { data, fetchNextPage, ...rest } = useInfiniteQuery('p2p_advertiser_list', {
+ payload: { ...payload, offset: payload?.offset, limit: payload?.limit },
+ options: {
+ getNextPageParam: (lastPage, pages) => {
+ if (!lastPage?.p2p_advertiser_list?.list?.length) return;
+
+ return pages.length;
+ },
+ enabled: isSuccess,
+ refetchOnWindowFocus: false,
+ },
+ });
+
+ // Flatten the data array.
+ const flatten_data = React.useMemo(() => {
+ if (!data?.pages?.length) return;
+
+ return data?.pages?.flatMap(page => page?.p2p_advertiser_list?.list);
+ }, [data?.pages]);
+
+ // Add additional information to the 'p2p_advertiser_list' data
+ const modified_data = React.useMemo(() => {
+ if (!flatten_data?.length) return undefined;
+
+ return flatten_data.map(advertiser => ({
+ ...advertiser,
+ /** Indicating whether the advertiser's identity has been verified. */
+ is_basic_verified: Boolean(advertiser?.basic_verification),
+ /** Indicating whether the advertiser's address has been verified. */
+ is_fully_verified: Boolean(advertiser?.full_verification),
+ /** The approval status of the advertiser. */
+ is_approved: Boolean(advertiser?.is_approved),
+ /** Indicates that the advertiser is blocked. */
+ is_blocked: Boolean(advertiser?.is_blocked),
+ /** Indicates that the advertiser is a favourite. */
+ is_favourite: Boolean(advertiser?.is_favourite),
+ /** Indicates if the advertiser's active adverts are listed. When false, adverts won't be listed regardless if they are active or not. */
+ is_listed: Boolean(advertiser?.is_listed),
+ /** Indicates if the advertiser is currently online. */
+ is_online: Boolean(advertiser?.is_online),
+ /** Indicates that the advertiser was recommended in the most recent review by the current user. */
+ is_recommended: Boolean(advertiser?.is_recommended),
+ }));
+ }, [flatten_data]);
+
+ return {
+ /** P2P advertiser list */
+ data: modified_data,
+ loadMoreAdvertisers: fetchNextPage,
+ ...rest,
+ };
+};
+
+export default useAdvertiserList;
diff --git a/src/hooks/api/advertiser/p2p-advertiser/useAdvertiserUpdate.ts b/src/hooks/api/advertiser/p2p-advertiser/useAdvertiserUpdate.ts
new file mode 100644
index 00000000..b8057fd9
--- /dev/null
+++ b/src/hooks/api/advertiser/p2p-advertiser/useAdvertiserUpdate.ts
@@ -0,0 +1,66 @@
+import { useCallback, useMemo } from 'react';
+import useMutation from '../../../../../useMutation';
+import useInvalidateQuery from '../../../../../useInvalidateQuery';
+
+type TPayload = NonNullable<
+ Parameters>['mutate']>[0]
+>['payload'];
+
+/** A custom hook that updates the information of the P2P advertiser for the current account. Can only be used by an approved P2P advertiser
+ *
+ * To update an advertiser, specify the payload arguments that should be updated, for instance:
+ * @example
+ * mutate({
+ "is_listed": 0 // optional
+ "upgrade_limits": 1 // optional
+ });
+ *
+*/
+const useAdvertiserUpdate = () => {
+ const invalidate = useInvalidateQuery();
+ const {
+ data,
+ mutate: _mutate,
+ ...rest
+ } = useMutation('p2p_advertiser_update', {
+ onSuccess: () => {
+ invalidate('p2p_advertiser_info');
+ },
+ });
+
+ const mutate = useCallback((payload: TPayload) => _mutate({ payload }), [_mutate]);
+
+ const modified_data = useMemo(() => {
+ const p2p_advertiser_update = data?.p2p_advertiser_update;
+ if (!p2p_advertiser_update) return undefined;
+
+ const { basic_verification, full_verification, is_approved, is_listed, is_online, show_name } =
+ p2p_advertiser_update;
+
+ return {
+ ...p2p_advertiser_update,
+ /** Indicating whether the advertiser's identity has been verified. */
+ is_basic_verified: Boolean(basic_verification),
+ /** Indicating whether the advertiser's address has been verified. */
+ is_fully_verified: Boolean(full_verification),
+ /** The approval status of the advertiser. */
+ is_approved: Boolean(is_approved),
+ /** Indicates if the advertiser's active adverts are listed. When false, adverts won't be listed regardless if they are active or not. */
+ is_listed: Boolean(is_listed),
+ /** Indicates if the advertiser is currently online. */
+ is_online: Boolean(is_online),
+ /** When true, the advertiser's real name will be displayed on to other users on adverts and orders. */
+ should_show_name: Boolean(show_name),
+ };
+ }, [data?.p2p_advertiser_update]);
+
+ return {
+ /** Returns latest information of the advertiser from p2p_advertiser endpoint */
+ data: modified_data,
+ /** Sends a request to update the information of the P2P advertiser for the current account. Can only be used by an approved P2P advertiser. */
+ mutate,
+ ...rest,
+ };
+};
+
+export default useAdvertiserUpdate;
diff --git a/src/hooks/api/chat/index.ts b/src/hooks/api/chat/index.ts
new file mode 100644
index 00000000..9819fc39
--- /dev/null
+++ b/src/hooks/api/chat/index.ts
@@ -0,0 +1 @@
+export * as chat from './p2p-chat';
diff --git a/src/hooks/api/chat/p2p-chat/index.ts b/src/hooks/api/chat/p2p-chat/index.ts
new file mode 100644
index 00000000..51bbba0b
--- /dev/null
+++ b/src/hooks/api/chat/p2p-chat/index.ts
@@ -0,0 +1 @@
+export { default as useCreate } from './useChatCreate';
diff --git a/src/hooks/api/chat/p2p-chat/useChatCreate.ts b/src/hooks/api/chat/p2p-chat/useChatCreate.ts
new file mode 100644
index 00000000..f0981666
--- /dev/null
+++ b/src/hooks/api/chat/p2p-chat/useChatCreate.ts
@@ -0,0 +1,36 @@
+import { useCallback } from 'react';
+import useMutation from '../../../../../useMutation';
+import useInvalidateQuery from '../../../../../useInvalidateQuery';
+
+type TPayload = NonNullable>['mutate']>>[0]['payload'];
+
+/**
+ * A custom hook to create a p2p chat for the specified order.
+ *
+ * @example
+ * const { data, mutate } = useChatCreate();
+ * mutate({ order_id: 'order_id' });
+ * **/
+const useChatCreate = () => {
+ const invalidate = useInvalidateQuery();
+ const {
+ data,
+ mutate: _mutate,
+ ...rest
+ } = useMutation('p2p_chat_create', {
+ onSuccess: () => {
+ invalidate('p2p_order_info');
+ },
+ });
+ const mutate = useCallback((payload: TPayload) => _mutate({ payload }), [_mutate]);
+
+ return {
+ /** An object containing the chat channel_url and order_id **/
+ data: data?.p2p_chat_create,
+ /** Function to create a p2p chat for the specified order **/
+ mutate,
+ ...rest,
+ };
+};
+
+export default useChatCreate;
diff --git a/src/hooks/api/counterparty/index.ts b/src/hooks/api/counterparty/index.ts
new file mode 100644
index 00000000..7ea25614
--- /dev/null
+++ b/src/hooks/api/counterparty/index.ts
@@ -0,0 +1 @@
+export * as counterparty from './p2p-advertiser-relations';
diff --git a/src/hooks/api/counterparty/p2p-advertiser-relations/index.ts b/src/hooks/api/counterparty/p2p-advertiser-relations/index.ts
new file mode 100644
index 00000000..f1ee6a91
--- /dev/null
+++ b/src/hooks/api/counterparty/p2p-advertiser-relations/index.ts
@@ -0,0 +1,3 @@
+export { default as useGet } from './useAdvertiserRelations';
+export { default as useBlock } from './useAdvertiserRelationsAddBlocked';
+export { default as useUnblock } from './useAdvertiserRelationsRemoveBlocked';
diff --git a/src/hooks/api/counterparty/p2p-advertiser-relations/useAdvertiserRelations.ts b/src/hooks/api/counterparty/p2p-advertiser-relations/useAdvertiserRelations.ts
new file mode 100644
index 00000000..0d5e5a0e
--- /dev/null
+++ b/src/hooks/api/counterparty/p2p-advertiser-relations/useAdvertiserRelations.ts
@@ -0,0 +1,36 @@
+import useInvalidateQuery from '../../../../../useInvalidateQuery';
+import useMutation from '../../../../../useMutation';
+import useQuery from '../../../../../useQuery';
+import useAuthorize from '../../../../useAuthorize';
+
+/** This hook returns favourite and blocked advertisers and the mutation function to update the block list of the current user. */
+const useAdvertiserRelations = () => {
+ const { isSuccess } = useAuthorize();
+ const invalidate = useInvalidateQuery();
+ const { data, ...rest } = useQuery('p2p_advertiser_relations', { options: { enabled: isSuccess } });
+ const { mutate, ...mutate_rest } = useMutation('p2p_advertiser_relations', {
+ onSuccess: () => {
+ invalidate('p2p_advertiser_relations');
+ invalidate('p2p_advertiser_list');
+ },
+ });
+
+ const advertiser_relations = data?.p2p_advertiser_relations;
+
+ return {
+ /** P2P advertiser relations information. */
+ data: advertiser_relations,
+ /** Blocked advertisers by the current user. */
+ blocked_advertisers: advertiser_relations?.blocked_advertisers,
+ /** Favourite advertisers of the current user. */
+ favourite_advertisers: advertiser_relations?.favourite_advertisers,
+
+ /** The mutation function to update (add/remove) the currrent user's block list. */
+ mutate,
+ /** The mutation related information. */
+ mutation: mutate_rest,
+ ...rest,
+ };
+};
+
+export default useAdvertiserRelations;
diff --git a/src/hooks/api/counterparty/p2p-advertiser-relations/useAdvertiserRelationsAddBlocked.ts b/src/hooks/api/counterparty/p2p-advertiser-relations/useAdvertiserRelationsAddBlocked.ts
new file mode 100644
index 00000000..46db9290
--- /dev/null
+++ b/src/hooks/api/counterparty/p2p-advertiser-relations/useAdvertiserRelationsAddBlocked.ts
@@ -0,0 +1,18 @@
+import { useCallback } from 'react';
+import useAdvertiserRelations from './useAdvertiserRelations';
+
+/** This hook blocks advertisers of the current user by passing the advertiser id. */
+const useAdvertiserRelationsAddBlocked = () => {
+ const { mutate, data, ...rest } = useAdvertiserRelations();
+
+ const addBlockedAdvertiser = useCallback((id: number[]) => mutate({ payload: { add_blocked: id } }), [mutate]);
+
+ return {
+ data,
+ /** Sends a request to block advertiser of the current user by passing the advertiser id. */
+ mutate: addBlockedAdvertiser,
+ ...rest,
+ };
+};
+
+export default useAdvertiserRelationsAddBlocked;
diff --git a/src/hooks/api/counterparty/p2p-advertiser-relations/useAdvertiserRelationsRemoveBlocked.ts b/src/hooks/api/counterparty/p2p-advertiser-relations/useAdvertiserRelationsRemoveBlocked.ts
new file mode 100644
index 00000000..eabdd851
--- /dev/null
+++ b/src/hooks/api/counterparty/p2p-advertiser-relations/useAdvertiserRelationsRemoveBlocked.ts
@@ -0,0 +1,21 @@
+import { useCallback } from 'react';
+import useAdvertiserRelations from './useAdvertiserRelations';
+
+/** This hook unblocks advertisers of the current user by passing the advertiser id. */
+const useAdvertiserRelationsRemoveBlocked = () => {
+ const { mutate, data, ...rest } = useAdvertiserRelations();
+
+ const removeBlockedAdvertiser = useCallback(
+ (id: number[]) => mutate({ payload: { remove_blocked: id } }),
+ [mutate]
+ );
+
+ return {
+ data,
+ /** Sends a request to unblock advertiser of the current user by passing the advertiser id. */
+ mutate: removeBlockedAdvertiser,
+ ...rest,
+ };
+};
+
+export default useAdvertiserRelationsRemoveBlocked;
diff --git a/src/hooks/api/country/index.ts b/src/hooks/api/country/index.ts
new file mode 100644
index 00000000..21df04f9
--- /dev/null
+++ b/src/hooks/api/country/index.ts
@@ -0,0 +1 @@
+export * as countryList from './p2p-country-list';
diff --git a/src/hooks/api/country/p2p-country-list/index.ts b/src/hooks/api/country/p2p-country-list/index.ts
new file mode 100644
index 00000000..fa030a68
--- /dev/null
+++ b/src/hooks/api/country/p2p-country-list/index.ts
@@ -0,0 +1 @@
+export { default as useGet } from './useCountryList';
diff --git a/src/hooks/api/country/p2p-country-list/useCountryList.ts b/src/hooks/api/country/p2p-country-list/useCountryList.ts
new file mode 100644
index 00000000..e8539c04
--- /dev/null
+++ b/src/hooks/api/country/p2p-country-list/useCountryList.ts
@@ -0,0 +1,25 @@
+import useQuery from '../../../../../useQuery';
+import useAuthorize from '../../../../useAuthorize';
+
+/**
+ * A custom hook that returns an object containing the list of countries available for P2P trading.
+ *
+ * For returning details of a specific country, the country code can be passed in the payload.
+ * @example: useCountryList({ country: 'id' })
+ *
+ */
+
+const useCountryList = (payload?: NonNullable>[1]>['payload']) => {
+ const { isSuccess } = useAuthorize();
+ const { data, ...rest } = useQuery('p2p_country_list', {
+ payload,
+ options: { enabled: isSuccess, refetchOnWindowFocus: false },
+ });
+
+ return {
+ data: data?.p2p_country_list,
+ ...rest,
+ };
+};
+
+export default useCountryList;
diff --git a/src/hooks/api/index.ts b/src/hooks/api/index.ts
new file mode 100644
index 00000000..08eed127
--- /dev/null
+++ b/src/hooks/api/index.ts
@@ -0,0 +1,8 @@
+export * from './advert';
+export * from './advertiser';
+export * from './counterparty';
+export * from './country';
+export * from './order-dispute';
+export * from './order';
+export * from './payment-method';
+export * from './settings';
diff --git a/src/hooks/api/order-dispute/index.ts b/src/hooks/api/order-dispute/index.ts
new file mode 100644
index 00000000..44b0e298
--- /dev/null
+++ b/src/hooks/api/order-dispute/index.ts
@@ -0,0 +1 @@
+export * as orderDispute from './p2p-order-dispute';
diff --git a/src/hooks/api/order-dispute/p2p-order-dispute/index.ts b/src/hooks/api/order-dispute/p2p-order-dispute/index.ts
new file mode 100644
index 00000000..0e0f68e5
--- /dev/null
+++ b/src/hooks/api/order-dispute/p2p-order-dispute/index.ts
@@ -0,0 +1 @@
+export { default as useDispute } from './useOrderDispute';
diff --git a/src/hooks/api/order-dispute/p2p-order-dispute/useOrderDispute.ts b/src/hooks/api/order-dispute/p2p-order-dispute/useOrderDispute.ts
new file mode 100644
index 00000000..2fae4c48
--- /dev/null
+++ b/src/hooks/api/order-dispute/p2p-order-dispute/useOrderDispute.ts
@@ -0,0 +1,80 @@
+import { useCallback, useMemo } from 'react';
+import useMutation from '../../../../../useMutation';
+import useInvalidateQuery from '../../../../../useInvalidateQuery';
+
+type TOrderDisputePayload = NonNullable<
+ Parameters>['mutate']>
+>[0]['payload'];
+
+/** A custom hook that disputes a P2P order.
+ *
+ * To dispute an order, specify the following payload arguments in the `mutate` call:
+ * @example
+ * mutate({
+ * id: "1234",
+ dispute_reason: "seller_not_released",
+ });
+ *
+*/
+const useOrderDispute = () => {
+ const invalidate = useInvalidateQuery();
+ const {
+ data,
+ mutate: _mutate,
+ ...rest
+ } = useMutation('p2p_order_dispute', {
+ onSuccess: () => {
+ invalidate('p2p_order_info');
+ },
+ });
+
+ const mutate = useCallback(
+ (payload: TOrderDisputePayload) => {
+ _mutate({ payload });
+ },
+ [_mutate]
+ );
+
+ const modified_data = useMemo(() => {
+ const p2p_order_dispute = data?.p2p_order_dispute;
+
+ if (!p2p_order_dispute) return undefined;
+
+ return {
+ ...p2p_order_dispute,
+ advert_details: {
+ ...p2p_order_dispute.advert_details,
+ /** Indicates if this is block trade advert or not. */
+ is_block_trade: Boolean(p2p_order_dispute.advert_details.block_trade),
+ },
+ advertiser_details: {
+ ...p2p_order_dispute.advertiser_details,
+ /** Indicates if the advertiser is currently online. */
+ is_online: Boolean(p2p_order_dispute.advertiser_details.is_online),
+ },
+ client_details: {
+ ...p2p_order_dispute.client_details,
+ /** Indicates if the client is currently online. */
+ is_online: Boolean(p2p_order_dispute.advertiser_details.is_online),
+ },
+ /** Indicates if the order is created for the advert of the current client, */
+ is_incoming: Boolean(p2p_order_dispute.is_incoming),
+ /** Indicates if a review can be given */
+ is_reviewable: Boolean(p2p_order_dispute.is_reviewable),
+ /** Indicates if the latest order changes have been seen by the current client */
+ is_seen: Boolean(p2p_order_dispute.is_seen),
+ /** Indicates that the seller in the process of confirming the order. */
+ is_verification_pending: Boolean(p2p_order_dispute.verification_pending),
+ };
+ }, [data]);
+
+ return {
+ /** Data returned after disputing an order */
+ data: modified_data,
+ /** mutate function to dispute an order */
+ mutate,
+ ...rest,
+ };
+};
+
+export default useOrderDispute;
diff --git a/src/hooks/api/order-review/index.ts b/src/hooks/api/order-review/index.ts
new file mode 100644
index 00000000..c2f0541b
--- /dev/null
+++ b/src/hooks/api/order-review/index.ts
@@ -0,0 +1 @@
+export * as orderReview from './p2p-order-review';
diff --git a/src/hooks/api/order-review/p2p-order-review/index.ts b/src/hooks/api/order-review/p2p-order-review/index.ts
new file mode 100644
index 00000000..9741ea57
--- /dev/null
+++ b/src/hooks/api/order-review/p2p-order-review/index.ts
@@ -0,0 +1 @@
+export { default as useReview } from './useOrderReview';
diff --git a/src/hooks/api/order-review/p2p-order-review/useOrderReview.ts b/src/hooks/api/order-review/p2p-order-review/useOrderReview.ts
new file mode 100644
index 00000000..33df4acd
--- /dev/null
+++ b/src/hooks/api/order-review/p2p-order-review/useOrderReview.ts
@@ -0,0 +1,61 @@
+import { useCallback, useMemo } from 'react';
+import useMutation from '../../../../../useMutation';
+import useInvalidateQuery from '../../../../../useInvalidateQuery';
+
+type TOrderReviewPayload = NonNullable<
+ Parameters>['mutate']>
+>[0]['payload'];
+
+/** A custom hook that creates a review for a specified order
+ *
+ * To create a review for an order, specify the required fields order_id and rating to the mutation payload:
+ * @example
+ * mutate({
+ order_id: '1234',
+ rating: 4,
+ recommended: 1 // optional
+ });
+ *
+*/
+const useOrderReview = () => {
+ const invalidate = useInvalidateQuery();
+ const {
+ data,
+ mutate: _mutate,
+ ...rest
+ } = useMutation('p2p_order_review', {
+ onSuccess: () => {
+ invalidate('p2p_order_list');
+ },
+ });
+
+ const mutate = useCallback(
+ (payload: TOrderReviewPayload) => {
+ _mutate({ payload });
+ },
+ [_mutate]
+ );
+
+ const modified_data = useMemo(() => {
+ const p2p_order_review = data?.p2p_order_review;
+
+ if (!p2p_order_review) return undefined;
+
+ return {
+ ...p2p_order_review,
+ // Flag to check if the advertiser is recommended
+ is_recommended: Boolean(p2p_order_review.recommended),
+ // Flag to check if the advertiser has not been recommended yet
+ has_not_been_recommended: p2p_order_review.recommended === null,
+ };
+ }, [data]);
+
+ return {
+ /** Data returned after a review was created for the order */
+ data: modified_data,
+ /** mutate function to create a review for a specified order */
+ mutate,
+ ...rest,
+ };
+};
+export default useOrderReview;
diff --git a/src/hooks/api/order/index.ts b/src/hooks/api/order/index.ts
new file mode 100644
index 00000000..9ceb2d69
--- /dev/null
+++ b/src/hooks/api/order/index.ts
@@ -0,0 +1 @@
+export * as order from './p2p-order';
diff --git a/src/hooks/api/order/p2p-order/index.ts b/src/hooks/api/order/p2p-order/index.ts
new file mode 100644
index 00000000..0d0f3f9f
--- /dev/null
+++ b/src/hooks/api/order/p2p-order/index.ts
@@ -0,0 +1,5 @@
+export { default as useCancel } from './useOrderCancel';
+export { default as useConfirm } from './useOrderConfirm';
+export { default as useCreate } from './useOrderCreate';
+export { default as useGet } from './useOrderInfo';
+export { default as useGetList } from './useOrderList';
diff --git a/src/hooks/api/order/p2p-order/useOrderCancel.ts b/src/hooks/api/order/p2p-order/useOrderCancel.ts
new file mode 100644
index 00000000..3b2042cb
--- /dev/null
+++ b/src/hooks/api/order/p2p-order/useOrderCancel.ts
@@ -0,0 +1,41 @@
+import { useCallback } from 'react';
+import useMutation from '../../../../../useMutation';
+import useInvalidateQuery from '../../../../../useInvalidateQuery';
+
+type TOrderCancelPayload = NonNullable<
+ Parameters>['mutate']>
+>[0]['payload'];
+
+/** A custom hook that cancels a P2P order.
+ *
+ * To cancel an order, specify the following payload arguments in the `mutate` call:
+ * @example
+ * mutate({
+ * id: "1234",
+ });
+ *
+*/
+const useOrderCancel = () => {
+ const invalidate = useInvalidateQuery();
+ const {
+ data,
+ mutate: _mutate,
+ ...rest
+ } = useMutation('p2p_order_cancel', {
+ onSuccess: () => {
+ invalidate('p2p_order_info');
+ },
+ });
+
+ const mutate = useCallback((payload: TOrderCancelPayload) => _mutate({ payload }), [_mutate]);
+
+ return {
+ /** An object that contains the id and status of the order */
+ data: data?.p2p_order_cancel,
+ /** A function that cancels a specific order */
+ mutate,
+ ...rest,
+ };
+};
+
+export default useOrderCancel;
diff --git a/src/hooks/api/order/p2p-order/useOrderConfirm.ts b/src/hooks/api/order/p2p-order/useOrderConfirm.ts
new file mode 100644
index 00000000..b5eca2c3
--- /dev/null
+++ b/src/hooks/api/order/p2p-order/useOrderConfirm.ts
@@ -0,0 +1,58 @@
+import { useCallback, useMemo } from 'react';
+import useMutation from '../../../../../useMutation';
+import useInvalidateQuery from '../../../../../useInvalidateQuery';
+
+type Tpayload = NonNullable>['mutate']>>[0]['payload'];
+
+/**
+ * A custom hook for handling P2P order confirmation
+ *
+ * @example
+ * ```typescript
+ * const { data, mutate } = useOrderConfirm();
+ *
+ * mutate({
+ * id: '1234',
+ * dry_run: 1,
+ * verification_code: 'verification_code',
+ * });
+ * // Access order confirmation details from 'data' and use 'mutate' function to confirm an order.
+ * ```
+ * **/
+const useOrderConfirm = () => {
+ const invalidate = useInvalidateQuery();
+
+ const {
+ data,
+ mutate: _mutate,
+ ...rest
+ } = useMutation('p2p_order_confirm', {
+ onSuccess: () => {
+ invalidate('p2p_order_info');
+ },
+ });
+
+ const modified_data = useMemo(() => {
+ const p2p_order_confirm = data?.p2p_order_confirm;
+
+ if (!p2p_order_confirm) return undefined;
+
+ return {
+ ...p2p_order_confirm,
+ /** Indicates whether a dry run was successful or not (for dry run confirmations) **/
+ is_dry_run_successful: Boolean(p2p_order_confirm.dry_run),
+ };
+ }, [data?.p2p_order_confirm]);
+
+ const mutate = useCallback((payload: Tpayload) => _mutate({ payload }), [_mutate]);
+
+ return {
+ /** Order confirmation details **/
+ data: modified_data,
+ /** Function to confirm an order or perform a dry run (incase the dry_run option is specified in the payload) **/
+ mutate,
+ ...rest,
+ };
+};
+
+export default useOrderConfirm;
diff --git a/src/hooks/api/order/p2p-order/useOrderCreate.ts b/src/hooks/api/order/p2p-order/useOrderCreate.ts
new file mode 100644
index 00000000..bc263f4b
--- /dev/null
+++ b/src/hooks/api/order/p2p-order/useOrderCreate.ts
@@ -0,0 +1,74 @@
+import { useCallback, useMemo } from 'react';
+import useMutation from '../../../../../useMutation';
+import useInvalidateQuery from '../../../../../useInvalidateQuery';
+
+type TOrderCreatePayload = Parameters>['mutate']>[0]['payload'];
+
+/** A custom hook that creates a P2P order.
+ *
+ * To create an order, specify the following payload arguments in the `mutate` call (some arguments are optional):
+ * @example
+ * mutate({
+ advert_id: '12345',
+ amount: '100',
+ contact_info: '012345678',
+ payment_info: 'Some payment info',
+ });
+ *
+*/
+const useOrderCreate = () => {
+ const invalidate = useInvalidateQuery();
+ const {
+ data,
+ mutate: _mutate,
+ ...rest
+ } = useMutation('p2p_order_create', {
+ onSuccess: () => {
+ invalidate('p2p_order_list');
+ },
+ });
+
+ const mutate = useCallback((payload: TOrderCreatePayload) => _mutate({ payload }), [_mutate]);
+
+ const modified_data = useMemo(() => {
+ if (!data?.p2p_order_create) return undefined;
+
+ const { advert_details, advertiser_details, client_details, is_incoming, is_reviewable, is_seen } =
+ data.p2p_order_create;
+
+ return {
+ ...data.p2p_order_create,
+ advert_details: {
+ ...advert_details,
+ /** Indicates if this is block trade advert or not. */
+ is_block_trade: Boolean(advert_details.block_trade),
+ },
+ advertiser_details: {
+ ...advertiser_details,
+ /** Indicates if the advertiser is currently online. */
+ is_online: Boolean(advertiser_details.is_online),
+ },
+ client_details: {
+ ...client_details,
+ /** Indicates if the advertiser is currently online. */
+ is_online: Boolean(client_details.is_online),
+ },
+ /** Indicates if the order is created for the advert of the current client. */
+ is_incoming: Boolean(is_incoming),
+ /** Indicates if a review can be given. */
+ is_reviewable: Boolean(is_reviewable),
+ /** Indicates if the latest order changes have been seen by the current client. */
+ is_seen: Boolean(is_seen),
+ };
+ }, [data?.p2p_order_create]);
+
+ return {
+ /** The 'p2p_order_create' response. */
+ data: modified_data,
+ /** Sends a request to create a P2P order. */
+ mutate,
+ ...rest,
+ };
+};
+
+export default useOrderCreate;
diff --git a/src/hooks/api/order/p2p-order/useOrderInfo.ts b/src/hooks/api/order/p2p-order/useOrderInfo.ts
new file mode 100644
index 00000000..f440e6f6
--- /dev/null
+++ b/src/hooks/api/order/p2p-order/useOrderInfo.ts
@@ -0,0 +1,89 @@
+import { useCallback, useMemo } from 'react';
+import useSubscription from '../../../../../useSubscription';
+
+type TPayload = WithRequiredProperty<
+ NonNullable>['subscribe']>>[0]['payload'],
+ 'id'
+>;
+
+// TODO: Convert this to use useSubscribe as it is a subscribable endpoint
+/** This custom hook that returns information about the given order ID */
+const useOrderInfo = () => {
+ const { data, subscribe: subscribeOrderInfo, ...rest } = useSubscription('p2p_order_info');
+
+ const subscribe = useCallback(
+ (payload: TPayload) => {
+ subscribeOrderInfo({ payload });
+ },
+ [subscribeOrderInfo]
+ );
+
+ // modify the data to add additional information
+ const modified_data = useMemo(() => {
+ if (!data?.p2p_order_info) return undefined;
+
+ const {
+ advert_details,
+ advertiser_details,
+ client_details,
+ is_incoming,
+ is_reviewable,
+ is_seen,
+ review_details,
+ verification_pending,
+ } = data.p2p_order_info;
+
+ return {
+ ...data.p2p_order_info,
+ advert_details: {
+ ...advert_details,
+ /** Indicates if this is block trade advert or not. */
+ is_block_trade: Boolean(advert_details.block_trade),
+ },
+ advertiser_details: {
+ ...advertiser_details,
+ /** Indicates if the advertiser is currently online. */
+ is_online: Boolean(advertiser_details.is_online),
+ /** Indicates that the advertiser was recommended in the most recent review by the current user. */
+ is_recommended: Boolean(client_details.is_recommended),
+ /** Indicates that the advertiser has not been recommended yet. */
+ has_not_been_recommended: advertiser_details.is_recommended === null,
+ },
+ client_details: {
+ ...client_details,
+ /** Indicates if the advertiser is currently online. */
+ is_online: Boolean(client_details.is_online),
+ /** Indicates that the client was recommended in the most recent review by the current user. */
+ is_recommended: Boolean(client_details.is_recommended),
+ /** Indicates that the client has not been recommended yet. */
+ has_not_been_recommended: client_details.is_recommended === null,
+ },
+ /** Indicates if the order is created for the advert of the client. */
+ is_incoming: Boolean(is_incoming),
+ /** Indicates if a review can be given. */
+ is_reviewable: Boolean(is_reviewable),
+ /** Indicates if the latest order changes have been seen by the current client. */
+ is_seen: Boolean(is_seen),
+ review_details: review_details
+ ? {
+ ...review_details,
+ /** Indicates if the advertiser is recommended or not. */
+ is_recommended: Boolean(review_details?.recommended),
+ /** Indicates that the advertiser has not been recommended yet. */
+ has_not_been_recommended: review_details?.recommended === null,
+ }
+ : undefined,
+ /** Indicates that the seller in the process of confirming the order. */
+ is_verification_pending: Boolean(verification_pending),
+ };
+ }, [data?.p2p_order_info]);
+
+ return {
+ /** The 'p2p_order_info' response. */
+ data: modified_data,
+ subscribe,
+ ...rest,
+ };
+};
+
+export default useOrderInfo;
diff --git a/src/hooks/api/order/p2p-order/useOrderList.ts b/src/hooks/api/order/p2p-order/useOrderList.ts
new file mode 100644
index 00000000..80a5fa35
--- /dev/null
+++ b/src/hooks/api/order/p2p-order/useOrderList.ts
@@ -0,0 +1,112 @@
+import useInfiniteQuery from '../../../../../useInfiniteQuery';
+import useSubscription from '../../../../../useSubscription';
+import useAuthorize from '../../../../useAuthorize';
+
+/** This custom hook returns a list of orders under the current client. */
+const useOrderList = (
+ payload?: NonNullable>[1]>['payload'],
+ config?: NonNullable>[1]>['options']
+) => {
+ const { isSuccess } = useAuthorize();
+ const { data: subscriptionData, subscribe, unsubscribe } = useSubscription('p2p_order_list');
+
+ // Subscribe to the p2p_order_list endpoint to keep track of the order list updates
+ React.useEffect(() => {
+ if (isSuccess) subscribe();
+
+ return () => {
+ unsubscribe();
+ };
+ }, [isSuccess]);
+
+ // Fetch the order list data which handles pagination
+ const {
+ data: queryData,
+ fetchNextPage,
+ refetch,
+ ...rest
+ } = useInfiniteQuery('p2p_order_list', {
+ payload: { ...payload, offset: payload?.offset, limit: payload?.limit },
+ options: {
+ getNextPageParam: (lastPage, pages) => {
+ if (lastPage?.p2p_order_list?.list?.length === 0) return;
+ return pages.length;
+ },
+ enabled: subscriptionData && (config?.enabled === undefined || config.enabled),
+ },
+ });
+
+ // Refetch the data when the subscription data changes
+ React.useEffect(() => {
+ if (subscriptionData) {
+ refetch();
+ }
+ }, [subscriptionData, refetch]);
+
+ // Flatten the data array
+ const flattened_data = React.useMemo(() => {
+ if (!queryData?.pages?.length) return;
+
+ return queryData?.pages?.flatMap(page => page?.p2p_order_list?.list);
+ }, [queryData?.pages]);
+
+ // Additional p2p_order_list data
+ const modified_data = React.useMemo(() => {
+ if (!flattened_data) return undefined;
+
+ return flattened_data.map(advert => ({
+ ...advert,
+ /** Details of the advert for this order. */
+ advert_details: {
+ ...advert?.advert_details,
+ /** Indicates if this is block trade advert or not. */
+ is_block_trade: Boolean(advert?.advert_details?.block_trade),
+ },
+ /** Details of the advertiser for this order. */
+ advertiser_details: {
+ ...advert?.advertiser_details,
+ /** Indicates if the advertiser is currently online. */
+ is_online: Boolean(advert?.advertiser_details?.is_online),
+ /** Indicates that the advertiser was recommended in the most recent review by the current user. */
+ is_recommended: Boolean(advert?.advertiser_details?.is_recommended),
+ /** Indicates that the advertiser has not been recommended yet. */
+ has_not_been_recommended: advert?.advertiser_details?.is_recommended === null,
+ },
+ /** Details of the client who created the order. */
+ client_details: {
+ ...advert?.client_details,
+ /** Indicates if the advertiser is currently online. */
+ is_online: Boolean(advert?.client_details?.is_online),
+ /** Indicates that the advertiser was recommended in the most recent review by the current user. */
+ is_recommended: Boolean(advert?.client_details?.is_recommended),
+ /** Indicates that the advertiser has not been recommended yet. */
+ has_not_been_recommended: advert?.client_details?.is_recommended === null,
+ },
+ is_incoming: Boolean(advert?.is_incoming),
+ /** Indicates if a review can be given. */
+ is_reviewable: Boolean(advert?.is_reviewable),
+ /** Indicates if the latest order changes have been seen by the current client. */
+ is_seen: Boolean(advert?.is_seen),
+ /** Details of the review you gave for this order, if any. */
+ review_details: {
+ ...advert?.review_details,
+ /** Indicates if the advertiser is recommended. */
+ is_recommended: Boolean(advert?.review_details?.recommended),
+ /** Indicates that the advertiser has not been recommended yet. */
+ has_not_been_recommended: advert?.review_details?.recommended === null,
+ },
+ /** Indicates that the seller in the process of confirming the order. */
+ is_verification_pending: Boolean(advert?.verification_pending),
+ }));
+ }, [flattened_data]);
+
+ return {
+ /** The 'p2p_order_list' response. */
+ data: modified_data,
+ /** Fetch the next page of orders. */
+ loadMoreOrders: fetchNextPage,
+ ...rest,
+ };
+};
+
+export default useOrderList;
diff --git a/src/hooks/api/payment-method/index.ts b/src/hooks/api/payment-method/index.ts
new file mode 100644
index 00000000..a7bf141b
--- /dev/null
+++ b/src/hooks/api/payment-method/index.ts
@@ -0,0 +1,2 @@
+export * as paymentMethods from './p2p-payment-methods';
+export * as advertiserPaymentMethods from './p2p-advertiser-payment-methods';
diff --git a/src/hooks/api/payment-method/p2p-advertiser-payment-methods/index.ts b/src/hooks/api/payment-method/p2p-advertiser-payment-methods/index.ts
new file mode 100644
index 00000000..b78aa812
--- /dev/null
+++ b/src/hooks/api/payment-method/p2p-advertiser-payment-methods/index.ts
@@ -0,0 +1,4 @@
+export { default as useGet } from './useAdvertiserPaymentMethods';
+export { default as useCreate } from './useCreateAdvertiserPaymentMethods';
+export { default as useUpdate } from './useUpdateAdvertiserPaymentMethods';
+export { default as useDelete } from './useDeleteAdvertiserPaymentMethods';
diff --git a/src/hooks/api/payment-method/p2p-advertiser-payment-methods/useAdvertiserPaymentMethods.ts b/src/hooks/api/payment-method/p2p-advertiser-payment-methods/useAdvertiserPaymentMethods.ts
new file mode 100644
index 00000000..0f2890e2
--- /dev/null
+++ b/src/hooks/api/payment-method/p2p-advertiser-payment-methods/useAdvertiserPaymentMethods.ts
@@ -0,0 +1,36 @@
+import { useMemo } from 'react';
+import useAuthorize from '../../../../useAuthorize';
+import useQuery from '../../../../../useQuery';
+
+/** A custom hook that returns the list of P2P Advertiser Payment Methods */
+const useAdvertiserPaymentMethods = (is_enabled = true) => {
+ const { isSuccess } = useAuthorize();
+ const { data, ...rest } = useQuery('p2p_advertiser_payment_methods', {
+ options: { enabled: isSuccess && is_enabled },
+ });
+
+ // Modify the response to add additional information
+ const modified_data = useMemo(() => {
+ const payment_methods = data?.p2p_advertiser_payment_methods;
+
+ if (!payment_methods) return undefined;
+
+ return Object.keys(payment_methods).map(key => {
+ const payment_method = payment_methods[key];
+
+ return {
+ ...payment_method,
+ /** The id of payment method */
+ id: key,
+ };
+ });
+ }, [data?.p2p_advertiser_payment_methods]);
+
+ return {
+ /** The list of P2P Advertiser Payment Methods */
+ data: modified_data,
+ ...rest,
+ };
+};
+
+export default useAdvertiserPaymentMethods;
diff --git a/src/hooks/api/payment-method/p2p-advertiser-payment-methods/useCreateAdvertiserPaymentMethods.ts b/src/hooks/api/payment-method/p2p-advertiser-payment-methods/useCreateAdvertiserPaymentMethods.ts
new file mode 100644
index 00000000..24f29c64
--- /dev/null
+++ b/src/hooks/api/payment-method/p2p-advertiser-payment-methods/useCreateAdvertiserPaymentMethods.ts
@@ -0,0 +1,27 @@
+import { useCallback } from 'react';
+import useInvalidateQuery from '../../../../../useInvalidateQuery';
+import useMutation from '../../../../../useMutation';
+
+type TPayloads = NonNullable<
+ NonNullable>['mutate']>[0]>['payload']
+>;
+type TCreatePayload = NonNullable[0];
+
+/** A custom hook that sends a request to create a new p2p advertiser payment method. */
+const useCreateAdvertiserPaymentMethods = () => {
+ const invalidate = useInvalidateQuery();
+ const { data, mutate, ...rest } = useMutation('p2p_advertiser_payment_methods', {
+ onSuccess: () => invalidate('p2p_advertiser_payment_methods'),
+ });
+
+ const create = useCallback((values: TCreatePayload) => mutate({ payload: { create: [{ ...values }] } }), [mutate]);
+
+ return {
+ data,
+ /** Sends a request to create a new p2p advertiser payment method */
+ create,
+ ...rest,
+ };
+};
+
+export default useCreateAdvertiserPaymentMethods;
diff --git a/src/hooks/api/payment-method/p2p-advertiser-payment-methods/useDeleteAdvertiserPaymentMethods.ts b/src/hooks/api/payment-method/p2p-advertiser-payment-methods/useDeleteAdvertiserPaymentMethods.ts
new file mode 100644
index 00000000..fa4faf2a
--- /dev/null
+++ b/src/hooks/api/payment-method/p2p-advertiser-payment-methods/useDeleteAdvertiserPaymentMethods.ts
@@ -0,0 +1,22 @@
+import { useCallback } from 'react';
+import useInvalidateQuery from '../../../../../useInvalidateQuery';
+import useMutation from '../../../../../useMutation';
+
+/** A custom hook that sends a request to delete an existing p2p advertiser payment method. */
+const useDeleteAdvertiserPaymentMethods = () => {
+ const invalidate = useInvalidateQuery();
+ const { data, mutate, ...rest } = useMutation('p2p_advertiser_payment_methods', {
+ onSuccess: () => invalidate('p2p_advertiser_payment_methods'),
+ });
+
+ const deletePaymentMethod = useCallback((id: number) => mutate({ payload: { delete: [id] } }), [mutate]);
+
+ return {
+ data,
+ /** Sends a request to delete an existing p2p advertiser payment method */
+ delete: deletePaymentMethod,
+ ...rest,
+ };
+};
+
+export default useDeleteAdvertiserPaymentMethods;
diff --git a/src/hooks/api/payment-method/p2p-advertiser-payment-methods/useUpdateAdvertiserPaymentMethods.ts b/src/hooks/api/payment-method/p2p-advertiser-payment-methods/useUpdateAdvertiserPaymentMethods.ts
new file mode 100644
index 00000000..ada56dd9
--- /dev/null
+++ b/src/hooks/api/payment-method/p2p-advertiser-payment-methods/useUpdateAdvertiserPaymentMethods.ts
@@ -0,0 +1,30 @@
+import { useCallback } from 'react';
+import useInvalidateQuery from '../../../../../useInvalidateQuery';
+import useMutation from '../../../../../useMutation';
+
+type TPayloads = NonNullable<
+ NonNullable>['mutate']>[0]>['payload']
+>;
+type TUpdatePayload = NonNullable[0];
+
+/** A custom hook that sends a request to update an existing p2p advertiser payment method. */
+const useUpdateAdvertiserPaymentMethods = () => {
+ const invalidate = useInvalidateQuery();
+ const { data, mutate, ...rest } = useMutation('p2p_advertiser_payment_methods', {
+ onSuccess: () => invalidate('p2p_advertiser_payment_methods'),
+ });
+
+ const update = useCallback(
+ (id: string, values: TUpdatePayload) => mutate({ payload: { update: { [id]: { ...values } } } }),
+ [mutate]
+ );
+
+ return {
+ data,
+ /** Sends a request to update an existing p2p advertiser payment method */
+ update,
+ ...rest,
+ };
+};
+
+export default useUpdateAdvertiserPaymentMethods;
diff --git a/src/hooks/api/payment-method/p2p-payment-methods/index.ts b/src/hooks/api/payment-method/p2p-payment-methods/index.ts
new file mode 100644
index 00000000..f9014451
--- /dev/null
+++ b/src/hooks/api/payment-method/p2p-payment-methods/index.ts
@@ -0,0 +1 @@
+export { default as useGet } from './usePaymentMethods';
diff --git a/src/hooks/api/payment-method/p2p-payment-methods/usePaymentMethods.ts b/src/hooks/api/payment-method/p2p-payment-methods/usePaymentMethods.ts
new file mode 100644
index 00000000..14ce6d39
--- /dev/null
+++ b/src/hooks/api/payment-method/p2p-payment-methods/usePaymentMethods.ts
@@ -0,0 +1,33 @@
+import { useMemo } from 'react';
+import useAuthorize from '../../../../useAuthorize';
+import useQuery from '../../../../../useQuery';
+
+/** A custom hook that returns a list of P2P available payment methods **/
+const usePaymentMethods = (enabled = true) => {
+ const { isSuccess } = useAuthorize();
+ const { data, ...rest } = useQuery('p2p_payment_methods', {
+ options: { enabled: isSuccess && enabled, refetchOnWindowFocus: false },
+ });
+ // Modify the data to add additional information.
+ const modified_data = useMemo(() => {
+ const p2p_payment_methods = data?.p2p_payment_methods;
+
+ if (!p2p_payment_methods) return undefined;
+
+ return Object.keys(p2p_payment_methods).map(key => {
+ const payment_method = p2p_payment_methods[key];
+ return {
+ ...payment_method,
+ /** Payment method id */
+ id: key,
+ };
+ });
+ }, [data]);
+
+ return {
+ data: modified_data,
+ ...rest,
+ };
+};
+
+export default usePaymentMethods;
diff --git a/src/hooks/api/settings/index.ts b/src/hooks/api/settings/index.ts
new file mode 100644
index 00000000..8a6b79fa
--- /dev/null
+++ b/src/hooks/api/settings/index.ts
@@ -0,0 +1 @@
+export * as settings from './p2p-settings';
diff --git a/src/hooks/api/settings/p2p-settings/index.ts b/src/hooks/api/settings/p2p-settings/index.ts
new file mode 100644
index 00000000..72062070
--- /dev/null
+++ b/src/hooks/api/settings/p2p-settings/index.ts
@@ -0,0 +1 @@
+export { default as useGetSettings } from './useSettings';
diff --git a/src/hooks/api/settings/p2p-settings/useSettings.ts b/src/hooks/api/settings/p2p-settings/useSettings.ts
new file mode 100644
index 00000000..0607b8db
--- /dev/null
+++ b/src/hooks/api/settings/p2p-settings/useSettings.ts
@@ -0,0 +1,108 @@
+import { useEffect } from 'react';
+import { useLocalStorage } from 'usehooks-ts';
+
+import { TSocketResponseData } from '../../../../../types';
+import useSubscription from '../../../../../useSubscription';
+
+type TP2PSettings =
+ | (TSocketResponseData<'p2p_settings'>['p2p_settings'] & {
+ currency_list: {
+ display_name: string;
+ has_adverts: 0 | 1;
+ is_default?: 1;
+ text: string;
+ value: string;
+ }[];
+ float_rate_offset_limit_string: string;
+ is_cross_border_ads_enabled: boolean;
+ is_disabled: boolean;
+ is_payment_methods_enabled: boolean;
+ localCurrency?: string;
+ rate_type: 'float' | 'fixed';
+ reached_target_date: boolean;
+ })
+ | undefined;
+
+type TCurrencyListItem = {
+ display_name: string;
+ has_adverts: 0 | 1;
+ is_default?: 1;
+ text: string;
+ value: string;
+};
+
+const useSettings = () => {
+ const { data, ...rest } = useSubscription('p2p_settings');
+ const [p2pSettings, setP2PSettings] = useLocalStorage>('p2p_v2_p2p_settings', {});
+
+ useEffect(() => {
+ if (data) {
+ const p2p_settings_data = data.p2p_settings;
+
+ if (!p2p_settings_data) return undefined;
+
+ const reached_target_date = () => {
+ if (!p2p_settings_data?.fixed_rate_adverts_end_date) return false;
+
+ const current_date = new Date(new Date().getTime()).setUTCHours(23, 59, 59, 999);
+ const cutoff_date = new Date(
+ new Date(p2p_settings_data?.fixed_rate_adverts_end_date).getTime()
+ ).setUTCHours(23, 59, 59, 999);
+
+ return current_date > cutoff_date;
+ };
+
+ let localCurrency;
+
+ const currency_list = p2p_settings_data.local_currencies.reduce((acc: TCurrencyListItem[], currency) => {
+ const { display_name, has_adverts, is_default, symbol } = currency;
+
+ if (is_default) localCurrency = symbol;
+
+ if (has_adverts) {
+ acc.push({
+ display_name,
+ has_adverts,
+ is_default,
+ text: symbol,
+ value: symbol,
+ });
+ }
+
+ return acc;
+ }, []);
+
+ setP2PSettings({
+ ...p2p_settings_data,
+ /** Modified list of local_currencies */
+ currency_list,
+ /** Indicates the maximum rate offset for floating rate adverts. */
+ float_rate_offset_limit_string:
+ p2p_settings_data?.float_rate_offset_limit?.toString().split('.')?.[1]?.length > 2
+ ? (p2p_settings_data?.float_rate_offset_limit - 0.005).toFixed(2)
+ : p2p_settings_data?.float_rate_offset_limit.toFixed(2),
+ /** Indicates if the cross border ads feature is enabled. */
+ is_cross_border_ads_enabled: Boolean(p2p_settings_data?.cross_border_ads_enabled),
+ /** Indicates if the P2P service is unavailable. */
+ is_disabled: Boolean(p2p_settings_data?.disabled),
+ /** Indicates if the payment methods feature is enabled. */
+ is_payment_methods_enabled: Boolean(p2p_settings_data?.payment_methods_enabled),
+ /** Indicates the default local currency */
+ localCurrency,
+ /** Indicates if the current rate type is floating or fixed rates */
+ rate_type: (p2p_settings_data?.float_rate_adverts === 'enabled' ? 'float' : 'fixed') as
+ | 'float'
+ | 'fixed',
+ /** Indicates if the fixed rate adverts end date has been reached. */
+ reached_target_date: reached_target_date(),
+ });
+ }
+ }, [data, setP2PSettings]);
+
+ return {
+ ...rest,
+ data: p2pSettings,
+ };
+};
+
+export default useSettings;
diff --git a/src/hooks/custom-hooks/index.ts b/src/hooks/custom-hooks/index.ts
new file mode 100644
index 00000000..5bc139b6
--- /dev/null
+++ b/src/hooks/custom-hooks/index.ts
@@ -0,0 +1,11 @@
+export { default as useAdvertiserStats } from './useAdvertiserStats';
+export { default as useCopyToClipboard } from './useCopyToClipboard';
+export { default as useDevice } from './useDevice';
+export { default as useExtendedOrderDetails } from './useExtendedOrderDetails';
+export { default as useFetchMore } from './useFetchMore';
+export { default as useFloatingRate } from './useFloatingRate';
+export { default as useIsAdvertiser } from './useIsAdvertiser';
+export { default as useModalManager } from './useModalManager';
+export { default as usePoiPoaStatus } from './usePoiPoaStatus';
+export { default as useQueryString } from './useQueryString';
+export { default as useSendbird } from './useSendbird';
diff --git a/src/hooks/custom-hooks/useAdvertiserStats.ts b/src/hooks/custom-hooks/useAdvertiserStats.ts
new file mode 100644
index 00000000..7dc1a781
--- /dev/null
+++ b/src/hooks/custom-hooks/useAdvertiserStats.ts
@@ -0,0 +1,137 @@
+import { useEffect, useMemo } from 'react';
+
+import { useAuthentication, useAuthorize, useSettings } from '@deriv/api-v2';
+
+import { useAdvertiserInfoState } from '@/providers/AdvertiserInfoStateProvider';
+import { daysSince, isEmptyObject } from '@/utils';
+
+import { api } from '..';
+
+/**
+ * Formats the advertiser duration into the following format:
+ * -1 if duration is not provided, in this case "-" would be displayed in the advertiser stats
+ * otherwise, converts the duration to minutes, and
+ * 1 if the duration is less than 60 seconds
+ */
+const toAdvertiserMinutes = (duration?: number | null) => {
+ if (!duration) return -1;
+ if (duration > 60) return Math.round(duration / 60);
+ return 1;
+};
+
+/**
+ * Hook to calculate an advertiser's stats based on their information.
+ *
+ * @param advertiserId - ID of the advertiser stats to reveal. If not provided, by default it will return the user's own stats.
+ */
+const useAdvertiserStats = (advertiserId?: string) => {
+ const { isSuccess } = useAuthorize();
+ const { data, subscribe, unsubscribe } = api.advertiser.useGetInfo(advertiserId);
+ const { data: settings, isSuccess: isSuccessSettings } = useSettings();
+ const { data: authenticationStatus, isSuccess: isSuccessAuthenticationStatus } = useAuthentication();
+ const { error, isIdle, isLoading, isSubscribed } = useAdvertiserInfoState();
+
+ useEffect(() => {
+ if (isSuccess && advertiserId) {
+ subscribe({ id: advertiserId });
+ }
+
+ return () => {
+ localStorage.removeItem(`p2p_v2_p2p_advertiser_info_${advertiserId}`);
+ unsubscribe();
+ };
+ }, [advertiserId, isSuccess, subscribe, unsubscribe]);
+
+ const transformedData = useMemo(() => {
+ if (!isSubscribed && isEmptyObject(data) && !isSuccessSettings && !isSuccessAuthenticationStatus)
+ return undefined;
+
+ const isAdvertiser = data.is_approved_boolean;
+
+ return {
+ ...data,
+
+ /** The average buy time in minutes */
+ averagePayTime: toAdvertiserMinutes(data?.buy_time_avg),
+
+ /** The average release time in minutes */
+ averageReleaseTime: toAdvertiserMinutes(data?.release_time_avg),
+
+ /** The percentage of completed orders out of total orders as a buyer within the past 30 days. */
+ buyCompletionRate: data?.buy_completion_rate || 0,
+
+ /** The number of buy order completed within the past 30 days. */
+ buyOrdersCount: Number(data?.buy_orders_count) || 0,
+
+ /** The daily available balance buy limit for P2P transactions in the past 24 hours. */
+ dailyAvailableBuyLimit: Number(data?.daily_buy_limit) - Number(data?.daily_buy) || 0,
+
+ /** The daily available balance sell limit for P2P transactions in the past 24 hours. */
+ dailyAvailableSellLimit: Number(data?.daily_sell_limit) - Number(data?.daily_sell) || 0,
+
+ /** The number of days since the user has became an advertiser */
+ daysSinceJoined: daysSince(
+ data?.created_time ? new Date(data.created_time * 1000).toISOString().split('T')[0] : ''
+ ),
+
+ /** The advertiser's full name */
+ fullName: `${settings?.first_name || ''} ${settings?.last_name || ''}`,
+
+ /** Checks if the advertiser has completed proof of address verification */
+ isAddressVerified: isAdvertiser
+ ? data.has_full_verification
+ : authenticationStatus?.document?.status === 'verified',
+
+ /** Checks if the user is already an advertiser */
+ isAdvertiser,
+
+ /** Checks if the user is eligible to upgrade their daily limits */
+ isEligibleForLimitUpgrade: Boolean(data?.upgradable_daily_limits),
+
+ /** Checks if the advertiser has completed proof of identity verification */
+ isIdentityVerified: isAdvertiser
+ ? data.has_basic_verification
+ : authenticationStatus?.identity?.status === 'verified',
+
+ /** The percentage of completed orders out of total orders as a seller within the past 30 days. */
+ sellCompletionRate: data?.sell_completion_rate || 0,
+
+ /** The number of sell order orders completed within the past 30 days. */
+ sellOrdersCount: Number(data?.sell_orders_count) || 0,
+
+ /** The total number of orders completed within the past 30 days*/
+ totalOrders: Number(data?.buy_orders_count) + Number(data?.sell_orders_count) || 0,
+
+ /** The total number of orders completed since registration */
+ totalOrdersLifetime: Number(data?.total_orders_count) || 0,
+
+ /** Number of different users the advertiser has traded with since registration. */
+ tradePartners: Number(data?.partner_count) || 0,
+
+ /** The total trade volume within the past 30 days */
+ tradeVolume: Number(data?.buy_orders_amount) + Number(data?.sell_orders_amount) || 0,
+
+ /** The total trade volume since registration */
+ tradeVolumeLifetime: Number(data?.total_turnover) || 0,
+ };
+ }, [
+ isSubscribed,
+ data,
+ isSuccessSettings,
+ isSuccessAuthenticationStatus,
+ settings?.first_name,
+ settings?.last_name,
+ authenticationStatus?.document?.status,
+ authenticationStatus?.identity?.status,
+ ]);
+
+ return {
+ data: transformedData,
+ error,
+ isIdle,
+ isLoading,
+ isSubscribed,
+ };
+};
+
+export default useAdvertiserStats;
diff --git a/src/hooks/custom-hooks/useCopyToClipboard.ts b/src/hooks/custom-hooks/useCopyToClipboard.ts
new file mode 100644
index 00000000..fef5212f
--- /dev/null
+++ b/src/hooks/custom-hooks/useCopyToClipboard.ts
@@ -0,0 +1,25 @@
+import { useState } from 'react';
+import { useCopyToClipboard as useCopyToClipboardHook } from 'usehooks-ts';
+
+type copyFn = (text: string) => Promise;
+type setterFn = (flag: boolean) => void;
+
+const useCopyToClipboard = (): [boolean, copyFn, setterFn] => {
+ const [isCopied, setIsCopied] = useState(false);
+ const [, copy] = useCopyToClipboardHook();
+
+ const copyToClipboard = async (text: string) => {
+ try {
+ copy(text);
+ setIsCopied(true);
+ return true;
+ } catch (error) {
+ setIsCopied(false);
+ return false;
+ }
+ };
+
+ return [isCopied, copyToClipboard, setIsCopied];
+};
+
+export default useCopyToClipboard;
diff --git a/src/hooks/custom-hooks/useDevice.ts b/src/hooks/custom-hooks/useDevice.ts
new file mode 100644
index 00000000..179694b2
--- /dev/null
+++ b/src/hooks/custom-hooks/useDevice.ts
@@ -0,0 +1,18 @@
+import { useWindowSize } from 'usehooks-ts';
+
+// NOTE: Replace this with useBreakpoint from quill-design
+/** A custom hook to check for the client device and determine the layout to be rendered */
+const useDevice = () => {
+ const { width } = useWindowSize();
+ const isMobile = width > 0 && width < 768;
+ const isTablet = width >= 768 && width < 1024;
+ const isDesktop = width >= 1024;
+
+ return {
+ isDesktop,
+ isMobile,
+ isTablet,
+ };
+};
+
+export default useDevice;
diff --git a/src/hooks/custom-hooks/useExtendedOrderDetails.ts b/src/hooks/custom-hooks/useExtendedOrderDetails.ts
new file mode 100644
index 00000000..2de569e9
--- /dev/null
+++ b/src/hooks/custom-hooks/useExtendedOrderDetails.ts
@@ -0,0 +1,324 @@
+import { THooks, TServerTime } from 'types';
+import { BUY_SELL, ORDERS_STATUS } from '@/constants'; // Update your import path
+import {
+ convertToMillis,
+ getFormattedDateString,
+ removeTrailingZeros,
+ roundOffDecimal,
+ setDecimalPlaces,
+ toMoment,
+} from '@/utils';
+import { FormatUtils } from '@deriv-com/utils';
+
+type TOrder = THooks.Order.Get;
+
+type TUserDetails = TOrder['advertiser_details'] | TOrder['client_details'];
+
+type TObject = Record;
+interface ExtendedOrderDetails extends TOrder {
+ counterpartyAdStatusString: TObject;
+ displayPaymentAmount: string;
+ hasReviewDetails: boolean;
+ hasTimerExpired: boolean;
+ isActiveOrder: boolean;
+ isBuyOrder: boolean;
+ isBuyOrderForUser: boolean;
+ isBuyerCancelledOrder: boolean;
+ isBuyerConfirmedOrder: boolean;
+ isCompletedOrder: boolean;
+ isDisputeCompletedOrder: boolean;
+ isDisputeRefundedOrder: boolean;
+ isDisputedOrder: boolean;
+ isExpiredOrOngoingTimerExpired: boolean;
+ isExpiredOrder: boolean;
+ isFinalisedOrder: boolean;
+ isInactiveOrder: boolean;
+ isIncomingOrder: boolean;
+ isMyAd: boolean;
+ isOngoingOrder: boolean;
+ isOrderReviewable: boolean;
+ isPendingOrder: boolean;
+ isRefundedOrder: boolean;
+ isSellOrder: boolean;
+ labels: TObject;
+ myAdStatusString: TObject;
+ orderExpiryMilliseconds: number;
+ otherUserDetails: TUserDetails;
+ purchaseTime: string;
+ rateAmount: string;
+ remainingSeconds: number;
+ shouldHighlightAlert: boolean;
+ shouldHighlightDanger: boolean;
+ shouldHighlightDisabled: boolean;
+ shouldHighlightSuccess: boolean;
+ shouldShowCancelAndPaidButton: boolean;
+ shouldShowComplainAndReceivedButton: boolean;
+ shouldShowLostFundsBanner: boolean;
+ shouldShowOnlyComplainButton: boolean;
+ shouldShowOnlyReceivedButton: boolean;
+ shouldShowOrderFooter: boolean;
+ shouldShowOrderTimer: boolean;
+ statusForBuyerConfirmedOrder: string;
+ statusForPendingOrder: string;
+ statusString: string;
+}
+
+const useExtendedOrderDetails = ({
+ loginId,
+ orderDetails,
+ serverTime,
+}: {
+ loginId?: string;
+ orderDetails: TOrder;
+ serverTime: TServerTime;
+}): { data: ExtendedOrderDetails } => {
+ const extendedOrderDetails: ExtendedOrderDetails = {
+ ...orderDetails,
+ // Derived properties
+ get counterpartyAdStatusString() {
+ return {
+ contactDetails: this.isBuyOrder ? "Seller's contact details" : 'Your contact details',
+ counterpartyNicknameLabel: this.isBuyOrder ? "Seller's nickname" : "Buyer's nickname",
+ counterpartyRealNameLabel: this.isBuyOrder ? "Seller's real name" : "Buyer's real name",
+ instructions: this.isBuyOrder ? "Seller's instructions" : "Buyer's instructions",
+ leftSendOrReceive: this.isBuyOrder ? 'Send' : 'Receive',
+ paymentDetails: this.isBuyOrder ? "Seller's payment details" : 'Your payment details',
+ resultString: this.isBuyOrder
+ ? `You've received ${this.amount_display} ${this.account_currency}`
+ : `You sold ${this.amount_display}${this.account_currency}`,
+ rightSendOrReceive: this.isBuyOrder ? 'Receive' : 'Send',
+ };
+ },
+ get displayPaymentAmount() {
+ return removeTrailingZeros(
+ FormatUtils.formatMoney(
+ Number(this.amount_display) * Number(roundOffDecimal(this.rate, setDecimalPlaces(this.rate, 6))),
+ { currency: this.local_currency }
+ )
+ );
+ },
+ get hasReviewDetails() {
+ return !!this.review_details;
+ },
+ get hasTimerExpired() {
+ const serverTimeAmount = serverTime?.server_time_moment;
+ const expiryTimeMoment = toMoment(this.expiry_time);
+ return serverTimeAmount?.isAfter(expiryTimeMoment) ?? false;
+ },
+ get isActiveOrder() {
+ return !this.isInactiveOrder;
+ },
+ get isBuyerCancelledOrder() {
+ return this.status === ORDERS_STATUS.CANCELLED;
+ },
+ get isBuyerConfirmedOrder() {
+ return this.status === ORDERS_STATUS.BUYER_CONFIRMED;
+ },
+ get isBuyOrder() {
+ return this.type === BUY_SELL.BUY;
+ },
+ get isBuyOrderForUser() {
+ return (this.isBuyOrder && !this.isMyAd) || (this.isSellOrder && this.isMyAd);
+ },
+ get isCompletedOrder() {
+ return this.status === ORDERS_STATUS.COMPLETED;
+ },
+ get isDisputeCompletedOrder() {
+ return this.status === ORDERS_STATUS.DISPUTE_COMPLETED;
+ },
+ get isDisputedOrder() {
+ return this.status === ORDERS_STATUS.DISPUTED;
+ },
+ get isDisputeRefundedOrder() {
+ return this.status === ORDERS_STATUS.DISPUTE_REFUNDED;
+ },
+ get isExpiredOrder() {
+ return this.status === ORDERS_STATUS.TIMED_OUT;
+ },
+ get isExpiredOrOngoingTimerExpired() {
+ return this.isExpiredOrder || (this.isOngoingOrder && this.hasTimerExpired);
+ },
+ get isFinalisedOrder() {
+ return this.isCompletedOrder || this.isBuyerCancelledOrder || this.isRefundedOrder;
+ },
+ get isInactiveOrder() {
+ return this.isFinalisedOrder || this.isDisputeCompletedOrder || this.isDisputeRefundedOrder;
+ },
+ get isIncomingOrder() {
+ return !!this.is_incoming;
+ },
+ get isMyAd() {
+ return this.advertiser_details?.loginid === loginId;
+ },
+ get isOngoingOrder() {
+ return this.isBuyerConfirmedOrder || this.isBuyerCancelledOrder;
+ },
+ get isOrderReviewable() {
+ return this.is_reviewable;
+ },
+ get isPendingOrder() {
+ return this.status === ORDERS_STATUS.PENDING;
+ },
+ get isRefundedOrder() {
+ return this.status === ORDERS_STATUS.REFUNDED;
+ },
+
+ get isSellOrder() {
+ return this.type === BUY_SELL.SELL;
+ },
+ get labels() {
+ if (this.isMyAd) {
+ return this.myAdStatusString;
+ }
+ return this.counterpartyAdStatusString;
+ },
+ get myAdStatusString() {
+ return {
+ contactDetails: this.isBuyOrder ? 'Your contact details' : "Seller's contact details",
+ counterpartyNicknameLabel: this.isBuyOrder ? "Buyer's nickname" : "Seller's nickname",
+ counterpartyRealNameLabel: this.isBuyOrder ? "Buyer's real name" : "Seller's real name",
+ instructions: 'Your instructions',
+ leftSendOrReceive: this.isBuyOrder ? 'Receive' : 'Send',
+ paymentDetails: this.isBuyOrder ? 'Your payment details' : "Seller's payment details",
+ resultString: this.isBuyOrder
+ ? `You sold ${this.amount_display}${this.account_currency}`
+ : `You've received ${this.amount_display} ${this.account_currency}`,
+ rightSendOrReceive: this.isBuyOrder ? 'Send' : 'Receive',
+ };
+ },
+ get orderExpiryMilliseconds() {
+ return convertToMillis(this.expiry_time ?? 0);
+ },
+ get otherUserDetails() {
+ return this.isMyAd ? this.client_details : this.advertiser_details;
+ },
+ get purchaseTime() {
+ return getFormattedDateString(
+ new Date(convertToMillis(this.created_time ?? 0)),
+ true,
+ false,
+ this.isInactiveOrder
+ );
+ },
+ get rateAmount() {
+ return removeTrailingZeros(
+ FormatUtils.formatMoney(this.rate, {
+ currency: this.local_currency,
+ decimalPlaces: setDecimalPlaces(this.rate, 6),
+ })
+ );
+ },
+ get remainingSeconds() {
+ const serverTimeAmount = serverTime?.server_time_moment;
+ const expiryTimeMoment = toMoment(this.expiry_time);
+ return expiryTimeMoment.diff(serverTimeAmount, 'seconds');
+ },
+ get shouldHighlightAlert() {
+ if (this.hasTimerExpired) return false;
+ if (this.isMyAd) {
+ return this.isBuyOrder ? this.isPendingOrder : this.isBuyerConfirmedOrder;
+ }
+ return this.isBuyOrder ? this.isBuyerConfirmedOrder : this.isPendingOrder;
+ },
+ get shouldHighlightDanger() {
+ if (this.hasTimerExpired) return false;
+ if (this.isMyAd) {
+ return this.isBuyOrder ? this.isBuyerConfirmedOrder : this.isPendingOrder;
+ }
+ return this.isBuyOrder ? this.isPendingOrder : this.isBuyerConfirmedOrder;
+ },
+ get shouldHighlightDisabled() {
+ return (
+ this.isBuyerCancelledOrder ||
+ this.isExpiredOrder ||
+ this.isRefundedOrder ||
+ this.isDisputedOrder ||
+ this.isDisputeRefundedOrder ||
+ (this.hasTimerExpired && !this.isCompletedOrder && !this.isDisputeCompletedOrder)
+ );
+ },
+ get shouldHighlightSuccess() {
+ return this.isCompletedOrder || this.isDisputeCompletedOrder;
+ },
+ get shouldShowCancelAndPaidButton() {
+ if (this.hasTimerExpired) return false;
+ return this.isPendingOrder && (this.isBuyOrder ? !this.isMyAd : this.isMyAd);
+ },
+ get shouldShowComplainAndReceivedButton() {
+ if (this.isFinalisedOrder) return false;
+ return this.isExpiredOrOngoingTimerExpired && (this.isSellOrder ? !this.isMyAd : this.isMyAd);
+ },
+ get shouldShowLostFundsBanner() {
+ return this.isPendingOrder || this.isBuyerConfirmedOrder;
+ },
+ get shouldShowOnlyComplainButton() {
+ if (this.isFinalisedOrder) return false;
+ if (this.isSellOrder) {
+ return this.isExpiredOrOngoingTimerExpired;
+ }
+ return this.isExpiredOrOngoingTimerExpired && !this.isMyAd;
+ },
+ get shouldShowOnlyReceivedButton() {
+ if (this.isDisputedOrder) {
+ return (!this.isIncomingOrder && this.isSellOrder) || (this.isIncomingOrder && this.isBuyOrder);
+ }
+ return this.isBuyerConfirmedOrder && (this.isBuyOrder ? this.isMyAd : !this.isMyAd);
+ },
+ get shouldShowOrderFooter() {
+ return (
+ this.shouldShowCancelAndPaidButton ||
+ this.shouldShowComplainAndReceivedButton ||
+ this.shouldShowOnlyComplainButton ||
+ this.shouldShowOnlyReceivedButton
+ );
+ },
+ get shouldShowOrderTimer() {
+ if (this.isFinalisedOrder) return false;
+ return this.isPendingOrder || this.isOngoingOrder;
+ },
+ get statusForBuyerConfirmedOrder() {
+ const confirmMessage = 'Confirm payment';
+ const waitMessage = 'Waiting for the seller to confirm';
+ if (this.isMyAd) {
+ return this.isBuyOrder ? confirmMessage : waitMessage;
+ }
+ return this.isBuyOrder ? waitMessage : confirmMessage;
+ },
+ get statusForPendingOrder() {
+ const waitMessage = 'Wait for payment';
+ const payMessage = 'Pay now';
+ if (this.isMyAd) {
+ return this.isBuyOrder ? waitMessage : payMessage;
+ }
+ return this.isBuyOrder ? payMessage : waitMessage;
+ },
+ get statusString() {
+ if (this.isCompletedOrder || this.isDisputeCompletedOrder) {
+ return 'Completed';
+ }
+ if (this.isBuyerCancelledOrder) {
+ return 'Cancelled';
+ }
+ if (this.isRefundedOrder || this.isDisputeRefundedOrder) {
+ return 'Expired';
+ }
+ if (this.isDisputedOrder) {
+ return 'Under dispute';
+ }
+ if (this.isExpiredOrder || this.hasTimerExpired) {
+ return 'Expired';
+ }
+ if (this.isPendingOrder) {
+ return this.statusForPendingOrder;
+ }
+ if (this.isBuyerConfirmedOrder) {
+ return this.statusForBuyerConfirmedOrder;
+ }
+ return 'Unknown';
+ },
+ };
+
+ return { data: extendedOrderDetails };
+};
+
+export default useExtendedOrderDetails;
diff --git a/src/hooks/custom-hooks/useFetchMore.ts b/src/hooks/custom-hooks/useFetchMore.ts
new file mode 100644
index 00000000..5ff4cdb5
--- /dev/null
+++ b/src/hooks/custom-hooks/useFetchMore.ts
@@ -0,0 +1,35 @@
+import { useCallback, useEffect } from 'react';
+
+type TProps = {
+ isFetching: boolean;
+ loadMore: () => void;
+ ref: React.RefObject;
+};
+
+/** A custom hook to load more items in the table on scroll to bottom of the table */
+const useFetchMore = ({ isFetching, loadMore, ref }: TProps) => {
+ //called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table
+ const fetchMoreOnBottomReached = useCallback(
+ (containerRefElement?: HTMLDivElement | null) => {
+ if (containerRefElement) {
+ const { clientHeight, scrollHeight, scrollTop } = containerRefElement;
+ //once the user has scrolled within 200px of the bottom of the table, fetch more data if we can
+ if (scrollHeight - scrollTop - clientHeight < 200 && !isFetching) {
+ loadMore();
+ }
+ }
+ },
+ [loadMore, isFetching]
+ );
+
+ //a check on mount and after a fetch to see if the table is already scrolled to the bottom and immediately needs to fetch more data
+ useEffect(() => {
+ fetchMoreOnBottomReached(ref.current);
+ }, [fetchMoreOnBottomReached]);
+
+ return {
+ fetchMoreOnBottomReached,
+ };
+};
+
+export default useFetchMore;
diff --git a/src/hooks/custom-hooks/useFloatingRate.ts b/src/hooks/custom-hooks/useFloatingRate.ts
new file mode 100644
index 00000000..a71ed145
--- /dev/null
+++ b/src/hooks/custom-hooks/useFloatingRate.ts
@@ -0,0 +1,27 @@
+import { RATE_TYPE } from '@/constants';
+
+import { api } from '..';
+
+type TReturnType = {
+ fixedRateAdvertsEndDate: string;
+ floatRateOffsetLimitString: string;
+ rateType: (typeof RATE_TYPE)[keyof typeof RATE_TYPE];
+ reachedTargetDate: boolean;
+};
+
+const useFloatingRate = (): TReturnType => {
+ const { data } = api.settings.useGetSettings();
+ const isFloatingRateEnabled = data?.float_rate_adverts === 'enabled';
+ const fixedRateAdvertsEndDate = data?.fixed_rate_adverts_end_date ?? '';
+ const reachedTargetDate = data?.reached_target_date ?? false;
+ const floatRateOffsetLimitString = data?.float_rate_offset_limit_string ?? '';
+
+ return {
+ fixedRateAdvertsEndDate,
+ floatRateOffsetLimitString,
+ rateType: isFloatingRateEnabled ? RATE_TYPE.FLOAT : RATE_TYPE.FIXED,
+ reachedTargetDate,
+ };
+};
+
+export default useFloatingRate;
diff --git a/src/hooks/custom-hooks/useIsAdvertiser.ts b/src/hooks/custom-hooks/useIsAdvertiser.ts
new file mode 100644
index 00000000..9773c881
--- /dev/null
+++ b/src/hooks/custom-hooks/useIsAdvertiser.ts
@@ -0,0 +1,27 @@
+import { useEffect, useState } from 'react';
+
+import { ERROR_CODES } from '@/constants';
+import { isEmptyObject } from '@/utils';
+
+import { api } from '..';
+
+/**
+ * Custom hook to check if the current user is an advertiser.
+ * @returns {boolean} isAdvertiser - True if the current user is an advertiser, false otherwise.
+ */
+const useIsAdvertiser = (): boolean => {
+ const { data, error } = api.advertiser.useGetInfo();
+ const [isAdvertiser, setIsAdvertiser] = useState(!error && !isEmptyObject(data));
+
+ useEffect(() => {
+ if (error && error.code === ERROR_CODES.ADVERTISER_NOT_FOUND) {
+ setIsAdvertiser(false);
+ } else if (!error && !isEmptyObject(data)) {
+ setIsAdvertiser(true);
+ }
+ }, [data, error]);
+
+ return isAdvertiser;
+};
+
+export default useIsAdvertiser;
diff --git a/src/hooks/custom-hooks/useModalManager.ts b/src/hooks/custom-hooks/useModalManager.ts
new file mode 100644
index 00000000..df706463
--- /dev/null
+++ b/src/hooks/custom-hooks/useModalManager.ts
@@ -0,0 +1,134 @@
+import { useEffect } from 'react';
+import { useEventListener, useMap } from 'usehooks-ts';
+import { useDevice } from '@deriv-com/ui';
+import useQueryString from './useQueryString';
+
+type TUseModalManagerConfig = {
+ shouldReinitializeModals?: boolean;
+};
+
+type TShowModalOptions = {
+ shouldStackModals?: boolean;
+};
+
+const MODAL_QUERY_SEPARATOR = ',';
+
+/**
+ * Hook to manage states for showing/hiding multiple modals
+ * Use this hook when you are managing more than 1 modal to show/hide
+ *
+ * @example
+ * ```
+ * const {isModalOpenFor, showModal} = useModalManager()
+ *
+ * return (
+ * <>
+ *
+ *
+ * showModal('ModalA')}>...
+ * >
+ * )
+ * ```
+ */
+export default function useModalManager(config?: TUseModalManagerConfig) {
+ const { deleteQueryString, queryString, setQueryString } = useQueryString();
+ const { isMobile } = useDevice();
+
+ const [isModalOpenScopes, actions] = useMap();
+
+ const syncModalParams = () => {
+ if (!queryString.modal) actions.setAll([]);
+
+ if (config?.shouldReinitializeModals !== undefined && config.shouldReinitializeModals === false) {
+ deleteQueryString('modal');
+ } else {
+ // sync modal query string in the URL with the initial modal open scopes
+ const modalHash = queryString.modal;
+ if (modalHash) {
+ const modalKeys = modalHash.split(MODAL_QUERY_SEPARATOR);
+ const currentModal = modalKeys.slice(-1)[0];
+ actions.setAll([]);
+ modalKeys.forEach(modalKey => {
+ actions.set(modalKey, isMobile);
+ });
+ actions.set(currentModal, true);
+ }
+ }
+ };
+
+ useEffect(() => {
+ // only sync the modal open states with the URL params when initial mount...
+ syncModalParams();
+ }, []);
+
+ // ...or when the user clicks the back button
+ useEventListener('popstate', () => {
+ syncModalParams();
+ });
+
+ const hideModal = () => {
+ const modalHash = queryString.modal;
+
+ if (modalHash) {
+ const modalIds = modalHash.split(MODAL_QUERY_SEPARATOR);
+ const currentModalId = modalIds.pop();
+ const previousModalId = modalIds.slice(-1)[0];
+ if (previousModalId) {
+ actions.set(currentModalId, false);
+ actions.set(previousModalId, true);
+ } else {
+ actions.set(currentModalId, false);
+ }
+ if (modalIds.length === 0) {
+ deleteQueryString('modal');
+ } else {
+ setQueryString({
+ modal: modalIds.join(MODAL_QUERY_SEPARATOR),
+ });
+ }
+ }
+ };
+
+ /**
+ * Keep the previous modal ids in the URL query strings separated by ','
+ * This way, when there is a new modal to be shown, we can track the previous modals from the query string based on the last 2 segments
+ *
+ * Example:
+ * - ModalA is shown, URL becomes /...?modal=ModalA (current modal is ModalA, there is no previous modal)
+ * - ModalB is shown next, URL becomes /...?modal=ModalA,ModalB (current modal is ModalB, previous modal is ModalA)
+ * - ModalC is shown next, URL becomes /...?modal=ModalA,ModalB,ModalC (current modal is ModalC, previous modal is ModalB)
+ * - ModalC is closed, URL becomes becomes /...?modal=modalA,ModalB (current modal is ModalB, previous modal is ModalA)
+ */
+ const showModal = (modalId: string, options?: TShowModalOptions) => {
+ const modalHash = queryString.modal;
+
+ if (modalHash) {
+ const modalIds = modalHash.split(MODAL_QUERY_SEPARATOR);
+ const currentModalId = modalIds.slice(-1)[0];
+ // set the previous modal open state to false if shouldStackModals is false, otherwise set it to true (default true for mobile)
+ // set the new modal open state to true
+ actions.set(currentModalId, options?.shouldStackModals || isMobile);
+ actions.set(modalId, true);
+ // push the state of the new modal to the hash
+ modalIds.push(modalId);
+ setQueryString({
+ modal: modalIds.join(MODAL_QUERY_SEPARATOR),
+ });
+ } else {
+ actions.set(modalId, true);
+ setQueryString({
+ modal: modalId,
+ });
+ }
+ };
+
+ const isModalOpenFor = (modalKey: string) => {
+ return isModalOpenScopes.get(modalKey) || false;
+ };
+
+ return {
+ hideModal,
+ isModalOpenFor,
+ showModal,
+ };
+}
diff --git a/src/hooks/custom-hooks/usePoiPoaStatus.ts b/src/hooks/custom-hooks/usePoiPoaStatus.ts
new file mode 100644
index 00000000..241e7c13
--- /dev/null
+++ b/src/hooks/custom-hooks/usePoiPoaStatus.ts
@@ -0,0 +1,33 @@
+import { useMemo } from 'react';
+import { useGetAccountStatus } from '@deriv/api-v2';
+
+/** A custom hook that returns the POA, POI status and if POA is required for P2P */
+const usePoiPoaStatus = () => {
+ const { data, ...rest } = useGetAccountStatus();
+
+ // create new response for poi/poa statuses
+ const modifiedAccountStatus = useMemo(() => {
+ if (!data) return undefined;
+
+ const documentStatus = data?.authentication?.document?.status;
+ const identityStatus = data?.authentication?.identity?.status;
+
+ return {
+ isP2PPoaRequired: data?.p2p_poa_required,
+ isPoaPending: documentStatus === 'pending',
+ isPoaVerified: documentStatus === 'verified',
+ isPoiPending: identityStatus === 'pending',
+ isPoiVerified: identityStatus === 'verified',
+ poaStatus: documentStatus,
+ poiStatus: identityStatus,
+ };
+ }, [data]);
+
+ return {
+ /** The POI & POA status. */
+ data: modifiedAccountStatus,
+ ...rest,
+ };
+};
+
+export default usePoiPoaStatus;
diff --git a/src/hooks/custom-hooks/useQueryString.ts b/src/hooks/custom-hooks/useQueryString.ts
new file mode 100644
index 00000000..dfc132d5
--- /dev/null
+++ b/src/hooks/custom-hooks/useQueryString.ts
@@ -0,0 +1,74 @@
+import { StringParam, useQueryParams } from 'use-query-params';
+
+/**
+ * A hook that uses `use-query-params` to sync URL params to the React lifecycle
+ * You can use this hook to conditionally render tabs, forms or other screens based on what the current URL parameters are.
+ * For instance, `/p2p-v2/my-profile?tab=Stats`:
+ * - calling this hook returns `queryString` which is an object that has a key of `tab` and value of `Stats`
+ * - You can use this to conditionally render the `Stats` tab screen by checking if `queryString.tab === 'Stats'`
+ *
+ * This avoids props drilling for passing boolean screen setters into its child components to switch between different screens/tabs.
+ *
+ * @example
+ * // Call the hook and render the tab based on `?=tab...`
+ * const { queryString } = useQueryString()
+ *
+ * if (queryString.tab === 'Stats') {
+ * // Show Stats component
+ * }
+ */
+function useQueryString() {
+ const [query, setQuery] = useQueryParams({
+ advertId: StringParam,
+ formAction: StringParam,
+ modal: StringParam,
+ paymentMethodId: StringParam,
+ tab: StringParam,
+ });
+
+ /**
+ * Removes the query string from the URL search string.
+ * The rest of the query strings will be preserved.
+ *
+ * @param key - The search name to delete from the search string
+ *
+ * @example
+ * // Deletes the search name `tab` from the URL search string.
+ * // p2p-v2/my-profile?tab=Stats&modal=NicknameModal` -> p2p-v2/my-profile?modal=NicknameModal`
+ * deleteQueryString('tab')
+ */
+ function deleteQueryString(key: keyof typeof query) {
+ setQuery(
+ {
+ [key]: undefined,
+ },
+ 'pushIn'
+ );
+ }
+
+ /**
+ * Add or replace a query string from the URL search string.
+ * The rest of the query strings will not be replaced unless specified in the argument.
+ *
+ * @param queryStrings - An object with the key as the search name, and value as the search value
+ *
+ * @example
+ * // Set a new query string 'modal' and replace the current query string 'tab' with 'Payment methods'
+ * // p2p-v2/my-profile?tab=Stats -> p2p-v2/my-profile?tab=Payment+methods&modal=NicknameModal`
+ * setQueryString({
+ * modal: 'NicknameModal',
+ * tab: 'Payment methods'
+ * })
+ */
+ function setQueryString(queryStrings: Parameters[0]) {
+ setQuery(queryStrings, 'pushIn');
+ }
+
+ return {
+ deleteQueryString,
+ queryString: query,
+ setQueryString,
+ };
+}
+
+export default useQueryString;
diff --git a/src/hooks/custom-hooks/useSendbird.ts b/src/hooks/custom-hooks/useSendbird.ts
new file mode 100644
index 00000000..300bb53d
--- /dev/null
+++ b/src/hooks/custom-hooks/useSendbird.ts
@@ -0,0 +1,300 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { useChatCreate, useSendbirdServiceToken, useServerTime } from '@deriv/api-v2';
+import SendbirdChat, { BaseChannel, User } from '@sendbird/chat';
+import { GroupChannel, GroupChannelHandler, GroupChannelModule } from '@sendbird/chat/groupChannel';
+import { BaseMessage, MessageType, MessageTypeFilter } from '@sendbird/chat/message';
+
+import { useOrderDetails } from '@/providers/OrderDetailsProvider';
+
+import { api } from '..';
+
+/**
+ * The function renames the files by removing any non ISO-8859-1 code point from filename and returns a new blob object with the updated file name.
+ * @returns {Blob}
+ */
+export const renameFile = (file: File) => {
+ const newFile = new Blob([file], { type: file.type });
+ newFile.name = file.name
+ .split('')
+ .filter(char => char.charCodeAt(0) >= 32 && char.charCodeAt(0) <= 126)
+ .join('');
+ return newFile;
+};
+
+const ChatMessageStatus = {
+ ERRORED: 1,
+ PENDING: 0,
+} as const;
+
+type ChatMessage = {
+ channelUrl: string;
+ createdAt: number;
+ customType?: string;
+ fileType?: 'file' | 'image' | 'pdf';
+ id: string;
+ message?: string;
+ messageType: string;
+ name?: string;
+ senderUserId?: string;
+ size?: number;
+ status?: number;
+ url?: string;
+};
+
+const getMessageType = (message: BaseMessage) => {
+ const isImageType = (type: string) => ['image/jpeg', 'image/png', 'image/gif'].includes(type);
+ const isPDFType = (type: string) => type === 'application/pdf';
+
+ if (message.isFileMessage()) {
+ if (isImageType(message.type)) {
+ return 'image';
+ } else if (isPDFType(message.type)) {
+ return 'pdf';
+ }
+ return 'file';
+ }
+};
+
+function createChatMessage(sendbirdMessage: BaseMessage): ChatMessage {
+ return {
+ channelUrl: sendbirdMessage.channelUrl,
+ createdAt: sendbirdMessage.createdAt,
+ customType: sendbirdMessage.customType,
+ fileType: getMessageType(sendbirdMessage),
+ id: sendbirdMessage.messageId.toString(),
+ message: sendbirdMessage.isUserMessage() ? sendbirdMessage.message : undefined,
+ messageType: sendbirdMessage.messageType,
+ name: sendbirdMessage.isFileMessage() ? sendbirdMessage.name : undefined,
+ senderUserId:
+ sendbirdMessage.isUserMessage() || sendbirdMessage.isFileMessage()
+ ? sendbirdMessage.sender?.userId
+ : undefined,
+ size: sendbirdMessage.isFileMessage() ? sendbirdMessage.size : undefined,
+ url: sendbirdMessage.isFileMessage() ? sendbirdMessage.url : undefined,
+ };
+}
+
+const useSendbird = (orderId: string) => {
+ const sendbirdApiRef = useRef>>();
+
+ const [isChatLoading, setIsChatLoading] = useState(false);
+ const [isFileUploading, setIsFileUploading] = useState(false);
+ const [isChatError, setIsChatError] = useState(false);
+ const [user, setUser] = useState(null);
+ const [messages, setMessages] = useState([]);
+ const [chatChannel, setChatChannel] = useState(null);
+ const [receivedMessage, setReceivedMessage] = useState(null);
+
+ const {
+ data: sendbirdServiceToken,
+ isError: isErrorSendbirdServiceToken,
+ isSuccess: isSuccessSendbirdServiceToken,
+ } = useSendbirdServiceToken();
+ const { data: advertiserInfo } = api.advertiser.useGetInfo();
+ //TODO: p2p_chat_create endpoint to be removed once chat_channel_url is created from p2p_order_create
+ const { isError: isErrorChatCreate, mutate: createChat } = useChatCreate();
+ const { isErrorOrderInfo, orderDetails } = useOrderDetails();
+ const { data: serverTime, isError: isErrorServerTime } = useServerTime();
+
+ const getUser = async (userId: string, token: string) => {
+ if (sendbirdApiRef?.current) {
+ const user = await sendbirdApiRef.current.connect(userId, token);
+ return user;
+ }
+ };
+
+ const onMessageReceived = useCallback(() => {
+ if (
+ receivedMessage?.channelUrl === chatChannel?.url &&
+ (receivedMessage?.isUserMessage() || receivedMessage?.isFileMessage())
+ ) {
+ setMessages(previousMessages => [...previousMessages, createChatMessage(receivedMessage)]);
+ }
+ }, [chatChannel?.url, receivedMessage]);
+
+ useEffect(() => {
+ onMessageReceived();
+ }, [receivedMessage, onMessageReceived]);
+
+ const getChannel = async (channelUrl: string) => {
+ if (sendbirdApiRef?.current) {
+ sendbirdApiRef.current.groupChannel.addGroupChannelHandler(
+ 'P2P_SENDBIRD_GROUP_CHANNEL_HANDLER',
+ new GroupChannelHandler({
+ onMessageReceived: (messageReceivedChannel: BaseChannel, _receivedMessage: BaseMessage) =>
+ setReceivedMessage(_receivedMessage),
+ })
+ );
+ const channel = await sendbirdApiRef.current.groupChannel.getChannel(channelUrl);
+ return channel;
+ }
+ };
+
+ const getMessages = useCallback(
+ async (channel: GroupChannel, fromTimestamp?: number) => {
+ const messagesFormatted: ChatMessage[] = [];
+ const timestamp = fromTimestamp || serverTime?.server_time_utc || 0;
+
+ const shouldSortFromMostRecent = messages ? messages?.length > 0 : false;
+ const retrievedMessages = await channel.getMessagesByTimestamp(timestamp, {
+ customTypesFilter: [''],
+ isInclusive: false,
+ messageTypeFilter: MessageTypeFilter.ALL,
+ nextResultSize: 0,
+ prevResultSize: 50,
+ reverse: shouldSortFromMostRecent,
+ });
+
+ retrievedMessages.forEach(message => {
+ if (message.isUserMessage() || message.isFileMessage()) {
+ messagesFormatted.push(createChatMessage(message));
+ }
+ });
+ return messagesFormatted;
+ },
+ [messages, serverTime?.server_time_utc]
+ );
+
+ const sendMessage = (message: string) => {
+ if (message.trim().length === 0) return;
+
+ const messageToSendId = `${Date.now()}${message.substring(0, 9)}${messages.length}`;
+ const messageToSend: ChatMessage = {
+ channelUrl: chatChannel?.url ?? '',
+ createdAt: serverTime?.server_time_utc || Date.now(),
+ id: messageToSendId,
+ message,
+ messageType: MessageType.USER,
+ senderUserId: user?.userId || '',
+ status: ChatMessageStatus.PENDING,
+ };
+
+ setMessages(previousMessages => [...previousMessages, messageToSend]);
+ chatChannel
+ ?.sendUserMessage({
+ data: messageToSendId,
+ message: message.trim(),
+ })
+ .onSucceeded(sentMessage => {
+ const idx = messages?.findIndex(msg => msg.id === messageToSendId);
+ if (sentMessage.isUserMessage()) {
+ setMessages(previousMessages => previousMessages.toSpliced(idx, 1, createChatMessage(sentMessage)));
+ }
+ })
+ .onFailed(() => {
+ const idx = messages?.findIndex(msg => msg.id === messageToSendId);
+ const errorMessage = {
+ ...messageToSend,
+ status: ChatMessageStatus.ERRORED,
+ };
+ setMessages(previousMessages => previousMessages.toSpliced(idx, 1, errorMessage));
+ });
+ };
+
+ const sendFile = (file: File) => {
+ const renamedFile = renameFile(file);
+
+ if (chatChannel) {
+ chatChannel
+ .sendFileMessage({
+ file: renamedFile,
+ fileName: file.name,
+ fileSize: file.size,
+ mimeType: file.type,
+ })
+ .onPending(() => {
+ setIsFileUploading(true);
+ })
+ .onSucceeded(sentMessage => {
+ if (sentMessage.channelUrl === chatChannel.url && sentMessage.isFileMessage()) {
+ setMessages(previousMessages => [...previousMessages, createChatMessage(sentMessage)]);
+ }
+ setIsFileUploading(false);
+ })
+ .onFailed(() => {
+ setIsChatError(true);
+ setIsFileUploading(false);
+ });
+ }
+ };
+
+ const closeChat = () => {
+ sendbirdApiRef?.current?.disconnect();
+ };
+
+ const initialiseChat = useCallback(async () => {
+ try {
+ if (isSuccessSendbirdServiceToken && sendbirdServiceToken?.app_id && advertiserInfo?.chat_user_id) {
+ setIsChatError(false);
+ setIsChatLoading(true);
+ const { app_id: appId, token } = sendbirdServiceToken;
+
+ sendbirdApiRef.current = SendbirdChat.init({
+ appId,
+ modules: [new GroupChannelModule()],
+ });
+
+ // 1. Check if the user exists
+ const user = await getUser(advertiserInfo.chat_user_id, token || '');
+ if (!user) {
+ setIsChatError(true);
+ } else if (orderDetails?.chat_channel_url) {
+ setUser(user);
+ // if there is no chat_channel_url, it needs to be created using useCreateChat hook first
+ // 2. Retrieve the P2P channel for the specific order
+ const channel = await getChannel(orderDetails.chat_channel_url);
+ if (!channel) {
+ setIsChatError(true);
+ } else {
+ setChatChannel(channel);
+ // 3. Retrieve any existing messages in the channel
+ const retrievedMessages = await getMessages(channel);
+ setMessages(retrievedMessages);
+ }
+ }
+ }
+ } catch (err) {
+ setIsChatError(true);
+ } finally {
+ setIsChatLoading(false);
+ }
+ }, [
+ isSuccessSendbirdServiceToken,
+ sendbirdServiceToken,
+ advertiserInfo?.chat_user_id,
+ orderDetails?.chat_channel_url,
+ getMessages,
+ ]);
+
+ useEffect(() => {
+ // close the Sendbird WS connection on unmount
+ return () => closeChat();
+ }, []);
+
+ useEffect(() => {
+ // if the user has not created a chat URL for the order yet, create one using p2p_create_chat endpoint
+ if (!orderDetails?.chat_channel_url) {
+ createChat({
+ order_id: orderId,
+ });
+ } else {
+ initialiseChat();
+ }
+ }, [orderId, orderDetails?.chat_channel_url]);
+
+ return {
+ activeChatChannel: chatChannel,
+ isChatLoading,
+ isError:
+ isChatError || isErrorChatCreate || isErrorOrderInfo || isErrorServerTime || isErrorSendbirdServiceToken,
+ isFileUploading,
+ messages,
+ refreshChat: initialiseChat,
+ sendFile,
+ sendMessage,
+ userId: user?.userId,
+ };
+};
+
+export default useSendbird;
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
new file mode 100644
index 00000000..b0ea3804
--- /dev/null
+++ b/src/hooks/index.ts
@@ -0,0 +1,2 @@
+export * as api from './api';
+export * from './custom-hooks';
diff --git a/src/main.tsx b/src/main.tsx
index 56ed2e60..10224b7a 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import ReactDOM from 'react-dom/client';
import { AppDataProvider } from '@deriv-com/api-hooks';
diff --git a/src/pages/advertiser/index.ts b/src/pages/advertiser/index.ts
new file mode 100644
index 00000000..c9de5c34
--- /dev/null
+++ b/src/pages/advertiser/index.ts
@@ -0,0 +1 @@
+export * from './screens';
diff --git a/src/pages/advertiser/screens/Advertiser/Advertiser.scss b/src/pages/advertiser/screens/Advertiser/Advertiser.scss
new file mode 100644
index 00000000..54516792
--- /dev/null
+++ b/src/pages/advertiser/screens/Advertiser/Advertiser.scss
@@ -0,0 +1,13 @@
+.p2p-advertiser {
+ position: absolute;
+ top: 8rem;
+ background-color: #fff;
+ width: 95.2rem;
+
+ @include mobile {
+ top: 4rem;
+ overflow-y: scroll;
+ height: calc(100vh - 8rem);
+ width: 100%;
+ }
+}
diff --git a/src/pages/advertiser/screens/Advertiser/Advertiser.tsx b/src/pages/advertiser/screens/Advertiser/Advertiser.tsx
new file mode 100644
index 00000000..b3e8de8e
--- /dev/null
+++ b/src/pages/advertiser/screens/Advertiser/Advertiser.tsx
@@ -0,0 +1,47 @@
+import { useHistory, useLocation, useParams } from 'react-router-dom';
+
+import { LabelPairedEllipsisVerticalLgRegularIcon } from '@deriv/quill-icons';
+import { useDevice } from '@deriv-com/ui';
+
+import { PageReturn, ProfileContent } from '@/components';
+import { BUY_SELL_URL, MY_PROFILE_URL } from '@/constants';
+import { api } from '@/hooks';
+
+import { AdvertiserAdvertsTable } from '../AdvertiserAdvertsTable';
+
+import './Advertiser.scss';
+
+const Advertiser = () => {
+ const { isMobile } = useDevice();
+ const { advertiserId } = useParams<{ advertiserId: string }>();
+ const { data: advertiserInfo } = api.advertiser.useGetInfo();
+
+ // Need to return undefined if the id is the same as the logged in user
+ // This will prevent the API from trying to resubscribe to the same user and grab the data from local storage
+ const id = advertiserId !== advertiserInfo.id ? advertiserId : undefined;
+ const history = useHistory();
+ const location = useLocation();
+
+ return (
+
+
+ history.push(
+ location.state?.from === 'MyProfile' ? `${MY_PROFILE_URL}?tab=My+counterparties` : BUY_SELL_URL
+ )
+ }
+ pageTitle='Advertiser’s page'
+ {...(isMobile && {
+ rightPlaceHolder: ,
+ })}
+ weight='bold'
+ />
+
+
+
+ );
+};
+
+export default Advertiser;
diff --git a/src/pages/advertiser/screens/Advertiser/__tests__/Advertiser.spec.tsx b/src/pages/advertiser/screens/Advertiser/__tests__/Advertiser.spec.tsx
new file mode 100644
index 00000000..907a6e2a
--- /dev/null
+++ b/src/pages/advertiser/screens/Advertiser/__tests__/Advertiser.spec.tsx
@@ -0,0 +1,81 @@
+import { APIProvider, AuthProvider } from '@deriv/api-v2';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import Advertiser from '../Advertiser';
+
+const mockUseHistory = {
+ location: { search: '?id=123' },
+ push: jest.fn(),
+};
+
+const mockUseLocation = {
+ state: { from: '' },
+};
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+ {children}
+
+);
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useHistory: () => mockUseHistory,
+ useLocation: () => mockUseLocation,
+ useParams: () => ({ advertiserId: '123' }),
+}));
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ p2p: {
+ advertiser: {
+ useGetInfo: jest.fn(() => ({
+ data: {
+ advertiser_info: {
+ id: '123',
+ },
+ },
+ })),
+ },
+ },
+}));
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn(() => ({ isMobile: false })),
+}));
+
+jest.mock('@/components', () => ({
+ ...jest.requireActual('@/components'),
+ ProfileContent: () => ProfileContent
,
+}));
+
+jest.mock('../../AdvertiserAdvertsTable', () => ({
+ AdvertiserAdvertsTable: () => AdvertiserAdvertsTable
,
+}));
+
+describe(' ', () => {
+ it('should render the Advertiser page component', () => {
+ render( , { wrapper });
+
+ expect(screen.getByText('Advertiser’s page')).toBeInTheDocument();
+ expect(screen.getByText('ProfileContent')).toBeInTheDocument();
+ expect(screen.getByText('AdvertiserAdvertsTable')).toBeInTheDocument();
+ });
+
+ it('should call navigate back to buy-sell page when the back button is clicked', async () => {
+ render( , { wrapper });
+ const backButton = screen.getByTestId('dt_page_return_btn');
+ await userEvent.click(backButton);
+ expect(mockUseHistory.push).toHaveBeenCalledWith('/cashier/p2p-v2/buy-sell');
+ });
+
+ it('should call navigate back to my-profile page when the back button is clicked', async () => {
+ mockUseLocation.state.from = 'MyProfile';
+ render( , { wrapper });
+ const backButton = screen.getByTestId('dt_page_return_btn');
+ await userEvent.click(backButton);
+ expect(mockUseHistory.push).toHaveBeenCalledWith('/cashier/p2p-v2/my-profile?tab=My+counterparties');
+ });
+});
diff --git a/src/pages/advertiser/screens/Advertiser/index.ts b/src/pages/advertiser/screens/Advertiser/index.ts
new file mode 100644
index 00000000..fef09fe6
--- /dev/null
+++ b/src/pages/advertiser/screens/Advertiser/index.ts
@@ -0,0 +1 @@
+export { default as Advertiser } from './Advertiser';
diff --git a/src/pages/advertiser/screens/AdvertiserAdvertsTable/AdvertiserAdvertsTable.scss b/src/pages/advertiser/screens/AdvertiserAdvertsTable/AdvertiserAdvertsTable.scss
new file mode 100644
index 00000000..5fae7ec1
--- /dev/null
+++ b/src/pages/advertiser/screens/AdvertiserAdvertsTable/AdvertiserAdvertsTable.scss
@@ -0,0 +1,22 @@
+.p2p-advertiser-adverts-table {
+ & .derivs-secondary-tabs {
+ &__btn {
+ padding: 0.5rem 0;
+ }
+
+ .deriv-text {
+ font-size: 1.4rem;
+ }
+ }
+
+ & .p2p-table {
+ &__header {
+ padding: 1.6rem;
+ border-bottom: 1px solid #f2f3f4;
+ grid-template-columns: repeat(4, 1fr);
+ }
+ &__content {
+ overflow: auto;
+ }
+ }
+}
diff --git a/src/pages/advertiser/screens/AdvertiserAdvertsTable/AdvertiserAdvertsTable.tsx b/src/pages/advertiser/screens/AdvertiserAdvertsTable/AdvertiserAdvertsTable.tsx
new file mode 100644
index 00000000..5bdae72c
--- /dev/null
+++ b/src/pages/advertiser/screens/AdvertiserAdvertsTable/AdvertiserAdvertsTable.tsx
@@ -0,0 +1,44 @@
+import { Tab, Tabs } from '@deriv-com/ui';
+
+import { ADVERT_TYPE, BUY_SELL } from '@/constants';
+import { api } from '@/hooks';
+import { useQueryString } from '@/hooks/custom-hooks';
+
+import { AdvertsTableRenderer } from './AdvertsTableRenderer';
+
+import './AdvertiserAdvertsTable.scss';
+
+type TAdvertiserAdvertsTableProps = {
+ advertiserId: string;
+};
+
+const TABS = [ADVERT_TYPE.BUY, ADVERT_TYPE.SELL];
+
+const AdvertiserAdvertsTable = ({ advertiserId }: TAdvertiserAdvertsTableProps) => {
+ const { queryString, setQueryString } = useQueryString();
+ const activeTab = queryString?.tab || ADVERT_TYPE.BUY;
+
+ const { data, isFetching, isLoading, loadMoreAdverts } = api.advert.useGetList({
+ advertiser_id: advertiserId,
+ counterparty_type: activeTab === ADVERT_TYPE.BUY ? BUY_SELL.BUY : BUY_SELL.SELL,
+ });
+
+ const setActiveTab = (index: number) => setQueryString({ tab: TABS[index] });
+
+ return (
+
+ );
+};
+
+export default AdvertiserAdvertsTable;
diff --git a/src/pages/advertiser/screens/AdvertiserAdvertsTable/AdvertsTableRenderer/AdvertsTableRenderer.tsx b/src/pages/advertiser/screens/AdvertiserAdvertsTable/AdvertsTableRenderer/AdvertsTableRenderer.tsx
new file mode 100644
index 00000000..32fb080a
--- /dev/null
+++ b/src/pages/advertiser/screens/AdvertiserAdvertsTable/AdvertsTableRenderer/AdvertsTableRenderer.tsx
@@ -0,0 +1,48 @@
+import { TAdvertsTableRowRenderer } from 'types';
+
+import { DerivLightIcNoDataIcon } from '@deriv/quill-icons';
+import { ActionScreen, Loader, Text } from '@deriv-com/ui';
+
+import { AdvertsTableRow, Table } from '@/components';
+
+const columns = [{ header: 'Limits' }, { header: 'Rate (1 USD)' }, { header: 'Payment methods' }];
+
+const headerRenderer = (header: string) => {header} ;
+
+type TAdvertsTableRenderer = {
+ data?: TAdvertsTableRowRenderer[];
+ isFetching: boolean;
+ isLoading: boolean;
+ loadMoreAdverts: () => void;
+};
+
+const AdvertsTableRenderer = ({ data, isFetching, isLoading, loadMoreAdverts }: TAdvertsTableRenderer) => {
+ if (isLoading) {
+ return ;
+ }
+
+ if (!data) {
+ return (
+
+
}
+ title={
There are no ads yet }
+ />
+
+ );
+ }
+ return (
+ }
+ tableClassname=''
+ />
+ );
+};
+
+export default AdvertsTableRenderer;
diff --git a/src/pages/advertiser/screens/AdvertiserAdvertsTable/AdvertsTableRenderer/index.ts b/src/pages/advertiser/screens/AdvertiserAdvertsTable/AdvertsTableRenderer/index.ts
new file mode 100644
index 00000000..3fb88892
--- /dev/null
+++ b/src/pages/advertiser/screens/AdvertiserAdvertsTable/AdvertsTableRenderer/index.ts
@@ -0,0 +1 @@
+export { default as AdvertsTableRenderer } from './AdvertsTableRenderer';
diff --git a/src/pages/advertiser/screens/AdvertiserAdvertsTable/__tests__/AdvertiserAdvertsTable.spec.tsx b/src/pages/advertiser/screens/AdvertiserAdvertsTable/__tests__/AdvertiserAdvertsTable.spec.tsx
new file mode 100644
index 00000000..9843516c
--- /dev/null
+++ b/src/pages/advertiser/screens/AdvertiserAdvertsTable/__tests__/AdvertiserAdvertsTable.spec.tsx
@@ -0,0 +1,134 @@
+import { APIProvider, AuthProvider } from '@deriv/api-v2';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { api } from '@/hooks';
+
+import AdvertiserAdvertsTable from '../AdvertiserAdvertsTable';
+
+let mockApiValues = {
+ isFetching: false,
+ isLoading: true,
+ loadMoreAdverts: jest.fn(),
+};
+
+jest.mock('use-query-params', () => ({
+ ...jest.requireActual('use-query-params'),
+ useQueryParams: jest.fn().mockReturnValue([{}, jest.fn()]),
+}));
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn(() => ({ isMobile: false })),
+}));
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ p2p: {
+ advert: {
+ useGetList: jest.fn(() => mockApiValues),
+ },
+ advertiser: {
+ useGetInfo: jest.fn(() => ({ data: { id: '123' } })),
+ },
+ advertiserPaymentMethods: {
+ useGet: jest.fn(() => ({ data: [] })),
+ },
+ paymentMethods: {
+ useGet: jest.fn(() => ({ data: [] })),
+ },
+ },
+}));
+
+const mockUseGetList = api.advert.useGetList as jest.Mock;
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+ {children}
+
+);
+
+describe(' ', () => {
+ it('should show the Loader component if isLoading is true', () => {
+ render( , { wrapper });
+
+ expect(screen.getByRole('button', { name: 'Buy' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Sell' })).toBeInTheDocument();
+ expect(screen.getByTestId('dt_derivs-loader')).toBeInTheDocument();
+ });
+
+ it('should show There are no adverts yet message if data is empty', () => {
+ mockApiValues = {
+ ...mockApiValues,
+ isLoading: false,
+ };
+ mockUseGetList.mockReturnValue(mockApiValues);
+
+ render( , { wrapper });
+
+ expect(screen.getByText('There are no ads yet')).toBeInTheDocument();
+ });
+
+ it('should show the AdvertsTableRenderer component if data is not empty', () => {
+ mockApiValues = {
+ ...mockApiValues,
+ data: [
+ {
+ account_currency: 'USD',
+ advertiser_id: '123',
+ counterparty_type: 'buy',
+ id: '123',
+ max_order_amount_limit_display: '100.00',
+ min_order_amount_limit_display: '10.00',
+ payment_method_names: ['Bank Transfer', 'Other'],
+ },
+ ],
+ isLoading: false,
+ };
+ mockUseGetList.mockReturnValue(mockApiValues);
+
+ render( , { wrapper });
+
+ expect(screen.getByText('Limits')).toBeInTheDocument();
+ expect(screen.getByText(/10.00-100.00 USD/)).toBeInTheDocument();
+
+ expect(screen.getByText('Rate (1 USD)')).toBeInTheDocument();
+ expect(screen.getByText('0.00')).toBeInTheDocument();
+
+ expect(screen.getByText('Payment methods')).toBeInTheDocument();
+ expect(screen.getByText('Bank Transfer')).toBeInTheDocument();
+ expect(screen.getByText('Other')).toBeInTheDocument();
+
+ expect(screen.getByRole('button', { name: 'Buy USD' })).toBeInTheDocument();
+ });
+
+ it('should show Sell Tab is active when tab is clicked on', async () => {
+ mockApiValues = {
+ ...mockApiValues,
+ data: [
+ {
+ account_currency: 'USD',
+ advertiser_id: '123',
+ counterparty_type: 'sell',
+ id: '123',
+ max_order_amount_limit_display: '100.00',
+ min_order_amount_limit_display: '10.00',
+ payment_method_names: ['Bank Transfer', 'Other'],
+ },
+ ],
+ isLoading: false,
+ };
+ mockUseGetList.mockReturnValue(mockApiValues);
+
+ render( , { wrapper });
+
+ await userEvent.click(screen.getByRole('button', { name: 'Sell' }));
+
+ const activeClass = 'derivs-secondary-tabs__btn derivs-secondary-tabs__btn--active';
+
+ expect(screen.getByRole('button', { name: 'Buy' })).not.toHaveClass(activeClass);
+ expect(screen.getByRole('button', { name: 'Sell' })).toHaveClass(activeClass);
+
+ expect(screen.getByRole('button', { name: 'Sell USD' })).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/advertiser/screens/AdvertiserAdvertsTable/index.ts b/src/pages/advertiser/screens/AdvertiserAdvertsTable/index.ts
new file mode 100644
index 00000000..2bc1342f
--- /dev/null
+++ b/src/pages/advertiser/screens/AdvertiserAdvertsTable/index.ts
@@ -0,0 +1 @@
+export { default as AdvertiserAdvertsTable } from './AdvertiserAdvertsTable';
diff --git a/src/pages/advertiser/screens/index.ts b/src/pages/advertiser/screens/index.ts
new file mode 100644
index 00000000..0311c224
--- /dev/null
+++ b/src/pages/advertiser/screens/index.ts
@@ -0,0 +1 @@
+export * from './Advertiser';
diff --git a/src/pages/buy-sell/components/CurrencyDropdown/CurrencyDropdown.scss b/src/pages/buy-sell/components/CurrencyDropdown/CurrencyDropdown.scss
new file mode 100644
index 00000000..926b2b17
--- /dev/null
+++ b/src/pages/buy-sell/components/CurrencyDropdown/CurrencyDropdown.scss
@@ -0,0 +1,66 @@
+.p2p-currency-dropdown {
+ &__dropdown {
+ display: inline-flex;
+ align-items: center;
+ justify-content: space-between;
+ position: relative;
+ border-radius: 4px;
+ text-align: left;
+ padding: 7px 8px;
+ border: 1px solid #d6dadb;
+ cursor: pointer;
+ width: 9rem;
+
+ @include mobile {
+ padding: 3px 8px;
+ }
+
+ &:hover:not(.p2p-currency-dropdown__dropdown--active) {
+ border-color: #999;
+ }
+
+ &--active {
+ border-color: #85acb0;
+ }
+
+ &-icon {
+ transition: transform 0.2s cubic-bezier(0.25, 0.1, 0.25, 1);
+
+ &--active {
+ transform: rotate(180deg);
+ }
+ }
+
+ &-text {
+ background-color: #fff;
+ padding: 0 4px;
+ height: fit-content;
+ transform: translate(-10%, -133%);
+ position: absolute;
+
+ @include mobile {
+ transform: translate(-10%, -109%);
+ }
+ }
+ }
+
+ &__full-page-modal {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 1;
+ height: calc(100vh - 12rem);
+
+ .p2p-search {
+ position: fixed;
+
+ .deriv-input {
+ padding: 2rem 1rem;
+
+ &__label {
+ left: 3.5rem;
+ }
+ }
+ }
+ }
+}
diff --git a/src/pages/buy-sell/components/CurrencyDropdown/CurrencyDropdown.tsx b/src/pages/buy-sell/components/CurrencyDropdown/CurrencyDropdown.tsx
new file mode 100644
index 00000000..69551c90
--- /dev/null
+++ b/src/pages/buy-sell/components/CurrencyDropdown/CurrencyDropdown.tsx
@@ -0,0 +1,101 @@
+import { useMemo, useRef, useState } from 'react';
+import clsx from 'clsx';
+import { useOnClickOutside } from 'usehooks-ts';
+
+import { LabelPairedChevronDownMdRegularIcon } from '@deriv/quill-icons';
+import { Text, useDevice } from '@deriv-com/ui';
+
+import { FullPageMobileWrapper } from '@/components';
+import { api } from '@/hooks';
+
+import { CurrencySelector } from './CurrencySelector';
+
+import './CurrencyDropdown.scss';
+
+type TCurrencyDropdownProps = {
+ selectedCurrency: string;
+ setSelectedCurrency: (value: string) => void;
+};
+
+const CurrencyDropdown = ({ selectedCurrency, setSelectedCurrency }: TCurrencyDropdownProps) => {
+ const { data } = api.settings.useGetSettings();
+ const { isMobile } = useDevice();
+ const [showCurrencySelector, setShowCurrencySelector] = useState(false);
+
+ const currencySelectorRef = useRef(null);
+ useOnClickOutside(currencySelectorRef, () => {
+ setShowCurrencySelector(false);
+ });
+
+ const localCurrencies = useMemo(() => {
+ return data?.currency_list
+ ? data.currency_list
+ .sort((a, b) => a.value.localeCompare(b.value))
+ .sort((a, b) => {
+ if (a.value === selectedCurrency) return -1;
+ if (b.value === selectedCurrency) return 1;
+ return 0;
+ })
+ : [];
+ }, [data?.currency_list, selectedCurrency]);
+
+ const onSelectItem = (currency: string) => {
+ setShowCurrencySelector(false);
+ setSelectedCurrency(currency);
+ };
+
+ if (showCurrencySelector && isMobile)
+ return (
+ {
+ setShowCurrencySelector(false);
+ }}
+ renderHeader={() => (
+
+ Preferred currency
+
+ )}
+ >
+
+
+ );
+
+ return (
+
+
setShowCurrencySelector(prev => !prev)}
+ >
+
+ Currency
+
+ {selectedCurrency}
+
+
+ {showCurrencySelector && (
+
+ )}
+
+ );
+};
+
+export default CurrencyDropdown;
diff --git a/src/pages/buy-sell/components/CurrencyDropdown/CurrencySelector/CurrencySelector.scss b/src/pages/buy-sell/components/CurrencyDropdown/CurrencySelector/CurrencySelector.scss
new file mode 100644
index 00000000..47a94351
--- /dev/null
+++ b/src/pages/buy-sell/components/CurrencyDropdown/CurrencySelector/CurrencySelector.scss
@@ -0,0 +1,35 @@
+.p2p-currency-selector {
+ position: absolute;
+ top: 7rem;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ z-index: 2;
+ border-radius: 0.4rem;
+ background: #fff;
+ box-shadow: 0 3.2rem 6.4rem rgba(14, 14, 14, 0.141);
+ overflow-y: auto;
+
+ @include mobile {
+ position: relative;
+ top: 0;
+ align-items: normal;
+ }
+
+ &__list {
+ overflow-x: hidden;
+ overflow-y: auto;
+ position: relative;
+ scroll-behavior: smooth;
+ scrollbar-width: thin;
+ max-height: 45rem;
+ margin: 0.8rem;
+ width: 24rem;
+
+ @include mobile {
+ margin-top: 4.6rem;
+ height: calc(100vh - 22rem);
+ width: unset;
+ }
+ }
+}
diff --git a/src/pages/buy-sell/components/CurrencyDropdown/CurrencySelector/CurrencySelector.tsx b/src/pages/buy-sell/components/CurrencyDropdown/CurrencySelector/CurrencySelector.tsx
new file mode 100644
index 00000000..d7d8153f
--- /dev/null
+++ b/src/pages/buy-sell/components/CurrencyDropdown/CurrencySelector/CurrencySelector.tsx
@@ -0,0 +1,94 @@
+import React, { useState } from 'react';
+import clsx from 'clsx';
+import { TCurrencyListItem } from 'types';
+
+import { Text, useDevice } from '@deriv-com/ui';
+
+import { Search } from '@/components';
+
+import './CurrencySelector.scss';
+
+type TCurrencySelectorProps = {
+ localCurrencies: TCurrencyListItem[];
+ onSelectItem: (value: string) => void;
+ selectedCurrency: string;
+};
+
+const CurrencySelector = ({ localCurrencies, onSelectItem, selectedCurrency }: TCurrencySelectorProps) => {
+ const [searchedCurrency, setSearchedCurrency] = useState('');
+ const [searchedCurrencies, setSearchedCurrencies] = useState(localCurrencies);
+ const { isMobile } = useDevice();
+
+ const textSize = isMobile ? 'md' : 'sm';
+
+ const searchCurrencies = (value: string) => {
+ if (!value) {
+ setSearchedCurrencies(localCurrencies);
+ return;
+ }
+
+ setSearchedCurrency(value);
+
+ setSearchedCurrencies(
+ localCurrencies.filter(currency => {
+ return (
+ currency.value.toLowerCase().includes(value.toLocaleLowerCase()) ||
+ currency.display_name.toLowerCase().includes(value.toLocaleLowerCase())
+ );
+ })
+ );
+ };
+
+ return (
+
+
searchCurrencies(value)}
+ placeholder='Search'
+ />
+
+ {searchedCurrencies.length > 0 ? (
+ searchedCurrencies.map(currency => {
+ const isSelectedCurrency = currency.value === selectedCurrency;
+
+ return (
+
onSelectItem(currency.value)}
+ >
+
+
+ {currency.text}
+
+
+ {currency.display_name}
+
+
+
+ );
+ })
+ ) : (
+
+ No results for "{searchedCurrency}".
+
+ )}
+
+
+ );
+};
+
+export default CurrencySelector;
diff --git a/src/pages/buy-sell/components/CurrencyDropdown/CurrencySelector/index.ts b/src/pages/buy-sell/components/CurrencyDropdown/CurrencySelector/index.ts
new file mode 100644
index 00000000..d2594555
--- /dev/null
+++ b/src/pages/buy-sell/components/CurrencyDropdown/CurrencySelector/index.ts
@@ -0,0 +1 @@
+export { default as CurrencySelector } from './CurrencySelector';
diff --git a/src/pages/buy-sell/components/CurrencyDropdown/__tests__/CurrencyDropdown.spec.tsx b/src/pages/buy-sell/components/CurrencyDropdown/__tests__/CurrencyDropdown.spec.tsx
new file mode 100644
index 00000000..5c7eb72f
--- /dev/null
+++ b/src/pages/buy-sell/components/CurrencyDropdown/__tests__/CurrencyDropdown.spec.tsx
@@ -0,0 +1,146 @@
+import { act, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import CurrencyDropdown from '../CurrencyDropdown';
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+
Click me
+ {children}
+
+);
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ p2p: {
+ settings: {
+ useGetSettings: () => ({
+ data: {
+ currency_list: [
+ {
+ display_name: 'BOB',
+ has_adverts: 1,
+ is_default: undefined,
+ text: 'Boliviano',
+ value: 'BOB',
+ },
+ {
+ display_name: 'IDR',
+ has_adverts: 1,
+ is_default: 1,
+ text: 'Indonesian Rupiah',
+ value: 'IDR',
+ },
+ ],
+ },
+ }),
+ },
+ },
+}));
+
+let mockIsMobile = false;
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn(() => ({ isMobile: mockIsMobile })),
+}));
+const mockProps = {
+ selectedCurrency: 'IDR',
+ setSelectedCurrency: jest.fn(),
+};
+
+beforeEach(() => {
+ jest.useFakeTimers();
+});
+
+afterEach(() => {
+ jest.useRealTimers();
+});
+
+describe(' ', () => {
+ it('should call setSelectedCurrency when a currency is selected from the dropdown', async () => {
+ render( , { wrapper });
+
+ const currencyDropdown = screen.getByText('IDR');
+ await userEvent.click(currencyDropdown);
+
+ const bobOption = screen.getByText('BOB');
+ await userEvent.click(bobOption);
+
+ expect(mockProps.setSelectedCurrency).toHaveBeenCalledWith('BOB');
+ });
+
+ it('should hide the list if the user clicks outside the dropdown', async () => {
+ render( , { wrapper });
+
+ const currencyDropdown = screen.getByText('IDR');
+ await userEvent.click(currencyDropdown);
+
+ const clickMe = screen.getByText('Click me');
+ await userEvent.click(clickMe);
+
+ expect(screen.queryByText('BOB')).not.toBeInTheDocument();
+ });
+
+ it('should show Preferred currency text and hide list if user clicks on arrow icon when isMobile is true', async () => {
+ mockIsMobile = true;
+ render( , { wrapper });
+
+ const currencyDropdown = screen.getByText('IDR');
+ await userEvent.click(currencyDropdown);
+
+ expect(screen.getByText('Preferred currency')).toBeInTheDocument();
+
+ const arrowIcon = screen.getByTestId('dt_mobile_wrapper_button');
+ await userEvent.click(arrowIcon);
+
+ expect(screen.queryByText('Preferred currency')).not.toBeInTheDocument();
+ });
+
+ it('should only show BOB in the currency list if BOB is searched', async () => {
+ render( , { wrapper });
+
+ const currencyDropdown = screen.getByText('IDR');
+ await userEvent.click(currencyDropdown);
+
+ const searchInput = screen.getByRole('searchbox');
+
+ act(async () => {
+ await userEvent.type(searchInput, 'BOB');
+ });
+
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ expect(screen.getByText('BOB')).toBeInTheDocument();
+ expect(screen.queryByText('IDR')).not.toBeInTheDocument();
+ });
+
+ it('should show No results for message if currency is not in the list', async () => {
+ render( , { wrapper });
+
+ const currencyDropdown = screen.getByText('IDR');
+ await userEvent.click(currencyDropdown);
+
+ const searchInput = screen.getByRole('searchbox');
+
+ act(async () => {
+ await userEvent.type(searchInput, 'JPY');
+ });
+
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ expect(screen.getByText(/No results for "JPY"./s)).toBeInTheDocument();
+
+ act(async () => {
+ await userEvent.clear(searchInput);
+ });
+
+ act(() => {
+ jest.runAllTimers();
+ });
+ });
+});
diff --git a/src/pages/buy-sell/components/CurrencyDropdown/index.ts b/src/pages/buy-sell/components/CurrencyDropdown/index.ts
new file mode 100644
index 00000000..bdc26b1f
--- /dev/null
+++ b/src/pages/buy-sell/components/CurrencyDropdown/index.ts
@@ -0,0 +1 @@
+export { default as CurrencyDropdown } from './CurrencyDropdown';
diff --git a/src/pages/buy-sell/components/SortDropdown/SortDropdown.scss b/src/pages/buy-sell/components/SortDropdown/SortDropdown.scss
new file mode 100644
index 00000000..c3359bc7
--- /dev/null
+++ b/src/pages/buy-sell/components/SortDropdown/SortDropdown.scss
@@ -0,0 +1,19 @@
+.p2p-sort-dropdown {
+ .deriv-dropdown__items {
+ top: 4.4rem;
+ }
+
+ .deriv-input {
+ &__container {
+ width: 24rem;
+ }
+
+ &__field {
+ font-size: 14px;
+
+ &:not(:placeholder-shown) ~ label {
+ transform: translate(-29%, -50%);
+ }
+ }
+ }
+}
diff --git a/src/pages/buy-sell/components/SortDropdown/SortDropdown.tsx b/src/pages/buy-sell/components/SortDropdown/SortDropdown.tsx
new file mode 100644
index 00000000..3fee37e4
--- /dev/null
+++ b/src/pages/buy-sell/components/SortDropdown/SortDropdown.tsx
@@ -0,0 +1,46 @@
+import { LabelPairedChevronDownMdRegularIcon } from '@deriv/quill-icons';
+import { Button, Dropdown, useDevice } from '@deriv-com/ui';
+
+import { TSortByValues } from '@/utils';
+
+import SortIcon from '../../../../public/ic-cashier-sort.svg';
+
+import './SortDropdown.scss';
+
+type TSortDropdownProps = {
+ list: readonly { text: string; value: string }[];
+ onSelect: (value: TSortByValues) => void;
+ setIsFilterModalOpen: (value: boolean) => void;
+ value: TSortByValues;
+};
+
+const SortDropdown = ({ list, onSelect, setIsFilterModalOpen, value }: TSortDropdownProps) => {
+ const { isMobile } = useDevice();
+
+ if (isMobile) {
+ return (
+ }
+ onClick={() => setIsFilterModalOpen(true)}
+ variant='outlined'
+ />
+ );
+ }
+
+ return (
+
+ }
+ label='Sort by'
+ list={list}
+ name='Sort by'
+ onSelect={(value: string) => onSelect(value as TSortByValues)}
+ value={value}
+ />
+
+ );
+};
+
+export default SortDropdown;
diff --git a/src/pages/buy-sell/components/SortDropdown/index.ts b/src/pages/buy-sell/components/SortDropdown/index.ts
new file mode 100644
index 00000000..9533adee
--- /dev/null
+++ b/src/pages/buy-sell/components/SortDropdown/index.ts
@@ -0,0 +1 @@
+export { default as SortDropdown } from './SortDropdown';
diff --git a/src/pages/buy-sell/components/index.ts b/src/pages/buy-sell/components/index.ts
new file mode 100644
index 00000000..14ba38ab
--- /dev/null
+++ b/src/pages/buy-sell/components/index.ts
@@ -0,0 +1,2 @@
+export * from './CurrencyDropdown';
+export * from './SortDropdown';
diff --git a/src/pages/buy-sell/index.ts b/src/pages/buy-sell/index.ts
new file mode 100644
index 00000000..c9de5c34
--- /dev/null
+++ b/src/pages/buy-sell/index.ts
@@ -0,0 +1 @@
+export * from './screens';
diff --git a/src/pages/buy-sell/screens/BuySell/BuySell.tsx b/src/pages/buy-sell/screens/BuySell/BuySell.tsx
new file mode 100644
index 00000000..1a86344c
--- /dev/null
+++ b/src/pages/buy-sell/screens/BuySell/BuySell.tsx
@@ -0,0 +1,11 @@
+import { BuySellTable } from '../BuySellTable';
+
+const BuySell = () => {
+ return (
+
+
+
+ );
+};
+
+export default BuySell;
diff --git a/src/pages/buy-sell/screens/BuySell/__tests__/BuySell.spec.tsx b/src/pages/buy-sell/screens/BuySell/__tests__/BuySell.spec.tsx
new file mode 100644
index 00000000..662518f2
--- /dev/null
+++ b/src/pages/buy-sell/screens/BuySell/__tests__/BuySell.spec.tsx
@@ -0,0 +1,13 @@
+import { render, screen } from '@testing-library/react';
+
+import BuySell from '../BuySell';
+
+jest.mock('../../BuySellTable/BuySellTable', () => jest.fn(() => BuySellTable
));
+
+describe(' ', () => {
+ it('should render the BuySell Component', () => {
+ render( );
+
+ expect(screen.getByText('BuySellTable')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/buy-sell/screens/BuySell/index.ts b/src/pages/buy-sell/screens/BuySell/index.ts
new file mode 100644
index 00000000..2650277f
--- /dev/null
+++ b/src/pages/buy-sell/screens/BuySell/index.ts
@@ -0,0 +1 @@
+export { default as BuySell } from './BuySell';
diff --git a/src/pages/buy-sell/screens/BuySellHeader/BuySellHeader.scss b/src/pages/buy-sell/screens/BuySellHeader/BuySellHeader.scss
new file mode 100644
index 00000000..827daff5
--- /dev/null
+++ b/src/pages/buy-sell/screens/BuySellHeader/BuySellHeader.scss
@@ -0,0 +1,74 @@
+.p2p-buy-sell-header {
+ padding: 2.5rem 0;
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+
+ @include mobile {
+ padding: 0;
+ flex-direction: column;
+ justify-content: center;
+ border: 1px solid #f2f3f4;
+ }
+
+ .deriv-input {
+ padding: 6px 8px;
+
+ @include mobile {
+ height: 3.2rem;
+ display: flex;
+ align-items: center;
+ line-height: 1rem;
+ }
+
+ &__helper-message {
+ display: none;
+ }
+ }
+
+ &__tabs {
+ @include mobile {
+ padding: 1.6rem;
+ }
+
+ & .derivs-primary-tabs {
+ height: 4rem;
+ width: 18rem;
+ border-radius: 0.6rem;
+ padding: 0.5rem;
+ }
+
+ & .derivs-primary-tabs__btn--active {
+ border-radius: 0.4rem;
+ padding: 0.8rem 0.6rem;
+ }
+ }
+
+ &__row {
+ display: flex;
+ flex-direction: row;
+ gap: 1rem;
+
+ @include mobile {
+ width: 100%;
+ padding: 1.6rem;
+ border-top: 1px solid #f2f3f4;
+ }
+
+ &-search {
+ .p2p-search {
+ .deriv-input {
+ &__container {
+ width: 24rem;
+
+ @include mobile {
+ width: 100%;
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/pages/buy-sell/screens/BuySellHeader/BuySellHeader.tsx b/src/pages/buy-sell/screens/BuySellHeader/BuySellHeader.tsx
new file mode 100644
index 00000000..5f469ca7
--- /dev/null
+++ b/src/pages/buy-sell/screens/BuySellHeader/BuySellHeader.tsx
@@ -0,0 +1,105 @@
+import React, { useState } from 'react';
+
+import { LabelPairedBarsFilterMdBoldIcon, LabelPairedBarsFilterSmBoldIcon } from '@deriv/quill-icons';
+import { Button, Tab, Tabs, useDevice } from '@deriv-com/ui';
+
+import { Search } from '@/components';
+import { FilterModal } from '@/components/Modals';
+import { SORT_BY_LIST } from '@/constants';
+import { TSortByValues } from '@/utils';
+
+import { CurrencyDropdown, SortDropdown } from '../../components';
+
+import './BuySellHeader.scss';
+
+type TBuySellHeaderProps = {
+ activeTab: string;
+ selectedCurrency: string;
+ selectedPaymentMethods: string[];
+ setActiveTab: (tab: number) => void;
+ setIsFilterModalOpen: (value: boolean) => void;
+ setSearchValue: (value: string) => void;
+ setSelectedCurrency: (value: string) => void;
+ setSelectedPaymentMethods: (value: string[]) => void;
+ setShouldUseClientLimits: (value: boolean) => void;
+ setSortDropdownValue: (value: TSortByValues) => void;
+ shouldUseClientLimits: boolean;
+ sortDropdownValue: TSortByValues;
+};
+
+const BuySellHeader = ({
+ activeTab,
+ selectedCurrency,
+ selectedPaymentMethods,
+ setActiveTab,
+ setIsFilterModalOpen,
+ setSearchValue,
+ setSelectedCurrency,
+ setSelectedPaymentMethods,
+ setShouldUseClientLimits,
+ setSortDropdownValue,
+ shouldUseClientLimits,
+ sortDropdownValue,
+}: TBuySellHeaderProps) => {
+ const { isMobile } = useDevice();
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ return (
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ )
+ }
+ onClick={() => setIsModalOpen(true)}
+ variant='outlined'
+ />
+
+
setIsModalOpen(false)}
+ onToggle={setShouldUseClientLimits}
+ selectedPaymentMethods={selectedPaymentMethods}
+ setSelectedPaymentMethods={setSelectedPaymentMethods}
+ />
+
+ );
+};
+
+export default BuySellHeader;
diff --git a/src/pages/buy-sell/screens/BuySellHeader/__tests__/BuySellHeader.spec.tsx b/src/pages/buy-sell/screens/BuySellHeader/__tests__/BuySellHeader.spec.tsx
new file mode 100644
index 00000000..69600a68
--- /dev/null
+++ b/src/pages/buy-sell/screens/BuySellHeader/__tests__/BuySellHeader.spec.tsx
@@ -0,0 +1,140 @@
+import { useDevice } from '@deriv-com/ui';
+import { act, render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { TSortByValues } from '@/utils';
+
+import BuySellHeader from '../BuySellHeader';
+
+const mockProps = {
+ activeTab: 'Buy',
+ list: [
+ {
+ text: 'Exchange rate',
+ value: 'rate',
+ },
+ {
+ text: 'User rating',
+ value: 'rating',
+ },
+ ],
+ selectedCurrency: 'IDR',
+ selectedPaymentMethods: [],
+ setActiveTab: jest.fn(),
+ setIsFilterModalOpen: jest.fn(),
+ setSearchValue: jest.fn(),
+ setSelectedCurrency: jest.fn(),
+ setSelectedPaymentMethods: jest.fn(),
+ setShouldUseClientLimits: jest.fn(),
+ setSortDropdownValue: jest.fn(),
+ shouldUseClientLimits: false,
+ sortDropdownValue: 'rate' as TSortByValues,
+};
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({
+ isMobile: false,
+ }),
+}));
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ p2p: {
+ settings: {
+ useGetSettings: () => ({
+ data: {},
+ }),
+ },
+ },
+}));
+
+jest.mock('../../../components/CurrencyDropdown/CurrencyDropdown', () => jest.fn(() => CurrencyDropdown
));
+jest.mock('@/components/Modals/FilterModal/FilterModal', () => jest.fn(() => FilterModal
));
+
+const mockUseDevice = useDevice as jest.Mock;
+
+jest.useFakeTimers();
+
+describe(' ', () => {
+ it('should render the BuySellHeader', () => {
+ render( );
+
+ const buySellHeader = screen.getByTestId('dt_buy_sell_header');
+
+ expect(within(buySellHeader).getByRole('button', { name: 'Buy' })).toBeInTheDocument();
+ expect(within(buySellHeader).getByRole('button', { name: 'Sell' })).toBeInTheDocument();
+ expect(screen.getByRole('searchbox')).toBeInTheDocument();
+ expect(screen.getByRole('combobox', { name: 'Sort by' })).toBeInTheDocument();
+ });
+
+ it('should call setActiveTab when Sell tab is clicked', async () => {
+ render( );
+
+ const sellTab = screen.getByRole('button', { name: 'Sell' });
+
+ await userEvent.click(sellTab);
+ expect(mockProps.setActiveTab).toHaveBeenCalledWith(1);
+ });
+
+ it('should call setActiveTab when Buy tab is clicked', async () => {
+ render( );
+
+ const buyTab = screen.getByRole('button', { name: 'Buy' });
+
+ await userEvent.click(buyTab);
+ expect(mockProps.setActiveTab).toHaveBeenCalledWith(0);
+ });
+
+ it('should call setSearchValue when a value is entered in the search input', () => {
+ render( );
+
+ const searchInput = screen.getByRole('searchbox');
+
+ act(async () => {
+ await userEvent.type(searchInput, 'John Doe');
+ });
+
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ expect(mockProps.setSearchValue).toHaveBeenCalledWith('John Doe');
+ });
+
+ it('should call setSortDropdownValue when a value is selected from the dropdown', async () => {
+ render( );
+
+ const dropdown = screen.getByRole('combobox', { name: 'Sort by' });
+
+ await userEvent.click(dropdown);
+
+ const ratingOption = screen.getByRole('option', { name: 'User rating' });
+
+ await userEvent.click(ratingOption);
+
+ expect(mockProps.setSortDropdownValue).toHaveBeenCalledWith('rating');
+ });
+
+ it('should call setIsFilterModalOpen when the filter button is clicked on responsive', async () => {
+ mockUseDevice.mockReturnValue({ isMobile: true });
+
+ render( );
+
+ const filterButton = screen.getByTestId('dt_sort_dropdown_button');
+
+ await userEvent.click(filterButton);
+
+ expect(mockProps.setIsFilterModalOpen).toHaveBeenCalledWith(true);
+ });
+
+ it('should allow users to click on filter button', async () => {
+ render( );
+
+ const filterButton = screen.getByTestId('dt_buy_sell_header_filter_button');
+
+ await userEvent.click(filterButton);
+
+ expect(screen.getByText('FilterModal')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/buy-sell/screens/BuySellHeader/index.ts b/src/pages/buy-sell/screens/BuySellHeader/index.ts
new file mode 100644
index 00000000..b8e031b1
--- /dev/null
+++ b/src/pages/buy-sell/screens/BuySellHeader/index.ts
@@ -0,0 +1 @@
+export { default as BuySellHeader } from './BuySellHeader';
diff --git a/src/pages/buy-sell/screens/BuySellTable/BuySellTable.scss b/src/pages/buy-sell/screens/BuySellTable/BuySellTable.scss
new file mode 100644
index 00000000..fa81a6e2
--- /dev/null
+++ b/src/pages/buy-sell/screens/BuySellTable/BuySellTable.scss
@@ -0,0 +1,32 @@
+.p2p-buy-sell-table {
+ & .p2p-table {
+ &__header {
+ padding: 1.6rem;
+ border-bottom: 1px solid #f2f3f4;
+ grid-template-columns: 2fr 1.4fr 1.4fr 2.4fr 0.8fr;
+ }
+ &__content {
+ overflow: auto;
+
+ @include mobile {
+ // stylelint-disable-next-line declaration-no-important
+ height: calc(100vh - 21rem) !important;
+ }
+ }
+ }
+
+ &__tabs {
+ & .derivs-primary-tabs {
+ height: 4rem;
+ width: 18rem;
+ margin: 2.4rem 0;
+ border-radius: 0.6rem;
+ padding: 0.5rem;
+ }
+
+ & .derivs-primary-tabs__btn--active {
+ border-radius: 0.4rem;
+ padding: 0.8rem 0.6rem;
+ }
+ }
+}
diff --git a/src/pages/buy-sell/screens/BuySellTable/BuySellTable.tsx b/src/pages/buy-sell/screens/BuySellTable/BuySellTable.tsx
new file mode 100644
index 00000000..f50544b4
--- /dev/null
+++ b/src/pages/buy-sell/screens/BuySellTable/BuySellTable.tsx
@@ -0,0 +1,87 @@
+import { useEffect, useState } from 'react';
+
+import { RadioGroupFilterModal } from '@/components/Modals';
+import { ADVERT_TYPE, BUY_SELL, SORT_BY_LIST } from '@/constants';
+import { api } from '@/hooks';
+import { useQueryString } from '@/hooks/custom-hooks';
+import { TSortByValues } from '@/utils';
+
+import { BuySellHeader } from '../BuySellHeader';
+
+import { BuySellTableRenderer } from './BuySellTableRenderer';
+
+import './BuySellTable.scss';
+
+const TABS = [ADVERT_TYPE.BUY, ADVERT_TYPE.SELL];
+
+const BuySellTable = () => {
+ const { data: p2pSettingsData } = api.settings.useGetSettings();
+ const { queryString, setQueryString } = useQueryString();
+ const activeTab = queryString.tab || ADVERT_TYPE.BUY;
+
+ const [selectedCurrency, setSelectedCurrency] = useState(p2pSettingsData?.localCurrency || '');
+ const [sortDropdownValue, setSortDropdownValue] = useState('rate');
+ const [searchValue, setSearchValue] = useState('');
+ const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
+ const [selectedPaymentMethods, setSelectedPaymentMethods] = useState([]);
+ const [shouldUseClientLimits, setShouldUseClientLimits] = useState(true);
+
+ const { data, isFetching, isLoading, loadMoreAdverts } = api.advert.useGetList({
+ advertiser_name: searchValue,
+ counterparty_type: activeTab === ADVERT_TYPE.BUY ? BUY_SELL.BUY : BUY_SELL.SELL,
+ local_currency: selectedCurrency,
+ payment_method: selectedPaymentMethods.length > 0 ? selectedPaymentMethods : undefined,
+ sort_by: sortDropdownValue,
+ use_client_limits: shouldUseClientLimits ? 1 : 0,
+ });
+
+ const onToggle = (value: string) => {
+ setSortDropdownValue(value as TSortByValues);
+ setIsFilterModalOpen(false);
+ };
+
+ const setActiveTab = (index: number) => {
+ setQueryString({
+ tab: TABS[index],
+ });
+ };
+
+ useEffect(() => {
+ if (p2pSettingsData?.localCurrency) setSelectedCurrency(p2pSettingsData.localCurrency);
+ }, [p2pSettingsData?.localCurrency]);
+
+ return (
+
+
+
+ setIsFilterModalOpen(false)}
+ onToggle={onToggle}
+ selected={sortDropdownValue as string}
+ />
+
+ );
+};
+
+export default BuySellTable;
diff --git a/src/pages/buy-sell/screens/BuySellTable/BuySellTableRenderer/BuySellTableRenderer.tsx b/src/pages/buy-sell/screens/BuySellTable/BuySellTableRenderer/BuySellTableRenderer.tsx
new file mode 100644
index 00000000..ec391a9d
--- /dev/null
+++ b/src/pages/buy-sell/screens/BuySellTable/BuySellTableRenderer/BuySellTableRenderer.tsx
@@ -0,0 +1,60 @@
+import React, { memo } from 'react';
+import { TAdvertsTableRowRenderer } from 'types';
+import { AdvertsTableRow, Table } from '@/components';
+import { DerivLightIcCashierNoAdsIcon } from '@deriv/quill-icons';
+import { ActionScreen, Loader, Text } from '@deriv-com/ui';
+
+const columns = [
+ { header: 'Advertisers' },
+ { header: 'Limits' },
+ { header: 'Rate (1 USD)' },
+ { header: 'Payment methods' },
+];
+
+const headerRenderer = (header: string) => {header} ;
+
+type TBuySellTableRowRendererProps = {
+ data?: TAdvertsTableRowRenderer[];
+ isFetching: boolean;
+ isLoading: boolean;
+ loadMoreAdverts: () => void;
+ searchValue: string;
+};
+
+const BuySellTableRenderer = ({
+ data,
+ isFetching,
+ isLoading,
+ loadMoreAdverts,
+ searchValue,
+}: TBuySellTableRowRendererProps) => {
+ if (isLoading) {
+ return ;
+ }
+
+ if (!data && !searchValue) {
+ return (
+
+
}
+ title={
No ads for this currency at the moment 😞 }
+ />
+
+ );
+ }
+
+ return (
+ }
+ tableClassname=''
+ />
+ );
+};
+
+export default memo(BuySellTableRenderer);
diff --git a/src/pages/buy-sell/screens/BuySellTable/BuySellTableRenderer/index.ts b/src/pages/buy-sell/screens/BuySellTable/BuySellTableRenderer/index.ts
new file mode 100644
index 00000000..8bec2d43
--- /dev/null
+++ b/src/pages/buy-sell/screens/BuySellTable/BuySellTableRenderer/index.ts
@@ -0,0 +1 @@
+export { default as BuySellTableRenderer } from './BuySellTableRenderer';
diff --git a/src/pages/buy-sell/screens/BuySellTable/__tests__/BuySellTable.spec.tsx b/src/pages/buy-sell/screens/BuySellTable/__tests__/BuySellTable.spec.tsx
new file mode 100644
index 00000000..e3e2480a
--- /dev/null
+++ b/src/pages/buy-sell/screens/BuySellTable/__tests__/BuySellTable.spec.tsx
@@ -0,0 +1,130 @@
+import { APIProvider, AuthProvider } from '@deriv/api-v2';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import BuySellTable from '../BuySellTable';
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+ {children}
+
+);
+
+const mockPush = jest.fn();
+
+let mockAdvertiserListData = {
+ data: [],
+ isFetching: false,
+ isLoading: true,
+ loadMoreAdverts: jest.fn(),
+};
+
+jest.mock('use-query-params', () => ({
+ ...jest.requireActual('use-query-params'),
+ useQueryParams: jest.fn().mockReturnValue([{}, jest.fn()]),
+}));
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useHistory: () => ({
+ push: mockPush,
+ }),
+}));
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ p2p: {
+ advert: {
+ useGetList: jest.fn(() => mockAdvertiserListData),
+ },
+ advertiser: {
+ useGetInfo: jest.fn(() => ({ data: { id: '123' } })),
+ },
+ advertiserPaymentMethods: {
+ useGet: jest.fn(() => ({ data: [] })),
+ },
+ paymentMethods: {
+ useGet: jest.fn(() => ({ data: [] })),
+ },
+ settings: {
+ useGetSettings: jest.fn(() => ({
+ data: {},
+ })),
+ },
+ },
+}));
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn(() => ({ isMobile: false })),
+}));
+
+jest.mock('../../BuySellHeader/BuySellHeader', () => jest.fn(() => BuySellHeader
));
+
+describe(' ', () => {
+ beforeEach(() => {
+ Object.defineProperty(window, 'location', {
+ value: {
+ href: 'https://app.deriv.com/cashier/p2p-v2/buy-sell',
+ },
+ writable: true,
+ });
+ });
+ it('should render the BuySellHeader component and loader component if isLoading is true', () => {
+ render( , { wrapper });
+
+ expect(screen.getByText('BuySellHeader')).toBeInTheDocument();
+ expect(screen.getByTestId('dt_derivs-loader')).toBeInTheDocument();
+ });
+
+ it('should render the Table component if data is not empty', async () => {
+ mockAdvertiserListData = {
+ data: [
+ // @ts-expect-error caused by typing of never[]
+ {
+ account_currency: 'USD',
+ advertiser_details: {
+ completed_orders_count: 300,
+ id: '1',
+ is_online: true,
+ name: 'John Doe',
+ rating_average: 5,
+ rating_count: 1,
+ },
+ counterparty_type: 'buy',
+ effective_rate: 0.0001,
+ local_currency: 'USD',
+ max_order_amount_limit_display: 100,
+ min_order_amount_limit_display: 10,
+ payment_method_names: ['Bank transfer'],
+ price_display: 100,
+ rate: 0.0001,
+ rate_type: 'fixed',
+ },
+ ],
+ isFetching: false,
+ isLoading: false,
+ loadMoreAdverts: jest.fn(),
+ };
+
+ render( , { wrapper });
+
+ await waitFor(() => {
+ expect(screen.getByText('John Doe')).toBeInTheDocument();
+ expect(screen.getByText('250+')).toBeInTheDocument();
+ expect(screen.getByText('10-100 USD')).toBeInTheDocument();
+ expect(screen.getByText('100.00 USD')).toBeInTheDocument();
+ expect(screen.getByText('Bank transfer')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Buy USD' })).toBeInTheDocument();
+ });
+ });
+
+ it('should call history.push when clicking on the table row', async () => {
+ render( , { wrapper });
+
+ const usernameText = screen.getByText('John Doe');
+ await userEvent.click(usernameText);
+
+ expect(mockPush).toHaveBeenCalledWith('/cashier/p2p-v2/advertiser/1');
+ });
+});
diff --git a/src/pages/buy-sell/screens/BuySellTable/index.ts b/src/pages/buy-sell/screens/BuySellTable/index.ts
new file mode 100644
index 00000000..c701be79
--- /dev/null
+++ b/src/pages/buy-sell/screens/BuySellTable/index.ts
@@ -0,0 +1 @@
+export { default as BuySellTable } from './BuySellTable';
diff --git a/src/pages/buy-sell/screens/index.ts b/src/pages/buy-sell/screens/index.ts
new file mode 100644
index 00000000..e89e670f
--- /dev/null
+++ b/src/pages/buy-sell/screens/index.ts
@@ -0,0 +1 @@
+export * from './BuySell';
diff --git a/src/pages/index.ts b/src/pages/index.ts
new file mode 100644
index 00000000..a7ce5827
--- /dev/null
+++ b/src/pages/index.ts
@@ -0,0 +1,5 @@
+export * from './advertiser';
+export * from './buy-sell';
+export * from './my-ads';
+export * from './my-profile';
+export * from './orders';
diff --git a/src/pages/my-ads/components/AdConditionBlockElement/AdConditionBlockElement.scss b/src/pages/my-ads/components/AdConditionBlockElement/AdConditionBlockElement.scss
new file mode 100644
index 00000000..440e5d5e
--- /dev/null
+++ b/src/pages/my-ads/components/AdConditionBlockElement/AdConditionBlockElement.scss
@@ -0,0 +1,25 @@
+.p2p-ad-condition-block-element {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 1.6rem;
+ height: 4rem;
+ width: 100%;
+ border: 1px solid #d6dadb;
+ border-radius: 4px;
+
+ @include desktop {
+ width: 12.4rem;
+ }
+
+ &:hover:not(&--selected) {
+ background-color: #d6dadb;
+ cursor: pointer;
+ color: #ffffff;
+ }
+ &:active,
+ &--selected {
+ background-color: #999999;
+ border-color: #999999;
+ }
+}
diff --git a/src/pages/my-ads/components/AdConditionBlockElement/AdConditionBlockElement.tsx b/src/pages/my-ads/components/AdConditionBlockElement/AdConditionBlockElement.tsx
new file mode 100644
index 00000000..ac2b38e0
--- /dev/null
+++ b/src/pages/my-ads/components/AdConditionBlockElement/AdConditionBlockElement.tsx
@@ -0,0 +1,30 @@
+import clsx from 'clsx';
+
+import { Text, useDevice } from '@deriv-com/ui';
+
+import './AdConditionBlockElement.scss';
+
+type TAdConditionBlockElementProps = {
+ isSelected: boolean;
+ label: string;
+ onClick: (value: number) => void;
+ value: number;
+};
+
+const AdConditionBlockElement = ({ isSelected, label, onClick, value }: TAdConditionBlockElementProps) => {
+ const { isMobile } = useDevice();
+ return (
+ onClick(value)}
+ >
+
+ {label}
+
+
+ );
+};
+
+export default AdConditionBlockElement;
diff --git a/src/pages/my-ads/components/AdConditionBlockElement/__tests__/AdConditionBlockElement.spec.tsx b/src/pages/my-ads/components/AdConditionBlockElement/__tests__/AdConditionBlockElement.spec.tsx
new file mode 100644
index 00000000..9ef7e9cb
--- /dev/null
+++ b/src/pages/my-ads/components/AdConditionBlockElement/__tests__/AdConditionBlockElement.spec.tsx
@@ -0,0 +1,37 @@
+import { useDevice } from '@deriv-com/ui';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import AdConditionBlockElement from '../AdConditionBlockElement';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({ isMobile: false }),
+}));
+
+const mockUseDevice = useDevice as jest.Mock;
+
+const mockProps = {
+ isSelected: true,
+ label: 'title',
+ onClick: jest.fn(),
+ value: 1,
+};
+
+describe('AdConditionBlockElement', () => {
+ it('should render the component as expected with given props', () => {
+ render( );
+ expect(screen.getByText('title')).toBeInTheDocument();
+ });
+ it('should handle onClick for element', async () => {
+ render( );
+ const element = screen.getByText('title');
+ await userEvent.click(element);
+ expect(mockProps.onClick).toHaveBeenCalledWith(1);
+ });
+ it('should render the component as selected with text color white', () => {
+ mockUseDevice.mockReturnValue({ isMobile: true });
+ render( );
+ expect(screen.getByText('title')).toHaveClass('derivs-text__color--white');
+ });
+});
diff --git a/src/pages/my-ads/components/AdConditionBlockElement/index.ts b/src/pages/my-ads/components/AdConditionBlockElement/index.ts
new file mode 100644
index 00000000..fe469e2a
--- /dev/null
+++ b/src/pages/my-ads/components/AdConditionBlockElement/index.ts
@@ -0,0 +1 @@
+export { default as AdConditionBlockElement } from './AdConditionBlockElement';
diff --git a/src/pages/my-ads/components/AdConditionBlockSelector/AdConditionBlockSelector.tsx b/src/pages/my-ads/components/AdConditionBlockSelector/AdConditionBlockSelector.tsx
new file mode 100644
index 00000000..8ce7bbc2
--- /dev/null
+++ b/src/pages/my-ads/components/AdConditionBlockSelector/AdConditionBlockSelector.tsx
@@ -0,0 +1,30 @@
+import { AD_CONDITION_CONTENT, AD_CONDITION_TYPES } from '@/constants';
+
+import { AdConditionBlockElement } from '../AdConditionBlockElement';
+import { AdConditionContentHeader } from '../AdConditionContentHeader';
+
+type TAdConditionBlockSelectorProps = {
+ onClick: (value: number) => void;
+ selectedValue?: number;
+ type: (typeof AD_CONDITION_TYPES)[keyof typeof AD_CONDITION_TYPES];
+};
+const AdConditionBlockSelector = ({ onClick, selectedValue, type }: TAdConditionBlockSelectorProps) => {
+ return (
+
+
+
+ {AD_CONDITION_CONTENT[type]?.options?.map(option => (
+
+ ))}
+
+
+ );
+};
+
+export default AdConditionBlockSelector;
diff --git a/src/pages/my-ads/components/AdConditionBlockSelector/__tests__/AdConditionBlockSelector.spec.tsx b/src/pages/my-ads/components/AdConditionBlockSelector/__tests__/AdConditionBlockSelector.spec.tsx
new file mode 100644
index 00000000..1c7786de
--- /dev/null
+++ b/src/pages/my-ads/components/AdConditionBlockSelector/__tests__/AdConditionBlockSelector.spec.tsx
@@ -0,0 +1,37 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import AdConditionBlockSelector from '../AdConditionBlockSelector';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({ isMobile: false }),
+}));
+
+jest.mock('@/constants', () => ({
+ ...jest.requireActual('@/constants'),
+ AD_CONDITION_CONTENT: {
+ completionRates: {
+ description: 'description',
+ options: [
+ {
+ label: 'label',
+ value: 1,
+ },
+ ],
+ title: 'title',
+ },
+ },
+}));
+describe('AdConditionBlockSelector', () => {
+ it('should render the component as expected', () => {
+ render( );
+ expect(screen.getByText('title')).toBeInTheDocument();
+ });
+ it('should handle the onClick for AdConditionBlockElement', async () => {
+ const mockOnClick = jest.fn();
+ render( );
+ await userEvent.click(screen.getByText('label'));
+ expect(mockOnClick).toHaveBeenCalledWith(1);
+ });
+});
diff --git a/src/pages/my-ads/components/AdConditionBlockSelector/index.ts b/src/pages/my-ads/components/AdConditionBlockSelector/index.ts
new file mode 100644
index 00000000..da3f5b3a
--- /dev/null
+++ b/src/pages/my-ads/components/AdConditionBlockSelector/index.ts
@@ -0,0 +1 @@
+export { default as AdConditionBlockSelector } from './AdConditionBlockSelector';
diff --git a/src/pages/my-ads/components/AdConditionContentHeader/AdConditionContentHeader.tsx b/src/pages/my-ads/components/AdConditionContentHeader/AdConditionContentHeader.tsx
new file mode 100644
index 00000000..5c8f7899
--- /dev/null
+++ b/src/pages/my-ads/components/AdConditionContentHeader/AdConditionContentHeader.tsx
@@ -0,0 +1,42 @@
+import React, { useState } from 'react';
+
+import { LabelPairedCircleInfoCaptionRegularIcon } from '@deriv/quill-icons';
+import { Button, Text, useDevice } from '@deriv-com/ui';
+
+import { AdConditionsModal } from '@/components/Modals';
+import { AD_CONDITION_CONTENT, AD_CONDITION_TYPES } from '@/constants';
+
+type TAdConditionContentHeaderProps = {
+ type: (typeof AD_CONDITION_TYPES)[keyof typeof AD_CONDITION_TYPES];
+};
+
+const AdConditionContentHeader = ({ type }: TAdConditionContentHeaderProps) => {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const { isMobile } = useDevice();
+ return (
+
+
+ {AD_CONDITION_CONTENT[type]?.title}
+
+
setIsModalOpen(true)}
+ type='button'
+ variant='outlined'
+ >
+
+
+ {isModalOpen && (
+
setIsModalOpen(false)} type={type} />
+ )}
+
+ );
+};
+
+export default AdConditionContentHeader;
diff --git a/src/pages/my-ads/components/AdConditionContentHeader/__tests__/AdConditionContentHeader.spec.tsx b/src/pages/my-ads/components/AdConditionContentHeader/__tests__/AdConditionContentHeader.spec.tsx
new file mode 100644
index 00000000..40917d83
--- /dev/null
+++ b/src/pages/my-ads/components/AdConditionContentHeader/__tests__/AdConditionContentHeader.spec.tsx
@@ -0,0 +1,38 @@
+import { useDevice } from '@deriv-com/ui';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import AdConditionContentHeader from '../AdConditionContentHeader';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({ isMobile: false }),
+}));
+
+jest.mock('@/constants', () => ({
+ ...jest.requireActual('@/constants'),
+ AD_CONDITION_CONTENT: {
+ type: {
+ description: 'description',
+ title: 'title',
+ },
+ },
+}));
+
+const mockUseDevice = useDevice as jest.Mock;
+
+describe('AdConditionContentHeader', () => {
+ it('should render the component as expected with given type', () => {
+ render( );
+ expect(screen.getByText('title')).toBeInTheDocument();
+ });
+ it('should handle clicking the tooltip icon', async () => {
+ mockUseDevice.mockReturnValue({ isMobile: true });
+ render( );
+ await userEvent.click(screen.getByTestId('dt_ad_condition_tooltip_icon'));
+ const okButton = screen.getByRole('button', { name: 'OK' });
+ expect(okButton).toBeInTheDocument();
+ await userEvent.click(okButton);
+ expect(screen.queryByRole('button', { name: 'OK' })).not.toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-ads/components/AdConditionContentHeader/index.ts b/src/pages/my-ads/components/AdConditionContentHeader/index.ts
new file mode 100644
index 00000000..6989e385
--- /dev/null
+++ b/src/pages/my-ads/components/AdConditionContentHeader/index.ts
@@ -0,0 +1 @@
+export { default as AdConditionContentHeader } from './AdConditionContentHeader';
diff --git a/src/pages/my-ads/components/AdConditionsSection/AdConditionsSection.tsx b/src/pages/my-ads/components/AdConditionsSection/AdConditionsSection.tsx
new file mode 100644
index 00000000..1b5f111a
--- /dev/null
+++ b/src/pages/my-ads/components/AdConditionsSection/AdConditionsSection.tsx
@@ -0,0 +1,76 @@
+import React, { MouseEventHandler } from 'react';
+import { useFormContext } from 'react-hook-form';
+import { TCountryListItem } from 'types';
+import { AD_CONDITION_TYPES } from '@/constants';
+import { Text, useDevice } from '@deriv-com/ui';
+import { AdConditionBlockSelector } from '../AdConditionBlockSelector';
+import { AdFormController } from '../AdFormController';
+import { AdSummary } from '../AdSummary';
+import { PreferredCountriesSelector } from '../PreferredCountriesSelector';
+
+type TAdConditionsSection = {
+ countryList: TCountryListItem;
+ currency: string;
+ getCurrentStep: () => number;
+ getTotalSteps: () => number;
+ goToNextStep: MouseEventHandler;
+ goToPreviousStep: MouseEventHandler;
+ localCurrency?: string;
+ rateType: string;
+};
+
+const AdConditionsSection = ({ countryList, currency, localCurrency, rateType, ...props }: TAdConditionsSection) => {
+ const {
+ formState: { errors },
+ getValues,
+ setValue,
+ watch,
+ } = useFormContext();
+ const { isMobile } = useDevice();
+ const labelSize = isMobile ? 'md' : 'sm';
+
+ const onClickBlockSelector = (value: number, type: string) => {
+ if (type === AD_CONDITION_TYPES.JOINING_DATE) {
+ setValue('min-join-days', value);
+ } else {
+ setValue('min-completion-rate', value);
+ }
+ };
+
+ const minJoinDays = watch('min-join-days');
+ const minCompletionRate = watch('min-completion-rate');
+ return (
+
+
+
+
+ Counterparty conditions (optional)
+
+
+ Only users who match these criteria will see your ad.
+
+
+
onClickBlockSelector(value, AD_CONDITION_TYPES.JOINING_DATE)}
+ selectedValue={minJoinDays && Number(minJoinDays)}
+ type={AD_CONDITION_TYPES.JOINING_DATE}
+ />
+ onClickBlockSelector(value, AD_CONDITION_TYPES.COMPLETION_RATE)}
+ selectedValue={minCompletionRate && Number(minCompletionRate)}
+ type={AD_CONDITION_TYPES.COMPLETION_RATE}
+ />
+
+
+
+ );
+};
+
+export default AdConditionsSection;
diff --git a/src/pages/my-ads/components/AdConditionsSection/__tests__/AdConditionsSection.spec.tsx b/src/pages/my-ads/components/AdConditionsSection/__tests__/AdConditionsSection.spec.tsx
new file mode 100644
index 00000000..32213e94
--- /dev/null
+++ b/src/pages/my-ads/components/AdConditionsSection/__tests__/AdConditionsSection.spec.tsx
@@ -0,0 +1,61 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import AdConditionsSection from '../AdConditionsSection';
+
+const mockProps = {
+ currency: 'USD',
+ getCurrentStep: jest.fn(),
+ getTotalSteps: jest.fn(),
+ goToNextStep: jest.fn(),
+ goToPreviousStep: jest.fn(),
+ localCurrency: 'USD',
+ rateType: 'fixed',
+};
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn(() => ({ isMobile: false })),
+}));
+
+jest.mock('@/hooks', () => ({
+ useQueryString: jest.fn().mockReturnValue({ queryString: { advertId: '' } }),
+}));
+
+const mockSetValue = jest.fn();
+jest.mock('react-hook-form', () => ({
+ ...jest.requireActual('react-hook-form'),
+ useFormContext: jest.fn(() => ({
+ formState: { errors: {} },
+ getValues: jest.fn(),
+ setValue: mockSetValue,
+ watch: jest.fn(),
+ })),
+}));
+
+jest.mock('../../AdSummary', () => ({
+ AdSummary: () => AdSummary
,
+}));
+
+jest.mock('../../PreferredCountriesSelector', () => ({
+ PreferredCountriesSelector: () => PreferredCountriesSelector
,
+}));
+describe('AdConditionsSection', () => {
+ it('should render the ad conditions section component', () => {
+ render( );
+ expect(screen.getByText('Counterparty conditions (optional)')).toBeInTheDocument();
+ expect(screen.getByText('Only users who match these criteria will see your ad.')).toBeInTheDocument();
+ expect(screen.getByText('AdSummary')).toBeInTheDocument();
+ expect(screen.getByText('PreferredCountriesSelector')).toBeInTheDocument();
+ });
+ it('should handle clicking on the block selector for joining date', async () => {
+ render( );
+ await userEvent.click(screen.getByText('15 days'));
+ expect(mockSetValue).toHaveBeenCalledWith('min-join-days', 15);
+ });
+ it('should handle clicking on the block selector for completion rate', async () => {
+ render( );
+ await userEvent.click(screen.getByText('90%'));
+ expect(mockSetValue).toHaveBeenCalledWith('min-completion-rate', 90);
+ });
+});
diff --git a/src/pages/my-ads/components/AdConditionsSection/index.ts b/src/pages/my-ads/components/AdConditionsSection/index.ts
new file mode 100644
index 00000000..45b7f3b4
--- /dev/null
+++ b/src/pages/my-ads/components/AdConditionsSection/index.ts
@@ -0,0 +1 @@
+export { default as AdConditionsSection } from './AdConditionsSection';
diff --git a/src/pages/my-ads/components/AdFormController/AdFormController.scss b/src/pages/my-ads/components/AdFormController/AdFormController.scss
new file mode 100644
index 00000000..d8d33a49
--- /dev/null
+++ b/src/pages/my-ads/components/AdFormController/AdFormController.scss
@@ -0,0 +1,16 @@
+.p2p-ad-form-controller {
+ display: flex;
+ justify-content: flex-end;
+ column-gap: 0.8rem;
+ border-top: 1px solid #f2f3f4;
+ padding: 2rem 0;
+ margin-top: 2.4rem;
+ @include mobile {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ width: 100%;
+ padding: 1.6rem;
+ background: #ffffff;
+ }
+}
diff --git a/src/pages/my-ads/components/AdFormController/AdFormController.tsx b/src/pages/my-ads/components/AdFormController/AdFormController.tsx
new file mode 100644
index 00000000..a629abbd
--- /dev/null
+++ b/src/pages/my-ads/components/AdFormController/AdFormController.tsx
@@ -0,0 +1,64 @@
+import React, { MouseEventHandler } from 'react';
+
+import { Button, useDevice } from '@deriv-com/ui';
+
+import { useQueryString } from '@/hooks/custom-hooks';
+
+import './AdFormController.scss';
+
+type TAdFormControllerProps = {
+ getCurrentStep: () => number;
+ getTotalSteps: () => number;
+ goToNextStep: MouseEventHandler;
+ goToPreviousStep: () => void;
+ isNextButtonDisabled: boolean;
+ onCancel?: () => void;
+};
+
+const AdFormController = ({
+ getCurrentStep,
+ getTotalSteps,
+ goToNextStep,
+ goToPreviousStep,
+ isNextButtonDisabled,
+ onCancel,
+}: TAdFormControllerProps) => {
+ const { isMobile } = useDevice();
+ const textSize = isMobile ? 'md' : 'sm';
+ const { queryString } = useQueryString();
+ const { advertId = '' } = queryString;
+ const isEdit = !!advertId;
+ return (
+
+ (onCancel ? onCancel() : goToPreviousStep())}
+ size='lg'
+ textSize={textSize}
+ type='button'
+ variant='outlined'
+ >
+ {onCancel ? 'Cancel' : 'Previous'}
+
+ {getCurrentStep() < getTotalSteps() ? (
+
+ Next
+
+ ) : (
+
+ {`${isEdit ? 'Save changes' : 'Post ad'}`}
+
+ )}
+
+ );
+};
+
+export default AdFormController;
diff --git a/src/pages/my-ads/components/AdFormController/index.ts b/src/pages/my-ads/components/AdFormController/index.ts
new file mode 100644
index 00000000..c63b749d
--- /dev/null
+++ b/src/pages/my-ads/components/AdFormController/index.ts
@@ -0,0 +1 @@
+export { default as AdFormController } from './AdFormController';
diff --git a/src/pages/my-ads/components/AdFormInput/AdFormInput.tsx b/src/pages/my-ads/components/AdFormInput/AdFormInput.tsx
new file mode 100644
index 00000000..5c82726b
--- /dev/null
+++ b/src/pages/my-ads/components/AdFormInput/AdFormInput.tsx
@@ -0,0 +1,56 @@
+import React, { ComponentProps, ReactNode } from 'react';
+import clsx from 'clsx';
+import { Controller, useFormContext } from 'react-hook-form';
+import { getValidationRules } from '@/utils';
+import { Input } from '@deriv-com/ui';
+
+type TAdFormInputProps = ComponentProps & {
+ currency?: string;
+ isDisabled?: boolean;
+ label: string;
+ name: string;
+ rightPlaceholder: ReactNode;
+ triggerValidationFunction?: () => void;
+};
+
+const AdFormInput = ({
+ isDisabled = false,
+ label,
+ name,
+ rightPlaceholder,
+ triggerValidationFunction,
+ ...props
+}: TAdFormInputProps) => {
+ const { control, getValues } = useFormContext();
+ return (
+ (
+
+ {
+ onChange(event);
+ triggerValidationFunction?.();
+ }}
+ rightPlaceholder={rightPlaceholder}
+ value={value}
+ wrapperClassName='w-full'
+ {...props}
+ />
+
+ )}
+ rules={{
+ validate: getValidationRules(name, getValues),
+ }}
+ />
+ );
+};
+
+export default AdFormInput;
diff --git a/src/pages/my-ads/components/AdFormInput/__tests__/AdFormInput.spec.tsx b/src/pages/my-ads/components/AdFormInput/__tests__/AdFormInput.spec.tsx
new file mode 100644
index 00000000..550a337b
--- /dev/null
+++ b/src/pages/my-ads/components/AdFormInput/__tests__/AdFormInput.spec.tsx
@@ -0,0 +1,40 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import AdFormInput from '../AdFormInput';
+
+jest.mock('react-hook-form', () => ({
+ ...jest.requireActual('react-hook-form'),
+ Controller: ({ control, defaultValue, name, render }) =>
+ render({
+ field: { control, name, onBlur: jest.fn(), onChange: jest.fn(), value: defaultValue },
+ fieldState: { error: null },
+ }),
+ useFormContext: () => ({
+ control: 'mockedControl',
+ getValues: jest.fn(),
+ }),
+}));
+
+const mockProps = {
+ currency: 'usd',
+ label: 'label',
+ name: 'name',
+ triggerValidationFunction: jest.fn(),
+};
+
+describe('AdFormInput', () => {
+ it('should render the form input component', () => {
+ render( );
+ expect(screen.getByText('label')).toBeInTheDocument();
+ });
+ it('should handle the input change', async () => {
+ render( );
+ const input = screen.getByPlaceholderText('label');
+ expect(input).toBeInTheDocument();
+ expect(input).toHaveValue('');
+ await userEvent.type(input, 'test');
+ expect(mockProps.triggerValidationFunction).toHaveBeenCalled();
+ expect(input).toHaveValue('test');
+ });
+});
diff --git a/src/pages/my-ads/components/AdFormInput/index.ts b/src/pages/my-ads/components/AdFormInput/index.ts
new file mode 100644
index 00000000..496fcc6b
--- /dev/null
+++ b/src/pages/my-ads/components/AdFormInput/index.ts
@@ -0,0 +1 @@
+export { default as AdFormInput } from './AdFormInput';
diff --git a/src/pages/my-ads/components/AdFormTextArea/AdFormTextArea.scss b/src/pages/my-ads/components/AdFormTextArea/AdFormTextArea.scss
new file mode 100644
index 00000000..5774a6fc
--- /dev/null
+++ b/src/pages/my-ads/components/AdFormTextArea/AdFormTextArea.scss
@@ -0,0 +1,9 @@
+.p2p-ad-form-textarea {
+ & .deriv-textarea {
+ &__footer {
+ & .deriv-text {
+ font-size: 1.2rem;
+ }
+ }
+ }
+}
diff --git a/src/pages/my-ads/components/AdFormTextArea/AdFormTextArea.tsx b/src/pages/my-ads/components/AdFormTextArea/AdFormTextArea.tsx
new file mode 100644
index 00000000..f707f44a
--- /dev/null
+++ b/src/pages/my-ads/components/AdFormTextArea/AdFormTextArea.tsx
@@ -0,0 +1,49 @@
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { TextArea } from '@deriv-com/ui';
+
+import { VALID_SYMBOLS_PATTERN } from '@/constants';
+import { getTextFieldError } from '@/utils';
+
+import './AdFormTextArea.scss';
+
+type TAdFormTextAreaProps = {
+ field: string;
+ hint?: string;
+ label: string;
+ name: string;
+ required?: boolean;
+};
+const AdFormTextArea = ({ field, hint = '', label, name, required = false }: TAdFormTextAreaProps) => {
+ const { control } = useFormContext();
+ return (
+ (
+
+
+
+ )}
+ rules={{
+ pattern: {
+ message: getTextFieldError(field),
+ value: VALID_SYMBOLS_PATTERN,
+ },
+ required: required ? `${field} is required` : undefined,
+ }}
+ />
+ );
+};
+
+export default AdFormTextArea;
diff --git a/src/pages/my-ads/components/AdFormTextArea/__tests__/AdFormTextArea.spec.tsx b/src/pages/my-ads/components/AdFormTextArea/__tests__/AdFormTextArea.spec.tsx
new file mode 100644
index 00000000..e7092caf
--- /dev/null
+++ b/src/pages/my-ads/components/AdFormTextArea/__tests__/AdFormTextArea.spec.tsx
@@ -0,0 +1,31 @@
+import { render, screen } from '@testing-library/react';
+
+import AdFormTextArea from '../AdFormTextArea';
+
+jest.mock('react-hook-form', () => ({
+ ...jest.requireActual('react-hook-form'),
+ Controller: ({ control, defaultValue, name, render }) =>
+ render({
+ field: { control, name, onBlur: jest.fn(), onChange: jest.fn(), value: defaultValue },
+ fieldState: { error: null },
+ }),
+ useFormContext: () => ({
+ control: 'mockedControl',
+ getValues: jest.fn(),
+ }),
+}));
+
+const mockProps = {
+ field: 'field',
+ hint: 'this is the hint',
+ label: 'label',
+ name: 'name',
+ required: true,
+};
+
+describe('AdFormTextArea', () => {
+ it('should render the form text area component', () => {
+ render( );
+ expect(screen.getByText('this is the hint')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-ads/components/AdFormTextArea/index.ts b/src/pages/my-ads/components/AdFormTextArea/index.ts
new file mode 100644
index 00000000..b7935c26
--- /dev/null
+++ b/src/pages/my-ads/components/AdFormTextArea/index.ts
@@ -0,0 +1 @@
+export { default as AdFormTextArea } from './AdFormTextArea';
diff --git a/src/pages/my-ads/components/AdPaymentDetailsSection/AdPaymentDetailsSection.tsx b/src/pages/my-ads/components/AdPaymentDetailsSection/AdPaymentDetailsSection.tsx
new file mode 100644
index 00000000..e3bad840
--- /dev/null
+++ b/src/pages/my-ads/components/AdPaymentDetailsSection/AdPaymentDetailsSection.tsx
@@ -0,0 +1,64 @@
+import React, { MouseEventHandler, useState } from 'react';
+import { useFormContext } from 'react-hook-form';
+import { BUY_SELL } from '@/constants';
+import { AdFormController } from '../AdFormController';
+import { AdPaymentSelection } from '../AdPaymentSelection';
+import { AdSummary } from '../AdSummary';
+import { OrderTimeSelection } from '../OrderTimeSelection';
+
+type TAdPaymentDetailsSection = {
+ currency: string;
+ getCurrentStep: () => number;
+ getTotalSteps: () => number;
+ goToNextStep: MouseEventHandler;
+ goToPreviousStep: MouseEventHandler;
+ localCurrency?: string;
+ rateType: string;
+};
+
+const AdPaymentDetailsSection = ({ currency, localCurrency, rateType, ...props }: TAdPaymentDetailsSection) => {
+ const {
+ formState: { errors, isValid },
+ getValues,
+ setValue,
+ } = useFormContext();
+
+ const [selectedPaymentMethods, setSelectedPaymentMethods] = useState<(number | string)[]>(
+ getValues('payment-method') ?? []
+ );
+ const isSellAdvert = getValues('ad-type') === BUY_SELL.SELL;
+
+ const onSelectPaymentMethod = (paymentMethod: number | string) => {
+ if (selectedPaymentMethods.includes(paymentMethod)) {
+ const newSelectedPaymentMethods = selectedPaymentMethods.filter(method => method !== paymentMethod);
+ setSelectedPaymentMethods(newSelectedPaymentMethods);
+ setValue('payment-method', newSelectedPaymentMethods);
+ } else {
+ const newSelectedPaymentMethods = [...selectedPaymentMethods, paymentMethod];
+ setSelectedPaymentMethods(newSelectedPaymentMethods);
+ setValue('payment-method', newSelectedPaymentMethods);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default AdPaymentDetailsSection;
diff --git a/src/pages/my-ads/components/AdPaymentDetailsSection/__tests__/AdPaymentDetailsSection.spec.tsx b/src/pages/my-ads/components/AdPaymentDetailsSection/__tests__/AdPaymentDetailsSection.spec.tsx
new file mode 100644
index 00000000..dc81a593
--- /dev/null
+++ b/src/pages/my-ads/components/AdPaymentDetailsSection/__tests__/AdPaymentDetailsSection.spec.tsx
@@ -0,0 +1,97 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import AdPaymentDetailsSection from '../AdPaymentDetailsSection';
+
+const mockGetValues = {
+ 'ad-type': 'buy',
+ amount: '100',
+ 'payment-method': ['alipay'],
+ 'rate-value': '1.2',
+};
+
+const mockFn = jest.fn();
+jest.mock('react-hook-form', () => ({
+ ...jest.requireActual('react-hook-form'),
+ useFormContext: () => ({
+ formState: { errors: {}, isValid: true },
+ getValues: name => mockGetValues[name],
+ setValue: mockFn,
+ }),
+}));
+
+jest.mock('@/hooks', () => ({
+ ...jest.requireActual('@/hooks'),
+ useQueryString: jest.fn().mockReturnValue({ queryString: { advertId: '' } }),
+}));
+
+jest.mock('@deriv/api-v2', () => ({
+ p2p: {
+ paymentMethods: {
+ useGet: () => ({
+ data: [
+ {
+ display_name: 'Bank Transfer',
+ fields: {
+ account: {
+ display_name: 'Account Number',
+ required: 1,
+ type: 'text',
+ value: 'Account Number',
+ },
+ bank_name: { display_name: 'Bank Transfer', required: 1, type: 'text', value: 'Bank Name' },
+ },
+ id: 'bank_transfer',
+ is_enabled: 0,
+ method: '',
+ type: 'bank',
+ used_by_adverts: null,
+ used_by_orders: null,
+ },
+ ],
+ }),
+ },
+ },
+}));
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({ isMobile: false }),
+}));
+
+jest.mock('../../AdSummary', () => ({
+ AdSummary: () => AdSummary
,
+}));
+
+jest.mock('../../OrderTimeSelection', () => ({
+ OrderTimeSelection: () => OrderTimeSelection
,
+}));
+const mockProps = {
+ currency: 'USD',
+ getCurrentStep: () => jest.fn(),
+ getTotalSteps: () => jest.fn(),
+ goToNextStep: jest.fn(),
+ goToPreviousStep: jest.fn(),
+ localCurrency: 'IDR',
+ rateType: 'fixed',
+};
+
+describe('AdPaymentDetailsSection', () => {
+ it('should render AdPaymentDetailsSection component', () => {
+ render( );
+ expect(screen.getByText('AdSummary')).toBeInTheDocument();
+ expect(screen.getByText('OrderTimeSelection')).toBeInTheDocument();
+ });
+ it('should handle selection of payment method', async () => {
+ render( );
+ await userEvent.click(screen.getByPlaceholderText('Add'));
+ await userEvent.click(screen.getByText('Bank Transfer'));
+ expect(mockFn).toHaveBeenCalledWith('payment-method', ['alipay', 'bank_transfer']);
+ });
+ it('should handle unselection of payment method', async () => {
+ mockGetValues['payment-method'] = ['bank_transfer'];
+ render( );
+ await userEvent.click(screen.getByTestId('dt_payment_delete_icon'));
+ expect(mockFn).toHaveBeenCalledWith('payment-method', []);
+ });
+});
diff --git a/src/pages/my-ads/components/AdPaymentDetailsSection/index.ts b/src/pages/my-ads/components/AdPaymentDetailsSection/index.ts
new file mode 100644
index 00000000..fa858978
--- /dev/null
+++ b/src/pages/my-ads/components/AdPaymentDetailsSection/index.ts
@@ -0,0 +1 @@
+export { default as AdPaymentDetailsSection } from './AdPaymentDetailsSection';
diff --git a/src/pages/my-ads/components/AdPaymentSelection/AdPaymentSelection.tsx b/src/pages/my-ads/components/AdPaymentSelection/AdPaymentSelection.tsx
new file mode 100644
index 00000000..ea29edbb
--- /dev/null
+++ b/src/pages/my-ads/components/AdPaymentSelection/AdPaymentSelection.tsx
@@ -0,0 +1,45 @@
+import { Text, useDevice } from '@deriv-com/ui';
+
+import { BuyAdPaymentSelection } from '../BuyAdPaymentSelection';
+import { SellAdPaymentSelection } from '../SellAdPaymentSelection';
+
+type TAdPaymentSectionProps = {
+ isSellAdvert: boolean;
+ onSelectPaymentMethod: (paymentMethod: number | string, action?: string) => void;
+ selectedPaymentMethods: (number | string)[];
+};
+const AdPaymentSelection = ({
+ isSellAdvert,
+ onSelectPaymentMethod,
+ selectedPaymentMethods,
+}: TAdPaymentSectionProps) => {
+ const { isMobile } = useDevice();
+ const textSize = isMobile ? 'md' : 'sm';
+
+ return (
+ <>
+
+
+ Payment methods
+
+
+ {isSellAdvert ? 'You may tap and choose up to 3.' : 'You may choose up to 3.'}
+
+
+
+ {isSellAdvert ? (
+
+ ) : (
+
+ )}
+ >
+ );
+};
+
+export default AdPaymentSelection;
diff --git a/src/pages/my-ads/components/AdPaymentSelection/__tests__/AdPaymentSelection.spec.tsx b/src/pages/my-ads/components/AdPaymentSelection/__tests__/AdPaymentSelection.spec.tsx
new file mode 100644
index 00000000..f2c4685c
--- /dev/null
+++ b/src/pages/my-ads/components/AdPaymentSelection/__tests__/AdPaymentSelection.spec.tsx
@@ -0,0 +1,37 @@
+import { render, screen } from '@testing-library/react';
+
+import AdPaymentSelection from '../AdPaymentSelection';
+
+const mockProps = {
+ isSellAdvert: false,
+ onSelectPaymentMethod: jest.fn(),
+ selectedPaymentMethods: [],
+};
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn(() => ({ isMobile: false })),
+}));
+
+jest.mock('../../BuyAdPaymentSelection', () => ({
+ BuyAdPaymentSelection: () => BuyAdPaymentSelection
,
+}));
+
+jest.mock('../../SellAdPaymentSelection', () => ({
+ SellAdPaymentSelection: () => SellAdPaymentSelection
,
+}));
+
+describe('AdPaymentSelection', () => {
+ it('should render the ad payment selection component', () => {
+ render( );
+ expect(screen.getByText('Payment methods')).toBeInTheDocument();
+ });
+ it('should render the buy ad payment selection component for buy ad', () => {
+ render( );
+ expect(screen.getByText('BuyAdPaymentSelection')).toBeInTheDocument();
+ });
+ it('should render the sell ad payment selection component for sell ad', () => {
+ render( );
+ expect(screen.getByText('SellAdPaymentSelection')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-ads/components/AdPaymentSelection/index.ts b/src/pages/my-ads/components/AdPaymentSelection/index.ts
new file mode 100644
index 00000000..978ef3e1
--- /dev/null
+++ b/src/pages/my-ads/components/AdPaymentSelection/index.ts
@@ -0,0 +1 @@
+export { default as AdPaymentSelection } from './AdPaymentSelection';
diff --git a/src/pages/my-ads/components/AdProgressBar/AdProgressBar.tsx b/src/pages/my-ads/components/AdProgressBar/AdProgressBar.tsx
new file mode 100644
index 00000000..6026fa8a
--- /dev/null
+++ b/src/pages/my-ads/components/AdProgressBar/AdProgressBar.tsx
@@ -0,0 +1,47 @@
+//TODO: Below component to replaced with progress bar from deriv-com/ui
+
+import { TStep } from 'types';
+
+type TAdProgressBar = {
+ currentStep: number;
+ steps: TStep[];
+};
+
+const AdProgressBar = ({ currentStep, steps }: TAdProgressBar) => {
+ const radius = 28;
+ const circumference = 2 * Math.PI * radius;
+ const percentage = ((currentStep + 1) / steps.length) * 100;
+ const offset = ((100 - percentage) * circumference) / 100;
+
+ return (
+
+
+
+
+
+
+ {currentStep + 1} / {steps.length}
+
+
+ );
+};
+
+export default AdProgressBar;
diff --git a/src/pages/my-ads/components/AdProgressBar/__tests__/AdProgressBar.spec.tsx b/src/pages/my-ads/components/AdProgressBar/__tests__/AdProgressBar.spec.tsx
new file mode 100644
index 00000000..9c87181a
--- /dev/null
+++ b/src/pages/my-ads/components/AdProgressBar/__tests__/AdProgressBar.spec.tsx
@@ -0,0 +1,12 @@
+import { render, screen } from '@testing-library/react';
+
+import AdProgressBar from '../AdProgressBar';
+
+const STEPS = [{ header: { title: 'step 1' } }, { header: { title: 'step 2' } }, { header: { title: 'step 3' } }];
+
+describe('AdProgressBar', () => {
+ it('should render the progress bar', () => {
+ render( );
+ expect(screen.getByText('2 / 3')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-ads/components/AdProgressBar/index.ts b/src/pages/my-ads/components/AdProgressBar/index.ts
new file mode 100644
index 00000000..07b7a29a
--- /dev/null
+++ b/src/pages/my-ads/components/AdProgressBar/index.ts
@@ -0,0 +1 @@
+export { default as AdProgressBar } from './AdProgressBar';
diff --git a/src/pages/my-ads/components/AdRateError/AdRateError.tsx b/src/pages/my-ads/components/AdRateError/AdRateError.tsx
new file mode 100644
index 00000000..2d77cbc2
--- /dev/null
+++ b/src/pages/my-ads/components/AdRateError/AdRateError.tsx
@@ -0,0 +1,22 @@
+import { useAuthorize } from '@deriv/api-v2';
+
+import { RATE_TYPE } from '@/constants';
+import { useFloatingRate } from '@/hooks/custom-hooks';
+
+const AdRateError = () => {
+ const { data } = useAuthorize();
+ const { fixedRateAdvertsEndDate, rateType, reachedTargetDate } = useFloatingRate();
+
+ const localCurrency = data.local_currencies?.[0];
+
+ if (rateType === RATE_TYPE.FLOAT) {
+ return reachedTargetDate || !fixedRateAdvertsEndDate
+ ? //TODO: handle translation
+ 'Your ads with fixed rates have been deactivated. Set floating rates to reactivate them.'
+ : `Floating rates are enabled for ${localCurrency}. Ads with fixed rates will be deactivated. Switch to floating rates by ${fixedRateAdvertsEndDate}.`;
+ }
+
+ return 'Your ads with floating rates have been deactivated. Set fixed rates to reactivate them.';
+};
+
+export default AdRateError;
diff --git a/src/pages/my-ads/components/AdRateError/__tests__/AdRateError.spec.tsx b/src/pages/my-ads/components/AdRateError/__tests__/AdRateError.spec.tsx
new file mode 100644
index 00000000..3415ddaa
--- /dev/null
+++ b/src/pages/my-ads/components/AdRateError/__tests__/AdRateError.spec.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from '@testing-library/react';
+
+import AdRateError from '../AdRateError';
+
+jest.mock('@deriv/api-v2', () => ({
+ useAuthorize: () => ({
+ data: {
+ local_currencies: ['USD'],
+ },
+ }),
+}));
+
+const mockFloatingRateHook = {
+ fixedRateAdvertsEndDate: '2024/12/31',
+ rateType: 'float',
+ reachedTargetDate: false,
+};
+
+jest.mock('@/hooks', () => ({
+ useFloatingRate: () => mockFloatingRateHook,
+}));
+
+describe('AdRateError', () => {
+ it('should render the component as expected', () => {
+ render( );
+ expect(
+ screen.getByText(
+ 'Floating rates are enabled for USD. Ads with fixed rates will be deactivated. Switch to floating rates by 2024/12/31.'
+ )
+ );
+ });
+ it('should render the corresponding message when rateType is fixed', () => {
+ mockFloatingRateHook.rateType = 'fixed';
+ render( );
+ expect(
+ screen.getByText('Your ads with floating rates have been deactivated. Set fixed rates to reactivate them.')
+ );
+ });
+ it('should render the corresponding message when reachedTargetDate is true', () => {
+ mockFloatingRateHook.reachedTargetDate = true;
+ render( );
+ expect(
+ screen.getByText('Your ads with floating rates have been deactivated. Set fixed rates to reactivate them.')
+ );
+ });
+});
diff --git a/src/pages/my-ads/components/AdRateError/index.ts b/src/pages/my-ads/components/AdRateError/index.ts
new file mode 100644
index 00000000..14924e07
--- /dev/null
+++ b/src/pages/my-ads/components/AdRateError/index.ts
@@ -0,0 +1 @@
+export { default as AdRateError } from './AdRateError';
diff --git a/src/pages/my-ads/components/AdStatus/AdStatus.scss b/src/pages/my-ads/components/AdStatus/AdStatus.scss
new file mode 100644
index 00000000..87d74bdc
--- /dev/null
+++ b/src/pages/my-ads/components/AdStatus/AdStatus.scss
@@ -0,0 +1,42 @@
+@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-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));
+
+ @include mobile {
+ margin-bottom: 0.8rem;
+ padding: 0.2rem 1rem;
+ }
+ width: fit-content;
+ }
+}
diff --git a/src/pages/my-ads/components/AdStatus/AdStatus.tsx b/src/pages/my-ads/components/AdStatus/AdStatus.tsx
new file mode 100644
index 00000000..87ad3665
--- /dev/null
+++ b/src/pages/my-ads/components/AdStatus/AdStatus.tsx
@@ -0,0 +1,31 @@
+import clsx from 'clsx';
+
+import { Text } from '@deriv-com/ui';
+
+import { useDevice } from '@/hooks/custom-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/src/pages/my-ads/components/AdStatus/__tests__/AdStatus.spec.tsx b/src/pages/my-ads/components/AdStatus/__tests__/AdStatus.spec.tsx
new file mode 100644
index 00000000..6d7d240d
--- /dev/null
+++ b/src/pages/my-ads/components/AdStatus/__tests__/AdStatus.spec.tsx
@@ -0,0 +1,14 @@
+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/src/pages/my-ads/components/AdStatus/index.ts b/src/pages/my-ads/components/AdStatus/index.ts
new file mode 100644
index 00000000..c746c4b3
--- /dev/null
+++ b/src/pages/my-ads/components/AdStatus/index.ts
@@ -0,0 +1 @@
+export { default as AdStatus } from './AdStatus';
diff --git a/src/pages/my-ads/components/AdSummary/AdSummary.tsx b/src/pages/my-ads/components/AdSummary/AdSummary.tsx
new file mode 100644
index 00000000..45707adc
--- /dev/null
+++ b/src/pages/my-ads/components/AdSummary/AdSummary.tsx
@@ -0,0 +1,107 @@
+import { useEffect } from 'react';
+
+import { useExchangeRateSubscription } from '@deriv/api-v2';
+import { Text, useDevice } from '@deriv-com/ui';
+import { FormatUtils } from '@deriv-com/utils';
+
+import { AD_ACTION, RATE_TYPE } from '@/constants';
+import { api } from '@/hooks';
+import { useQueryString } from '@/hooks/custom-hooks';
+import { percentOf, roundOffDecimal, setDecimalPlaces } from '@/utils';
+
+type TAdSummaryProps = {
+ adRateType?: string; // ratetype for the ad when action is edit
+ currency: string;
+ localCurrency?: string;
+ offerAmount: string;
+ priceRate: number;
+ rateType: string;
+ type: string;
+};
+
+const AdSummary = ({
+ adRateType = '',
+ currency,
+ localCurrency = '',
+ offerAmount,
+ priceRate,
+ rateType,
+ type,
+}: TAdSummaryProps) => {
+ const { isMobile } = useDevice();
+ const { queryString } = useQueryString();
+ const adOption = queryString.formAction;
+ const { data: p2pSettings } = api.settings.useGetSettings();
+ const { data: exchangeRateValue, subscribe } = useExchangeRateSubscription();
+ const overrideExchangeRate = p2pSettings?.override_exchange_rate;
+
+ const marketRateType = adOption === AD_ACTION.CREATE ? rateType : adRateType;
+ const displayOfferAmount = offerAmount ? FormatUtils.formatMoney(Number(offerAmount), { currency }) : '';
+ const adText = adOption === AD_ACTION.CREATE ? 'creating' : 'editing';
+ const adTypeText = type;
+
+ let displayPriceRate: number | string = '';
+ let displayTotal = '';
+
+ useEffect(() => {
+ subscribe({
+ base_currency: 'USD',
+ target_currency: localCurrency,
+ });
+ }, [localCurrency, subscribe]);
+
+ const exchangeRate = exchangeRateValue?.rates?.[localCurrency];
+ const marketRate = overrideExchangeRate ? Number(overrideExchangeRate) : exchangeRate;
+ const marketFeed = marketRateType === RATE_TYPE.FLOAT ? marketRate : null;
+ const summaryTextSize = isMobile ? 'md' : 'sm';
+
+ if (priceRate) {
+ displayPriceRate = marketFeed ? roundOffDecimal(percentOf(marketFeed, priceRate), 6) : priceRate;
+ }
+
+ if (offerAmount) {
+ if (priceRate) {
+ displayTotal = FormatUtils.formatMoney(
+ Number(offerAmount) * Number(marketFeed ? displayPriceRate : priceRate),
+ { currency: localCurrency }
+ );
+ const formattedPriceRate = FormatUtils.formatMoney(Number(displayPriceRate), {
+ currency: localCurrency,
+ decimalPlaces: setDecimalPlaces(Number(displayPriceRate), 6),
+ });
+ return (
+
+ {`You’re ${adText} an ad to ${adTypeText}`}
+
+ {` ${displayOfferAmount} ${currency} `}
+
+ for
+
+ {` ${displayTotal} ${localCurrency}`}
+
+
+ {` (${formattedPriceRate} ${localCurrency}/${currency})`}
+
+
+ );
+ }
+
+ return (
+
+ {`You’re ${adText} an ad to ${adTypeText}`}
+
+ {` ${displayOfferAmount} ${currency}`}
+
+ ...
+
+ );
+ }
+
+ return (
+
+ {`You’re ${adText} an ad to ${adTypeText}...`}
+
+ );
+};
+
+export default AdSummary;
diff --git a/src/pages/my-ads/components/AdSummary/__tests__/AdSummary.spec.tsx b/src/pages/my-ads/components/AdSummary/__tests__/AdSummary.spec.tsx
new file mode 100644
index 00000000..59660a6f
--- /dev/null
+++ b/src/pages/my-ads/components/AdSummary/__tests__/AdSummary.spec.tsx
@@ -0,0 +1,78 @@
+import { render, screen } from '@testing-library/react';
+
+import AdSummary from '../AdSummary';
+
+const mockProps = {
+ currency: 'USD',
+ localCurrency: 'IDR',
+ offerAmount: '',
+ priceRate: 0,
+ rateType: 'fixed',
+ type: 'buy',
+};
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({
+ isMobile: false,
+ }),
+}));
+
+jest.mock('@/hooks', () => ({
+ ...jest.requireActual('@/hooks'),
+ useQueryString: jest.fn().mockReturnValue({
+ queryString: {
+ formAction: 'create',
+ },
+ }),
+}));
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ p2p: {
+ settings: {
+ useGetSettings: () => ({
+ data: {
+ order_payment_period: 60,
+ override_exchange_rate: 0.01,
+ },
+ }),
+ },
+ },
+ useExchangeRateSubscription: () => ({
+ data: {
+ rates: {
+ IDR: 14000,
+ },
+ },
+ subscribe: jest.fn(),
+ }),
+}));
+
+describe(' ', () => {
+ it('should render the default ad summary line with buy ad for create', () => {
+ render( );
+ expect(screen.getByText('You’re creating an ad to buy...')).toBeInTheDocument();
+ });
+ it('should render the default ad summary line with sell ad for create', () => {
+ render( );
+ expect(screen.getByText('You’re creating an ad to sell...')).toBeInTheDocument();
+ });
+ it('should render the ad summary line with offer amount for buy ad for create', () => {
+ render( );
+ expect(screen.getByText(/You’re creating an ad to buy/)).toBeInTheDocument();
+ expect(screen.getByText('100.00 USD')).toBeInTheDocument();
+ });
+ it('should render the ad summary line with offer amount and price rate for buy ad for create', () => {
+ render( );
+ expect(screen.getByText(/You’re creating an ad to buy/)).toBeInTheDocument();
+ expect(screen.getByText('100.00 USD')).toBeInTheDocument();
+ expect(screen.getByText('(0.01 IDR/USD)')).toBeInTheDocument();
+ });
+ it('should render the ad summary line with offer amount and price rate for sell ad for create', () => {
+ render( );
+ expect(screen.getByText(/You’re creating an ad to sell/)).toBeInTheDocument();
+ expect(screen.getByText('100.00 USD')).toBeInTheDocument();
+ expect(screen.getByText('(2.00 IDR/USD)')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-ads/components/AdSummary/index.ts b/src/pages/my-ads/components/AdSummary/index.ts
new file mode 100644
index 00000000..ff83ddad
--- /dev/null
+++ b/src/pages/my-ads/components/AdSummary/index.ts
@@ -0,0 +1 @@
+export { default as AdSummary } from './AdSummary';
diff --git a/src/pages/my-ads/components/AdType/AdType.scss b/src/pages/my-ads/components/AdType/AdType.scss
new file mode 100644
index 00000000..5434b08f
--- /dev/null
+++ b/src/pages/my-ads/components/AdType/AdType.scss
@@ -0,0 +1,20 @@
+.p2p-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/src/pages/my-ads/components/AdType/AdType.tsx b/src/pages/my-ads/components/AdType/AdType.tsx
new file mode 100644
index 00000000..139cea01
--- /dev/null
+++ b/src/pages/my-ads/components/AdType/AdType.tsx
@@ -0,0 +1,22 @@
+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/src/pages/my-ads/components/AdType/__tests__/AdType.spec.tsx b/src/pages/my-ads/components/AdType/__tests__/AdType.spec.tsx
new file mode 100644
index 00000000..2edcb944
--- /dev/null
+++ b/src/pages/my-ads/components/AdType/__tests__/AdType.spec.tsx
@@ -0,0 +1,16 @@
+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/src/pages/my-ads/components/AdType/index.ts b/src/pages/my-ads/components/AdType/index.ts
new file mode 100644
index 00000000..73f48c39
--- /dev/null
+++ b/src/pages/my-ads/components/AdType/index.ts
@@ -0,0 +1 @@
+export { default as AdType } from './AdType';
diff --git a/src/pages/my-ads/components/AdTypeSection/AdTypeSection.scss b/src/pages/my-ads/components/AdTypeSection/AdTypeSection.scss
new file mode 100644
index 00000000..6e05fa62
--- /dev/null
+++ b/src/pages/my-ads/components/AdTypeSection/AdTypeSection.scss
@@ -0,0 +1,7 @@
+.p2p-ad-type-section {
+ @include mobile {
+ padding: 0 1.6rem;
+ overflow: auto;
+ max-height: calc(100vh - 27rem);
+ }
+}
diff --git a/src/pages/my-ads/components/AdTypeSection/AdTypeSection.tsx b/src/pages/my-ads/components/AdTypeSection/AdTypeSection.tsx
new file mode 100644
index 00000000..c0d49ce6
--- /dev/null
+++ b/src/pages/my-ads/components/AdTypeSection/AdTypeSection.tsx
@@ -0,0 +1,176 @@
+import React, { MouseEventHandler } from 'react';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { Text, useDevice } from '@deriv-com/ui';
+
+import { FloatingRate, RadioGroup } from '@/components';
+import { BUY_SELL, RATE_TYPE } from '@/constants';
+import { useQueryString } from '@/hooks/custom-hooks';
+import { getValidationRules, restrictDecimalPlace } from '@/utils';
+
+import { AdFormController } from '../AdFormController';
+import { AdFormInput } from '../AdFormInput';
+import { AdFormTextArea } from '../AdFormTextArea';
+
+import './AdTypeSection.scss';
+
+type TAdTypeSectionProps = {
+ currency: string;
+ getCurrentStep: () => number;
+ getTotalSteps: () => number;
+ goToNextStep: MouseEventHandler;
+ goToPreviousStep: MouseEventHandler;
+ localCurrency?: string;
+ onCancel: () => void;
+ rateType: string;
+};
+
+const AdTypeSection = ({ currency, localCurrency, onCancel, rateType, ...props }: TAdTypeSectionProps) => {
+ const { queryString } = useQueryString();
+ const { advertId = '' } = queryString;
+ const isEdit = !!advertId;
+ const { isMobile } = useDevice();
+ const {
+ control,
+ formState: { isValid },
+ getValues,
+ setValue,
+ trigger,
+ watch,
+ } = useFormContext();
+
+ const isSell = watch('ad-type') === BUY_SELL.SELL;
+ const textSize = isMobile ? 'md' : 'sm';
+
+ const onChangeAdTypeHandler = (userInput: 'buy' | 'sell') => {
+ setValue('ad-type', userInput);
+ setValue('payment-method', []);
+ if (rateType === RATE_TYPE.FLOAT) {
+ if (userInput === BUY_SELL.SELL) {
+ setValue('rate-value', '+0.01');
+ } else {
+ setValue('rate-value', '-0.01');
+ }
+ }
+ };
+
+ const triggerValidation = (fieldNames: string[]) => {
+ // Loop through the provided field names
+ fieldNames.forEach(fieldName => {
+ // Check if the field has a value
+ if (getValues(fieldName)) {
+ // Trigger validation for the field
+ trigger(fieldName);
+ }
+ });
+ };
+
+ return (
+
+ {!isEdit && (
+
{
+ return (
+
+ {
+ onChangeAdTypeHandler(event.target.value as 'buy' | 'sell');
+ onChange(event);
+ }}
+ required
+ selected={value}
+ textSize={textSize}
+ >
+
+
+
+
+ );
+ }}
+ rules={{ required: true }}
+ />
+ )}
+
+
+ {currency}
+
+ }
+ triggerValidationFunction={() => triggerValidation(['min-order', 'max-order'])}
+ />
+
+ {rateType === RATE_TYPE.FLOAT ? (
+ {
+ return (
+ restrictDecimalPlace(e, onChange)}
+ errorMessages={error?.message ?? ''}
+ fiatCurrency={currency}
+ localCurrency={localCurrency ?? ''}
+ onChange={onChange}
+ value={value}
+ />
+ );
+ }}
+ rules={{ validate: getValidationRules('rate-value', getValues) }}
+ />
+ ) : (
+
+ {localCurrency}
+
+ }
+ />
+ )}
+
+
+
+ {currency}
+
+ }
+ triggerValidationFunction={() => triggerValidation(['amount', 'max-order'])}
+ />
+
+ {currency}
+
+ }
+ triggerValidationFunction={() => triggerValidation(['amount', 'min-order'])}
+ />
+
+ {isSell && (
+
+ )}
+
+
+
+ );
+};
+
+export default AdTypeSection;
diff --git a/src/pages/my-ads/components/AdTypeSection/__tests__/AdTypeSection.spec.tsx b/src/pages/my-ads/components/AdTypeSection/__tests__/AdTypeSection.spec.tsx
new file mode 100644
index 00000000..b3136b0b
--- /dev/null
+++ b/src/pages/my-ads/components/AdTypeSection/__tests__/AdTypeSection.spec.tsx
@@ -0,0 +1,86 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import AdTypeSection from '../AdTypeSection';
+
+jest.mock('../../AdFormTextArea', () => ({
+ AdFormTextArea: () => AdFormTextArea
,
+}));
+
+const mockSetFieldValue = jest.fn();
+const mockTriggerFunction = jest.fn();
+jest.mock('react-hook-form', () => ({
+ ...jest.requireActual('react-hook-form'),
+ Controller: ({ control, defaultValue, name, render }) =>
+ render({
+ field: { control, name, onBlur: jest.fn(), onChange: jest.fn(), value: defaultValue },
+ fieldState: { error: null },
+ }),
+ useFormContext: () => ({
+ control: 'mockedControl',
+ formState: {
+ dirtyFields: { amount: true },
+ isDirty: false,
+ isValid: true,
+ },
+ getValues: jest.fn(() => 'mockedValues'),
+ setValue: mockSetFieldValue,
+ trigger: mockTriggerFunction,
+ watch: jest.fn(() => 'buy'),
+ }),
+}));
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({ isMobile: false }),
+}));
+
+jest.mock('@/hooks', () => ({
+ ...jest.requireActual('@/hooks'),
+ useQueryString: jest.fn().mockReturnValue({ queryString: { advertId: '' } }),
+}));
+
+const mockProps = {
+ currency: 'usd',
+ getCurrentStep: jest.fn(() => 1),
+ getTotalSteps: jest.fn(),
+ goToNextStep: jest.fn(),
+ goToPreviousStep: jest.fn(),
+ localCurrency: 'usd',
+ onCancel: jest.fn(),
+ rateType: 'float',
+};
+
+jest.mock('@/components', () => ({
+ ...jest.requireActual('@/components'),
+ FloatingRate: () => FloatingRate
,
+}));
+
+describe('AdTypeSection', () => {
+ it('should render the ad type section component', () => {
+ render( );
+ expect(screen.getByText('Total amount')).toBeInTheDocument();
+ expect(screen.getByText('Min order')).toBeInTheDocument();
+ expect(screen.getByText('Max order')).toBeInTheDocument();
+ expect(screen.getByText('AdFormTextArea')).toBeInTheDocument();
+ });
+ it('should handle ad type change', async () => {
+ render( );
+ const element = screen.getByRole('radio', { name: /sell/i });
+ await userEvent.click(element);
+ expect(mockSetFieldValue).toHaveBeenCalledWith('ad-type', 'sell');
+ });
+ it('should handle Cancel button click', async () => {
+ render( );
+ const element = screen.getByRole('button', { name: 'Cancel' });
+ await userEvent.click(element);
+ expect(mockProps.onCancel).toHaveBeenCalled();
+ });
+ it('should handle the trigger validation', async () => {
+ render( );
+ await userEvent.type(screen.getByPlaceholderText('Max order'), '200');
+ const element = screen.getByPlaceholderText('Total amount');
+ await userEvent.type(element, '100');
+ expect(mockTriggerFunction).toHaveBeenCalled();
+ });
+});
diff --git a/src/pages/my-ads/components/AdTypeSection/index.ts b/src/pages/my-ads/components/AdTypeSection/index.ts
new file mode 100644
index 00000000..8d6b8a2e
--- /dev/null
+++ b/src/pages/my-ads/components/AdTypeSection/index.ts
@@ -0,0 +1 @@
+export { default as AdTypeSection } from './AdTypeSection';
diff --git a/src/pages/my-ads/components/AdWizard/AdWizard.scss b/src/pages/my-ads/components/AdWizard/AdWizard.scss
new file mode 100644
index 00000000..2cd64674
--- /dev/null
+++ b/src/pages/my-ads/components/AdWizard/AdWizard.scss
@@ -0,0 +1,46 @@
+.p2p-ad-wizard {
+ @include desktop {
+ width: 67.2rem;
+ }
+
+ > div:first-child {
+ @include mobile {
+ display: flex;
+ align-items: center;
+ column-gap: 1rem;
+ padding: 0.8rem 2rem;
+ background-color: #f2f3f4;
+
+ > div {
+ flex: 1;
+
+ @include mobile {
+ & .deriv-button {
+ background-color: transparent;
+ }
+ }
+ }
+ }
+ }
+
+ .p2p-wizard__main-step {
+ padding-top: 1rem;
+ @include desktop {
+ max-height: calc(100vh - 35rem);
+ overflow-y: auto;
+ }
+ }
+
+ .p2p-form-progress {
+ background-color: #f2f3f4;
+ margin-top: 2rem;
+
+ &__step {
+ width: 24rem;
+ }
+
+ &__steps {
+ margin-bottom: 1.6rem;
+ }
+ }
+}
diff --git a/src/pages/my-ads/components/AdWizard/AdWizard.tsx b/src/pages/my-ads/components/AdWizard/AdWizard.tsx
new file mode 100644
index 00000000..16a1d510
--- /dev/null
+++ b/src/pages/my-ads/components/AdWizard/AdWizard.tsx
@@ -0,0 +1,68 @@
+import React, { useState } from 'react';
+import { TCountryListItem, TStep } from 'types';
+import { FormProgress, Wizard } from '@/components';
+import { LabelPairedXmarkLgBoldIcon } from '@deriv/quill-icons';
+import { Button, Text, useDevice } from '@deriv-com/ui';
+import { AdConditionsSection } from '../AdConditionsSection';
+import { AdPaymentDetailsSection } from '../AdPaymentDetailsSection';
+import { AdProgressBar } from '../AdProgressBar';
+import { AdTypeSection } from '../AdTypeSection';
+import './AdWizard.scss';
+
+type TAdWizardNav = {
+ countryList: TCountryListItem;
+ currency: string;
+ localCurrency?: string;
+ onCancel: () => void;
+ rateType: string;
+ steps: TStep[];
+};
+
+const AdWizard = ({ countryList, onCancel, steps, ...rest }: TAdWizardNav) => {
+ const { isDesktop } = useDevice();
+ const [currentStep, setCurrentStep] = useState(0);
+
+ return (
+
+ {isDesktop ? (
+
+ ) : (
+
+
+
+ {steps[currentStep].header.title}
+ {steps[currentStep + 1] ? (
+
+ {`Next: ${steps[currentStep + 1].header.title}`}
+
+ ) : (
+
+ Last step
+
+ )}
+
+
}
+ onClick={onCancel}
+ type='button'
+ variant='contained'
+ />
+
+ )}
+
+ }
+ onStepChange={step => setCurrentStep(step.activeStep - 1)}
+ >
+
+
+
+
+ );
+};
+
+export default AdWizard;
diff --git a/src/pages/my-ads/components/AdWizard/__tests__/AdWizard.spec.tsx b/src/pages/my-ads/components/AdWizard/__tests__/AdWizard.spec.tsx
new file mode 100644
index 00000000..76aeb4b3
--- /dev/null
+++ b/src/pages/my-ads/components/AdWizard/__tests__/AdWizard.spec.tsx
@@ -0,0 +1,47 @@
+import { useDevice } from '@deriv-com/ui';
+import { render, screen } from '@testing-library/react';
+
+import AdWizard from '../AdWizard';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({
+ isDesktop: true,
+ }),
+}));
+
+const mockUseDevice = useDevice as jest.Mock;
+
+jest.mock('@/components', () => ({
+ ...jest.requireActual('@/components'),
+ FormProgress: () => FormProgress
,
+}));
+
+jest.mock('../../AdTypeSection', () => ({
+ AdTypeSection: () => AdTypeSection
,
+}));
+
+jest.mock('../../AdProgressBar', () => ({
+ AdProgressBar: () => AdProgressBar
,
+}));
+
+const mockProps = {
+ currency: 'usd',
+ localCurrency: 'usd',
+ rateType: 'float',
+ steps: [{ header: { title: 'step 1' } }, { header: { title: 'step 2' } }, { header: { title: 'step 3' } }],
+};
+
+describe('AdWizard', () => {
+ it('should render the ad wizard component', () => {
+ render( );
+ expect(screen.getByText('FormProgress')).toBeInTheDocument();
+ });
+ it('should render the AdProgressBar component in responsive view', () => {
+ mockUseDevice.mockReturnValue({
+ isDesktop: false,
+ });
+ render( );
+ expect(screen.getByText('AdProgressBar')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-ads/components/AdWizard/index.ts b/src/pages/my-ads/components/AdWizard/index.ts
new file mode 100644
index 00000000..d46d5fe8
--- /dev/null
+++ b/src/pages/my-ads/components/AdWizard/index.ts
@@ -0,0 +1 @@
+export { default as AdWizard } from './AdWizard';
diff --git a/src/pages/my-ads/components/AlertComponent/AlertComponent.scss b/src/pages/my-ads/components/AlertComponent/AlertComponent.scss
new file mode 100644
index 00000000..57cd39a2
--- /dev/null
+++ b/src/pages/my-ads/components/AlertComponent/AlertComponent.scss
@@ -0,0 +1,17 @@
+.p2p-alert-component {
+ display: flex;
+ justify-content: center;
+ position: absolute;
+ align-items: center;
+ right: 4.7rem;
+ height: 100%;
+
+ span {
+ display: flex;
+ }
+ @include mobile {
+ position: unset;
+ margin-top: -0.4rem;
+ margin-left: 1rem;
+ }
+}
diff --git a/src/pages/my-ads/components/AlertComponent/AlertComponent.tsx b/src/pages/my-ads/components/AlertComponent/AlertComponent.tsx
new file mode 100644
index 00000000..3ff18d7a
--- /dev/null
+++ b/src/pages/my-ads/components/AlertComponent/AlertComponent.tsx
@@ -0,0 +1,21 @@
+import { Button, Tooltip } from '@deriv-com/ui';
+
+import AlertIcon from '../../../../public/ic-alert-warning.svg';
+
+import './AlertComponent.scss';
+
+type TAlertComponentProps = {
+ onClick: () => void;
+};
+
+const AlertComponent = ({ onClick }: TAlertComponentProps) => (
+
+);
+
+export default AlertComponent;
diff --git a/src/pages/my-ads/components/AlertComponent/__tests__/AlertComponent.spec.tsx b/src/pages/my-ads/components/AlertComponent/__tests__/AlertComponent.spec.tsx
new file mode 100644
index 00000000..5ebaddbc
--- /dev/null
+++ b/src/pages/my-ads/components/AlertComponent/__tests__/AlertComponent.spec.tsx
@@ -0,0 +1,26 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import AlertComponent from '../AlertComponent';
+
+const mockProps = {
+ onClick: jest.fn(),
+};
+describe('AlertComponent', () => {
+ it('should render the component as expected', () => {
+ render( );
+ expect(screen.getByTestId('dt_alert_icon')).toBeInTheDocument();
+ });
+ it('should show the tooltip text on hovering the icon', async () => {
+ render( );
+ const icon = screen.getByTestId('dt_alert_icon');
+ await userEvent.hover(icon);
+ expect(screen.getByText('Ad not listed')).toBeInTheDocument();
+ });
+ it('should handle the onclick', async () => {
+ render( );
+ const button = screen.getByRole('button');
+ await userEvent.click(button);
+ expect(mockProps.onClick).toBeCalledTimes(1);
+ });
+});
diff --git a/src/pages/my-ads/components/AlertComponent/index.ts b/src/pages/my-ads/components/AlertComponent/index.ts
new file mode 100644
index 00000000..169563d4
--- /dev/null
+++ b/src/pages/my-ads/components/AlertComponent/index.ts
@@ -0,0 +1 @@
+export { default as AlertComponent } from './AlertComponent';
diff --git a/src/pages/my-ads/components/BuyAdPaymentSelection/BuyAdPaymentSelection.scss b/src/pages/my-ads/components/BuyAdPaymentSelection/BuyAdPaymentSelection.scss
new file mode 100644
index 00000000..f65dca94
--- /dev/null
+++ b/src/pages/my-ads/components/BuyAdPaymentSelection/BuyAdPaymentSelection.scss
@@ -0,0 +1,18 @@
+.p2p-buy-ad-payment-selection {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem 1.2rem 0;
+ border-radius: 4px;
+ border: 1px solid #d6dadb;
+ margin-bottom: 2rem;
+
+ //TODO: This is a temporary fix for the button hover color issue, need to remove this once the issue is fixed
+ & .deriv-button {
+ &__variant--contained {
+ &.deriv-button__color--white:hover:not(:disabled) {
+ background-color: transparent;
+ }
+ }
+ }
+}
diff --git a/src/pages/my-ads/components/BuyAdPaymentSelection/BuyAdPaymentSelection.tsx b/src/pages/my-ads/components/BuyAdPaymentSelection/BuyAdPaymentSelection.tsx
new file mode 100644
index 00000000..9087f41f
--- /dev/null
+++ b/src/pages/my-ads/components/BuyAdPaymentSelection/BuyAdPaymentSelection.tsx
@@ -0,0 +1,53 @@
+import { LabelPairedTrashCaptionBoldIcon } from '@deriv/quill-icons';
+import { Button } from '@deriv-com/ui';
+
+import { PaymentMethodWithIcon } from '@/components';
+import { api } from '@/hooks';
+import { getPaymentMethodObjects } from '@/utils';
+
+import { BuyPaymentMethodsList } from '../BuyPaymentMethodsList';
+
+import './BuyAdPaymentSelection.scss';
+
+type TBuyAdPaymentSelectionProps = {
+ onSelectPaymentMethod: (paymentMethod: string, action?: string) => void;
+ selectedPaymentMethods: string[];
+};
+
+const BuyAdPaymentSelection = ({ onSelectPaymentMethod, selectedPaymentMethods }: TBuyAdPaymentSelectionProps) => {
+ // Enabled for payment method modal
+ const { data: paymentMethodList } = api.paymentMethods.useGet(false);
+ const list = (
+ paymentMethodList?.map(paymentMethod => ({
+ text: paymentMethod.display_name,
+ value: paymentMethod.id,
+ })) ?? []
+ ).filter(paymentMethod => !selectedPaymentMethods.includes(paymentMethod.value));
+
+ const paymentMethodObjects = getPaymentMethodObjects(paymentMethodList, 'id');
+ return (
+ <>
+ {selectedPaymentMethods?.length > 0 &&
+ selectedPaymentMethods.map(method => {
+ const { display_name: name, type } = paymentMethodObjects[method] ?? {};
+ return (
+
+
+
onSelectPaymentMethod(method, 'delete')}
+ variant='contained'
+ >
+
+
+
+ );
+ })}
+ {selectedPaymentMethods?.length < 3 && (
+
+ )}
+ >
+ );
+};
+
+export default BuyAdPaymentSelection;
diff --git a/src/pages/my-ads/components/BuyAdPaymentSelection/__tests__/BuyAdPaymentSelection.spec.tsx b/src/pages/my-ads/components/BuyAdPaymentSelection/__tests__/BuyAdPaymentSelection.spec.tsx
new file mode 100644
index 00000000..0a025498
--- /dev/null
+++ b/src/pages/my-ads/components/BuyAdPaymentSelection/__tests__/BuyAdPaymentSelection.spec.tsx
@@ -0,0 +1,101 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import BuyAdPaymentSelection from '../BuyAdPaymentSelection';
+
+jest.mock('@deriv/api-v2', () => ({
+ p2p: {
+ paymentMethods: {
+ useGet: () => ({
+ data: [
+ {
+ display_name: 'Bank Transfer',
+ fields: {
+ account: {
+ display_name: 'Account Number',
+ required: 1,
+ type: 'text',
+ value: 'Account Number',
+ },
+ bank_name: { display_name: 'Bank Transfer', required: 1, type: 'text', value: 'Bank Name' },
+ },
+ id: 'test1',
+ is_enabled: 0,
+ method: '',
+ type: 'bank',
+ used_by_adverts: null,
+ used_by_orders: null,
+ },
+ {
+ display_name: 'Ali pay',
+ fields: {
+ account: {
+ display_name: 'Account Number',
+ required: 1,
+ type: 'text',
+ value: 'Account Number',
+ },
+ bank_name: { display_name: 'Ali pay', required: 1, type: 'text', value: 'Bank Name' },
+ },
+ id: 'test2',
+ is_enabled: 0,
+ method: '',
+ type: 'wallet',
+ used_by_adverts: null,
+ used_by_orders: null,
+ },
+ {
+ display_name: 'Skrill',
+ fields: {
+ account: {
+ display_name: 'Account Number',
+ required: 1,
+ type: 'text',
+ value: 'Account Number',
+ },
+ bank_name: { display_name: 'Skrill', required: 1, type: 'text', value: 'Bank Name' },
+ },
+ id: 'test3',
+ is_enabled: 0,
+ method: '',
+ type: 'wallet',
+ used_by_adverts: null,
+ used_by_orders: null,
+ },
+ ],
+ }),
+ },
+ },
+}));
+
+jest.mock('../../BuyPaymentMethodsList', () => ({
+ BuyPaymentMethodsList: () => BuyPaymentMethodsList
,
+}));
+
+jest.mock('@/components', () => ({
+ ...jest.requireActual('@/components'),
+ PaymentMethodWithIcon: () => PaymentMethodWithIcon
,
+}));
+
+const mockProps = {
+ onSelectPaymentMethod: jest.fn(),
+ selectedPaymentMethods: [],
+};
+
+describe('BuyAdPaymentSelection', () => {
+ it('should render the buy ad payment selection component', () => {
+ render( );
+ expect(screen.getByText('BuyPaymentMethodsList')).toBeInTheDocument();
+ });
+ it('should not render the dropdown if 3 payment methods are selected', () => {
+ render( );
+ expect(screen.queryByPlaceholderText('BuyPaymentMethodsList')).not.toBeInTheDocument();
+ });
+ it('should render the delete button for each selected payment method', async () => {
+ render( );
+ expect(screen.getByText('PaymentMethodWithIcon')).toBeInTheDocument();
+ const button = screen.getByTestId('dt_payment_delete_icon');
+ await userEvent.click(button);
+ expect(mockProps.onSelectPaymentMethod).toHaveBeenCalledWith('test', 'delete');
+ });
+});
diff --git a/src/pages/my-ads/components/BuyAdPaymentSelection/index.ts b/src/pages/my-ads/components/BuyAdPaymentSelection/index.ts
new file mode 100644
index 00000000..d74f5c6a
--- /dev/null
+++ b/src/pages/my-ads/components/BuyAdPaymentSelection/index.ts
@@ -0,0 +1 @@
+export { default as BuyAdPaymentSelection } from './BuyAdPaymentSelection';
diff --git a/src/pages/my-ads/components/BuyPaymentMethodsList/BuyPaymentMethodsList.scss b/src/pages/my-ads/components/BuyPaymentMethodsList/BuyPaymentMethodsList.scss
new file mode 100644
index 00000000..80e0937d
--- /dev/null
+++ b/src/pages/my-ads/components/BuyPaymentMethodsList/BuyPaymentMethodsList.scss
@@ -0,0 +1,30 @@
+.p2p-buy-payment-methods-list {
+ &__dropdown {
+ border-style: dashed;
+ & .deriv-input {
+ &__field {
+ padding-left: 1rem;
+ &::placeholder {
+ visibility: visible;
+ color: #999999;
+ }
+ }
+ &__label {
+ display: none;
+ }
+ }
+ }
+
+ & .deriv-dropdown {
+ &__content {
+ & .deriv-input__container {
+ width: 100%;
+ }
+ }
+ &__items {
+ top: unset;
+ bottom: 7rem;
+ max-height: 20rem;
+ }
+ }
+}
diff --git a/src/pages/my-ads/components/BuyPaymentMethodsList/BuyPaymentMethodsList.tsx b/src/pages/my-ads/components/BuyPaymentMethodsList/BuyPaymentMethodsList.tsx
new file mode 100644
index 00000000..cd442fb0
--- /dev/null
+++ b/src/pages/my-ads/components/BuyPaymentMethodsList/BuyPaymentMethodsList.tsx
@@ -0,0 +1,29 @@
+import React, { ComponentProps } from 'react';
+import { LabelPairedCirclePlusCaptionRegularIcon } from '@deriv/quill-icons';
+import { Dropdown } from '@deriv-com/ui';
+import './BuyPaymentMethodsList.scss';
+
+type TBuyPaymentMethodsList = {
+ list: ComponentProps['list'];
+ onSelectPaymentMethod: (paymentMethod: string) => void;
+};
+
+const BuyPaymentMethodsList = ({ list, onSelectPaymentMethod }: TBuyPaymentMethodsList) => {
+ return (
+
+ }
+ isFullWidth
+ list={list}
+ name='payment-method-list'
+ onSelect={onSelectPaymentMethod}
+ placeholder='Add'
+ value=''
+ variant='prompt'
+ />
+
+ );
+};
+
+export default BuyPaymentMethodsList;
diff --git a/src/pages/my-ads/components/BuyPaymentMethodsList/__tests__/BuyPaymentMethodsList.spec.tsx b/src/pages/my-ads/components/BuyPaymentMethodsList/__tests__/BuyPaymentMethodsList.spec.tsx
new file mode 100644
index 00000000..718e3548
--- /dev/null
+++ b/src/pages/my-ads/components/BuyPaymentMethodsList/__tests__/BuyPaymentMethodsList.spec.tsx
@@ -0,0 +1,27 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import BuyPaymentMethodsList from '../BuyPaymentMethodsList';
+
+const mockProps = {
+ list: [
+ {
+ text: 'Bank Transfer',
+ value: 'bank_transfer',
+ },
+ ],
+ onSelectPaymentMethod: jest.fn(),
+};
+
+describe('BuyPaymentMethodsList', () => {
+ it('should render the buy payment methods list component', () => {
+ render( );
+ expect(screen.getByPlaceholderText('Add')).toBeInTheDocument();
+ });
+ it('should call onSelectPaymentMethod when clicking on the payment method', async () => {
+ render( );
+ await userEvent.click(screen.getByPlaceholderText('Add'));
+ await userEvent.click(screen.getByText('Bank Transfer'));
+ expect(mockProps.onSelectPaymentMethod).toHaveBeenCalledWith('bank_transfer');
+ });
+});
diff --git a/src/pages/my-ads/components/BuyPaymentMethodsList/index.ts b/src/pages/my-ads/components/BuyPaymentMethodsList/index.ts
new file mode 100644
index 00000000..a05d5877
--- /dev/null
+++ b/src/pages/my-ads/components/BuyPaymentMethodsList/index.ts
@@ -0,0 +1 @@
+export { default as BuyPaymentMethodsList } from './BuyPaymentMethodsList';
diff --git a/src/pages/my-ads/components/OrderTimeSelection/OrderTimeSelection.tsx b/src/pages/my-ads/components/OrderTimeSelection/OrderTimeSelection.tsx
new file mode 100644
index 00000000..a616ef79
--- /dev/null
+++ b/src/pages/my-ads/components/OrderTimeSelection/OrderTimeSelection.tsx
@@ -0,0 +1,61 @@
+import React, { useState } from 'react';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { LabelPairedChevronDownMdRegularIcon, LabelPairedCircleInfoCaptionRegularIcon } from '@deriv/quill-icons';
+import { Button, Dropdown, Text, Tooltip, useDevice } from '@deriv-com/ui';
+
+import { OrderTimeTooltipModal } from '@/components/Modals';
+import { ORDER_COMPLETION_TIME_LIST, ORDER_TIME_INFO_MESSAGE } from '@/constants';
+
+const OrderTimeSelection = () => {
+ const { control } = useFormContext();
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const { isMobile } = useDevice();
+
+ return (
+
+
+
+ Orders must be completed in
+
+
+
+ setIsModalOpen(true) : () => undefined}
+ type='button'
+ variant='contained'
+ >
+
+
+
+
+
+
(
+ }
+ list={ORDER_COMPLETION_TIME_LIST}
+ name='order-completion-time'
+ onSelect={onChange}
+ value={value}
+ variant='comboBox'
+ />
+ )}
+ />
+
+ {isModalOpen && (
+ setIsModalOpen(false)} />
+ )}
+
+ );
+};
+
+export default OrderTimeSelection;
diff --git a/src/pages/my-ads/components/OrderTimeSelection/__tests__/OrderTimeSelection.spec.tsx b/src/pages/my-ads/components/OrderTimeSelection/__tests__/OrderTimeSelection.spec.tsx
new file mode 100644
index 00000000..c7e36ea1
--- /dev/null
+++ b/src/pages/my-ads/components/OrderTimeSelection/__tests__/OrderTimeSelection.spec.tsx
@@ -0,0 +1,51 @@
+import { useDevice } from '@deriv-com/ui';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import OrderTimeSelection from '../OrderTimeSelection';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({ isMobile: false }),
+}));
+
+const mockUseDevice = useDevice as jest.Mock;
+
+jest.mock('react-hook-form', () => ({
+ ...jest.requireActual('react-hook-form'),
+ Controller: ({ control, defaultValue, name, render }) =>
+ render({
+ field: { onChange: jest.fn(), value: defaultValue },
+ fieldState: { error: null },
+ }),
+ useFormContext: () => ({
+ control: 'mockedControl',
+ }),
+}));
+
+describe('OrderTimeSelection', () => {
+ it('should render the order time selection component', () => {
+ render( );
+ expect(screen.getByText('Orders must be completed in')).toBeInTheDocument();
+ });
+ it('should handle the dropdown click', async () => {
+ render( );
+ await userEvent.click(screen.getByRole('combobox'));
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+ expect(screen.getByText('1 hour')).toBeInTheDocument();
+ });
+ it('should not do anything on clicking info icon in desktop view', async () => {
+ render( );
+ await userEvent.click(screen.getByTestId('dt_order_info_icon'));
+ expect(screen.queryByRole('button', { name: 'Ok' })).not.toBeInTheDocument();
+ });
+ it('should handle the modal open in mobile view', async () => {
+ mockUseDevice.mockReturnValue({ isMobile: true });
+ render( );
+ await userEvent.click(screen.getByTestId('dt_order_info_icon'));
+ const okButton = screen.getByRole('button', { name: 'Ok' });
+ expect(okButton).toBeInTheDocument();
+ await userEvent.click(okButton);
+ expect(screen.queryByRole('button', { name: 'Ok' })).not.toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-ads/components/OrderTimeSelection/index.ts b/src/pages/my-ads/components/OrderTimeSelection/index.ts
new file mode 100644
index 00000000..64cf9132
--- /dev/null
+++ b/src/pages/my-ads/components/OrderTimeSelection/index.ts
@@ -0,0 +1 @@
+export { default as OrderTimeSelection } from './OrderTimeSelection';
diff --git a/src/pages/my-ads/components/PreferredCountriesSelector/PreferredCountriesSelector.scss b/src/pages/my-ads/components/PreferredCountriesSelector/PreferredCountriesSelector.scss
new file mode 100644
index 00000000..41edc9cc
--- /dev/null
+++ b/src/pages/my-ads/components/PreferredCountriesSelector/PreferredCountriesSelector.scss
@@ -0,0 +1,23 @@
+.p2p-preferred-countries-selector {
+ display: flex;
+ flex-direction: column;
+ gap: 0.8rem;
+
+ &__field {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 32.8rem;
+ height: 4rem;
+ border: 1px solid #d6dadb;
+ padding: 1rem 1.6rem;
+ cursor: pointer;
+
+ &__text {
+ width: 100%;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ }
+ }
+}
diff --git a/src/pages/my-ads/components/PreferredCountriesSelector/PreferredCountriesSelector.tsx b/src/pages/my-ads/components/PreferredCountriesSelector/PreferredCountriesSelector.tsx
new file mode 100644
index 00000000..59a9e937
--- /dev/null
+++ b/src/pages/my-ads/components/PreferredCountriesSelector/PreferredCountriesSelector.tsx
@@ -0,0 +1,66 @@
+import React, { useState } from 'react';
+import { useFormContext } from 'react-hook-form';
+import { TCountryListItem } from 'types';
+import { PreferredCountriesModal } from '@/components/Modals';
+import { AD_CONDITION_TYPES } from '@/constants';
+import { LabelPairedChevronRightSmRegularIcon } from '@deriv/quill-icons';
+import { Text, useDevice } from '@deriv-com/ui';
+import { AdConditionContentHeader } from '../AdConditionContentHeader';
+import './PreferredCountriesSelector.scss';
+
+type TPreferredCountriesSelectorProps = {
+ countryList: TCountryListItem;
+ type: typeof AD_CONDITION_TYPES[keyof typeof AD_CONDITION_TYPES];
+};
+
+const PreferredCountriesSelector = ({ countryList, type }: TPreferredCountriesSelectorProps) => {
+ const { isMobile } = useDevice();
+ const { getValues, setValue } = useFormContext();
+ const countries = Object.keys(countryList).map(key => ({
+ text: countryList[key]?.country_name,
+ value: key,
+ }));
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [selectedValues, setSelectedValues] = useState(
+ getValues('preferred-countries') ? getValues('preferred-countries') : countries.map(country => country.value)
+ );
+
+ const getSelectedCountriesText = () => {
+ const selectedCountries = getValues('preferred-countries');
+ if (selectedCountries?.length === countries.length) {
+ return 'All countries';
+ }
+ return selectedCountries?.map((value: string) => countryList[value]?.country_name).join(', ');
+ };
+
+ return (
+
+
+
setIsModalOpen(true)}>
+
+ {getSelectedCountriesText()}
+
+
+
+ {isModalOpen && (
+
{
+ setValue('preferred-countries', selectedValues);
+ setIsModalOpen(false);
+ }}
+ onRequestClose={() => setIsModalOpen(false)}
+ selectedCountries={selectedValues}
+ setSelectedCountries={setSelectedValues}
+ />
+ )}
+
+ );
+};
+
+export default PreferredCountriesSelector;
diff --git a/src/pages/my-ads/components/PreferredCountriesSelector/__tests__/PreferredCountriesSelector.spec.tsx b/src/pages/my-ads/components/PreferredCountriesSelector/__tests__/PreferredCountriesSelector.spec.tsx
new file mode 100644
index 00000000..67d9182e
--- /dev/null
+++ b/src/pages/my-ads/components/PreferredCountriesSelector/__tests__/PreferredCountriesSelector.spec.tsx
@@ -0,0 +1,116 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import PreferredCountriesSelector from '../PreferredCountriesSelector';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({ isMobile: false }),
+}));
+
+const mockFn = jest.fn();
+const mockGetValues = jest.fn().mockReturnValue(['countryA', 'countryB']);
+
+jest.mock('react-hook-form', () => ({
+ ...jest.requireActual('react-hook-form'),
+ useFormContext: () => ({
+ formState: { errors: {}, isValid: true },
+ getValues: mockGetValues,
+ setValue: mockFn,
+ }),
+}));
+
+const mockProps = {
+ countryList: {
+ countryA: {
+ country_name: 'countryA',
+ cross_border_ads_enabled: 1,
+ fixed_rate_adverts: 'enabled',
+ float_rate_adverts: 'disabled',
+ float_rate_offset_limit: 10,
+ local_currency: 'CA',
+ payment_methods: {
+ alipay: {
+ display_name: 'Alipay',
+ fields: {
+ account: {
+ display_name: 'Alipay ID',
+ required: 1,
+ type: 'text',
+ },
+ instructions: {
+ display_name: 'Instructions',
+ required: 0,
+ type: 'memo',
+ },
+ },
+ type: 'ewallet',
+ },
+ },
+ },
+ countryB: {
+ country_name: 'countryB',
+ cross_border_ads_enabled: 1,
+ fixed_rate_adverts: 'enabled',
+ float_rate_adverts: 'disabled',
+ float_rate_offset_limit: 10,
+ local_currency: 'CA',
+ payment_methods: {
+ alipay: {
+ display_name: 'Alipay',
+ fields: {
+ account: {
+ display_name: 'Alipay ID',
+ required: 1,
+ type: 'text',
+ },
+ instructions: {
+ display_name: 'Instructions',
+ required: 0,
+ type: 'memo',
+ },
+ },
+ type: 'ewallet',
+ },
+ },
+ },
+ },
+ type: 'preferredCountries',
+};
+
+describe('PreferredCountriesSelector', () => {
+ it('should render the component as expected with given props', () => {
+ render( );
+ expect(screen.getByText('All countries')).toBeInTheDocument();
+ expect(screen.getByText('Preferred countries')).toBeInTheDocument();
+ });
+ it('should open the modal when the preferred countries field is clicked', async () => {
+ render( );
+ const element = screen.getByText('All countries');
+ await userEvent.click(element);
+ expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
+ });
+ it('should close the modal when the apply button is clicked', async () => {
+ render( );
+ const element = screen.getByText('All countries');
+ await userEvent.click(element);
+ const applyButton = screen.getByRole('button', { name: 'Apply' });
+ await userEvent.click(applyButton);
+ expect(mockFn).toHaveBeenCalledWith('preferred-countries', ['countryA', 'countryB']);
+ expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
+ });
+ it('should close the modal when the close icon is clicked', async () => {
+ render( );
+ const element = screen.getByText('All countries');
+ await userEvent.click(element);
+ const closeButton = screen.getByTestId('dt-close-icon');
+ await userEvent.click(closeButton);
+ expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
+ });
+ it('should display selected countries when not all countries are selected', () => {
+ mockGetValues.mockReturnValue(['countryA']);
+ render( );
+ expect(screen.getByText('countryA')).toBeInTheDocument();
+ expect(screen.queryByRole('All countries')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-ads/components/PreferredCountriesSelector/index.ts b/src/pages/my-ads/components/PreferredCountriesSelector/index.ts
new file mode 100644
index 00000000..ebfa764c
--- /dev/null
+++ b/src/pages/my-ads/components/PreferredCountriesSelector/index.ts
@@ -0,0 +1 @@
+export { default as PreferredCountriesSelector } from './PreferredCountriesSelector';
diff --git a/src/pages/my-ads/components/ProgressIndicator/ProgressIndicator.scss b/src/pages/my-ads/components/ProgressIndicator/ProgressIndicator.scss
new file mode 100644
index 00000000..22a0780d
--- /dev/null
+++ b/src/pages/my-ads/components/ProgressIndicator/ProgressIndicator.scss
@@ -0,0 +1,28 @@
+.p2p-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/src/pages/my-ads/components/ProgressIndicator/ProgressIndicator.tsx b/src/pages/my-ads/components/ProgressIndicator/ProgressIndicator.tsx
new file mode 100644
index 00000000..0bf6f832
--- /dev/null
+++ b/src/pages/my-ads/components/ProgressIndicator/ProgressIndicator.tsx
@@ -0,0 +1,26 @@
+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/src/pages/my-ads/components/ProgressIndicator/__tests__/ProgressIndicator.spec.tsx b/src/pages/my-ads/components/ProgressIndicator/__tests__/ProgressIndicator.spec.tsx
new file mode 100644
index 00000000..d41b6410
--- /dev/null
+++ b/src/pages/my-ads/components/ProgressIndicator/__tests__/ProgressIndicator.spec.tsx
@@ -0,0 +1,15 @@
+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_progress_indicator')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-ads/components/ProgressIndicator/index.ts b/src/pages/my-ads/components/ProgressIndicator/index.ts
new file mode 100644
index 00000000..ea074f1f
--- /dev/null
+++ b/src/pages/my-ads/components/ProgressIndicator/index.ts
@@ -0,0 +1 @@
+export { default as ProgressIndicator } from './ProgressIndicator';
diff --git a/src/pages/my-ads/components/SellAdPaymentSelection/SellAdPaymentSelection.scss b/src/pages/my-ads/components/SellAdPaymentSelection/SellAdPaymentSelection.scss
new file mode 100644
index 00000000..d03a9ed1
--- /dev/null
+++ b/src/pages/my-ads/components/SellAdPaymentSelection/SellAdPaymentSelection.scss
@@ -0,0 +1,30 @@
+.p2p-sell-ad-payment-selection {
+ &__card {
+ display: inline-flex;
+ flex-wrap: wrap;
+ @include mobile {
+ flex-wrap: nowrap;
+ width: calc(100vw - 3.2rem);
+ overflow-x: auto;
+ gap: 1rem;
+ }
+ }
+ &__button {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+ margin: 1.6rem 1.6rem 1.6rem 0;
+ padding: 1.6rem;
+ border: 1px dashed #d6dadb;
+ min-height: 12.8rem;
+
+ @include mobile {
+ margin-right: 0;
+ min-height: 8.8rem;
+ padding: 0.9rem 1rem;
+ min-width: 13.6rem;
+ }
+ }
+}
diff --git a/src/pages/my-ads/components/SellAdPaymentSelection/SellAdPaymentSelection.tsx b/src/pages/my-ads/components/SellAdPaymentSelection/SellAdPaymentSelection.tsx
new file mode 100644
index 00000000..901916e8
--- /dev/null
+++ b/src/pages/my-ads/components/SellAdPaymentSelection/SellAdPaymentSelection.tsx
@@ -0,0 +1,48 @@
+import { LabelPairedPlusLgBoldIcon } from '@deriv/quill-icons';
+import { Button, Text } from '@deriv-com/ui';
+
+import { PaymentMethodCard } from '@/components';
+import { api } from '@/hooks';
+import { useIsAdvertiser } from '@/hooks/custom-hooks';
+
+import './SellAdPaymentSelection.scss';
+
+type TSellAdPaymentSelectionProps = {
+ onSelectPaymentMethod: (paymentMethod: number) => void;
+ selectedPaymentMethodIds: number[];
+};
+const SellAdPaymentSelection = ({ onSelectPaymentMethod, selectedPaymentMethodIds }: TSellAdPaymentSelectionProps) => {
+ const isAdvertiser = useIsAdvertiser();
+ const { data: advertiserPaymentMethods } = api.advertiserPaymentMethods.useGet(isAdvertiser);
+
+ return (
+
+ {advertiserPaymentMethods?.map(paymentMethod => {
+ const isDisabled =
+ selectedPaymentMethodIds.length >= 3 &&
+ !selectedPaymentMethodIds.includes(Number(paymentMethod.id));
+ return (
+
= 3
+ key={paymentMethod.id}
+ medium
+ onSelectPaymentMethodCard={onSelectPaymentMethod}
+ paymentMethod={paymentMethod}
+ selectedPaymentMethodIds={selectedPaymentMethodIds}
+ />
+ );
+ })}
+
+ undefined} //TODO: show add payment method modal
+ >
+
+
+ Payment method
+
+
+ );
+};
+
+export default SellAdPaymentSelection;
diff --git a/src/pages/my-ads/components/SellAdPaymentSelection/__tests__/SellAdPaymentSelection.spec.tsx b/src/pages/my-ads/components/SellAdPaymentSelection/__tests__/SellAdPaymentSelection.spec.tsx
new file mode 100644
index 00000000..47916e6e
--- /dev/null
+++ b/src/pages/my-ads/components/SellAdPaymentSelection/__tests__/SellAdPaymentSelection.spec.tsx
@@ -0,0 +1,61 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import SellAdPaymentSelection from '../SellAdPaymentSelection';
+
+jest.mock('@deriv/api-v2', () => ({
+ p2p: {
+ advertiserPaymentMethods: {
+ useGet: () => ({
+ data: [
+ {
+ display_name: 'Bank Transfer',
+ fields: {
+ account: {
+ display_name: 'Account Number',
+ required: 1,
+ type: 'text',
+ value: 'Account Number',
+ },
+ bank_name: { display_name: 'Bank Transfer', required: 1, type: 'text', value: 'Bank Name' },
+ },
+ id: '1',
+ is_enabled: 0,
+ method: '',
+ type: 'bank',
+ used_by_adverts: null,
+ used_by_orders: null,
+ },
+ ],
+ }),
+ },
+ },
+}));
+
+jest.mock('@/hooks', () => ({
+ ...jest.requireActual('@/hooks'),
+ useIsAdvertiser: jest.fn(() => true),
+}));
+
+const mockProps = {
+ onSelectPaymentMethod: jest.fn(),
+ selectedPaymentMethodIds: [],
+};
+
+describe('SellAdPaymentSelection', () => {
+ it('should render the sell ad payment selection component', () => {
+ render( );
+ expect(screen.getByText('Payment method')).toBeInTheDocument();
+ });
+ it('should render the payment method cards if user already has payment methods', () => {
+ render( );
+ expect(screen.getByText('Bank Transfer')).toBeInTheDocument();
+ expect(screen.getByText('Account Number')).toBeInTheDocument();
+ expect(screen.getByText('Bank Name')).toBeInTheDocument();
+ });
+ it('should handle payment method selection', async () => {
+ render( );
+ await userEvent.click(screen.getByRole('checkbox'));
+ expect(mockProps.onSelectPaymentMethod).toHaveBeenCalledWith(1);
+ });
+});
diff --git a/src/pages/my-ads/components/SellAdPaymentSelection/index.ts b/src/pages/my-ads/components/SellAdPaymentSelection/index.ts
new file mode 100644
index 00000000..6b5321b3
--- /dev/null
+++ b/src/pages/my-ads/components/SellAdPaymentSelection/index.ts
@@ -0,0 +1 @@
+export { default as SellAdPaymentSelection } from './SellAdPaymentSelection';
diff --git a/src/pages/my-ads/components/index.ts b/src/pages/my-ads/components/index.ts
new file mode 100644
index 00000000..0b8ccb34
--- /dev/null
+++ b/src/pages/my-ads/components/index.ts
@@ -0,0 +1,8 @@
+export * from './AdProgressBar';
+export * from './AdRateError';
+export * from './AdStatus';
+export * from './AdType';
+export * from './AdTypeSection';
+export * from './AdWizard';
+export * from './AlertComponent';
+export * from './ProgressIndicator';
diff --git a/src/pages/my-ads/index.ts b/src/pages/my-ads/index.ts
new file mode 100644
index 00000000..c9de5c34
--- /dev/null
+++ b/src/pages/my-ads/index.ts
@@ -0,0 +1 @@
+export * from './screens';
diff --git a/src/pages/my-ads/screens/CreateEditAd/CreateEditAd.tsx b/src/pages/my-ads/screens/CreateEditAd/CreateEditAd.tsx
new file mode 100644
index 00000000..df40dc35
--- /dev/null
+++ b/src/pages/my-ads/screens/CreateEditAd/CreateEditAd.tsx
@@ -0,0 +1,233 @@
+import { useCallback, useEffect } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+import { useHistory } from 'react-router-dom';
+import { THooks } from 'types';
+
+import { useActiveAccount } from '@deriv/api-v2';
+import { Loader } from '@deriv-com/ui';
+
+import { AdCancelCreateEditModal, AdCreateEditErrorModal, AdCreateEditSuccessModal } from '@/components/Modals';
+import { MY_ADS_URL, RATE_TYPE } from '@/constants';
+import { api } from '@/hooks';
+import { useFloatingRate, useModalManager, useQueryString } from '@/hooks/custom-hooks';
+
+import { AdWizard } from '../../components';
+
+const getSteps = (isEdit = false) => {
+ const text = isEdit ? 'Edit' : 'Set';
+ const steps = [
+ { header: { title: `${text} ad type and amount` } },
+ { header: { title: `${text} payment details` } },
+ { header: { title: `${text} ad conditions` } },
+ ];
+ return steps;
+};
+type FormValues = {
+ 'ad-type': 'buy' | 'sell';
+ amount: string;
+ 'contact-details': string;
+ 'float-rate-offset-limit': string;
+ instructions: string;
+ 'max-order': string;
+ 'min-completion-rate': string;
+ 'min-join-days': string;
+ 'min-order': string;
+ 'order-completion-time': string;
+ 'payment-method': number[] | string[];
+ 'preferred-countries': string[];
+ 'rate-type-string': string;
+ 'rate-value': string;
+};
+
+const CreateEditAd = () => {
+ const { queryString } = useQueryString();
+ const { advertId = '' } = queryString;
+ const { data: advertInfo, isLoading } = api.advert.useGet(
+ { id: advertId },
+ { enabled: !!advertId, refetchOnWindowFocus: false }
+ );
+ const isEdit = !!advertId;
+ const { hideModal, isModalOpenFor, showModal } = useModalManager({ shouldReinitializeModals: false });
+ const { data: countryList = {} } = api.countryList.useGet();
+ const { data: paymentMethodList = [] } = api.paymentMethods.useGet();
+ const { floatRateOffsetLimitString, rateType } = useFloatingRate();
+ const { data: activeAccount } = useActiveAccount();
+ const { data: p2pSettings } = api.settings.useGetSettings();
+ const { order_payment_period: orderPaymentPeriod } = p2pSettings ?? {};
+ const { error, isError, isSuccess, mutate } = api.advert.useCreate();
+ const {
+ error: updateError,
+ isError: isUpdateError,
+ isSuccess: isUpdateSuccess,
+ mutate: updateMutate,
+ } = api.advert.useUpdate();
+ const history = useHistory();
+ const methods = useForm({
+ defaultValues: {
+ 'ad-type': 'buy',
+ amount: '',
+ 'contact-details': '',
+ 'float-rate-offset-limit': floatRateOffsetLimitString,
+ instructions: '',
+ 'max-order': '',
+ 'min-completion-rate': '',
+ 'min-join-days': '',
+ 'min-order': '',
+ 'order-completion-time': `${orderPaymentPeriod ? (orderPaymentPeriod * 60).toString() : '3600'}`,
+ 'payment-method': [],
+ 'preferred-countries': Object.keys(countryList),
+ 'rate-type-string': rateType,
+ 'rate-value': rateType === RATE_TYPE.FLOAT ? '-0.01' : '',
+ },
+ mode: 'onBlur',
+ });
+
+ const {
+ formState: { isDirty },
+ getValues,
+ handleSubmit,
+ setValue,
+ } = methods;
+ useEffect(() => {
+ if (Object.keys(countryList).length > 0 && getValues('preferred-countries').length === 0) {
+ setValue('preferred-countries', Object.keys(countryList));
+ }
+ }, [countryList, getValues, setValue]);
+
+ const shouldNotShowArchiveMessageAgain = localStorage.getItem('should_not_show_auto_archive_message_again');
+
+ const onSubmit = () => {
+ const payload = {
+ amount: Number(getValues('amount')),
+ eligible_countries: getValues('preferred-countries'),
+ max_order_amount: Number(getValues('max-order')),
+ min_order_amount: Number(getValues('min-order')),
+ rate: Number(getValues('rate-value')),
+ rate_type: rateType,
+ type: getValues('ad-type'),
+ };
+
+ if (getValues('ad-type') === 'buy') {
+ payload.payment_method_names = getValues('payment-method');
+ } else {
+ payload.contact_info = getValues('contact-details');
+ payload.payment_method_ids = getValues('payment-method');
+ }
+ if (getValues('instructions')) {
+ payload.description = getValues('instructions');
+ }
+ if (getValues('min-completion-rate')) {
+ payload.min_completion_rate = Number(getValues('min-completion-rate'));
+ }
+ if (getValues('min-join-days')) {
+ payload.min_join_days = Number(getValues('min-join-days'));
+ }
+
+ if (isEdit) {
+ delete payload.amount;
+ delete payload.type;
+ updateMutate({ id: advertId, ...payload });
+ return;
+ }
+ mutate(payload);
+ };
+
+ useEffect(() => {
+ if (isSuccess || isUpdateSuccess) {
+ // TODO: Show success modal and other 2 visibility modals after modal manager implementation or update ad impelementation
+ // Redirect to the ad list page
+ if (shouldNotShowArchiveMessageAgain !== 'true') {
+ showModal('AdCreateEditSuccessModal');
+ } else {
+ history.push(MY_ADS_URL);
+ }
+ } else if (isError || isUpdateError) {
+ showModal('AdCreateEditErrorModal');
+ }
+ }, [isSuccess, history, shouldNotShowArchiveMessageAgain, isError, isUpdateSuccess, isUpdateError]);
+
+ const setInitialAdRate = () => {
+ if (rateType !== advertInfo?.rate_type) {
+ if (rateType === RATE_TYPE.FLOAT) {
+ return advertInfo?.is_buy ? '-0.01' : '+0.01';
+ }
+ return '';
+ }
+ return advertInfo?.rate;
+ };
+
+ const setFormValues = useCallback(
+ (advertInfo: THooks.Advert.Get) => {
+ setValue('ad-type', advertInfo.type);
+ setValue('amount', advertInfo.amount);
+ setValue('instructions', advertInfo.description);
+ setValue('max-order', advertInfo.max_order_amount);
+ setValue('min-completion-rate', advertInfo.min_completion_rate);
+ setValue('min-join-days', advertInfo.min_join_days);
+ setValue('min-order', advertInfo.min_order_amount);
+ setValue('rate-value', setInitialAdRate() as string);
+ setValue('preferred-countries', advertInfo.eligible_countries ?? Object.keys(countryList));
+ setValue('order-completion-time', `${advertInfo.order_expiry_period}`);
+ if (advertInfo.type === 'sell') {
+ setValue('contact-details', advertInfo.contact_info);
+ setValue('payment-method', Object.keys(advertInfo.payment_method_details ?? {}).map(Number));
+ } else {
+ const paymentMethodNames = advertInfo?.payment_method_names;
+ const paymentMethodKeys = paymentMethodNames?.map(
+ name => paymentMethodList.find(method => method.display_name === name)?.id
+ );
+ setValue('payment-method', paymentMethodKeys);
+ }
+ },
+ [setValue, paymentMethodList, countryList]
+ );
+
+ useEffect(() => {
+ if (advertInfo && isEdit) {
+ setFormValues(advertInfo);
+ }
+ }, [advertInfo, isEdit, setFormValues]);
+
+ if ((isLoading && isEdit) || (isEdit && !advertInfo)) {
+ return ;
+ }
+
+ const onClickCancel = () => {
+ if (isDirty) showModal('AdCancelCreateEditModal');
+ else history.push(MY_ADS_URL);
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default CreateEditAd;
diff --git a/src/pages/my-ads/screens/CreateEditAd/__tests__/CreateEditAd.spec.tsx b/src/pages/my-ads/screens/CreateEditAd/__tests__/CreateEditAd.spec.tsx
new file mode 100644
index 00000000..150323ab
--- /dev/null
+++ b/src/pages/my-ads/screens/CreateEditAd/__tests__/CreateEditAd.spec.tsx
@@ -0,0 +1,204 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { MY_ADS_URL } from '@/constants';
+
+import CreateEditAd from '../CreateEditAd';
+
+import '../../../components/AdFormInput';
+
+const mockOnChange = jest.fn();
+jest.mock('react-hook-form', () => ({
+ ...jest.requireActual('react-hook-form'),
+ FormProvider: ({ children }) => {children}
,
+ Controller: ({ control, defaultValue, name, render }) =>
+ render({
+ field: { control, name, onBlur: jest.fn(), onChange: mockOnChange, value: defaultValue },
+ fieldState: { error: null },
+ }),
+ useFormContext: () => ({
+ control: 'mockedControl',
+ formState: { errors: {}, isValid: true },
+ getValues: () => ({
+ 'ad-type': 'buy',
+ amount: '100',
+ 'payment-method': [],
+ 'rate-value': '1.2',
+ }),
+ watch: jest.fn(),
+ }),
+}));
+
+const mockFn = jest.fn();
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useHistory: () => ({
+ push: mockFn,
+ }),
+}));
+
+jest.mock('../../../components/AdFormInput', () => ({
+ AdFormInput: () => AdFormInput
,
+}));
+
+jest.mock('../../../components/AdFormTextArea', () => ({
+ AdFormTextArea: () => AdFormTextArea
,
+}));
+jest.mock('@deriv/api-v2', () => ({
+ p2p: {
+ advert: {
+ useCreate: () => ({
+ error: undefined,
+ isError: false,
+ isSuccess: false,
+ mutate: jest.fn(),
+ }),
+ useGet: () => ({
+ data: {},
+ isLoading: false,
+ }),
+ useUpdate: () => ({
+ error: undefined,
+ isError: false,
+ isSuccess: false,
+ mutate: jest.fn(),
+ }),
+ },
+ paymentMethods: {
+ useGet: () => ({
+ data: [
+ {
+ display_name: 'Bank Transfer',
+ fields: {
+ account: {
+ display_name: 'Account Number',
+ required: 1,
+ type: 'text',
+ value: 'Account Number',
+ },
+ bank_name: { display_name: 'Bank Transfer', required: 1, type: 'text', value: 'Bank Name' },
+ },
+ id: 'test1',
+ is_enabled: 0,
+ method: '',
+ type: 'bank',
+ used_by_adverts: null,
+ used_by_orders: null,
+ },
+ {
+ display_name: 'Ali pay',
+ fields: {
+ account: {
+ display_name: 'Account Number',
+ required: 1,
+ type: 'text',
+ value: 'Account Number',
+ },
+ bank_name: { display_name: 'Ali pay', required: 1, type: 'text', value: 'Bank Name' },
+ },
+ id: 'test2',
+ is_enabled: 0,
+ method: '',
+ type: 'wallet',
+ used_by_adverts: null,
+ used_by_orders: null,
+ },
+ {
+ display_name: 'Skrill',
+ fields: {
+ account: {
+ display_name: 'Account Number',
+ required: 1,
+ type: 'text',
+ value: 'Account Number',
+ },
+ bank_name: { display_name: 'Skrill', required: 1, type: 'text', value: 'Bank Name' },
+ },
+ id: 'test3',
+ is_enabled: 0,
+ method: '',
+ type: 'wallet',
+ used_by_adverts: null,
+ used_by_orders: null,
+ },
+ ],
+ }),
+ },
+ countryList: {
+ useGet: () => ({
+ data: {
+ af: {
+ country_name: 'Afghanistan',
+ cross_border_ads_enabled: 1,
+ fixed_rate_adverts: 'enabled',
+ float_rate_adverts: 'disabled',
+ float_rate_offset_limit: 10,
+ local_currency: 'AFN',
+ payment_methods: {
+ alipay: {
+ display_name: 'Alipay',
+ fields: {
+ account: {
+ display_name: 'Alipay ID',
+ required: 1,
+ type: 'text',
+ },
+ instructions: {
+ display_name: 'Instructions',
+ required: 0,
+ type: 'memo',
+ },
+ },
+ type: 'ewallet',
+ },
+ },
+ },
+ },
+ }),
+ },
+ settings: {
+ useGetSettings: () => ({
+ data: {
+ order_payment_period: 60,
+ },
+ }),
+ },
+ },
+ useActiveAccount: () => ({ data: { currency: 'USD' } }),
+}));
+
+jest.mock('@/hooks', () => {
+ const modalManager = {
+ hideModal: jest.fn(),
+ isModalOpenFor: jest.fn(),
+ showModal: jest.fn(),
+ };
+ modalManager.showModal.mockImplementation(() => {
+ modalManager.isModalOpenFor.mockReturnValue(true);
+ });
+ return {
+ ...jest.requireActual('@/hooks'),
+ useFloatingRate: () => ({ rateType: 'floating' }),
+ useModalManager: jest.fn().mockReturnValue(modalManager),
+ useQueryString: jest.fn().mockReturnValue({ queryString: { advertId: '' } }),
+ };
+});
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: false }),
+}));
+
+describe('CreateEditAd', () => {
+ it('should render the create edit ad component', () => {
+ render( );
+ expect(screen.getByText('Set ad type and amount')).toBeInTheDocument();
+ });
+ it('should handle clicking on Cancel button', async () => {
+ render( );
+ const cancelButton = screen.getByRole('button', { name: 'Cancel' });
+ expect(cancelButton).toBeInTheDocument();
+ await userEvent.click(cancelButton);
+ expect(mockFn).toHaveBeenCalledWith(MY_ADS_URL);
+ });
+});
diff --git a/src/pages/my-ads/screens/CreateEditAd/index.ts b/src/pages/my-ads/screens/CreateEditAd/index.ts
new file mode 100644
index 00000000..dcfd08ea
--- /dev/null
+++ b/src/pages/my-ads/screens/CreateEditAd/index.ts
@@ -0,0 +1 @@
+export { default as CreateEditAd } from './CreateEditAd';
diff --git a/src/pages/my-ads/screens/MyAds/MyAds.tsx b/src/pages/my-ads/screens/MyAds/MyAds.tsx
new file mode 100644
index 00000000..390a1d5c
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAds/MyAds.tsx
@@ -0,0 +1,16 @@
+import clsx from 'clsx';
+
+import { useDevice } from '@deriv-com/ui';
+
+import { MyAdsTable } from './MyAdsTable';
+
+const MyAds = () => {
+ const { isMobile } = useDevice();
+
+ return (
+
+
+
+ );
+};
+export default MyAds;
diff --git a/src/pages/my-ads/screens/MyAds/MyAdsTable/MyAdsDisplayWrapper.tsx b/src/pages/my-ads/screens/MyAds/MyAdsTable/MyAdsDisplayWrapper.tsx
new file mode 100644
index 00000000..39d67fd2
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAds/MyAdsTable/MyAdsDisplayWrapper.tsx
@@ -0,0 +1,48 @@
+import React, { PropsWithChildren } from 'react';
+import { useHistory } from 'react-router-dom';
+import { FullPageMobileWrapper } from '@/components';
+import { MY_ADS_URL } from '@/constants';
+import { Button, useDevice } from '@deriv-com/ui';
+import { MyAdsToggle } from '../MyAdsToggle';
+
+type TMyAdsDisplayWrapperProps = {
+ isPaused: boolean;
+ onClickToggle: () => void;
+};
+
+const MyAdsDisplayWrapper = ({ children, isPaused, onClickToggle }: PropsWithChildren) => {
+ const { isMobile } = useDevice();
+ const history = useHistory();
+
+ const goToCreateAd = () => history.push(`${MY_ADS_URL}/adForm?formAction=create`);
+
+ if (isMobile) {
+ return (
+ (
+
+ Create new ad
+
+ )}
+ renderHeader={() => }
+ shouldShowBackIcon={false}
+ >
+ {children}
+
+ );
+ }
+
+ return (
+ <>
+
+
+ Create new ad
+
+
+
+ {children}
+ >
+ );
+};
+
+export default MyAdsDisplayWrapper;
diff --git a/src/pages/my-ads/screens/MyAds/MyAdsTable/MyAdsTable.scss b/src/pages/my-ads/screens/MyAds/MyAdsTable/MyAdsTable.scss
new file mode 100644
index 00000000..2668bbbf
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAds/MyAdsTable/MyAdsTable.scss
@@ -0,0 +1,21 @@
+.p2p-my-ads-table {
+ &__list {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ & .p2p-table {
+ &__header {
+ grid-template-columns: repeat(3, 1.6fr) 1.9fr 3fr 1.9fr;
+ }
+
+ &__content {
+ height: 100%;
+
+ @include mobile {
+ // stylelint-disable-next-line declaration-no-important
+ height: calc(100vh - 26rem) !important;
+ }
+ }
+ }
+ }
+}
diff --git a/src/pages/my-ads/screens/MyAds/MyAdsTable/MyAdsTable.tsx b/src/pages/my-ads/screens/MyAds/MyAdsTable/MyAdsTable.tsx
new file mode 100644
index 00000000..6b9ce2b6
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAds/MyAdsTable/MyAdsTable.tsx
@@ -0,0 +1,103 @@
+import { memo } from 'react';
+import { THooks } from 'types';
+
+import { Loader } from '@deriv-com/ui';
+
+import { Table } from '@/components';
+import { api } from '@/hooks';
+import { useIsAdvertiser } from '@/hooks/custom-hooks';
+
+import { MyAdsEmpty } from '../../MyAdsEmpty';
+import MyAdsTableRowView from '../MyAdsTableRow/MyAdsTableRowView';
+
+import MyAdsDisplayWrapper from './MyAdsDisplayWrapper';
+
+import './MyAdsTable.scss';
+
+export type TMyAdsTableRowRendererProps = Required[0] & {
+ balanceAvailable: number;
+ dailyBuyLimit: string;
+ dailySellLimit: string;
+ isBarred: boolean;
+ isListed: boolean;
+};
+
+const MyAdsTableRowRenderer = memo((values: TMyAdsTableRowRendererProps) => );
+MyAdsTableRowRenderer.displayName = 'MyAdsTableRowRenderer';
+
+const headerRenderer = (header: string) => {header} ;
+
+const columns = [
+ {
+ header: 'Ad ID',
+ },
+ {
+ header: 'Limits',
+ },
+ {
+ header: 'Rate (1 USD)',
+ },
+ {
+ header: 'Available amount',
+ },
+ {
+ header: 'Payment methods',
+ },
+ {
+ header: 'Status',
+ },
+];
+
+const MyAdsTable = () => {
+ const isAdvertiser = useIsAdvertiser();
+ const {
+ data = [],
+ isFetching,
+ isLoading,
+ loadMoreAdverts,
+ } = api.advertiserAdverts.useGet(undefined, {
+ enabled: isAdvertiser,
+ });
+ const { data: advertiserInfo } = api.advertiser.useGetInfo();
+ const {
+ balance_available: balanceAvailable,
+ blocked_until: blockedUntil,
+ daily_buy_limit: dailyBuyLimit,
+ daily_sell_limit: dailySellLimit,
+ is_listed_boolean: isListed,
+ } = advertiserInfo || {};
+ const { mutate: updateAds } = api.advertiser.useUpdate();
+
+ if (isLoading && isFetching) return ;
+
+ if (!data.length) return ;
+
+ const onClickToggle = () => updateAds({ is_listed: isListed ? 0 : 1 });
+
+ return (
+
+
+
(
+
+ )}
+ tableClassname=''
+ />
+
+
+ );
+};
+
+export default MyAdsTable;
diff --git a/src/pages/my-ads/screens/MyAds/MyAdsTable/__tests__/MyAdsDisplayWrapper.spec.tsx b/src/pages/my-ads/screens/MyAds/MyAdsTable/__tests__/MyAdsDisplayWrapper.spec.tsx
new file mode 100644
index 00000000..7586daa1
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAds/MyAdsTable/__tests__/MyAdsDisplayWrapper.spec.tsx
@@ -0,0 +1,40 @@
+import { useDevice } from '@deriv-com/ui';
+import { render, screen } from '@testing-library/react';
+
+import MyAdsDisplayWrapper from '../MyAdsDisplayWrapper';
+
+const mockProps = {
+ isPaused: false,
+ onClickToggle: jest.fn(),
+};
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({
+ isMobile: false,
+ }),
+}));
+
+const mockUseDevice = useDevice as jest.Mock;
+
+describe('MyAdsDisplayWrapper', () => {
+ it('should render the component as expected', () => {
+ render(
+
+ children
+
+ );
+ expect(screen.queryByTestId('dt_full_page_mobile_wrapper')).not.toBeInTheDocument();
+ });
+ it('should render the content inside full page mobile wrapper in mobile view', () => {
+ mockUseDevice.mockReturnValue({
+ isMobile: true,
+ });
+ render(
+
+ children
+
+ );
+ expect(screen.queryByTestId('dt_full_page_mobile_wrapper')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-ads/screens/MyAds/MyAdsTable/index.ts b/src/pages/my-ads/screens/MyAds/MyAdsTable/index.ts
new file mode 100644
index 00000000..a77ac553
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAds/MyAdsTable/index.ts
@@ -0,0 +1 @@
+export { default as MyAdsTable } from './MyAdsTable';
diff --git a/src/pages/my-ads/screens/MyAds/MyAdsTableRow/MyAdsTableRow.scss b/src/pages/my-ads/screens/MyAds/MyAdsTableRow/MyAdsTableRow.scss
new file mode 100644
index 00000000..1512d307
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAds/MyAdsTableRow/MyAdsTableRow.scss
@@ -0,0 +1,132 @@
+@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: #e6e9e9;
+ border-radius: 0.5rem;
+ }
+ }
+}
+
+.p2p-my-ads-table-row {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+
+ &__line {
+ border-bottom: 1px solid #f2f3f4;
+ padding: 1.6rem;
+ position: relative;
+ display: grid;
+ align-items: center;
+ grid-template-columns: repeat(3, 1.6fr) 1.9fr 3fr 1.7fr;
+
+ &-disabled {
+ span:not(.p2p-my-ads-table-row__actions span) {
+ &:not(.p2p-popover-dropdown__list-item span) {
+ color: #999;
+ }
+ }
+ .p2p-progress-indicator {
+ &__container {
+ background-color: #999;
+ }
+ &__bar {
+ background-color: #eaeced;
+ }
+ }
+ }
+
+ @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: #4bb4b3;
+ 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;
+ }
+ }
+ }
+
+ &__actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-right: 1rem;
+ &-popovers {
+ background-color: #fff;
+ display: flex;
+ 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: #e6e9e9;
+ }
+ &__size--md {
+ padding: 0.6rem 0.85rem;
+ }
+ }
+ & svg {
+ fill: #333333;
+ }
+
+ @include mobile {
+ display: flex;
+ justify-content: unset;
+ }
+
+ div {
+ margin: auto;
+ }
+ }
+ }
+}
diff --git a/src/pages/my-ads/screens/MyAds/MyAdsTableRow/MyAdsTableRow.tsx b/src/pages/my-ads/screens/MyAds/MyAdsTableRow/MyAdsTableRow.tsx
new file mode 100644
index 00000000..01340b93
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAds/MyAdsTableRow/MyAdsTableRow.tsx
@@ -0,0 +1,222 @@
+import React, { memo, useEffect, useState } from 'react';
+import clsx from 'clsx';
+import { PaymentMethodLabel, PopoverDropdown } from '@/components';
+import { AD_ACTION, ADVERT_TYPE, RATE_TYPE } from '@/constants';
+import { useFloatingRate } from '@/hooks/custom-hooks';
+import { generateEffectiveRate, shouldShowTooltipIcon } from '@/utils';
+import { useExchangeRateSubscription } from '@deriv/api-v2';
+import { Text, useDevice } from '@deriv-com/ui';
+import { FormatUtils } from '@deriv-com/utils';
+import { AdStatus, AdType, AlertComponent, ProgressIndicator } from '../../../components';
+import { TMyAdsTableRowRendererProps } from '../MyAdsTable/MyAdsTable';
+import './MyAdsTableRow.scss';
+
+const BASE_CURRENCY = 'USD';
+
+const getList = (isActive = false) => [
+ { label: 'Edit', value: 'edit' },
+ { label: 'Copy', value: 'copy' },
+ { label: 'Share', value: 'share' },
+ { label: `${isActive ? 'Deactivate' : 'Activate'}`, value: `${isActive ? 'deactivate' : 'activate'}` },
+ { label: 'Delete', value: 'delete' },
+];
+
+type TProps = {
+ currentRateType: ReturnType['rateType'];
+ onClickIcon: (value: string) => void;
+ showModal: (value: string) => void;
+};
+
+type TMyAdsTableProps = Omit &
+ TProps;
+
+const MyAdsTableRow = ({ currentRateType, showModal, ...rest }: TMyAdsTableProps) => {
+ const { isMobile } = useDevice();
+ const { data: exchangeRateValue, subscribe } = useExchangeRateSubscription();
+
+ const {
+ account_currency: accountCurrency,
+ amount,
+ amount_display: amountDisplay,
+ effective_rate: effectiveRate,
+ id,
+ is_active: isActive,
+ isBarred,
+ isListed,
+ local_currency: localCurrency,
+ max_order_amount_display: maxOrderAmountDisplay,
+ min_order_amount_display: minOrderAmountDisplay,
+ onClickIcon,
+ payment_method_names: paymentMethodNames,
+ price_display: priceDisplay,
+ rate_display: rateDisplay,
+ rate_type: rateType,
+ remaining_amount: remainingAmount,
+ remaining_amount_display: remainingAmountDisplay,
+ type,
+ visibility_status: visibilityStatus = [],
+ } = rest;
+
+ const isFloatingRate = rateType === RATE_TYPE.FLOAT;
+
+ useEffect(() => {
+ if (localCurrency) {
+ subscribe({
+ base_currency: BASE_CURRENCY,
+ target_currency: localCurrency,
+ });
+ }
+ }, [localCurrency, subscribe]);
+
+ const [showAlertIcon, setShowAlertIcon] = useState(false);
+ const isAdvertListed = isListed && !isBarred;
+ const adPauseColor = isAdvertListed ? 'general' : 'less-prominent';
+ const amountDealt = amount - remainingAmount;
+
+ const isRowDisabled = !isActive || isBarred || !isListed;
+ const isAdActive = !!isActive && !isBarred;
+
+ const exchangeRate = exchangeRateValue?.rates?.[localCurrency ?? ''];
+ const enableActionPoint = currentRateType !== rateType;
+
+ useEffect(() => {
+ setShowAlertIcon(enableActionPoint || shouldShowTooltipIcon(visibilityStatus) || !isListed);
+ }, [enableActionPoint, isListed, shouldShowTooltipIcon]);
+
+ const { displayEffectiveRate } = generateEffectiveRate({
+ exchangeRate,
+ localCurrency,
+ marketRate: Number(effectiveRate),
+ price: Number(priceDisplay),
+ rate: Number(rateDisplay),
+ rateType,
+ });
+
+ const advertType = type === 'buy' ? ADVERT_TYPE.BUY : ADVERT_TYPE.SELL;
+
+ const handleClick = (action: string) => {
+ if (action === AD_ACTION.EDIT || action === AD_ACTION.COPY) {
+ if (enableActionPoint && rateType !== currentRateType) {
+ showModal('AdRateSwitchModal');
+ return;
+ }
+ }
+ onClickIcon(action);
+ };
+
+ if (isMobile) {
+ return (
+
+
+ {`Ad ID ${id} `}
+
+
+
+ {advertType} {accountCurrency}
+
+
+
+ {showAlertIcon &&
showModal('AdErrorTooltipModal')} />}
+
+
+
+
+
+ {`${FormatUtils.formatMoney(amountDealt, { currency: accountCurrency })}`} {accountCurrency}
+
+ {advertType === 'Buy' ? 'Bought' : 'Sold'}
+
+
+ {amountDisplay} {accountCurrency}
+
+
+
+
+
+ Limits
+
+
+ {`Rate (1 ${accountCurrency})`}
+
+
+
+
+ {minOrderAmountDisplay} - {maxOrderAmountDisplay} {accountCurrency}
+
+
+
+ {displayEffectiveRate} {localCurrency}
+ {isFloatingRate &&
}
+
+
+
+
+ {paymentMethodNames?.map(paymentMethod => (
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
+ {advertType} {id}
+
+
+ {minOrderAmountDisplay} - {maxOrderAmountDisplay} {accountCurrency}
+
+
+ {displayEffectiveRate} {localCurrency}
+ {isFloatingRate && }
+
+
+
+ {remainingAmountDisplay}/{amountDisplay} {accountCurrency}
+
+
+ {paymentMethodNames?.map(paymentMethod => (
+
+ ))}
+
+
+
+
+ {showAlertIcon &&
showModal('AdErrorTooltipModal')} />}
+
+
+ );
+};
+
+export default memo(MyAdsTableRow);
diff --git a/src/pages/my-ads/screens/MyAds/MyAdsTableRow/MyAdsTableRowView.tsx b/src/pages/my-ads/screens/MyAds/MyAdsTableRow/MyAdsTableRowView.tsx
new file mode 100644
index 00000000..57a28672
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAds/MyAdsTableRow/MyAdsTableRowView.tsx
@@ -0,0 +1,126 @@
+import { memo, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+
+import {
+ AdErrorTooltipModal,
+ AdRateSwitchModal,
+ ErrorModal,
+ MyAdsDeleteModal,
+ ShareAdsModal,
+} from '@/components/Modals';
+import { AD_ACTION, MY_ADS_URL } from '@/constants';
+import { api } from '@/hooks';
+import { useFloatingRate, useModalManager } from '@/hooks/custom-hooks';
+import { getVisibilityErrorCodes } from '@/utils';
+
+import { TMyAdsTableRowRendererProps } from '../MyAdsTable/MyAdsTable';
+
+import MyAdsTableRow from './MyAdsTableRow';
+
+const MyAdsTableRowView = ({
+ balanceAvailable,
+ dailyBuyLimit,
+ dailySellLimit,
+ isListed,
+ ...rest
+}: TMyAdsTableRowRendererProps) => {
+ const { hideModal, isModalOpenFor, showModal } = useModalManager({ shouldReinitializeModals: false });
+ const { rateType: currentRateType, reachedTargetDate } = useFloatingRate();
+ const { error: updateError, isError: isErrorUpdate, mutate } = api.advert.useUpdate();
+ const { error, isError, mutate: deleteAd } = api.advert.useDelete();
+ const history = useHistory();
+
+ const {
+ account_currency: accountCurrency,
+ id = '',
+ rate_type: rateType,
+ remaining_amount: remainingAmount,
+ type,
+ visibility_status: visibilityStatus = [],
+ } = rest;
+
+ useEffect(() => {
+ if (isError && error?.error?.message) {
+ showModal('MyAdsDeleteModal');
+ }
+ }, [error?.error?.message, isError]);
+
+ useEffect(() => {
+ if (isErrorUpdate && updateError?.error?.message) {
+ showModal('ErrorModal');
+ }
+ }, [updateError?.error?.message]);
+
+ const onClickIcon = (action: string) => {
+ switch (action) {
+ case AD_ACTION.ACTIVATE:
+ mutate({ id, is_active: 1 });
+ break;
+ case AD_ACTION.DEACTIVATE:
+ mutate({ id, is_active: 0 });
+ break;
+ case AD_ACTION.DELETE: {
+ showModal('MyAdsDeleteModal');
+ break;
+ }
+ case AD_ACTION.SHARE: {
+ showModal('ShareAdsModal');
+ break;
+ }
+ case AD_ACTION.EDIT: {
+ history.push(`${MY_ADS_URL}/adForm?formAction=edit&advertId=${id}`);
+ break;
+ }
+ default:
+ break;
+ }
+ };
+ const onClickDelete = () => {
+ deleteAd({ id });
+ hideModal();
+ };
+ return (
+ <>
+
+
+
+
+ onClickIcon(AD_ACTION.EDIT)}
+ onRequestClose={hideModal}
+ rateType={currentRateType}
+ reachedEndDate={reachedTargetDate}
+ />
+
+ >
+ );
+};
+
+export default memo(MyAdsTableRowView);
diff --git a/src/pages/my-ads/screens/MyAds/MyAdsTableRow/__tests__/MyAdsTableRow.spec.tsx b/src/pages/my-ads/screens/MyAds/MyAdsTableRow/__tests__/MyAdsTableRow.spec.tsx
new file mode 100644
index 00000000..13176dd6
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAds/MyAdsTableRow/__tests__/MyAdsTableRow.spec.tsx
@@ -0,0 +1,146 @@
+import { useExchangeRateSubscription } from '@deriv/api-v2';
+import { useDevice } from '@deriv-com/ui';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import MyAdsTableRow from '../MyAdsTableRow';
+
+const mockProps = {
+ account_currency: 'USD',
+ active_orders: 0,
+ advertiser_details: {
+ completed_orders_count: 0,
+ has_not_been_recommended: false,
+ id: '34',
+ is_blocked: false,
+ is_favourite: false,
+ is_online: true,
+ is_recommended: false,
+ last_online_time: 1688480346,
+ name: 'client CR90000212',
+ rating_average: null,
+ rating_count: 0,
+ recommended_average: null,
+ recommended_count: null,
+ total_completion_rate: null,
+ },
+ amount: 22,
+ amount_display: '22.00',
+ block_trade: false,
+ contact_info: '',
+ counterparty_type: 'sell' as const,
+ country: 'id',
+ created_time: new Date(1688460999),
+ currentRateType: 'fixed' as const,
+ days_until_archive: 1,
+ description: '',
+ effective_rate: 22,
+ effective_rate_display: '22.00',
+ eligible_countries: ['ID'],
+ id: '138',
+ is_active: true,
+ is_visible: true,
+ isBarred: false,
+ isListed: 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_completion_rate: 22,
+ min_join_days: 4,
+ min_order_amount: 22,
+ min_order_amount_display: '22.00',
+ min_order_amount_limit: 22,
+ min_order_amount_limit_display: '22.00',
+ min_rating: 4,
+ onClickIcon: jest.fn(),
+ order_expiry_period: 900,
+ 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',
+ showModal: jest.fn(),
+ type: 'buy' as const,
+ visibility_status: [],
+};
+
+jest.mock('@deriv/api-v2', () => ({
+ useExchangeRateSubscription: jest.fn(),
+}));
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({ 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', async () => {
+ render( );
+ const button = screen.getByTestId('dt_popover_dropdown_icon');
+ await userEvent.click(button);
+ await waitFor(() => {
+ expect(screen.getByText('Edit')).toBeInTheDocument();
+ expect(screen.getByText('Deactivate')).toBeInTheDocument();
+ expect(screen.getByText('Delete')).toBeInTheDocument();
+ expect(screen.getByText('Copy')).toBeInTheDocument();
+ });
+ });
+ it('should handle onClick for edit item', async () => {
+ render( );
+ const button = screen.getByTestId('dt_popover_dropdown_icon');
+ await userEvent.click(button);
+ await waitFor(async () => {
+ const edit = screen.getByText('Edit');
+ expect(edit).toBeInTheDocument();
+ await userEvent.click(edit);
+ });
+ await waitFor(() => {
+ expect(mockProps.showModal).toHaveBeenCalledWith('AdRateSwitchModal');
+ });
+ });
+ it('should handle onclick for edit when rate type is same as current rate type', async () => {
+ render( );
+ const button = screen.getByTestId('dt_popover_dropdown_icon');
+ await userEvent.click(button);
+ await waitFor(async () => {
+ const edit = screen.getByText('Edit');
+ expect(edit).toBeInTheDocument();
+ await userEvent.click(edit);
+ });
+ await waitFor(() => {
+ expect(mockProps.onClickIcon).toHaveBeenCalledWith('edit');
+ });
+ });
+});
diff --git a/src/pages/my-ads/screens/MyAds/MyAdsTableRow/__tests__/MyAdsTableRowView.spec.tsx b/src/pages/my-ads/screens/MyAds/MyAdsTableRow/__tests__/MyAdsTableRowView.spec.tsx
new file mode 100644
index 00000000..e9bdd088
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAds/MyAdsTableRow/__tests__/MyAdsTableRowView.spec.tsx
@@ -0,0 +1,152 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { MY_ADS_URL } from '@/constants';
+import { api } from '@/hooks';
+import { useModalManager } from '@/hooks/custom-hooks';
+
+import MyAdsTableRowView from '../MyAdsTableRowView';
+
+const mockProps = {
+ account_currency: 'USD',
+ active_orders: 0,
+ advertiser_details: {
+ completed_orders_count: 0,
+ has_not_been_recommended: false,
+ id: '34',
+ is_blocked: false,
+ is_favourite: false,
+ is_online: true,
+ is_recommended: false,
+ last_online_time: 1688480346,
+ name: 'client CR90000212',
+ rating_average: null,
+ rating_count: 0,
+ recommended_average: null,
+ recommended_count: null,
+ total_completion_rate: null,
+ },
+ amount: 22,
+ amount_display: '22.00',
+ block_trade: false,
+ contact_info: '',
+ counterparty_type: 'sell' as const,
+ country: 'id',
+ created_time: new Date(1688460999),
+ currentRateType: 'fixed' as const,
+ days_until_archive: 1,
+ description: '',
+ effective_rate: 22,
+ effective_rate_display: '22.00',
+ eligible_countries: ['ID'],
+ id: '138',
+ is_active: true,
+ is_visible: true,
+ isBarred: false,
+ isListed: 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_completion_rate: 22,
+ min_join_days: 4,
+ min_order_amount: 22,
+ min_order_amount_display: '22.00',
+ min_order_amount_limit: 22,
+ min_order_amount_limit_display: '22.00',
+ min_rating: 4,
+ order_expiry_period: 900,
+ payment_info: '',
+};
+const mockHistory = {
+ push: jest.fn(),
+};
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useHistory: () => mockHistory,
+}));
+
+jest.mock('@/hooks', () => {
+ const modalManager = {
+ hideModal: jest.fn(),
+ isModalOpenFor: jest.fn(),
+ showModal: jest.fn(),
+ };
+ modalManager.showModal.mockImplementation(() => {
+ modalManager.isModalOpenFor.mockReturnValue(true);
+ });
+ return {
+ ...jest.requireActual('@/hooks'),
+ useFloatingRate: () => ({
+ rateType: 'fixed',
+ reachedTargetDate: false,
+ }),
+ useModalManager: jest.fn().mockReturnValue(modalManager),
+ };
+});
+
+jest.mock('@deriv/api-v2', () => ({
+ p2p: {
+ advert: {
+ useDelete: jest.fn().mockReturnValue({ error: null, isError: false, mutate: jest.fn() }),
+ useUpdate: jest.fn().mockReturnValue({ error: null, isError: false, mutate: jest.fn() }),
+ },
+ },
+}));
+
+jest.mock('@/components/Modals', () => ({
+ AdErrorTooltipModal: () => AdErrorTooltipModal
,
+ AdRateSwitchModal: () => AdRateSwitchModal
,
+ ErrorModal: () => ErrorModal
,
+ MyAdsDeleteModal: () => MyAdsDeleteModal
,
+ ShareAdsModal: () => ShareAdsModal
,
+}));
+
+jest.mock('../MyAdsTableRow', () => {
+ return jest.fn().mockImplementation(({ onClickIcon }) => {
+ return (
+
+ onClickIcon('share')}>Share Icon
+ onClickIcon('edit')}>Edit Icon
+ onClickIcon('delete')}>Delete Icon
+ onClickIcon('activate')}>Activate Icon
+ onClickIcon('deactivate')}>Deactivate Icon
+
+ );
+ });
+});
+
+describe('MyAdsTableRowView', () => {
+ it('should handle share ads', async () => {
+ render( );
+ const button = screen.getByRole('button', { name: 'Share Icon' });
+ await userEvent.click(button);
+ expect(useModalManager().showModal).toHaveBeenCalledWith('ShareAdsModal');
+ });
+ it('should handle edit ads', async () => {
+ render( );
+ const button = screen.getByRole('button', { name: 'Edit Icon' });
+ await userEvent.click(button);
+ expect(mockHistory.push).toHaveBeenCalledWith(`${MY_ADS_URL}/adForm?formAction=edit&advertId=138`);
+ });
+ it('should handle delete ads', async () => {
+ render( );
+ const button = screen.getByRole('button', { name: 'Delete Icon' });
+ await userEvent.click(button);
+ expect(useModalManager().showModal).toHaveBeenCalledWith('MyAdsDeleteModal');
+ });
+ it('should handle activate ads', async () => {
+ render( );
+ const button = screen.getByRole('button', { name: 'Activate Icon' });
+ await userEvent.click(button);
+ expect(api.advert.useUpdate().mutate).toHaveBeenCalledWith({ id: '138', is_active: 1 });
+ });
+ it('should handle deactive ads', async () => {
+ render( );
+ const button = screen.getByRole('button', { name: 'Deactivate Icon' });
+ await userEvent.click(button);
+ expect(api.advert.useUpdate().mutate).toHaveBeenCalledWith({ id: '138', is_active: 0 });
+ });
+});
diff --git a/src/pages/my-ads/screens/MyAds/MyAdsTableRow/index.ts b/src/pages/my-ads/screens/MyAds/MyAdsTableRow/index.ts
new file mode 100644
index 00000000..35accfc0
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAds/MyAdsTableRow/index.ts
@@ -0,0 +1 @@
+export { default as MyAdsTableRow } from './MyAdsTableRow';
diff --git a/src/pages/my-ads/screens/MyAds/MyAdsToggle/MyAdsToggle.tsx b/src/pages/my-ads/screens/MyAds/MyAdsToggle/MyAdsToggle.tsx
new file mode 100644
index 00000000..46586f69
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAds/MyAdsToggle/MyAdsToggle.tsx
@@ -0,0 +1,22 @@
+import clsx from 'clsx';
+
+import { Text, ToggleSwitch } from '@deriv-com/ui';
+
+import { useDevice } from '@/hooks/custom-hooks';
+
+type TMyAdsToggleProps = {
+ isPaused: boolean;
+ onClickToggle: () => void;
+};
+const MyAdsToggle = ({ isPaused, onClickToggle }: TMyAdsToggleProps) => {
+ const { isMobile } = useDevice();
+ return (
+
+
+ Hide my ads
+
+
+
+ );
+};
+export default MyAdsToggle;
diff --git a/src/pages/my-ads/screens/MyAds/MyAdsToggle/__tests__/MyAdsToggle.spec.tsx b/src/pages/my-ads/screens/MyAds/MyAdsToggle/__tests__/MyAdsToggle.spec.tsx
new file mode 100644
index 00000000..6525f72e
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAds/MyAdsToggle/__tests__/MyAdsToggle.spec.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import MyAdsToggle from '../MyAdsToggle';
+
+const mockProps = {
+ isPaused: false,
+ onClickToggle: jest.fn(),
+};
+
+describe('MyAdsToggle', () => {
+ it('should render the MyAdsToggle component', () => {
+ render( );
+ expect(screen.getByText('Hide my ads')).toBeInTheDocument();
+ const input: HTMLInputElement = screen.getByRole('checkbox');
+ expect(input).not.toBeChecked();
+ });
+ it('should be on when isPaused is true', () => {
+ const newProps = { ...mockProps, isPaused: true };
+ render( );
+ const input: HTMLInputElement = screen.getByRole('checkbox');
+ expect(input).toBeChecked();
+ });
+ it('should handle onclick toggle', async () => {
+ render( );
+ const input: HTMLInputElement = screen.getByRole('checkbox');
+ await userEvent.click(input);
+ expect(mockProps.onClickToggle).toHaveBeenCalled();
+ });
+});
diff --git a/src/pages/my-ads/screens/MyAds/MyAdsToggle/index.ts b/src/pages/my-ads/screens/MyAds/MyAdsToggle/index.ts
new file mode 100644
index 00000000..4975ee1f
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAds/MyAdsToggle/index.ts
@@ -0,0 +1 @@
+export { default as MyAdsToggle } from './MyAdsToggle';
diff --git a/src/pages/my-ads/screens/MyAds/__tests__/MyAds.spec.tsx b/src/pages/my-ads/screens/MyAds/__tests__/MyAds.spec.tsx
new file mode 100644
index 00000000..131f7fa1
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAds/__tests__/MyAds.spec.tsx
@@ -0,0 +1,21 @@
+import { render, screen } from '@testing-library/react';
+
+import MyAds from '../MyAds';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({
+ isMobile: false,
+ }),
+}));
+
+jest.mock('../MyAdsTable', () => ({
+ MyAdsTable: () => MyAdsTable
,
+}));
+
+describe('MyAds', () => {
+ it('should render the MyAdsTable component', () => {
+ render( );
+ expect(screen.getByText('MyAdsTable')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-ads/screens/MyAds/index.ts b/src/pages/my-ads/screens/MyAds/index.ts
new file mode 100644
index 00000000..c8c92e26
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAds/index.ts
@@ -0,0 +1 @@
+export { default as MyAds } from './MyAds';
diff --git a/src/pages/my-ads/screens/MyAdsEmpty/MyAdsEmpty.tsx b/src/pages/my-ads/screens/MyAdsEmpty/MyAdsEmpty.tsx
new file mode 100644
index 00000000..459e1a9f
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAdsEmpty/MyAdsEmpty.tsx
@@ -0,0 +1,40 @@
+import { useHistory } from 'react-router-dom';
+
+import { DerivLightIcCashierNoAdsIcon } from '@deriv/quill-icons';
+import { ActionScreen, Button, Text, useDevice } from '@deriv-com/ui';
+
+import { MY_ADS_URL } from '@/constants';
+
+const MyAdsEmpty = () => {
+ const { isMobile } = useDevice();
+ const history = useHistory();
+ const textSize = isMobile ? 'lg' : 'md';
+ return (
+
+
history.push(`${MY_ADS_URL}/adForm?formAction=create`)}
+ size='lg'
+ textSize={isMobile ? 'md' : 'sm'}
+ >
+ Create new ad
+
+ }
+ description={
+
+ Looking to buy or sell USD? You can post your own ad for others to respond.
+
+ }
+ icon={ }
+ title={
+
+ You have no ads 😞
+
+ }
+ />
+
+ );
+};
+
+export default MyAdsEmpty;
diff --git a/src/pages/my-ads/screens/MyAdsEmpty/__tests__/MyAdsEmpty.spec.tsx b/src/pages/my-ads/screens/MyAdsEmpty/__tests__/MyAdsEmpty.spec.tsx
new file mode 100644
index 00000000..ceffdd50
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAdsEmpty/__tests__/MyAdsEmpty.spec.tsx
@@ -0,0 +1,35 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { MY_ADS_URL } from '@/constants';
+
+import MyAdsEmpty from '../MyAdsEmpty';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({
+ isMobile: false,
+ }),
+}));
+
+const mockFn = jest.fn();
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useHistory: () => ({
+ push: mockFn,
+ }),
+}));
+
+describe('MyAdsEmpty', () => {
+ it('should render the empty ads section as expected', () => {
+ render( );
+ expect(screen.getByText('You have no ads 😞')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Create new ad' })).toBeInTheDocument();
+ });
+ it('should handle onClick for create new ad button', async () => {
+ render( );
+ const createNewAdButton = screen.getByRole('button', { name: 'Create new ad' });
+ await userEvent.click(createNewAdButton);
+ expect(mockFn).toHaveBeenCalledWith(`${MY_ADS_URL}/adForm?formAction=create`);
+ });
+});
diff --git a/src/pages/my-ads/screens/MyAdsEmpty/index.ts b/src/pages/my-ads/screens/MyAdsEmpty/index.ts
new file mode 100644
index 00000000..cb8fa867
--- /dev/null
+++ b/src/pages/my-ads/screens/MyAdsEmpty/index.ts
@@ -0,0 +1 @@
+export { default as MyAdsEmpty } from './MyAdsEmpty';
diff --git a/src/pages/my-ads/screens/index.ts b/src/pages/my-ads/screens/index.ts
new file mode 100644
index 00000000..30998300
--- /dev/null
+++ b/src/pages/my-ads/screens/index.ts
@@ -0,0 +1,2 @@
+export * from './CreateEditAd';
+export * from './MyAds';
diff --git a/src/pages/my-profile/index.ts b/src/pages/my-profile/index.ts
new file mode 100644
index 00000000..c9de5c34
--- /dev/null
+++ b/src/pages/my-profile/index.ts
@@ -0,0 +1 @@
+export * from './screens';
diff --git a/src/pages/my-profile/screens/MyProfile/MyProfile.scss b/src/pages/my-profile/screens/MyProfile/MyProfile.scss
new file mode 100644
index 00000000..866c4be6
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfile/MyProfile.scss
@@ -0,0 +1,26 @@
+.p2p-my-profile {
+ display: flex;
+ flex-direction: column;
+ padding-top: 2.4rem;
+
+ @include mobile {
+ overflow-y: scroll;
+ height: calc(100vh - 12rem);
+ padding: 0;
+ }
+
+ &__tabs {
+ margin: 2.4rem 0;
+ border-radius: 0.6rem;
+ padding: 0.5rem;
+ width: 74rem;
+
+ &-tab {
+ border-radius: 0.4rem;
+
+ & > span {
+ font-size: 1.4rem;
+ }
+ }
+ }
+}
diff --git a/src/pages/my-profile/screens/MyProfile/MyProfile.tsx b/src/pages/my-profile/screens/MyProfile/MyProfile.tsx
new file mode 100644
index 00000000..0c68773a
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfile/MyProfile.tsx
@@ -0,0 +1,84 @@
+import React, { useEffect, useState } from 'react';
+
+import { Loader, Tab, Tabs, useDevice } from '@deriv-com/ui';
+
+import { ProfileContent, Verification } from '@/components';
+import { NicknameModal } from '@/components/Modals';
+import { useAdvertiserStats, useIsAdvertiser, usePoiPoaStatus, useQueryString } from '@/hooks/custom-hooks';
+
+import { MyProfileAdDetails } from '../MyProfileAdDetails';
+import { MyProfileCounterparties } from '../MyProfileCounterparties';
+import { MyProfileStats } from '../MyProfileStats';
+import { PaymentMethods } from '../PaymentMethods';
+
+import MyProfileMobile from './MyProfileMobile';
+
+import './MyProfile.scss';
+
+const TABS = ['Stats', 'Payment methods', 'Ad details', 'My counterparties'];
+
+const MyProfile = () => {
+ const { isMobile } = useDevice();
+ const { queryString, setQueryString } = useQueryString();
+ const { data } = usePoiPoaStatus();
+ const { data: advertiserStats, isLoading } = useAdvertiserStats();
+ const { isP2PPoaRequired, isPoaVerified, isPoiVerified } = data || {};
+ const isAdvertiser = useIsAdvertiser();
+ const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false);
+
+ const currentTab = queryString.tab;
+
+ const tabs = [
+ { component: , title: 'Stats' },
+ { component: , title: 'Payment methods' },
+ { component: , title: 'Ad details' },
+ { component: , title: 'My counterparties' },
+ ];
+
+ useEffect(() => {
+ const isPoaPoiVerified = (!isP2PPoaRequired || isPoaVerified) && isPoiVerified;
+ if (isPoaPoiVerified && !isAdvertiser) setIsNicknameModalOpen(true);
+ }, [isAdvertiser, isP2PPoaRequired, isPoaVerified, isPoiVerified]);
+
+ if (isLoading && !advertiserStats) {
+ return ;
+ }
+
+ if (!isPoiVerified || !isPoaVerified) {
+ return ;
+ }
+
+ if (isMobile) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
{
+ setQueryString({
+ tab: TABS[index],
+ });
+ }}
+ variant='primary'
+ >
+ {tabs.map(tab => (
+
+ {tab.component}
+
+ ))}
+
+
+
+ );
+};
+
+export default MyProfile;
diff --git a/src/pages/my-profile/screens/MyProfile/MyProfileMobile.tsx b/src/pages/my-profile/screens/MyProfile/MyProfileMobile.tsx
new file mode 100644
index 00000000..dc13f8c7
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfile/MyProfileMobile.tsx
@@ -0,0 +1,41 @@
+import { MobileTabs, ProfileContent } from '@/components';
+import { useQueryString } from '@/hooks/custom-hooks';
+
+import { MyProfileAdDetails } from '../MyProfileAdDetails';
+import { MyProfileCounterparties } from '../MyProfileCounterparties';
+import MyProfileStatsMobile from '../MyProfileStats/MyProfileStatsMobile';
+import { PaymentMethods } from '../PaymentMethods';
+
+const MyProfileMobile = () => {
+ const { queryString, setQueryString } = useQueryString();
+ const currentTab = queryString.tab;
+
+ if (currentTab === 'Stats') {
+ return ;
+ }
+ if (currentTab === 'Ad details') {
+ return ;
+ }
+ if (currentTab === 'My counterparties') {
+ return ;
+ }
+ if (currentTab === 'Payment methods') {
+ return ;
+ }
+
+ return (
+ <>
+
+
+ setQueryString({
+ tab: clickedTab,
+ })
+ }
+ tabs={['Stats', 'Payment methods', 'Ad details', 'My counterparties']}
+ />
+ >
+ );
+};
+
+export default MyProfileMobile;
diff --git a/src/pages/my-profile/screens/MyProfile/__tests__/MyProfile.spec.tsx b/src/pages/my-profile/screens/MyProfile/__tests__/MyProfile.spec.tsx
new file mode 100644
index 00000000..7df73283
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfile/__tests__/MyProfile.spec.tsx
@@ -0,0 +1,155 @@
+import { APIProvider, AuthProvider } from '@deriv/api-v2';
+import { useDevice } from '@deriv-com/ui';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { useAdvertiserStats, useIsAdvertiser, usePoiPoaStatus } from '@/hooks/custom-hooks';
+
+import MyProfile from '../MyProfile';
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+
+ {children}
+
+);
+
+jest.mock('use-query-params', () => ({
+ ...jest.requireActual('use-query-params'),
+ useQueryParams: jest.fn().mockReturnValue([{}, jest.fn()]),
+}));
+
+jest.mock('@/components', () => ({
+ ...jest.requireActual('@/components'),
+ ProfileContent: jest.fn(() => ProfileContentScreen
),
+ Verification: jest.fn(() => Verification
),
+}));
+
+jest.mock('use-query-params', () => ({
+ ...jest.requireActual('use-query-params'),
+ useQueryParams: jest.fn().mockReturnValue([{}, jest.fn()]),
+}));
+
+jest.mock('@/components/Modals/NicknameModal', () => ({
+ NicknameModal: jest.fn(({ isModalOpen }) => {
+ if (isModalOpen) return NicknameModal
;
+ return <>>;
+ }),
+}));
+
+jest.mock('../../MyProfileStats', () => ({
+ MyProfileStats: jest.fn(() => MyProfileStatsScreen
),
+}));
+jest.mock('../../MyProfileAdDetails', () => ({
+ MyProfileAdDetails: jest.fn(() => MyProfileAdDetailsScreen
),
+}));
+jest.mock('../../MyProfileCounterparties', () => ({
+ MyProfileCounterparties: jest.fn(() => MyProfileCounterpartiesScreen
),
+}));
+jest.mock('../../PaymentMethods', () => ({
+ PaymentMethods: jest.fn(() => PaymentMethodsScreen
),
+}));
+jest.mock('../MyProfileMobile', () => ({
+ __esModule: true,
+ default: jest.fn(() => MyProfileMobile
),
+}));
+
+const mockUseDevice = useDevice as jest.MockedFunction;
+const mockUsePoiPoaStatus = usePoiPoaStatus as jest.MockedFunction;
+const mockUseAdvertiserStats = useAdvertiserStats as jest.MockedFunction;
+const mockUseIsAdvertiser = useIsAdvertiser as jest.MockedFunction;
+jest.mock('@/hooks', () => ({
+ useAdvertiserStats: jest.fn().mockReturnValue({
+ data: {
+ fullName: 'Jane Done',
+ },
+ error: undefined,
+ isLoading: false,
+ }),
+ useIsAdvertiser: jest.fn().mockReturnValue(true),
+ usePoiPoaStatus: jest.fn().mockReturnValue({
+ data: {
+ isP2PPoaRequired: false,
+ isPoaVerified: true,
+ isPoiVerified: true,
+ },
+ isLoading: false,
+ }),
+ useQueryString: jest.fn(() => ({
+ queryString: {},
+ setQueryString: jest.fn(),
+ })),
+}));
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn(() => mockUseDevice),
+}));
+
+describe('MyProfile', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+ it('should render the loader component when data is not ready', () => {
+ (mockUseAdvertiserStats as jest.Mock).mockReturnValueOnce({
+ isLoading: true,
+ });
+ render( , { wrapper });
+ expect(screen.getByTestId('dt_derivs-loader')).toBeInTheDocument();
+ });
+ it('should render the verification component if user has not completed POI ', () => {
+ (mockUsePoiPoaStatus as jest.Mock).mockReturnValueOnce({
+ data: { isPoaVerified: true, isPoiVerified: false },
+ isLoading: false,
+ });
+
+ render( , { wrapper });
+ expect(screen.getByText('Verification')).toBeInTheDocument();
+ });
+ it('should render the verification component if user has not completed POA', () => {
+ (mockUsePoiPoaStatus as jest.Mock).mockReturnValueOnce({
+ data: { isPoaVerified: false, isPoiVerified: true },
+ isLoading: false,
+ });
+
+ render( , { wrapper });
+ expect(screen.getByText('Verification')).toBeInTheDocument();
+ });
+ it('should show the nickname modal if user has completed POI or POA for the first time', () => {
+ (mockUsePoiPoaStatus as jest.Mock).mockReturnValueOnce({
+ data: { isPoaVerified: true, isPoiVerified: true },
+ isLoading: false,
+ });
+
+ (mockUseIsAdvertiser as jest.Mock).mockReturnValueOnce(false);
+
+ (mockUseAdvertiserStats as jest.Mock).mockReturnValueOnce({
+ data: {
+ fullName: 'Jane Doe',
+ },
+ error: 'Failure',
+ isLoading: false,
+ });
+
+ render( , { wrapper });
+ expect(screen.getByText('NicknameModal')).toBeInTheDocument();
+ });
+ it('should render the tabs and correct screens', async () => {
+ render( , { wrapper });
+ expect(screen.getByText('MyProfileStatsScreen')).toBeInTheDocument();
+
+ const paymentMethodsBtn = screen.getByRole('button', {
+ name: 'Payment methods',
+ });
+ await userEvent.click(paymentMethodsBtn);
+ expect(screen.getByText('PaymentMethodsScreen')).toBeInTheDocument();
+ });
+ it('should render the mobile view', () => {
+ (mockUseDevice as jest.Mock).mockReturnValueOnce({
+ isMobile: true,
+ });
+
+ render( , { wrapper });
+ expect(screen.getByText('MyProfileMobile')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-profile/screens/MyProfile/__tests__/MyProfileMobile.spec.tsx b/src/pages/my-profile/screens/MyProfile/__tests__/MyProfileMobile.spec.tsx
new file mode 100644
index 00000000..7bb8e785
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfile/__tests__/MyProfileMobile.spec.tsx
@@ -0,0 +1,78 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import MyProfileMobile from '../MyProfileMobile';
+
+jest.mock('@/components/ProfileContent', () => ({
+ ProfileContent: jest.fn(() => ProfileContent
),
+}));
+jest.mock('../../MyProfileStats/MyProfileStatsMobile', () => ({
+ __esModule: true,
+ default: jest.fn(() => MyProfileStatsMobile
),
+}));
+jest.mock('../../MyProfileAdDetails', () => ({
+ MyProfileAdDetails: jest.fn(() => MyProfileAdDetailsScreen
),
+}));
+jest.mock('../../MyProfileCounterparties', () => ({
+ MyProfileCounterparties: jest.fn(() => MyProfileCounterpartiesScreen
),
+}));
+jest.mock('../../PaymentMethods', () => ({
+ PaymentMethods: jest.fn(() => PaymentMethodsScreen
),
+}));
+
+function resetMockedData() {
+ mockQueryString = {
+ tab: 'default',
+ };
+}
+
+let mockQueryString = {
+ tab: 'default',
+};
+
+const mockSetQueryString = jest.fn();
+jest.mock('@/hooks', () => ({
+ useQueryString: jest.fn(() => ({
+ queryString: mockQueryString,
+ setQueryString: mockSetQueryString,
+ })),
+}));
+
+describe('MyProfileMobile', () => {
+ afterEach(() => {
+ resetMockedData();
+ });
+ it('should render the default tab', () => {
+ render( );
+ expect(screen.getByText('ProfileContent')).toBeInTheDocument();
+ });
+ it('should render the appropriate screens', () => {
+ render( );
+
+ const clickTabAndRender = async (tab: string) => {
+ const btn = screen.getByRole('button', {
+ name: tab,
+ });
+ await userEvent.click(btn);
+ expect(mockSetQueryString).toBeCalledWith({
+ tab,
+ });
+ mockQueryString = {
+ tab,
+ };
+ render( );
+ };
+
+ clickTabAndRender('Stats');
+ expect(screen.getByText('MyProfileStatsMobile')).toBeInTheDocument();
+
+ clickTabAndRender('Payment methods');
+ expect(screen.getByText('PaymentMethodsScreen')).toBeInTheDocument();
+
+ clickTabAndRender('Ad details');
+ expect(screen.getByText('MyProfileAdDetailsScreen')).toBeInTheDocument();
+
+ clickTabAndRender('My counterparties');
+ expect(screen.getByText('MyProfileCounterpartiesScreen')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-profile/screens/MyProfile/index.ts b/src/pages/my-profile/screens/MyProfile/index.ts
new file mode 100644
index 00000000..d27e8b13
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfile/index.ts
@@ -0,0 +1 @@
+export { default as MyProfile } from './MyProfile';
diff --git a/src/pages/my-profile/screens/MyProfileAdDetails/MyProfileAdDetails.scss b/src/pages/my-profile/screens/MyProfileAdDetails/MyProfileAdDetails.scss
new file mode 100644
index 00000000..f3a03214
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileAdDetails/MyProfileAdDetails.scss
@@ -0,0 +1,25 @@
+.p2p-my-profile-ad-details {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 2.4rem;
+
+ &__header {
+ font-size: 1.6rem;
+ font-weight: 800;
+ }
+
+ @include mobile {
+ padding: 2rem;
+ }
+
+ &__border {
+ border-top: 2px solid #f2f3f4;
+ }
+
+ &__mobile-wrapper {
+ position: absolute;
+ top: 4rem;
+ height: calc(100vh - 8rem);
+ }
+}
diff --git a/src/pages/my-profile/screens/MyProfileAdDetails/MyProfileAdDetails.tsx b/src/pages/my-profile/screens/MyProfileAdDetails/MyProfileAdDetails.tsx
new file mode 100644
index 00000000..3a7ba0e5
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileAdDetails/MyProfileAdDetails.tsx
@@ -0,0 +1,112 @@
+import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react';
+import { THooks } from 'types';
+
+import { Button, Loader } from '@deriv-com/ui';
+
+import { FullPageMobileWrapper, TextArea } from '@/components';
+import { api } from '@/hooks';
+import { useDevice, useQueryString } from '@/hooks/custom-hooks';
+
+import './MyProfileAdDetails.scss';
+
+type TMYProfileAdDetailsTextAreaProps = {
+ advertiserInfo: THooks.Advertiser.GetInfo;
+ setAdvertDescription: Dispatch>;
+ setContactInfo: Dispatch>;
+};
+
+const MyProfileAdDetailsTextArea = ({
+ advertiserInfo,
+ setAdvertDescription,
+ setContactInfo,
+}: TMYProfileAdDetailsTextAreaProps) => {
+ return (
+ <>
+ setContactInfo(e.target.value)}
+ placeholder='My contact details'
+ testId='dt_profile_ad_details_contact'
+ value={advertiserInfo?.contact_info || ''}
+ />
+ setAdvertDescription(e.target.value)}
+ placeholder='Instructions'
+ testId='dt_profile_ad_details_description'
+ value={advertiserInfo?.default_advert_description || ''}
+ />
+ >
+ );
+};
+
+const MyProfileAdDetails = () => {
+ const { data: advertiserInfo, isLoading } = api.advertiser.useGetInfo();
+ const { mutate: updateAdvertiser } = api.advertiser.useUpdate();
+ const [contactInfo, setContactInfo] = useState('');
+ const [advertDescription, setAdvertDescription] = useState('');
+ const { isMobile } = useDevice();
+ const { setQueryString } = useQueryString();
+
+ const hasUpdated = useMemo(() => {
+ return (
+ contactInfo !== advertiserInfo?.contact_info ||
+ advertDescription !== advertiserInfo?.default_advert_description
+ );
+ }, [advertiserInfo?.contact_info, advertiserInfo?.default_advert_description, contactInfo, advertDescription]);
+
+ useEffect(() => {
+ setContactInfo(advertiserInfo?.contact_info || '');
+ setAdvertDescription(advertiserInfo?.default_advert_description || '');
+ }, [advertiserInfo]);
+
+ const submitAdDetails = () => {
+ updateAdvertiser({
+ contact_info: contactInfo,
+ default_advert_description: advertDescription,
+ });
+ };
+
+ if (isLoading) return ;
+
+ if (isMobile) {
+ return (
+
+ setQueryString({
+ tab: 'default',
+ })
+ }
+ renderFooter={() => (
+
+ Save
+
+ )}
+ renderHeader={() => Ad Details }
+ >
+
+
+
+
+ );
+ }
+ return (
+
+ );
+};
+
+export default MyProfileAdDetails;
diff --git a/src/pages/my-profile/screens/MyProfileAdDetails/__tests__/MyProfileAdDetails.spec.tsx b/src/pages/my-profile/screens/MyProfileAdDetails/__tests__/MyProfileAdDetails.spec.tsx
new file mode 100644
index 00000000..5288e486
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileAdDetails/__tests__/MyProfileAdDetails.spec.tsx
@@ -0,0 +1,133 @@
+import { APIProvider, AuthProvider } from '@deriv/api-v2';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import MyProfileAdDetails from '../MyProfileAdDetails';
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+
+
+ {children}
+
+
+);
+
+const mockUseAdvertiserInfo = {
+ data: {},
+ isLoading: true,
+};
+
+const mockMutateAdvertiser = jest.fn();
+const mockUseUpdateAdvertiser = {
+ data: {
+ contact_info: '',
+ default_advert_description: '',
+ },
+ isLoading: false,
+ mutate: mockMutateAdvertiser,
+};
+const mockUseDevice = {
+ isMobile: false,
+};
+const mockSetQueryString = jest.fn();
+
+jest.mock('@/hooks/useDevice', () => ({
+ __esModule: true,
+ default: jest.fn(() => mockUseDevice),
+}));
+jest.mock('@/hooks/useQueryString', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ setQueryString: mockSetQueryString,
+ })),
+}));
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ p2p: {
+ advertiser: {
+ useGetInfo: jest.fn(() => mockUseAdvertiserInfo),
+ useUpdate: jest.fn(() => mockUseUpdateAdvertiser),
+ },
+ },
+}));
+
+describe('MyProfileBalance', () => {
+ it('should render the loader when initial data is fetching', () => {
+ render( , { wrapper });
+ expect(screen.getByTestId('dt_derivs-loader')).toBeInTheDocument();
+ mockUseAdvertiserInfo.isLoading = false;
+ });
+ it('should render initial default details when user has not updated their details yet', () => {
+ mockUseAdvertiserInfo.data = {};
+ render( , { wrapper });
+ const contactTextAreaNode = screen.getByTestId('dt_profile_ad_details_contact');
+ expect(within(contactTextAreaNode).getByRole('textbox')).toHaveValue('');
+ const descriptionTextAreaNode = screen.getByTestId('dt_profile_ad_details_description');
+ expect(within(descriptionTextAreaNode).getByRole('textbox')).toHaveValue('');
+ });
+ it('should render initial empty details when user has not updated their details yet', () => {
+ mockUseAdvertiserInfo.data = {
+ contact_info: '',
+ default_advert_description: '',
+ };
+ render( , { wrapper });
+ const contactTextAreaNode = screen.getByTestId('dt_profile_ad_details_contact');
+ expect(within(contactTextAreaNode).getByRole('textbox')).toHaveValue('');
+ const descriptionTextAreaNode = screen.getByTestId('dt_profile_ad_details_description');
+ expect(within(descriptionTextAreaNode).getByRole('textbox')).toHaveValue('');
+ });
+ it('should render initial details when user has previously updated their details', () => {
+ mockUseAdvertiserInfo.data = {
+ contact_info: '0123456789',
+ default_advert_description: 'mock description',
+ };
+ render( , { wrapper });
+ const contactTextAreaNode = screen.getByTestId('dt_profile_ad_details_contact');
+ expect(within(contactTextAreaNode).getByRole('textbox')).toHaveValue('0123456789');
+ const descriptionTextAreaNode = screen.getByTestId('dt_profile_ad_details_description');
+ expect(within(descriptionTextAreaNode).getByRole('textbox')).toHaveValue('mock description');
+ });
+ it('should render and post updated details when user updates their details', async () => {
+ mockUseAdvertiserInfo.data = {
+ contact_info: '0123456789',
+ default_advert_description: 'mock description',
+ };
+ render( , { wrapper });
+ const contactTextBoxNode = within(screen.getByTestId('dt_profile_ad_details_contact')).getByRole('textbox');
+ const descriptionTextBoxNode = within(screen.getByTestId('dt_profile_ad_details_description')).getByRole(
+ 'textbox'
+ );
+ const submitBtn = screen.getByRole('button', {
+ name: 'Save',
+ });
+ // tests by default if not edited, button should be disabled
+ expect(submitBtn).toBeDisabled();
+ await userEvent.type(contactTextBoxNode, '0');
+ await userEvent.type(descriptionTextBoxNode, ' here');
+ expect(submitBtn).toBeEnabled();
+
+ await userEvent.click(submitBtn);
+ expect(mockMutateAdvertiser).toBeCalledWith({
+ contact_info: '01234567890',
+ default_advert_description: 'mock description here',
+ });
+ });
+ it('should render mobile screen', async () => {
+ mockUseDevice.isMobile = true;
+ render( , { wrapper });
+ const contactTextBoxNode = within(screen.getByTestId('dt_profile_ad_details_contact')).getByRole('textbox');
+ const descriptionTextBoxNode = within(screen.getByTestId('dt_profile_ad_details_description')).getByRole(
+ 'textbox'
+ );
+ expect(contactTextBoxNode).toBeInTheDocument();
+ expect(descriptionTextBoxNode).toBeInTheDocument();
+
+ const goBackBtn = screen.getByTestId('dt_mobile_wrapper_button');
+ await userEvent.click(goBackBtn);
+ expect(mockSetQueryString).toBeCalledWith({
+ tab: 'default',
+ });
+ });
+});
diff --git a/src/pages/my-profile/screens/MyProfileAdDetails/index.ts b/src/pages/my-profile/screens/MyProfileAdDetails/index.ts
new file mode 100644
index 00000000..864b80ac
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileAdDetails/index.ts
@@ -0,0 +1 @@
+export { default as MyProfileAdDetails } from './MyProfileAdDetails';
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterparties.scss b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterparties.scss
new file mode 100644
index 00000000..c6c746ac
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterparties.scss
@@ -0,0 +1,28 @@
+.p2p-my-profile-counterparties {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+
+ @include mobile {
+ padding: 1.6rem 0;
+ }
+
+ &__content {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ height: 100%;
+ position: relative;
+ flex-direction: column;
+
+ &-header {
+ @include mobile {
+ padding: 0 1.6rem;
+ }
+ }
+ }
+
+ &__header {
+ align-self: center;
+ }
+}
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterparties.tsx b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterparties.tsx
new file mode 100644
index 00000000..e22d6c7d
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterparties.tsx
@@ -0,0 +1,86 @@
+import React, { PropsWithChildren, useState } from 'react';
+
+import { Text } from '@deriv-com/ui';
+
+import { FullPageMobileWrapper } from '@/components';
+import { RadioGroupFilterModal } from '@/components/Modals';
+import { COUNTERPARTIES_DROPDOWN_LIST } from '@/constants';
+import { useDevice, useQueryString } from '@/hooks/custom-hooks';
+
+import { MyProfileCounterpartiesHeader } from './MyProfileCounterpartiesHeader';
+import { MyProfileCounterpartiesTable } from './MyProfileCounterpartiesTable';
+
+import './MyProfileCounterparties.scss';
+
+const MyProfileCounterpartiesDisplayWrapper = ({ children }: PropsWithChildren) => {
+ const { setQueryString } = useQueryString();
+ const { isMobile } = useDevice();
+
+ if (isMobile) {
+ return (
+
+ setQueryString({
+ tab: 'default',
+ })
+ }
+ renderHeader={() => (
+
+ My counterparties
+
+ )}
+ >
+ {children}
+
+ );
+ }
+ return children;
+};
+
+const MyProfileCounterparties = () => {
+ const [searchValue, setSearchValue] = useState('');
+ const [dropdownValue, setDropdownValue] = useState('all');
+ const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
+ const [showHeader, setShowHeader] = useState(false);
+
+ const onClickFilter = () => {
+ setIsFilterModalOpen(true);
+ };
+
+ const onToggle = (value: string) => {
+ setDropdownValue(value);
+ setIsFilterModalOpen(false);
+ };
+
+ return (
+
+
+ {showHeader && (
+
+ )}
+
+
+
+
setIsFilterModalOpen(false)}
+ onToggle={onToggle}
+ selected={dropdownValue}
+ />
+
+
+ );
+};
+
+export default MyProfileCounterparties;
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesEmpty/MyProfileCounterpartiesEmpty.scss b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesEmpty/MyProfileCounterpartiesEmpty.scss
new file mode 100644
index 00000000..1ad39260
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesEmpty/MyProfileCounterpartiesEmpty.scss
@@ -0,0 +1,6 @@
+.p2p-my-profile-counterparties-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+}
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesEmpty/MyProfileCounterpartiesEmpty.tsx b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesEmpty/MyProfileCounterpartiesEmpty.tsx
new file mode 100644
index 00000000..c7c3eaf1
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesEmpty/MyProfileCounterpartiesEmpty.tsx
@@ -0,0 +1,12 @@
+import { DerivLightIcEmptyBlockedAdvertisersIcon } from '@deriv/quill-icons';
+import { Text } from '@deriv-com/ui';
+
+import './MyProfileCounterpartiesEmpty.scss';
+
+const MyProfileCounterpartiesEmpty = () => (
+
+
+ No one to show here
+
+);
+export default MyProfileCounterpartiesEmpty;
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesEmpty/__tests__/MyProfileCounterpartiesEmpty.spec.tsx b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesEmpty/__tests__/MyProfileCounterpartiesEmpty.spec.tsx
new file mode 100644
index 00000000..ac5aaf9d
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesEmpty/__tests__/MyProfileCounterpartiesEmpty.spec.tsx
@@ -0,0 +1,10 @@
+import { render, screen } from '@testing-library/react';
+
+import MyProfileCounterpartiesEmpty from '../MyProfileCounterpartiesEmpty';
+
+describe('MyProfileCounterpartiesEmpty', () => {
+ it('should render the component as expected', () => {
+ render( );
+ expect(screen.getByText('No one to show here')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesEmpty/index.ts b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesEmpty/index.ts
new file mode 100644
index 00000000..db0d389e
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesEmpty/index.ts
@@ -0,0 +1 @@
+export { default as MyProfileCounterpartiesEmpty } from './MyProfileCounterpartiesEmpty';
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesHeader/MyProfileCounterpartiesHeader.scss b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesHeader/MyProfileCounterpartiesHeader.scss
new file mode 100644
index 00000000..b5384b54
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesHeader/MyProfileCounterpartiesHeader.scss
@@ -0,0 +1,30 @@
+.p2p-my-profile-counterparties-header {
+ display: grid;
+ gap: 1.6rem;
+ margin-top: 2.4rem;
+ grid-template-columns: repeat(3, 1fr);
+
+ @include mobile {
+ display: flex;
+ margin: 2.4rem 0;
+
+ .p2p-search {
+ max-width: 28rem;
+
+ @include mobile {
+ max-width: 100%;
+ }
+ }
+ }
+
+ &__sort-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ border: 1px solid #d6dadb;
+ border-radius: 4px;
+ padding: 0 1rem;
+ height: 4rem;
+ }
+}
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesHeader/MyProfileCounterpartiesHeader.tsx b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesHeader/MyProfileCounterpartiesHeader.tsx
new file mode 100644
index 00000000..35312207
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesHeader/MyProfileCounterpartiesHeader.tsx
@@ -0,0 +1,57 @@
+import { Button, Text } from '@deriv-com/ui';
+
+import { Dropdown, Search } from '@/components';
+import { COUNTERPARTIES_DROPDOWN_LIST } from '@/constants';
+import { useDevice } from '@/hooks/custom-hooks';
+
+import SortIcon from '../../../../../public/ic-cashier-sort.svg';
+
+import './MyProfileCounterpartiesHeader.scss';
+
+type MyProfileCounterpartiesHeaderProps = {
+ dropdownValue: string;
+ onClickFilter: () => void;
+ setDropdownValue: (value: string) => void;
+ setSearchValue: (value: string) => void;
+};
+
+const MyProfileCounterpartiesHeader = ({
+ dropdownValue,
+ onClickFilter,
+ setDropdownValue,
+ setSearchValue,
+}: MyProfileCounterpartiesHeaderProps) => {
+ const { isMobile } = useDevice();
+ return (
+
+
+ When you block someone, you won’t see their ads, and they can’t see yours. Your ads will be hidden from
+ their search results, too.
+
+
+ {/* TODO: to be replaced by deriv-com/ui search component */}
+
+ {/* TODO: to be replaced by deriv-com/ui dropdown component */}
+ {isMobile ? (
+ }
+ onClick={onClickFilter}
+ variant='outlined'
+ />
+ ) : (
+
+ )}
+
+
+ );
+};
+export default MyProfileCounterpartiesHeader;
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesHeader/__tests__/MyProfileCounterpartiesHeader.spec.tsx b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesHeader/__tests__/MyProfileCounterpartiesHeader.spec.tsx
new file mode 100644
index 00000000..4272f5e2
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesHeader/__tests__/MyProfileCounterpartiesHeader.spec.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import MyProfileCounterpartiesHeader from '../MyProfileCounterpartiesHeader';
+
+const mockProps = {
+ dropdownValue: 'all',
+ onClickFilter: jest.fn(),
+ setDropdownValue: jest.fn(),
+ setSearchValue: jest.fn(),
+};
+
+describe('MyProfileCounterpartiesHeader', () => {
+ it('should render the component as expected', () => {
+ render( );
+ expect(
+ screen.getByText(
+ 'When you block someone, you won’t see their ads, and they can’t see yours. Your ads will be hidden from their search results, too.'
+ )
+ ).toBeInTheDocument();
+ expect(screen.getByText('Filter by')).toBeInTheDocument();
+ const dropdownField = screen.getByRole('combobox');
+ expect(dropdownField).toHaveValue('All');
+ });
+ it('should handle onclick of dropdown', async () => {
+ render( );
+ const dropdownField = screen.getByRole('combobox');
+ await userEvent.click(dropdownField);
+ expect(screen.getByText('All')).toBeInTheDocument();
+ expect(screen.getByText('Blocked')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesHeader/index.ts b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesHeader/index.ts
new file mode 100644
index 00000000..85813046
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesHeader/index.ts
@@ -0,0 +1 @@
+export { default as MyProfileCounterpartiesHeader } from './MyProfileCounterpartiesHeader';
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.scss b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.scss
new file mode 100644
index 00000000..dfe75668
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.scss
@@ -0,0 +1,19 @@
+.p2p-my-profile-counterparties-table {
+ & .deriv-table__content {
+ max-height: 24.8rem;
+
+ @include mobile {
+ height: calc(100vh - 30rem);
+ max-height: 100%;
+ }
+ }
+
+ &__row {
+ &:last-child {
+ position: relative;
+ }
+ &:not(:last-child) {
+ border-bottom: 1px solid var(--general-section-1);
+ }
+ }
+}
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.tsx b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.tsx
new file mode 100644
index 00000000..ec9e1f0d
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.tsx
@@ -0,0 +1,79 @@
+import { useEffect } from 'react';
+
+import { Loader, Table, Text } from '@deriv-com/ui';
+
+import { api } from '@/hooks';
+
+import { MyProfileCounterpartiesEmpty } from '../MyProfileCounterpartiesEmpty';
+import { MyProfileCounterpartiesTableRow } from '../MyProfileCounterpartiesTableRow';
+
+import './MyProfileCounterpartiesTable.scss';
+
+type TMyProfileCounterpartiesTableProps = {
+ dropdownValue: string;
+ searchValue: string;
+ setShowHeader: (show: boolean) => void;
+};
+
+type TMyProfileCounterpartiesTableRowRendererProps = {
+ id?: string;
+ is_blocked: boolean;
+ name?: string;
+};
+
+const MyProfileCounterpartiesTableRowRenderer = ({
+ id,
+ is_blocked: isBlocked,
+ name,
+}: TMyProfileCounterpartiesTableRowRendererProps) => (
+
+);
+
+//TODO: rewrite the implementation in accordance with @deriv-com/ui table component
+const MyProfileCounterpartiesTable = ({
+ dropdownValue,
+ searchValue,
+ setShowHeader,
+}: TMyProfileCounterpartiesTableProps) => {
+ const {
+ data = [],
+ isFetching,
+ isLoading,
+ loadMoreAdvertisers,
+ } = api.advertiser.useGetList({
+ advertiser_name: searchValue,
+ is_blocked: dropdownValue === 'blocked' ? 1 : 0,
+ trade_partners: 1,
+ });
+
+ useEffect(() => {
+ if (data.length > 0) {
+ setShowHeader(true);
+ }
+ }, [data, setShowHeader]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!isFetching && data.length === 0) {
+ if (searchValue === '') return ;
+ return There are no matching name ;
+ }
+
+ return (
+ (
+
+ )}
+ tableClassname='p2p-my-profile-counterparties-table'
+ />
+ );
+};
+
+export default MyProfileCounterpartiesTable;
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/__tests__/MyProfileCounterpartiesTable.spec.tsx b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/__tests__/MyProfileCounterpartiesTable.spec.tsx
new file mode 100644
index 00000000..0a3ed4bd
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/__tests__/MyProfileCounterpartiesTable.spec.tsx
@@ -0,0 +1,69 @@
+import { render, screen, waitFor } from '@testing-library/react';
+
+import { api } from '@/hooks';
+
+import MyProfileCounterpartiesTable from '../MyProfileCounterpartiesTable';
+
+const mockProps = {
+ dropdownValue: 'all',
+ setShowHeader: jest.fn(),
+ searchValue: '',
+};
+
+const mockApiValues = {
+ data: [],
+ isFetching: false,
+ isLoading: false,
+ loadMoreAdvertisers: jest.fn(),
+};
+
+jest.mock('@deriv/api-v2', () => ({
+ p2p: {
+ advertiser: {
+ useGetList: jest.fn(() => mockApiValues),
+ },
+ },
+}));
+
+jest.mock('@/components/Modals/BlockUnblockUserModal', () => ({
+ BlockUnblockUserModal: () => BlockUnblockUserModal
,
+}));
+
+const mockUseGetList = api.advertiser.useGetList as jest.Mock;
+describe('MyProfileCounterpartiesTable', () => {
+ it('should render the empty results when there is no data', () => {
+ render( );
+ expect(screen.getByText('No one to show here')).toBeInTheDocument();
+ });
+ it('should render Loader when isLoading is true', () => {
+ mockUseGetList.mockReturnValue({
+ ...mockApiValues,
+ isLoading: true,
+ });
+ render( );
+ expect(screen.getByTestId('dt_derivs-loader')).toBeInTheDocument();
+ });
+ it('should show header when data is present', async () => {
+ mockUseGetList.mockReturnValue({
+ ...mockApiValues,
+ data: [{ id: 'id1', name: 'name1', is_blocked: false }],
+ });
+ render( );
+
+ await waitFor(() => {
+ expect(mockProps.setShowHeader).toHaveBeenCalledWith(true);
+ });
+ });
+ it('should show the corresponding message when search value is provided and no matching name is found', () => {
+ mockUseGetList.mockReturnValue({
+ ...mockApiValues,
+ data: [],
+ });
+ const newProps = {
+ ...mockProps,
+ searchValue: 'test',
+ };
+ render( );
+ expect(screen.getByText('There are no matching name')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/index.ts b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/index.ts
new file mode 100644
index 00000000..1154aff5
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/index.ts
@@ -0,0 +1 @@
+export { default as MyProfileCounterpartiesTable } from './MyProfileCounterpartiesTable';
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/MyProfileCounterpartiesTableRow.scss b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/MyProfileCounterpartiesTableRow.scss
new file mode 100644
index 00000000..e4111ad5
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/MyProfileCounterpartiesTableRow.scss
@@ -0,0 +1,16 @@
+.p2p-my-profile-counterparties-table-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ &:hover {
+ background: var(--general-hover);
+ }
+
+ &__nickname-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 0.8rem;
+ cursor: pointer;
+ }
+}
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/MyProfileCounterpartiesTableRow.tsx b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/MyProfileCounterpartiesTableRow.tsx
new file mode 100644
index 00000000..9e1ecb22
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/MyProfileCounterpartiesTableRow.tsx
@@ -0,0 +1,56 @@
+import React, { memo, useState } from 'react';
+import { useHistory } from 'react-router-dom';
+
+import { Button, Text } from '@deriv-com/ui';
+
+import { UserAvatar } from '@/components';
+import { BlockUnblockUserModal } from '@/components/Modals';
+import { ADVERTISER_URL } from '@/constants';
+import { useDevice } from '@/hooks/custom-hooks';
+
+import './MyProfileCounterpartiesTableRow.scss';
+
+type TMyProfileCounterpartiesTableRowProps = {
+ id: string;
+ isBlocked: boolean;
+ nickname: string;
+};
+
+const MyProfileCounterpartiesTableRow = ({ id, isBlocked, nickname }: TMyProfileCounterpartiesTableRowProps) => {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const { isMobile } = useDevice();
+ const history = useHistory();
+
+ return (
+ <>
+
+
history.push(`${ADVERTISER_URL}/${id}`, { from: 'MyProfile' })}
+ >
+
+ {nickname}
+
+ {/* TODO: variant to be replaced after available in @deriv-com/ui */}
+
setIsModalOpen(true)}
+ variant='outlined'
+ >
+ {isBlocked ? 'Unblock' : 'Block'}
+
+
+ {/* TODO: to be replaced by deriv-com/ui modal component */}
+ setIsModalOpen(false)}
+ />
+ >
+ );
+};
+
+export default memo(MyProfileCounterpartiesTableRow);
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/__tests__/MyProfileCounterpartiesTableRow.spec.tsx b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/__tests__/MyProfileCounterpartiesTableRow.spec.tsx
new file mode 100644
index 00000000..17d76703
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/__tests__/MyProfileCounterpartiesTableRow.spec.tsx
@@ -0,0 +1,85 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import MyProfileCounterpartiesTableRow from '../MyProfileCounterpartiesTableRow';
+
+const mockProps = {
+ id: 'id1',
+ is_blocked: false,
+ nickname: 'nickname',
+};
+
+const mockPush = jest.fn();
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useHistory: () => ({
+ push: mockPush,
+ }),
+}));
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: false }),
+}));
+
+jest.mock('@/components/UserAvatar', () => ({
+ UserAvatar: () => UserAvatar
,
+}));
+
+jest.mock('@deriv/api-v2', () => ({
+ p2p: {
+ counterparty: {
+ useBlock: () => ({
+ mutate: jest.fn(),
+ }),
+ useUnblock: () => ({
+ mutate: jest.fn(),
+ }),
+ },
+ },
+}));
+
+const elModal = document.createElement('div');
+describe('MyProfileCounterpartiesTableRow', () => {
+ beforeAll(() => {
+ elModal.setAttribute('id', 'v2_modal_root');
+ document.body.appendChild(elModal);
+ });
+
+ afterAll(() => {
+ document.body.removeChild(elModal);
+ });
+ it('should render the component as expected', () => {
+ render( );
+ expect(screen.getByText('nickname')).toBeInTheDocument();
+ expect(screen.getByText('Block')).toBeInTheDocument();
+ expect(screen.getByText('UserAvatar')).toBeInTheDocument();
+ });
+ it('should handle open modal for click of block/unblock button in the row', async () => {
+ render( );
+ await userEvent.click(screen.getByText('Block'));
+ await waitFor(() => {
+ expect(screen.getByText('Block nickname?')).toBeInTheDocument();
+ });
+ });
+ it('should close modal for onRequest close of modal', async () => {
+ render( );
+ await userEvent.click(screen.getByText('Block'));
+ await waitFor(async () => {
+ expect(screen.getByText('Block nickname?')).toBeInTheDocument();
+ const button = screen.getByRole('button', { name: 'Cancel' });
+ await userEvent.click(button);
+ });
+ await waitFor(() => {
+ expect(screen.queryByText('Block nickname?')).not.toBeInTheDocument();
+ });
+ });
+
+ it('should call history.push when clicking on the nickname', async () => {
+ render( );
+ const nickname = screen.getByText('nickname');
+ await userEvent.click(nickname);
+ expect(mockPush).toHaveBeenCalledWith('/cashier/p2p-v2/advertiser/id1', { from: 'MyProfile' });
+ });
+});
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/index.ts b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/index.ts
new file mode 100644
index 00000000..7a706984
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/index.ts
@@ -0,0 +1 @@
+export { default as MyProfileCounterpartiesTableRow } from './MyProfileCounterpartiesTableRow';
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/__tests__/MyProfileCounterparties.spec.tsx b/src/pages/my-profile/screens/MyProfileCounterparties/__tests__/MyProfileCounterparties.spec.tsx
new file mode 100644
index 00000000..77398f9b
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/__tests__/MyProfileCounterparties.spec.tsx
@@ -0,0 +1,44 @@
+import { render, screen } from '@testing-library/react';
+
+import { useDevice } from '@/hooks/custom-hooks';
+
+import MyProfileCounterparties from '../MyProfileCounterparties';
+
+jest.mock('../../MyProfileCounterparties/MyProfileCounterpartiesHeader', () => ({
+ MyProfileCounterpartiesHeader: () => MyProfileCounterpartiesHeader
,
+}));
+
+jest.mock('../../MyProfileCounterparties/MyProfileCounterpartiesTable', () => ({
+ MyProfileCounterpartiesTable: () => MyProfileCounterpartiesTable
,
+}));
+
+jest.mock('@/components/Modals/RadioGroupFilterModal', () => ({
+ RadioGroupFilterModal: jest.fn(() => RadioGroupFilterModal
),
+}));
+
+jest.mock('@/hooks/useDevice', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ isMobile: false,
+ })),
+}));
+
+jest.mock('@/hooks/useQueryString', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ setQueryString: jest.fn(),
+ })),
+}));
+
+describe('MyProfileCounterparties', () => {
+ it('should render the component as expected', () => {
+ render( );
+ expect(screen.getByText('MyProfileCounterpartiesTable')).toBeInTheDocument();
+ });
+ it('should render the mobile view as expected', () => {
+ (useDevice as jest.Mock).mockReturnValue({ isMobile: true });
+ render( );
+ expect(screen.getByText('My counterparties')).toBeInTheDocument();
+ expect(screen.getByText('MyProfileCounterpartiesTable')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/index.ts b/src/pages/my-profile/screens/MyProfileCounterparties/index.ts
new file mode 100644
index 00000000..854336a3
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileCounterparties/index.ts
@@ -0,0 +1 @@
+export { default as MyProfileCounterparties } from './MyProfileCounterparties';
diff --git a/src/pages/my-profile/screens/MyProfileStats/MyProfileStats.scss b/src/pages/my-profile/screens/MyProfileStats/MyProfileStats.scss
new file mode 100644
index 00000000..a19c6efc
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileStats/MyProfileStats.scss
@@ -0,0 +1,12 @@
+.p2p-my-profile-stats {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ margin-bottom: 2.4rem;
+ gap: 2.4rem;
+
+ @include mobile {
+ grid-template-columns: repeat(2, 1fr);
+ grid-template-rows: repeat(2, 1fr);
+ padding: 2rem;
+ }
+}
diff --git a/src/pages/my-profile/screens/MyProfileStats/MyProfileStats.tsx b/src/pages/my-profile/screens/MyProfileStats/MyProfileStats.tsx
new file mode 100644
index 00000000..3180ee4b
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileStats/MyProfileStats.tsx
@@ -0,0 +1,92 @@
+import React, { useState } from 'react';
+
+import { useActiveAccount } from '@deriv/api-v2';
+import { Loader } from '@deriv-com/ui';
+
+import { useAdvertiserStats } from '@/hooks/custom-hooks';
+import { numberToCurrencyText } from '@/utils';
+
+import MyProfileStatsItem from './MyProfileStatsItem';
+
+import './MyProfileStats.scss';
+
+type TMyProfileStatsProps = {
+ advertiserId?: string;
+};
+
+const MyProfileStats = ({ advertiserId }: TMyProfileStatsProps) => {
+ const [shouldShowTradeVolumeLifetime, setShouldShowTradeVolumeLifetime] = useState(false);
+ const [shouldShowTotalOrdersLifetime, setShouldShowTotalOrdersLifetime] = useState(false);
+ const { data, isLoading } = useAdvertiserStats(advertiserId);
+ const { data: activeAccount } = useActiveAccount();
+
+ if (isLoading || !data) return ;
+
+ const {
+ averagePayTime,
+ averageReleaseTime,
+ buyCompletionRate,
+ buyOrdersCount,
+ sellCompletionRate,
+ sellOrdersCount,
+ totalOrders,
+ totalOrdersLifetime,
+ tradePartners,
+ tradeVolume,
+ tradeVolumeLifetime,
+ } = data;
+
+ const getTimeValueText = (minutes: number) => `${minutes === 1 ? '< ' : ''}${minutes} min`;
+
+ return (
+
+
+
+
+
+ setShouldShowTradeVolumeLifetime(hasClickedLifetime)}
+ shouldShowLifetime
+ testId='dt_profile_stats_trade_volume'
+ value={
+ shouldShowTradeVolumeLifetime
+ ? numberToCurrencyText(tradeVolumeLifetime)
+ : numberToCurrencyText(tradeVolume)
+ }
+ />
+ setShouldShowTotalOrdersLifetime(hasClickedLifetime)}
+ shouldShowLifetime
+ testId='dt_profile_stats_total_orders'
+ value={shouldShowTotalOrdersLifetime ? totalOrdersLifetime.toString() : totalOrders.toString()}
+ />
+
+
+ );
+};
+
+export default MyProfileStats;
diff --git a/src/pages/my-profile/screens/MyProfileStats/MyProfileStatsItem.scss b/src/pages/my-profile/screens/MyProfileStats/MyProfileStatsItem.scss
new file mode 100644
index 00000000..eaab3cf2
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileStats/MyProfileStatsItem.scss
@@ -0,0 +1,47 @@
+.p2p-my-profile-stats__item {
+ display: flex;
+ flex-direction: column;
+ border-right: 1px solid #f2f3f4;
+ padding-right: 2.4rem;
+ gap: 0.4rem;
+
+ & button {
+ padding-left: 0;
+ background: none;
+ font-size: 14px;
+ border: none;
+ font-style: italic;
+ cursor: pointer;
+
+ &:hover {
+ background: none;
+ text-decoration: none;
+ }
+ }
+
+ &--inactive {
+ color: #999999;
+ }
+
+ &--active {
+ color: #ff444f;
+ }
+
+ &:nth-child(4n) {
+ border-right: none;
+ }
+
+ &:last-child {
+ border-right: none;
+ }
+
+ @include mobile {
+ &:nth-child(even) {
+ border-right: none;
+ }
+
+ &:last-child {
+ border-right: none;
+ }
+ }
+}
diff --git a/src/pages/my-profile/screens/MyProfileStats/MyProfileStatsItem.tsx b/src/pages/my-profile/screens/MyProfileStats/MyProfileStatsItem.tsx
new file mode 100644
index 00000000..5b119dec
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileStats/MyProfileStatsItem.tsx
@@ -0,0 +1,71 @@
+import React, { useState } from 'react';
+
+import { Text, useDevice } from '@deriv-com/ui';
+
+import './MyProfileStatsItem.scss';
+
+type TMyProfileStatsItemProps = {
+ currency?: string;
+ label: string;
+ onClickLifetime?: (isLifetimeClicked: boolean) => void;
+ shouldShowDuration?: boolean;
+ shouldShowLifetime?: boolean;
+ testId?: string;
+ value: string;
+};
+const MyProfileStatsItem = ({
+ currency,
+ label,
+ onClickLifetime,
+ shouldShowDuration = true,
+ shouldShowLifetime,
+ testId,
+ value,
+}: TMyProfileStatsItemProps) => {
+ const [hasClickedLifetime, setHasClickedLifetime] = useState(false);
+ const { isMobile } = useDevice();
+ const textSize = isMobile ? 'xs' : 'sm';
+
+ const onClickLabel = (showLifetime: boolean) => {
+ setHasClickedLifetime(showLifetime);
+ onClickLifetime?.(showLifetime);
+ };
+
+ // TODO: Replace the button components below with Button once you can remove hover effect from Button
+ return (
+
+
+
+ {label}{' '}
+
+ {shouldShowDuration && (
+ onClickLabel(false)}>
+
+ 30d{' '}
+
+
+ )}{' '}
+ {shouldShowLifetime && (
+ <>
+
+ |{' '}
+
+ onClickLabel(true)}>
+
+ lifetime
+
+
+ >
+ )}
+
+
+ {value} {currency}
+
+
+ );
+};
+
+export default MyProfileStatsItem;
diff --git a/src/pages/my-profile/screens/MyProfileStats/MyProfileStatsMobile.tsx b/src/pages/my-profile/screens/MyProfileStats/MyProfileStatsMobile.tsx
new file mode 100644
index 00000000..d5d3467b
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileStats/MyProfileStatsMobile.tsx
@@ -0,0 +1,29 @@
+import { Text } from '@deriv-com/ui';
+
+import { FullPageMobileWrapper } from '@/components';
+import { useQueryString } from '@/hooks/custom-hooks';
+
+import MyProfileStats from './MyProfileStats';
+
+const MyProfileStatsMobile = () => {
+ const { setQueryString } = useQueryString();
+ return (
+
+ setQueryString({
+ tab: 'default',
+ })
+ }
+ renderHeader={() => (
+
+ Stats
+
+ )}
+ >
+
+
+ );
+};
+
+export default MyProfileStatsMobile;
diff --git a/src/pages/my-profile/screens/MyProfileStats/__tests__/MyProfileStats.spec.tsx b/src/pages/my-profile/screens/MyProfileStats/__tests__/MyProfileStats.spec.tsx
new file mode 100644
index 00000000..fa84ce67
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileStats/__tests__/MyProfileStats.spec.tsx
@@ -0,0 +1,113 @@
+import { APIProvider, AuthProvider } from '@deriv/api-v2';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import MyProfileStats from '../MyProfileStats';
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+ {children}
+
+);
+
+let mockUseAdvertiserStats = {
+ data: {
+ averagePayTime: 1,
+ averageReleaseTime: 2,
+ buyCompletionRate: 3,
+ buyOrdersCount: 4,
+ sellCompletionRate: 4.2,
+ sellOrdersCount: 5,
+ totalOrders: 28,
+ totalOrdersLifetime: 169,
+ tradePartners: 2,
+ tradeVolume: 40,
+ tradeVolumeLifetime: 150,
+ },
+ isLoading: true,
+};
+const mockUseActiveAccount = {
+ data: {
+ currency: 'USD',
+ },
+ isLoading: false,
+};
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: false }),
+}));
+
+jest.mock('@/hooks', () => ({
+ ...jest.requireActual('@/hooks'),
+ useAdvertiserStats: jest.fn(() => mockUseAdvertiserStats),
+}));
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ useActiveAccount: jest.fn(() => mockUseActiveAccount),
+}));
+
+describe('MyProfileStats', () => {
+ it('should render loader when data is not available', () => {
+ render( , { wrapper });
+ expect(screen.getByTestId('dt_derivs-loader')).toBeInTheDocument();
+ mockUseAdvertiserStats.isLoading = false;
+ });
+ it('should render the correct stats', async () => {
+ render( , { wrapper });
+ const buyCompletionNode = screen.getByTestId('dt_profile_stats_buy_completion');
+ expect(within(buyCompletionNode).getByText('3% (4)')).toBeInTheDocument();
+ const sellCompletionNode = screen.getByTestId('dt_profile_stats_sell_completion');
+ expect(within(sellCompletionNode).getByText('4.2% (5)')).toBeInTheDocument();
+ const avgPayTimeNode = screen.getByTestId('dt_profile_stats_avg_pay_time');
+ expect(within(avgPayTimeNode).getByText('< 1 min')).toBeInTheDocument();
+ const avgReleaseTimeNode = screen.getByTestId('dt_profile_stats_avg_release_time');
+ expect(within(avgReleaseTimeNode).getByText('2 min')).toBeInTheDocument();
+ const tradePartnersNode = screen.getByTestId('dt_profile_stats_trade_partners');
+ expect(within(tradePartnersNode).getByText('2')).toBeInTheDocument();
+
+ // test 30d and lifetime button toggles
+ const tradeVolumeNode = screen.getByTestId('dt_profile_stats_trade_volume');
+ expect(within(tradeVolumeNode).getByText('40.00 USD')).toBeInTheDocument();
+ const tradeVolumeLifetimeBtn = within(tradeVolumeNode).getByRole('button', {
+ name: 'lifetime',
+ });
+ await userEvent.click(tradeVolumeLifetimeBtn);
+ expect(within(tradeVolumeNode).getByText('150.00 USD')).toBeInTheDocument();
+
+ const totalOrdersNode = screen.getByTestId('dt_profile_stats_total_orders');
+ expect(within(totalOrdersNode).getByText('28')).toBeInTheDocument();
+ const totalOrdersLifetimeBtn = within(totalOrdersNode).getByRole('button', {
+ name: 'lifetime',
+ });
+ await userEvent.click(totalOrdersLifetimeBtn);
+ expect(within(totalOrdersNode).getByText('169')).toBeInTheDocument();
+ });
+
+ it('should render the correct default values', () => {
+ mockUseAdvertiserStats = {
+ // @ts-expect-error Assert some properties to be missing to mock default values
+ data: {
+ averagePayTime: -1,
+ averageReleaseTime: -1,
+ totalOrders: 28,
+ totalOrdersLifetime: 169,
+ tradePartners: 2,
+ tradeVolume: 40,
+ tradeVolumeLifetime: 150,
+ },
+ isLoading: false,
+ };
+
+ render( , { wrapper });
+ const buyCompletionNode = screen.getByTestId('dt_profile_stats_buy_completion');
+ expect(within(buyCompletionNode).getByText('-')).toBeInTheDocument();
+ const sellCompletionNode = screen.getByTestId('dt_profile_stats_sell_completion');
+ expect(within(sellCompletionNode).getByText('-')).toBeInTheDocument();
+ const avgPayTimeNode = screen.getByTestId('dt_profile_stats_avg_pay_time');
+ expect(within(avgPayTimeNode).getByText('-')).toBeInTheDocument();
+ const avgReleaseTimeNode = screen.getByTestId('dt_profile_stats_avg_release_time');
+ expect(within(avgReleaseTimeNode).getByText('-')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-profile/screens/MyProfileStats/__tests__/MyProfileStatsItem.spec.tsx b/src/pages/my-profile/screens/MyProfileStats/__tests__/MyProfileStatsItem.spec.tsx
new file mode 100644
index 00000000..56b5b12b
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileStats/__tests__/MyProfileStatsItem.spec.tsx
@@ -0,0 +1,53 @@
+import React, { useState } from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import MyProfileStatsItem from '../MyProfileStatsItem';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: false }),
+}));
+
+const MockApp = () => {
+ const [shouldShowLifetime, setShouldShowLifetime] = useState(false);
+ return (
+ setShouldShowLifetime(!shouldShowLifetime)}
+ shouldShowDuration
+ shouldShowLifetime
+ value={shouldShowLifetime ? '150' : '20'}
+ />
+ );
+};
+
+describe('MyProfileStatsItem', () => {
+ it('should render correct info', () => {
+ render( );
+ expect(screen.getByText('30')).toBeInTheDocument();
+ expect(screen.getByText('Total orders')).toBeInTheDocument();
+ });
+ it('should render with currency', () => {
+ render( );
+ expect(screen.getByText('20 USD')).toBeInTheDocument();
+ expect(screen.getByText('Trade volume')).toBeInTheDocument();
+ });
+ it('should render with duration and lifetime', () => {
+ render( );
+
+ const daysBtn = screen.getByRole('button', {
+ name: '30d',
+ });
+ expect(daysBtn).toBeInTheDocument();
+ const lifetimeBtn = screen.getByRole('button', {
+ name: 'lifetime',
+ });
+ expect(lifetimeBtn).toBeInTheDocument();
+
+ userEvent.click(lifetimeBtn);
+ expect(screen.getByText('150 USD')).toBeInTheDocument();
+ userEvent.click(daysBtn);
+ expect(screen.getByText('20 USD')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-profile/screens/MyProfileStats/__tests__/MyProfileStatsMobile.spec.tsx b/src/pages/my-profile/screens/MyProfileStats/__tests__/MyProfileStatsMobile.spec.tsx
new file mode 100644
index 00000000..99451c7b
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileStats/__tests__/MyProfileStatsMobile.spec.tsx
@@ -0,0 +1,35 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import MyProfileStatsMobile from '../MyProfileStatsMobile';
+
+jest.mock('../MyProfileStats', () => ({
+ __esModule: true,
+ default: () => MyProfileStats
,
+}));
+const mockSetQueryString = jest.fn();
+jest.mock('@/hooks/useQueryString', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ setQueryString: mockSetQueryString,
+ })),
+}));
+jest.mock('@/hooks/useDevice', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ isMobile: true,
+ })),
+}));
+
+describe('MyProfileStatsMobile', () => {
+ it('should render loader when data is not available', async () => {
+ render( );
+ expect(screen.getByText('MyProfileStats')).toBeInTheDocument();
+ expect(screen.getByText('Stats')).toBeInTheDocument();
+ const goBackBtn = screen.getByTestId('dt_mobile_wrapper_button');
+ await userEvent.click(goBackBtn);
+ expect(mockSetQueryString).toBeCalledWith({
+ tab: 'default',
+ });
+ });
+});
diff --git a/src/pages/my-profile/screens/MyProfileStats/index.ts b/src/pages/my-profile/screens/MyProfileStats/index.ts
new file mode 100644
index 00000000..6eadae4d
--- /dev/null
+++ b/src/pages/my-profile/screens/MyProfileStats/index.ts
@@ -0,0 +1 @@
+export { default as MyProfileStats } from './MyProfileStats';
diff --git a/src/pages/my-profile/screens/PaymentMethods/PaymentMethods.tsx b/src/pages/my-profile/screens/PaymentMethods/PaymentMethods.tsx
new file mode 100644
index 00000000..454afde1
--- /dev/null
+++ b/src/pages/my-profile/screens/PaymentMethods/PaymentMethods.tsx
@@ -0,0 +1,83 @@
+import { useCallback, useReducer } from 'react';
+import { TSelectedPaymentMethod } from 'types';
+
+import { Loader } from '@deriv-com/ui';
+
+import { PaymentMethodForm } from '@/components';
+import { api } from '@/hooks';
+import { useIsAdvertiser } from '@/hooks/custom-hooks';
+import { advertiserPaymentMethodsReducer } from '@/reducers';
+
+import { PaymentMethodsEmpty } from './PaymentMethodsEmpty';
+import { PaymentMethodsList } from './PaymentMethodsList';
+
+/**
+ * @component This component is the main component of the PaymentMethods screen. It's used to conditionally display the list of payment methods if it exists otherwise, it will display the empty state and the form to add a new payment method
+ * @returns {JSX.Element}
+ * @example
+ * **/
+const PaymentMethods = () => {
+ const isAdvertiser = useIsAdvertiser();
+ const { data: p2pAdvertiserPaymentMethods, isLoading } = api.advertiserPaymentMethods.useGet(isAdvertiser);
+ const [formState, dispatch] = useReducer(advertiserPaymentMethodsReducer, {});
+
+ const handleAddPaymentMethod = (selectedPaymentMethod?: TSelectedPaymentMethod) => {
+ dispatch({
+ payload: {
+ selectedPaymentMethod,
+ },
+ type: 'ADD',
+ });
+ };
+ const handleEditPaymentMethod = (selectedPaymentMethod?: TSelectedPaymentMethod) => {
+ dispatch({
+ payload: {
+ selectedPaymentMethod,
+ },
+ type: 'EDIT',
+ });
+ };
+ const handleDeletePaymentMethod = (selectedPaymentMethod?: TSelectedPaymentMethod) => {
+ dispatch({
+ payload: {
+ selectedPaymentMethod,
+ },
+ type: 'DELETE',
+ });
+ };
+
+ const handleResetFormState = useCallback(() => {
+ dispatch({ type: 'RESET' });
+ }, []);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!p2pAdvertiserPaymentMethods?.length && !formState.isVisible) {
+ return ;
+ }
+
+ if (formState?.isVisible) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default PaymentMethods;
diff --git a/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsEmpty/PaymentMethodsEmpty.scss b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsEmpty/PaymentMethodsEmpty.scss
new file mode 100644
index 00000000..841a2822
--- /dev/null
+++ b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsEmpty/PaymentMethodsEmpty.scss
@@ -0,0 +1,28 @@
+.p2p-payment-methods-empty {
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+
+ @include mobile {
+ margin-top: 7rem;
+ justify-content: center;
+ }
+
+ &__button {
+ margin-top: 2.4rem;
+ height: 4rem;
+ }
+
+ &__icon {
+ margin: 2rem auto 0;
+
+ @include mobile {
+ margin: 11.2rem auto 0;
+ }
+ }
+
+ &__heading {
+ margin: 3.9rem 0 0.8rem;
+ }
+}
diff --git a/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsEmpty/PaymentMethodsEmpty.tsx b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsEmpty/PaymentMethodsEmpty.tsx
new file mode 100644
index 00000000..c9ddeb37
--- /dev/null
+++ b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsEmpty/PaymentMethodsEmpty.tsx
@@ -0,0 +1,77 @@
+import { DerivLightIcPaymentMethodsWalletIcon } from '@deriv/quill-icons';
+import { Button, Text, useDevice } from '@deriv-com/ui';
+
+import { FullPageMobileWrapper } from '@/components';
+import { useQueryString } from '@/hooks/custom-hooks';
+
+import './PaymentMethodsEmpty.scss';
+
+type TPaymentMethodsEmptyProps = {
+ onAddPaymentMethod: () => void;
+};
+
+/**
+ * @component This component is used to display the empty state of the PaymentMethods screen
+ * @param {Function} onAddPaymentMethod - Callback to open the form to add a new payment method
+ * @returns {JSX.Element}
+ * @example
+ * **/
+const PaymentMethodsEmpty = ({ onAddPaymentMethod }: TPaymentMethodsEmptyProps) => {
+ const { isMobile } = useDevice();
+ const { setQueryString } = useQueryString();
+
+ if (isMobile) {
+ return (
+ {
+ setQueryString({
+ tab: 'default',
+ });
+ }}
+ renderHeader={() => (
+
+ Payment Methods
+
+ )}
+ >
+
+
+ {/* TODO: Remember to localize the text below */}
+
+ You haven’t added any payment methods yet
+
+ Hit the button below to add payment methods.
+ {
+ onAddPaymentMethod();
+ }}
+ >
+ Add payment methods
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* TODO: Remember to localize the text below */}
+
+ You haven’t added any payment methods yet
+
+ Hit the button below to add payment methods.
+ {
+ onAddPaymentMethod();
+ }}
+ >
+ Add payment methods
+
+
+ );
+};
+export default PaymentMethodsEmpty;
diff --git a/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsEmpty/__tests__/PaymentMethodsEmpty.spec.tsx b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsEmpty/__tests__/PaymentMethodsEmpty.spec.tsx
new file mode 100644
index 00000000..d2d206cb
--- /dev/null
+++ b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsEmpty/__tests__/PaymentMethodsEmpty.spec.tsx
@@ -0,0 +1,83 @@
+import { useDevice } from '@deriv-com/ui';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { useQueryString } from '@/hooks/custom-hooks';
+
+import PaymentMethodsEmpty from '../PaymentMethodsEmpty';
+
+jest.mock('@/hooks', () => ({
+ ...jest.requireActual('@/hooks'),
+ useQueryString: jest.fn().mockReturnValue({ setQueryString: jest.fn() }),
+}));
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({ isDesktop: false, isMobile: false, isTablet: false }),
+}));
+
+const mockUseDevice = useDevice as jest.MockedFunction;
+const mockUseQueryString = useQueryString as jest.MockedFunction;
+
+describe('PaymentMethodsEmpty', () => {
+ it('should render the component correctly', () => {
+ const onAddPaymentMethod = jest.fn();
+ render( );
+ expect(screen.getByText('You haven’t added any payment methods yet')).toBeInTheDocument();
+ expect(screen.getByText('Hit the button below to add payment methods.')).toBeInTheDocument();
+ expect(screen.getByText('Add payment methods')).toBeInTheDocument();
+ });
+ it('should call onAddPaymentMethods when isMobile is false', async () => {
+ const mockOnAddPaymentMethod = jest.fn();
+ render( );
+ const button = screen.getByRole('button', { name: 'Add payment methods' });
+ await userEvent.click(button);
+ expect(mockOnAddPaymentMethod).toHaveBeenCalled();
+ });
+ it('should call onAddPaymentMethods when isMobile is true', async () => {
+ mockUseDevice.mockReturnValueOnce({
+ isDesktop: false,
+ isMobile: true,
+ isTablet: false,
+ });
+ const onAddPaymentMethod = jest.fn();
+ render( );
+ const button = screen.getByRole('button', { name: 'Add payment methods' });
+ await userEvent.click(button);
+ expect(onAddPaymentMethod).toHaveBeenCalled();
+ });
+ it('should render the correct content when isMobile is false', () => {
+ const mockOnAddPaymentMethod = jest.fn();
+ render( );
+ expect(screen.getByText('You haven’t added any payment methods yet')).toBeInTheDocument();
+ expect(screen.getByText('Hit the button below to add payment methods.')).toBeInTheDocument();
+ expect(screen.getByText('Add payment methods')).toBeInTheDocument();
+ expect(screen.queryByTestId('dt_mobile_wrapper_button')).not.toBeInTheDocument();
+ });
+ it('should render the correct content when isMobile is true', () => {
+ mockUseDevice.mockReturnValueOnce({
+ isDesktop: false,
+ isMobile: true,
+ isTablet: false,
+ });
+ const onAddPaymentMethod = jest.fn();
+ render( );
+ expect(screen.getByText('You haven’t added any payment methods yet')).toBeInTheDocument();
+ expect(screen.getByText('Hit the button below to add payment methods.')).toBeInTheDocument();
+ expect(screen.getByText('Add payment methods')).toBeInTheDocument();
+ expect(screen.getByTestId('dt_mobile_wrapper_button')).toBeInTheDocument();
+ });
+ it('should call setQueryString when isMobile is true', async () => {
+ const { setQueryString: mockSetQueryString } = mockUseQueryString();
+ mockUseDevice.mockReturnValueOnce({
+ isDesktop: false,
+ isMobile: true,
+ isTablet: false,
+ });
+ const onAddPaymentMethod = jest.fn();
+ render( );
+ const back = screen.getByTestId('dt_mobile_wrapper_button');
+ await userEvent.click(back);
+ expect(mockSetQueryString).toHaveBeenCalledWith({ tab: 'default' });
+ });
+});
diff --git a/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsEmpty/index.ts b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsEmpty/index.ts
new file mode 100644
index 00000000..b7995402
--- /dev/null
+++ b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsEmpty/index.ts
@@ -0,0 +1 @@
+export { default as PaymentMethodsEmpty } from './PaymentMethodsEmpty';
diff --git a/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/AddNewButton.tsx b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/AddNewButton.tsx
new file mode 100644
index 00000000..8848a7b2
--- /dev/null
+++ b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/AddNewButton.tsx
@@ -0,0 +1,22 @@
+import { Button } from '@deriv-com/ui';
+
+type TAddNewButtonProps = {
+ isMobile: boolean;
+ onAdd: () => void;
+};
+
+/**
+ * @component This component is used to display the add new button
+ * @param isMobile - Whether the current device is mobile or not
+ * @param onAdd - The function to be called when the button is clicked
+ * @returns {JSX.Element}
+ * @example
+ * **/
+const AddNewButton = ({ isMobile, onAdd }: TAddNewButtonProps) => (
+ onAdd()} size='lg' textSize={isMobile ? 'md' : 'sm'}>
+ {/* TODO Remember to translate this*/}
+ Add new
+
+);
+
+export default AddNewButton;
diff --git a/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/PaymentMethodsList.scss b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/PaymentMethodsList.scss
new file mode 100644
index 00000000..e4b7a841
--- /dev/null
+++ b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/PaymentMethodsList.scss
@@ -0,0 +1,20 @@
+.p2p-payment-methods-list {
+ &__mobile-wrapper {
+ position: absolute;
+ top: 4rem;
+
+ & .p2p-mobile-wrapper {
+ &__body {
+ height: calc(100vh - 22rem);
+ overflow-y: scroll;
+ }
+
+ &__footer {
+ position: fixed;
+ background: #fff;
+ bottom: 0;
+ width: 100%;
+ }
+ }
+ }
+}
diff --git a/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/PaymentMethodsList.tsx b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/PaymentMethodsList.tsx
new file mode 100644
index 00000000..b5c77463
--- /dev/null
+++ b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/PaymentMethodsList.tsx
@@ -0,0 +1,85 @@
+import { THooks, TSelectedPaymentMethod } from 'types';
+
+import { Text, useDevice } from '@deriv-com/ui';
+
+import { FullPageMobileWrapper } from '@/components';
+import { useQueryString } from '@/hooks/custom-hooks';
+import { TFormState } from '@/reducers/types';
+
+import AddNewButton from './AddNewButton';
+import { PaymentMethodsListContent } from './PaymentMethodsListContent';
+
+import './PaymentMethodsList.scss';
+
+type TPaymentMethodsListProps = {
+ formState: TFormState;
+ onAdd: (selectedPaymentMethod?: TSelectedPaymentMethod) => void;
+ onDelete: (selectedPaymentMethod?: TSelectedPaymentMethod) => void;
+ onEdit: (selectedPaymentMethod?: TSelectedPaymentMethod) => void;
+ onResetFormState: () => void;
+ p2pAdvertiserPaymentMethods: THooks.AdvertiserPaymentMethods.Get;
+};
+
+/**
+ * @component This component is used to display the list of advertiser payment methods
+ * @param formState - The form state of the payment method form
+ * @returns {JSX.Element}
+ * @example
+ * **/
+const PaymentMethodsList = ({
+ formState,
+ onAdd,
+ onDelete,
+ onEdit,
+ onResetFormState,
+ p2pAdvertiserPaymentMethods,
+}: TPaymentMethodsListProps) => {
+ const { isMobile } = useDevice();
+ const { setQueryString } = useQueryString();
+
+ if (isMobile) {
+ return (
+
+ setQueryString({
+ tab: 'default',
+ })
+ }
+ renderFooter={() => }
+ // TODO: Remember to translate the title
+ renderHeader={() => (
+
+ Payment methods
+
+ )}
+ >
+ {!!p2pAdvertiserPaymentMethods?.length && (
+
+ )}
+
+ );
+ }
+
+ return p2pAdvertiserPaymentMethods?.length === 0 ? null : (
+
+ );
+};
+
+export default PaymentMethodsList;
diff --git a/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/PaymentMethodsListContent/PaymentMethodsListContent.scss b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/PaymentMethodsListContent/PaymentMethodsListContent.scss
new file mode 100644
index 00000000..30ff2ef6
--- /dev/null
+++ b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/PaymentMethodsListContent/PaymentMethodsListContent.scss
@@ -0,0 +1,25 @@
+.p2p-payment-methods-list-content {
+ width: 100%;
+
+ @include mobile {
+ max-width: 67.2rem;
+ min-width: 36rem;
+ padding: 1rem 2rem;
+ }
+
+ &__group {
+ &:not(:first-child) {
+ margin-top: 2.4rem;
+ }
+
+ &-body {
+ display: flex;
+ flex-wrap: wrap;
+
+ @include mobile {
+ justify-content: flex-start;
+ gap: 1.6rem;
+ }
+ }
+ }
+}
diff --git a/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/PaymentMethodsListContent/PaymentMethodsListContent.tsx b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/PaymentMethodsListContent/PaymentMethodsListContent.tsx
new file mode 100644
index 00000000..9c8b7739
--- /dev/null
+++ b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/PaymentMethodsListContent/PaymentMethodsListContent.tsx
@@ -0,0 +1,159 @@
+import { useEffect, useMemo, useState } from 'react';
+import { THooks, TSelectedPaymentMethod } from 'types';
+
+import { Text } from '@deriv-com/ui';
+
+import { PaymentMethodCard } from '@/components';
+import { PaymentMethodErrorModal, PaymentMethodModal } from '@/components/Modals';
+import { PAYMENT_METHOD_CATEGORIES } from '@/constants';
+import { api } from '@/hooks';
+import { TFormState } from '@/reducers/types';
+import { sortPaymentMethods } from '@/utils';
+
+import AddNewButton from '../AddNewButton';
+
+import './PaymentMethodsListContent.scss';
+
+type TPaymentMethodsGroup = Record<
+ string,
+ {
+ paymentMethods: THooks.AdvertiserPaymentMethods.Get;
+ title: string;
+ }
+>;
+
+type TPaymentMethodsListContentProps = {
+ formState: TFormState;
+ isMobile: boolean;
+ onAdd: (selectedPaymentMethod?: TSelectedPaymentMethod) => void;
+ onDelete: (selectedPaymentMethod?: TSelectedPaymentMethod) => void;
+ onEdit: (selectedPaymentMethod?: TSelectedPaymentMethod) => void;
+ onResetFormState: () => void;
+ p2pAdvertiserPaymentMethods: THooks.AdvertiserPaymentMethods.Get;
+};
+
+/**
+ * @component This component is used to display a list of payment methods. It's the content of the PaymentMethodsList component, when the list is not empty
+ * @param formState - The current state of the form
+ * @param isMobile - Whether the current device is mobile or not
+ * @param p2pAdvertiserPaymentMethods - The list of payment methods
+ * @returns {JSX.Element}
+ * @example
+ * **/
+const PaymentMethodsListContent = ({
+ formState,
+ isMobile,
+ onAdd,
+ onDelete,
+ onEdit,
+ onResetFormState,
+ p2pAdvertiserPaymentMethods,
+}: TPaymentMethodsListContentProps) => {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const {
+ delete: deleteAdvertiserPaymentMethod,
+ error: deleteError,
+ isError: isDeleteError,
+ isSuccess: isDeleteSuccessful,
+ } = api.advertiserPaymentMethods.useDelete();
+ const { actionType, selectedPaymentMethod } = formState;
+ const groupedPaymentMethods = useMemo(() => {
+ const groups: TPaymentMethodsGroup = {};
+ const sortedPaymentMethods = sortPaymentMethods(p2pAdvertiserPaymentMethods);
+ sortedPaymentMethods?.forEach(advertiserPaymentMethod => {
+ if (groups[advertiserPaymentMethod.type]) {
+ groups[advertiserPaymentMethod.type]?.paymentMethods?.push(advertiserPaymentMethod);
+ } else {
+ groups[advertiserPaymentMethod.type] = {
+ paymentMethods: [advertiserPaymentMethod],
+ title: PAYMENT_METHOD_CATEGORIES[advertiserPaymentMethod.type],
+ };
+ }
+ });
+ return groups;
+ }, [p2pAdvertiserPaymentMethods]);
+
+ useEffect(() => {
+ if (isDeleteError) {
+ setIsModalOpen(true);
+ }
+ }, [isDeleteError]);
+
+ useEffect(() => {
+ if (isDeleteSuccessful) {
+ setIsModalOpen(false);
+ onResetFormState();
+ }
+ }, [isDeleteSuccessful, onResetFormState]);
+
+ return (
+
+ {!isMobile &&
}
+ {Object.keys(groupedPaymentMethods)?.map(key => {
+ return (
+
+
+ {groupedPaymentMethods[key].title}
+
+
+ {groupedPaymentMethods[key].paymentMethods?.map(advertiserPaymentMethod => {
+ return (
+
{
+ onDelete(advertiserPaymentMethod);
+ setIsModalOpen(true);
+ }}
+ onEditPaymentMethod={() => {
+ onEdit({
+ displayName: advertiserPaymentMethod.display_name,
+ fields: advertiserPaymentMethod.fields,
+ id: advertiserPaymentMethod.id,
+ method: advertiserPaymentMethod.method,
+ });
+ }}
+ paymentMethod={advertiserPaymentMethod}
+ shouldShowPaymentMethodDisplayName={false}
+ />
+ );
+ })}
+
+
+ );
+ })}
+ {/* TODO: Remember to translate these strings */}
+ {actionType === 'DELETE' && isDeleteError && (
+
{
+ setIsModalOpen(false);
+ }}
+ title='Something’s not right'
+ />
+ )}
+ {actionType === 'DELETE' && !isDeleteError && (
+ {
+ deleteAdvertiserPaymentMethod(Number(selectedPaymentMethod?.id));
+ }}
+ onReject={() => {
+ setIsModalOpen(false);
+ }}
+ primaryButtonLabel='No'
+ secondaryButtonLabel='Yes, remove'
+ title={`Delete ${
+ selectedPaymentMethod?.fields?.bank_name?.value ??
+ selectedPaymentMethod?.fields?.name?.value ??
+ selectedPaymentMethod?.display_name
+ }?`}
+ />
+ )}
+
+ );
+};
+
+export default PaymentMethodsListContent;
diff --git a/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/PaymentMethodsListContent/index.ts b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/PaymentMethodsListContent/index.ts
new file mode 100644
index 00000000..693d5c0b
--- /dev/null
+++ b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/PaymentMethodsListContent/index.ts
@@ -0,0 +1 @@
+export { default as PaymentMethodsListContent } from './PaymentMethodsListContent';
diff --git a/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/__test__/AddNewButton.spec.tsx b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/__test__/AddNewButton.spec.tsx
new file mode 100644
index 00000000..62d6d2fc
--- /dev/null
+++ b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/__test__/AddNewButton.spec.tsx
@@ -0,0 +1,17 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import AddNewButton from '../AddNewButton';
+
+describe('AddNewButton', () => {
+ it('should render the component correctly', () => {
+ render( );
+ expect(screen.getByText('Add new')).toBeInTheDocument();
+ });
+ it('should handle the onadd action', async () => {
+ const mockOnAdd = jest.fn();
+ render( );
+ await userEvent.click(screen.getByText('Add new'));
+ expect(mockOnAdd).toHaveBeenCalled();
+ });
+});
diff --git a/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/__test__/PaymentMethodsList.spec.tsx b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/__test__/PaymentMethodsList.spec.tsx
new file mode 100644
index 00000000..a26650ff
--- /dev/null
+++ b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/__test__/PaymentMethodsList.spec.tsx
@@ -0,0 +1,142 @@
+import { useDevice } from '@deriv-com/ui';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { useQueryString } from '@/hooks/custom-hooks';
+
+import PaymentMethodsList from '../PaymentMethodsList';
+
+jest.mock('../PaymentMethodsListContent/PaymentMethodsListContent', () =>
+ jest.fn().mockReturnValue(PaymentMethodsListContent
)
+);
+
+jest.mock('@/hooks', () => ({
+ ...jest.requireActual('@/hooks'),
+ useQueryString: jest.fn().mockReturnValue({ setQueryString: jest.fn() }),
+}));
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: jest.fn().mockReturnValue({ isDesktop: true, isMobile: false, isTablet: false }),
+}));
+
+const mockUseDevice = useDevice as jest.MockedFunction;
+const mockUseQueryString = useQueryString as jest.MockedFunction;
+
+describe('PaymentMethodsList', () => {
+ it('should render the component when an empty list of payment methods is provided and isMobile is true', () => {
+ mockUseDevice.mockReturnValueOnce({
+ isDesktop: false,
+ isMobile: true,
+ isTablet: false,
+ });
+ render(
+
+ );
+ expect(screen.queryByText('PaymentMethodsListContent')).not.toBeInTheDocument();
+ });
+ it('should render the component when an empty list of payment methods is provided and isMobile is false', () => {
+ mockUseDevice.mockReturnValueOnce({
+ isDesktop: false,
+ isMobile: false,
+ isTablet: false,
+ });
+ render(
+
+ );
+ expect(screen.queryByText('PaymentMethodsListContent')).not.toBeInTheDocument();
+ });
+ it('should render the component when a list of payment methods is provided and isMobile is true', () => {
+ mockUseDevice.mockReturnValueOnce({
+ isDesktop: false,
+ isMobile: true,
+ isTablet: false,
+ });
+ render(
+
+ );
+ expect(screen.queryByText('PaymentMethodsListContent')).toBeInTheDocument();
+ });
+ it('should render the component when a list of payment methods is provided and isMobile is false', () => {
+ mockUseDevice.mockReturnValueOnce({
+ isDesktop: false,
+ isMobile: false,
+ isTablet: false,
+ });
+ render(
+
+ );
+ expect(screen.queryByText('PaymentMethodsListContent')).toBeInTheDocument();
+ });
+ it('should handle onclick for the back button for mobile', async () => {
+ const { setQueryString: mockSetQueryString } = mockUseQueryString();
+ mockUseDevice.mockReturnValueOnce({
+ isDesktop: false,
+ isMobile: true,
+ isTablet: false,
+ });
+ render(
+
+ );
+ const backButton = screen.getByTestId('dt_mobile_wrapper_button');
+ await userEvent.click(backButton);
+ expect(mockSetQueryString).toHaveBeenCalledWith({ tab: 'default' });
+ });
+});
diff --git a/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/__test__/PaymentMethodsListContent.spec.tsx b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/__test__/PaymentMethodsListContent.spec.tsx
new file mode 100644
index 00000000..87660512
--- /dev/null
+++ b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/__test__/PaymentMethodsListContent.spec.tsx
@@ -0,0 +1,294 @@
+import { ComponentProps } from 'react';
+import { THooks } from 'types';
+
+import { APIProvider, AuthProvider } from '@deriv/api-v2';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { PaymentMethodErrorModal, PaymentMethodModal } from '@/components/Modals';
+import { api } from '@/hooks';
+
+import { PaymentMethodsListContent } from '../PaymentMethodsListContent';
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+
+ {children}
+
+
+);
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ p2p: {
+ advertiserPaymentMethods: {
+ useDelete: jest.fn(),
+ },
+ },
+}));
+
+const mockPaymentMethodsData: THooks.AdvertiserPaymentMethods.Get = [
+ {
+ display_name: 'Other',
+ fields: {
+ account: {
+ display_name: 'Account 1',
+ required: 0,
+ type: 'text',
+ value: 'Account 1',
+ },
+ },
+ id: 'other',
+ is_enabled: 1,
+ method: 'other',
+ type: 'other',
+ used_by_adverts: null,
+ used_by_orders: null,
+ },
+ {
+ display_name: 'Other 1',
+ fields: {
+ account: {
+ display_name: 'Account 2',
+ required: 0,
+ type: 'text',
+ value: 'Account 2',
+ },
+ },
+ id: 'other1',
+ is_enabled: 1,
+ method: 'other1',
+ type: 'other',
+ used_by_adverts: null,
+ used_by_orders: null,
+ },
+];
+
+const mockUseDeleteResponse: ReturnType = {
+ context: undefined,
+ data: undefined,
+ delete: jest.fn(),
+ error: null,
+ failureCount: 0,
+ failureReason: null,
+ isError: false,
+ isIdle: false,
+ isLoading: false,
+ isPaused: false,
+ isSuccess: true,
+ mutateAsync: () => Promise.resolve({}),
+ reset: () => undefined,
+ status: 'success',
+ variables: undefined,
+};
+
+jest.mock('@/components/Modals', () => ({
+ ...jest.requireActual('@/components/Modals'),
+ PaymentMethodErrorModal: jest.fn(({ isModalOpen, onConfirm }: ComponentProps) => {
+ return isModalOpen ? (
+
+ PaymentMethodErrorModal
+
+ Ok
+
+
+ ) : null;
+ }),
+ PaymentMethodModal: jest.fn(({ isModalOpen, onConfirm, onReject }: ComponentProps) => {
+ return isModalOpen ? (
+
+ PaymentMethodModal
+
+ Confirm
+
+
+ Reject
+
+
+ ) : null;
+ }),
+}));
+
+const mockUseDelete = api.advertiserPaymentMethods.useDelete as jest.MockedFunction<
+ typeof api.advertiserPaymentMethods.useDelete
+>;
+
+describe('PaymentMethodsListContent', () => {
+ it('should render the component correctly', () => {
+ mockUseDelete.mockReturnValueOnce(mockUseDeleteResponse);
+ render(
+ ,
+ { wrapper }
+ );
+ expect(screen.getByText('Add new')).toBeInTheDocument();
+ });
+ it('should render the component when p2padvertiserpaymentmethods are provided', () => {
+ mockUseDelete.mockReturnValueOnce(mockUseDeleteResponse);
+ render(
+ ,
+ { wrapper }
+ );
+ expect(screen.getByText('Others')).toBeInTheDocument();
+ expect(screen.getByText('Account 1')).toBeInTheDocument();
+ expect(screen.getByText('Account 2')).toBeInTheDocument();
+ });
+ it('should handle edit when the edit menu item is clicked', async () => {
+ mockUseDelete.mockReturnValue(mockUseDeleteResponse);
+ const onEdit = jest.fn();
+ render(
+ ,
+ { wrapper }
+ );
+ await userEvent.click(screen.getByTestId('dt_flyout_toggle'));
+ const editMenuItem = screen.getByText('Edit');
+ expect(editMenuItem).toBeInTheDocument();
+ await userEvent.click(editMenuItem);
+ expect(onEdit).toBeCalled();
+ });
+ it('should handle delete when the delete menu item is clicked', async () => {
+ mockUseDelete.mockReturnValue(mockUseDeleteResponse);
+ const onDelete = jest.fn();
+ render(
+ ,
+ { wrapper }
+ );
+ await userEvent.click(screen.getByTestId('dt_flyout_toggle'));
+ const deleteMenuItem = screen.getByText('Delete');
+ expect(deleteMenuItem).toBeInTheDocument();
+ await userEvent.click(deleteMenuItem);
+ expect(onDelete).toBeCalled();
+ });
+
+ it('should handle confirm when the confirm button is clicked on the payment method modal and delete status is successful', async () => {
+ const onResetFormState = jest.fn();
+ mockUseDelete.mockReturnValue({
+ ...mockUseDeleteResponse,
+ isSuccess: true,
+ status: 'success',
+ });
+ render(
+ ,
+ { wrapper }
+ );
+ await userEvent.click(screen.getByTestId('dt_flyout_toggle'));
+ const deleteMenuItem = screen.getByText('Delete');
+ expect(deleteMenuItem).toBeInTheDocument();
+ await userEvent.click(deleteMenuItem);
+ await userEvent.click(screen.getByTestId('dt_payment_method_confirm_button'));
+ expect(onResetFormState).toBeCalled();
+ });
+ it('should hide the modal when the reject button of the modal is clicked', async () => {
+ mockUseDelete.mockReturnValue(mockUseDeleteResponse);
+ render(
+ ,
+ { wrapper }
+ );
+ await userEvent.click(screen.getByTestId('dt_flyout_toggle'));
+ const deleteMenuItem = screen.getByText('Delete');
+ expect(deleteMenuItem).toBeInTheDocument();
+ await userEvent.click(deleteMenuItem);
+ await userEvent.click(screen.getByTestId('dt_payment_method_reject_button'));
+ expect(screen.queryByText('PaymentMethodModal')).not.toBeInTheDocument();
+ });
+ it('should show the error modal when delete is unsuccessful and handle on confirm when the ok button is clicked', async () => {
+ mockUseDelete.mockReturnValue({
+ ...mockUseDeleteResponse,
+ error: {
+ echo_req: {
+ delete: [101, 102],
+ p2p_advertiser_payment_methods: 1,
+ },
+ error: {
+ code: 'AuthorizationRequired',
+ message: 'Please log in.',
+ },
+ msg_type: 'p2p_advertiser_payment_methods',
+ },
+ isError: true,
+ isSuccess: false,
+ status: 'error',
+ });
+
+ render(
+ ,
+ { wrapper }
+ );
+ expect(screen.queryByText('PaymentMethodErrorModal')).toBeInTheDocument();
+ await userEvent.click(screen.getByTestId('dt_payment_method_error_ok_button'));
+ expect(screen.queryByText('PaymentMethodErrorModal')).not.toBeInTheDocument(); // modal should be hidden
+ });
+});
diff --git a/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/index.ts b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/index.ts
new file mode 100644
index 00000000..12b9623b
--- /dev/null
+++ b/src/pages/my-profile/screens/PaymentMethods/PaymentMethodsList/index.ts
@@ -0,0 +1 @@
+export { default as PaymentMethodsList } from './PaymentMethodsList';
diff --git a/src/pages/my-profile/screens/PaymentMethods/__tests__/PaymentMethods.spec.tsx b/src/pages/my-profile/screens/PaymentMethods/__tests__/PaymentMethods.spec.tsx
new file mode 100644
index 00000000..2cce9d32
--- /dev/null
+++ b/src/pages/my-profile/screens/PaymentMethods/__tests__/PaymentMethods.spec.tsx
@@ -0,0 +1,205 @@
+import { ComponentProps, useReducer } from 'react';
+
+import { APIProvider, AuthProvider } from '@deriv/api-v2';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { PaymentMethodForm } from '@/components';
+import { api } from '@/hooks';
+
+import PaymentMethods from '../PaymentMethods';
+import { PaymentMethodsList } from '../PaymentMethodsList';
+
+const data: ReturnType['data'] = [
+ {
+ display_name: 'Other',
+ fields: {
+ account: {
+ display_name: 'Account 1',
+ required: 0,
+ type: 'text',
+ value: 'Account 1',
+ },
+ },
+ id: 'other',
+ is_enabled: 1,
+ method: 'other',
+ type: 'other',
+ used_by_adverts: null,
+ used_by_orders: null,
+ },
+];
+
+const mockUseGetResponse: ReturnType = {
+ data: undefined,
+ dataUpdatedAt: 0,
+ error: null,
+ errorUpdateCount: 0,
+ errorUpdatedAt: 0,
+ failureCount: 0,
+ failureReason: null,
+ fetchStatus: 'idle',
+ isError: false,
+ isFetched: false,
+ isFetchedAfterMount: false,
+ isFetching: false,
+ isInitialLoading: false,
+ isLoading: false,
+ isLoadingError: false,
+ isPaused: false,
+ isPlaceholderData: false,
+ isPreviousData: false,
+ isRefetchError: false,
+ isRefetching: false,
+ isStale: false,
+ isSuccess: true,
+ refetch: () => new Promise(() => undefined),
+ remove: () => undefined,
+ status: 'success',
+};
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useReducer: jest.fn().mockReturnValue([]),
+}));
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ Loader: () => Loader
,
+}));
+
+jest.mock('@deriv/api-v2', () => ({
+ ...jest.requireActual('@deriv/api-v2'),
+ p2p: {
+ advertiserPaymentMethods: {
+ useGet: jest.fn(() => ({})),
+ },
+ },
+}));
+
+jest.mock('@/components', () => ({
+ ...jest.requireActual('@/components'),
+ PaymentMethodForm: jest.fn(({ onResetFormState }: ComponentProps) => (
+
+ PaymentMethodForm
+
+ Cancel
+
+
+ )),
+}));
+
+jest.mock('@/hooks', () => ({
+ ...jest.requireActual('@/hooks'),
+ useIsAdvertiser: jest.fn(() => true),
+}));
+
+jest.mock('../PaymentMethodsEmpty', () => ({
+ PaymentMethodsEmpty: jest.fn(() => PaymentMethodsEmpty
),
+}));
+
+jest.mock('../PaymentMethodsList', () => ({
+ PaymentMethodsList: jest.fn(({ onAdd, onDelete, onEdit }: ComponentProps) => (
+
+ PaymentMethodsList
+ onAdd()}>Add
+ onEdit(data[0])}>Edit
+ onDelete(data[0])}>Delete
+
+ )),
+}));
+
+const mockUseGet = api.advertiserPaymentMethods.useGet as jest.MockedFunction<
+ typeof api.advertiserPaymentMethods.useGet
+>;
+const mockUseReducer = useReducer as jest.MockedFunction;
+
+const wrapper = ({ children }: { children: JSX.Element }) => (
+
+
+ {children}
+
+
+);
+
+describe('PaymentMethods', () => {
+ it('should call dispatch with the type add', async () => {
+ const mockDispatch = jest.fn();
+ mockUseReducer.mockReturnValue([{ isVisible: false }, mockDispatch]);
+ mockUseGet.mockReturnValue({ ...mockUseGetResponse, data });
+ render( , { wrapper });
+ await userEvent.click(screen.getByText('Add'));
+ expect(mockDispatch).toHaveBeenCalledWith({
+ payload: {
+ selectedPaymentMethod: undefined,
+ },
+ type: 'ADD',
+ });
+ });
+ it('should call dispatch with the type edit', async () => {
+ const mockDispatch = jest.fn();
+ mockUseReducer.mockReturnValue([{ isVisible: false }, mockDispatch]);
+ mockUseGet.mockReturnValue({ ...mockUseGetResponse, data });
+ render( , { wrapper });
+ await userEvent.click(screen.getByText('Edit'));
+ expect(mockDispatch).toHaveBeenCalledWith({ payload: { selectedPaymentMethod: data[0] }, type: 'EDIT' });
+ });
+ it('should call dispatch with type delete', async () => {
+ const mockDispatch = jest.fn();
+ mockUseReducer.mockReturnValue([{ isVisible: false }, mockDispatch]);
+ mockUseGet.mockReturnValue({ ...mockUseGetResponse, data });
+ render( , { wrapper });
+ await userEvent.click(screen.getByText('Delete'));
+ expect(mockDispatch).toHaveBeenCalledWith({ payload: { selectedPaymentMethod: data[0] }, type: 'DELETE' });
+ });
+ it('should call dispatch with type reset', async () => {
+ const mockDispatch = jest.fn();
+ mockUseReducer.mockReturnValue([{ isVisible: true }, mockDispatch]);
+ render( , { wrapper });
+ await userEvent.click(screen.getByTestId('dt_cancel_button'));
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'RESET' });
+ });
+ it('should show the loader when isloading is true', () => {
+ mockUseGet.mockReturnValue({ ...mockUseGetResponse, isLoading: true, isSuccess: false, status: 'loading' });
+ render( , { wrapper });
+ expect(screen.getByText('Loader')).toBeInTheDocument();
+ });
+ it('should render the payment methods empty component when data undefined and formstate.isvisible is false', () => {
+ mockUseReducer.mockReturnValue([{ isVisible: false }, jest.fn()]);
+ mockUseGet.mockReturnValue({ ...mockUseGetResponse, data: undefined });
+ render( , { wrapper });
+ expect(screen.getByText('PaymentMethodsEmpty')).toBeInTheDocument();
+ });
+ it('should render the payment method form when the formstate. isvisible is true', () => {
+ mockUseReducer.mockReturnValue([{ isVisible: true }, jest.fn()]);
+ render( , { wrapper });
+ expect(screen.getByText('PaymentMethodForm')).toBeInTheDocument();
+ });
+ it('should render payment methods list when data is defined and formstate.isvisible is false', () => {
+ mockUseReducer.mockReturnValue([{ isVisible: false }, jest.fn()]);
+ mockUseGet.mockReturnValue({
+ ...mockUseGetResponse,
+ data: [
+ {
+ display_name: 'Other',
+ fields: {
+ account: {
+ display_name: 'Account 1',
+ required: 0,
+ type: 'text',
+ value: 'Account 1',
+ },
+ },
+ id: 'other',
+ is_enabled: 1,
+ method: 'other',
+ type: 'other',
+ used_by_adverts: null,
+ used_by_orders: null,
+ },
+ ],
+ });
+ render( , { wrapper });
+ expect(screen.getByText('PaymentMethodsList')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/my-profile/screens/PaymentMethods/index.ts b/src/pages/my-profile/screens/PaymentMethods/index.ts
new file mode 100644
index 00000000..2eacb199
--- /dev/null
+++ b/src/pages/my-profile/screens/PaymentMethods/index.ts
@@ -0,0 +1 @@
+export { default as PaymentMethods } from './PaymentMethods';
diff --git a/src/pages/my-profile/screens/index.ts b/src/pages/my-profile/screens/index.ts
new file mode 100644
index 00000000..aeeb237e
--- /dev/null
+++ b/src/pages/my-profile/screens/index.ts
@@ -0,0 +1,4 @@
+export * from './MyProfile';
+export * from './MyProfileAdDetails';
+export * from './MyProfileCounterparties';
+export * from './MyProfileStats';
diff --git a/src/pages/orders/components/ChatError/ChatError.tsx b/src/pages/orders/components/ChatError/ChatError.tsx
new file mode 100644
index 00000000..c7116904
--- /dev/null
+++ b/src/pages/orders/components/ChatError/ChatError.tsx
@@ -0,0 +1,21 @@
+import React, { MouseEventHandler } from 'react';
+import { Button, Text, useDevice } from '@deriv-com/ui';
+
+type TChatErrorProps = {
+ onClickRetry: MouseEventHandler;
+};
+
+const ChatError = ({ onClickRetry }: TChatErrorProps) => {
+ const { isMobile } = useDevice();
+
+ return (
+
+ Oops, something went wrong
+
+ Retry
+
+
+ );
+};
+
+export default ChatError;
diff --git a/src/pages/orders/components/ChatError/__tests__/ChatError.spec.tsx b/src/pages/orders/components/ChatError/__tests__/ChatError.spec.tsx
new file mode 100644
index 00000000..8dda3511
--- /dev/null
+++ b/src/pages/orders/components/ChatError/__tests__/ChatError.spec.tsx
@@ -0,0 +1,24 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import ChatError from '../ChatError';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: true }),
+}));
+
+describe('ChatError', () => {
+ it('should render the component as expected', () => {
+ render( );
+ expect(screen.getByText('Oops, something went wrong')).toBeInTheDocument();
+ });
+
+ it('should handle the onclick', async () => {
+ const mockFn = jest.fn();
+ render( );
+ const button = screen.getByRole('button', { name: 'Retry' });
+ await userEvent.click(button);
+ expect(mockFn).toHaveBeenCalled();
+ });
+});
diff --git a/src/pages/orders/components/ChatError/index.ts b/src/pages/orders/components/ChatError/index.ts
new file mode 100644
index 00000000..35d06bd7
--- /dev/null
+++ b/src/pages/orders/components/ChatError/index.ts
@@ -0,0 +1 @@
+export { default as ChatError } from './ChatError';
diff --git a/src/pages/orders/components/ChatFooter/ChatFooter.tsx b/src/pages/orders/components/ChatFooter/ChatFooter.tsx
new file mode 100644
index 00000000..5db863c0
--- /dev/null
+++ b/src/pages/orders/components/ChatFooter/ChatFooter.tsx
@@ -0,0 +1,101 @@
+import React, { ChangeEvent, KeyboardEvent, useRef, useState } from 'react';
+import { Input, Text, useDevice } from '@deriv-com/ui';
+import ChatFooterIcon from '../ChatFooterIcon/ChatFooterIcon';
+import { TextAreaWithIcon } from '../TextAreaWithIcon';
+
+type TChatFooterProps = {
+ isClosed: boolean;
+ sendFile: (file: File) => void;
+ sendMessage: (message: string) => void;
+};
+const ChatFooter = ({ isClosed, sendFile, sendMessage }: TChatFooterProps) => {
+ const { isMobile } = useDevice();
+ const [value, setValue] = useState('');
+ const fileInputRef = useRef(null);
+ const textInputRef = useRef(null);
+
+ const onChange = (event: ChangeEvent) => {
+ setValue(event.target.value);
+ };
+ if (isClosed) {
+ return (
+
+ This conversation is closed
+
+ );
+ }
+
+ const sendChatMessage = () => {
+ const elTarget = textInputRef.current;
+ const shouldRestoreFocus = document.activeElement === elTarget;
+
+ if (elTarget?.value) {
+ sendMessage(elTarget.value);
+ elTarget.value = '';
+ setValue('');
+
+ if (shouldRestoreFocus) {
+ elTarget.focus();
+ }
+ }
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Enter' && !isMobile) {
+ if (event.ctrlKey || event.metaKey) {
+ const element = event.currentTarget;
+ const { value } = element;
+
+ if (typeof element.selectionStart === 'number' && typeof element.selectionEnd === 'number') {
+ element.value = `${value.slice(0, element.selectionStart)}\n${value.slice(element.selectionEnd)}`;
+ } else if (document.selection?.createRange) {
+ element.focus();
+
+ const range = document.selection.createRange();
+
+ range.text = '\r\n';
+ range.collapse(false);
+ range.select();
+ }
+ } else {
+ event.preventDefault();
+ sendChatMessage();
+ }
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default ChatFooter;
diff --git a/src/pages/orders/components/ChatFooter/__tests__/ChatFooter.spec.tsx b/src/pages/orders/components/ChatFooter/__tests__/ChatFooter.spec.tsx
new file mode 100644
index 00000000..b1387c8d
--- /dev/null
+++ b/src/pages/orders/components/ChatFooter/__tests__/ChatFooter.spec.tsx
@@ -0,0 +1,52 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import ChatFooter from '../ChatFooter';
+
+const mockProps = {
+ isClosed: false,
+ sendFile: jest.fn(),
+ sendMessage: jest.fn(),
+};
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: false }),
+}));
+describe('ChatFooter', () => {
+ it('should render the component as expected', () => {
+ render( );
+ expect(screen.getByPlaceholderText('Enter message')).toBeInTheDocument();
+ });
+ it('should render the conversation closed message', () => {
+ render( );
+ expect(screen.getByText('This conversation is closed')).toBeInTheDocument();
+ });
+ it('should expect value to be set on changing input', async () => {
+ render( );
+ const input = screen.getByPlaceholderText('Enter message');
+ await userEvent.type(input, 'Hello');
+ expect(input).toHaveValue('Hello');
+ });
+ it('should handle click send message', async () => {
+ render( );
+ const input = screen.getByPlaceholderText('Enter message');
+ await userEvent.type(input, 'Hello');
+ await userEvent.click(screen.getByRole('button'));
+ expect(mockProps.sendMessage).toHaveBeenCalledWith('Hello');
+ });
+ it('should handle click send attachment', async () => {
+ const file: File = new File(['bye'], 'bye.png', { type: 'image/png' });
+ render( );
+ const input = screen.getByTestId('dt_file_input');
+ await userEvent.upload(input, file);
+ expect(mockProps.sendFile).toHaveBeenCalledWith(file);
+ });
+ it('should handle keyboard enter event without new line', async () => {
+ render( );
+ const input = screen.getByPlaceholderText('Enter message');
+ await userEvent.type(input, 'Hello');
+ await userEvent.type(input, '{enter}');
+ expect(mockProps.sendMessage).toHaveBeenCalledWith('Hello');
+ });
+});
diff --git a/src/pages/orders/components/ChatFooter/index.ts b/src/pages/orders/components/ChatFooter/index.ts
new file mode 100644
index 00000000..bee5f658
--- /dev/null
+++ b/src/pages/orders/components/ChatFooter/index.ts
@@ -0,0 +1 @@
+export { default as ChatFooter } from './ChatFooter';
diff --git a/src/pages/orders/components/ChatFooterIcon/ChatFooterIcon.tsx b/src/pages/orders/components/ChatFooterIcon/ChatFooterIcon.tsx
new file mode 100644
index 00000000..e199e824
--- /dev/null
+++ b/src/pages/orders/components/ChatFooterIcon/ChatFooterIcon.tsx
@@ -0,0 +1,24 @@
+import React, { MouseEventHandler } from 'react';
+import { Button } from '@deriv-com/ui';
+import AttachmentIcon from '../../../../public/ic-attachment.svg';
+import SendMessageIcon from '../../../../public/ic-send-message.svg';
+
+type TChatFooterIconProps = {
+ length: number;
+ onClick: MouseEventHandler;
+};
+
+const ChatFooterIcon = ({ length, onClick }: TChatFooterIconProps) => {
+ return (
+ 0 ? : }
+ onClick={onClick}
+ type='button'
+ variant='contained'
+ />
+ );
+};
+
+export default ChatFooterIcon;
diff --git a/src/pages/orders/components/ChatFooterIcon/index.ts b/src/pages/orders/components/ChatFooterIcon/index.ts
new file mode 100644
index 00000000..f35b6e1f
--- /dev/null
+++ b/src/pages/orders/components/ChatFooterIcon/index.ts
@@ -0,0 +1 @@
+export { default as ChatFooterIcon } from './ChatFooterIcon';
diff --git a/src/pages/orders/components/ChatHeader/ChatHeader.scss b/src/pages/orders/components/ChatHeader/ChatHeader.scss
new file mode 100644
index 00000000..5b4e77ef
--- /dev/null
+++ b/src/pages/orders/components/ChatHeader/ChatHeader.scss
@@ -0,0 +1,6 @@
+.p2p-chat-header {
+ padding: 1.6rem 2.4rem;
+ @include mobile {
+ padding: 0;
+ }
+}
diff --git a/src/pages/orders/components/ChatHeader/ChatHeader.tsx b/src/pages/orders/components/ChatHeader/ChatHeader.tsx
new file mode 100644
index 00000000..01dfdcb6
--- /dev/null
+++ b/src/pages/orders/components/ChatHeader/ChatHeader.tsx
@@ -0,0 +1,27 @@
+import { Text, useDevice } from '@deriv-com/ui';
+
+import { OnlineStatusLabel, UserAvatar } from '@/components';
+
+import './ChatHeader.scss';
+
+type TChatHeaderProps = {
+ isOnline: boolean;
+ lastOnlineTime?: number | null;
+ nickname?: string;
+};
+const ChatHeader = ({ isOnline, lastOnlineTime, nickname }: TChatHeaderProps) => {
+ const { isMobile } = useDevice();
+ return (
+
+
+
+
+ {nickname}
+
+
+
+
+ );
+};
+
+export default ChatHeader;
diff --git a/src/pages/orders/components/ChatHeader/__tests__/ChatHeader.spec.tsx b/src/pages/orders/components/ChatHeader/__tests__/ChatHeader.spec.tsx
new file mode 100644
index 00000000..831a5d64
--- /dev/null
+++ b/src/pages/orders/components/ChatHeader/__tests__/ChatHeader.spec.tsx
@@ -0,0 +1,21 @@
+import { render, screen } from '@testing-library/react';
+
+import ChatHeader from '../ChatHeader';
+
+const mockProps = {
+ isOnline: 1 as 0 | 1,
+ lastOnlineTime: 1709810646,
+ nickname: 'client CR90000313',
+};
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isMobile: false }),
+}));
+
+describe('ChatHeader', () => {
+ it('should render the component as expected with the passed props', () => {
+ render( );
+ expect(screen.getByText('client CR90000313')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/orders/components/ChatHeader/index.ts b/src/pages/orders/components/ChatHeader/index.ts
new file mode 100644
index 00000000..003fef11
--- /dev/null
+++ b/src/pages/orders/components/ChatHeader/index.ts
@@ -0,0 +1 @@
+export { default as ChatHeader } from './ChatHeader';
diff --git a/src/pages/orders/components/ChatMessageReceipt/ChatMessageReceipt.tsx b/src/pages/orders/components/ChatMessageReceipt/ChatMessageReceipt.tsx
new file mode 100644
index 00000000..bb0d2cd3
--- /dev/null
+++ b/src/pages/orders/components/ChatMessageReceipt/ChatMessageReceipt.tsx
@@ -0,0 +1,36 @@
+import React, { ComponentType, SVGAttributes } from 'react';
+import { CHAT_MESSAGE_STATUS } from '@/constants';
+import { useSendbird } from '@/hooks/custom-hooks';
+import MessageDeliveredIcon from '../../../../public/ic-message-delivered.svg';
+import MessageErroredIcon from '../../../../public/ic-message-errored.svg';
+import MessagePendingIcon from '../../../../public/ic-message-pending.svg';
+import MessageSeenIcon from '../../../../public/ic-message-seen.svg';
+
+type TChatMessageReceiptProps = {
+ chatChannel: NonNullable['activeChatChannel']>;
+ message: ReturnType['messages'][number];
+ userId: string;
+};
+
+const ChatMessageReceipt = ({ chatChannel, message, userId }: TChatMessageReceiptProps) => {
+ let Icon: ComponentType>;
+
+ if (message.status === CHAT_MESSAGE_STATUS.PENDING) {
+ Icon = MessagePendingIcon;
+ } else if (message.status === CHAT_MESSAGE_STATUS.ERRORED) {
+ Icon = MessageErroredIcon;
+ } else {
+ const channelUserIds = Object.keys(chatChannel.cachedUnreadMemberState);
+ const otherSendbirdUserId = channelUserIds.find(id => id !== userId);
+ // User's last read timestamp is larger than or equal to this message's createdAt.
+ if (chatChannel.cachedUnreadMemberState[otherSendbirdUserId] >= message.createdAt) {
+ Icon = MessageSeenIcon;
+ } else {
+ Icon = MessageDeliveredIcon;
+ }
+ }
+
+ return ;
+};
+
+export default ChatMessageReceipt;
diff --git a/src/pages/orders/components/ChatMessageReceipt/__tests__/ChatMessageReceipt.spec.tsx b/src/pages/orders/components/ChatMessageReceipt/__tests__/ChatMessageReceipt.spec.tsx
new file mode 100644
index 00000000..84a5667b
--- /dev/null
+++ b/src/pages/orders/components/ChatMessageReceipt/__tests__/ChatMessageReceipt.spec.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from '@testing-library/react';
+
+import ChatMessageReceipt from '../ChatMessageReceipt';
+
+const mockProps = {
+ chatChannel: {
+ cachedUnreadMemberState: {
+ '123': 123,
+ },
+ },
+ message: {
+ createdAt: 123,
+ status: 2,
+ },
+ userId: '123',
+};
+
+describe('ChatMessageReceipt', () => {
+ it('should render the component as expected with the passed props', () => {
+ render( );
+ expect(screen.getByTestId('dt_chat_message_receipt_icon')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/orders/components/ChatMessageReceipt/index.ts b/src/pages/orders/components/ChatMessageReceipt/index.ts
new file mode 100644
index 00000000..8b7f2f90
--- /dev/null
+++ b/src/pages/orders/components/ChatMessageReceipt/index.ts
@@ -0,0 +1 @@
+export { default as ChatMessageReceipt } from './ChatMessageReceipt';
diff --git a/src/pages/orders/components/ChatMessageText/ChatMessageText.scss b/src/pages/orders/components/ChatMessageText/ChatMessageText.scss
new file mode 100644
index 00000000..95ccdd2e
--- /dev/null
+++ b/src/pages/orders/components/ChatMessageText/ChatMessageText.scss
@@ -0,0 +1,11 @@
+.p2p-chat-message-text {
+ border-radius: 1.6rem;
+ max-width: 100%;
+ padding: 0.8rem 1.6rem;
+ width: fit-content;
+ word-break: break-word;
+
+ > p {
+ white-space: pre-wrap;
+ }
+}
diff --git a/src/pages/orders/components/ChatMessageText/ChatMessageText.tsx b/src/pages/orders/components/ChatMessageText/ChatMessageText.tsx
new file mode 100644
index 00000000..bc24331b
--- /dev/null
+++ b/src/pages/orders/components/ChatMessageText/ChatMessageText.tsx
@@ -0,0 +1,21 @@
+import React, { memo, PropsWithChildren } from 'react';
+import { Text, useDevice } from '@deriv-com/ui';
+import './ChatMessageText.scss';
+
+type TChatMessageTextProps = {
+ color: string;
+ type?: string;
+};
+
+const ChatMessageText = ({ children, color, type = '' }: PropsWithChildren) => {
+ const { isDesktop } = useDevice();
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export default memo(ChatMessageText);
diff --git a/src/pages/orders/components/ChatMessageText/__tests__/ChatMessageText.spec.tsx b/src/pages/orders/components/ChatMessageText/__tests__/ChatMessageText.spec.tsx
new file mode 100644
index 00000000..3b29cdf5
--- /dev/null
+++ b/src/pages/orders/components/ChatMessageText/__tests__/ChatMessageText.spec.tsx
@@ -0,0 +1,16 @@
+import { render, screen } from '@testing-library/react';
+
+import ChatMessageText from '../ChatMessageText';
+
+jest.mock('@deriv-com/ui', () => ({
+ ...jest.requireActual('@deriv-com/ui'),
+ useDevice: () => ({ isDesktop: true }),
+}));
+
+describe('ChatMessageText', () => {
+ it('should render the component as expected with the children', () => {
+ const children = 'this is the message';
+ render({children} );
+ expect(screen.getByText('this is the message')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/orders/components/ChatMessageText/index.ts b/src/pages/orders/components/ChatMessageText/index.ts
new file mode 100644
index 00000000..43eb1449
--- /dev/null
+++ b/src/pages/orders/components/ChatMessageText/index.ts
@@ -0,0 +1 @@
+export { default as ChatMessageText } from './ChatMessageText';
diff --git a/src/pages/orders/components/ChatMessages/ChatMessages.scss b/src/pages/orders/components/ChatMessages/ChatMessages.scss
new file mode 100644
index 00000000..bc556bee
--- /dev/null
+++ b/src/pages/orders/components/ChatMessages/ChatMessages.scss
@@ -0,0 +1,81 @@
+.p2p-chat-messages {
+ background-color: #ffffff;
+ margin-top: auto;
+ margin-right: 0.8rem;
+ height: calc(70vh - 16rem);
+ overflow-y: auto;
+
+ @include mobile {
+ height: 100%;
+ }
+ &__date {
+ margin-top: 1.6rem;
+ text-align: center;
+ }
+ &__item {
+ display: flex;
+ flex-direction: column;
+ margin: 1.6rem 1.2rem 1.6rem 2.4rem;
+
+ &__file {
+ color: inherit;
+ }
+ &__image {
+ width: 50%;
+
+ > img {
+ border: 1px solid #d6dadb;
+ border-radius: 4px;
+ width: 100%;
+ height: auto;
+
+ &:hover {
+ border: 1px solid #999999;
+ }
+ }
+ }
+ &__pdf {
+ background-color: #ffffff;
+ border-radius: 0.8rem;
+ padding: 1rem 0.8rem 1rem 1rem;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 0.4rem;
+ a {
+ color: #999999;
+ text-decoration: none;
+ }
+ }
+ &__timestamp {
+ align-items: center;
+ display: flex;
+ margin-top: 0.4rem;
+ }
+
+ &__incoming {
+ align-items: flex-start;
+
+ .p2p-chat-message-text {
+ background-color: #d6dadb;
+ border-bottom-left-radius: 0;
+ }
+ }
+ &__outgoing {
+ align-items: flex-end;
+
+ .p2p-chat-message-text {
+ background-color: #85acb0;
+ border-bottom-right-radius: 0;
+ }
+ }
+
+ &__admin {
+ align-items: center;
+ background: transparentize(#ffad3a, 0.84);
+ border: 1px solid #ffad3a;
+ padding: 0.8rem 0;
+ border-radius: 0.8rem;
+ }
+ }
+}
diff --git a/src/pages/orders/components/ChatMessages/ChatMessages.tsx b/src/pages/orders/components/ChatMessages/ChatMessages.tsx
new file mode 100644
index 00000000..5efba9e0
--- /dev/null
+++ b/src/pages/orders/components/ChatMessages/ChatMessages.tsx
@@ -0,0 +1,145 @@
+import React, { Fragment, SyntheticEvent, useEffect, useRef } from 'react';
+import clsx from 'clsx';
+import { CHAT_FILE_TYPE, CHAT_MESSAGE_TYPE } from '@/constants';
+import { useSendbird } from '@/hooks/custom-hooks';
+import { convertToMB, formatMilliseconds } from '@/utils';
+import { Text, useDevice } from '@deriv-com/ui';
+import PDFIcon from '../../../../public/ic-pdf.svg';
+import { ChatMessageReceipt } from '../ChatMessageReceipt';
+import { ChatMessageText } from '../ChatMessageText';
+import './ChatMessages.scss';
+
+type TChatMessages = NonNullable['messages']>;
+type TChatMessagesProps = {
+ chatChannel: ReturnType['activeChatChannel'];
+ chatMessages: TChatMessages;
+ userId?: string;
+};
+
+const AdminMessage = () => (
+
+
+ Hello! This is where you can chat with the counterparty to confirm the order details.
+
+ Note: In case of a dispute, we’ll use this chat as a reference.
+
+
+);
+
+const ChatMessages = ({ chatChannel, chatMessages = [], userId }: TChatMessagesProps) => {
+ const { isMobile } = useDevice();
+ let currentDate = '';
+ const scrollRef = useRef(null);
+
+ useEffect(() => {
+ if (chatMessages.length > 0 && scrollRef.current) {
+ // Scroll all the way to the bottom of the container.
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+ }
+ }, [chatMessages.length]);
+
+ const getMessageFormat = (chatMessage: TChatMessages[number], messageColor: string) => {
+ const { fileType = '', name, size = 0, url } = chatMessage ?? {};
+ switch (fileType) {
+ case CHAT_FILE_TYPE.IMAGE:
+ return (
+
+
+
+ );
+ case CHAT_FILE_TYPE.PDF:
+ return (
+
+
+ {`${convertToMB(size).toFixed(2)}MB`}
+
+ );
+
+ default:
+ return (
+
+
+ {name}
+
+
+ );
+ }
+ };
+
+ const onImageLoad = (event: SyntheticEvent) => {
+ // Height of element changes after the image is loaded. Accommodate
+ // this extra height in the scroll.
+ if (scrollRef.current) {
+ scrollRef.current.scrollTop += event.currentTarget.parentElement
+ ? event.currentTarget.parentElement.clientHeight
+ : 0;
+ }
+ };
+
+ return (
+
+
+ {chatMessages.map(chatMessage => {
+ const isMyMessage = chatMessage.senderUserId === userId;
+ const messageDate = formatMilliseconds(chatMessage.createdAt, 'MMMM D, YYYY');
+ const messageColor = isMyMessage ? 'white' : 'general';
+ const shouldRenderDate = currentDate !== messageDate && !!(currentDate = messageDate);
+ const { customType, message, messageType } = chatMessage;
+
+ return (
+
+ {shouldRenderDate && (
+
+